Records

The read-only archive of finished runs, for auditors and anyone reconstructing what happened.

Routes: /records, /records/[processId] APIs: /api/records, /api/records/[processId], /api/records/export


The idea: derived, never stored

There is no records collection. Every record is computed from the process document on read:

"Derived from the process document; never stored. Deliberately small — the archive lists thousands of runs without shipping full context."

Nothing to sync, nothing to drift, no migration. The cost is that every read recomputes, and the list still loads every process — see storage.


Permission

All three routes require processes:audit, deliberately not processes:read_all:

const denied = await requirePermission(request, PERMISSIONS.PROCESSES_AUDIT, …);

read_all also confers write. An auditor should be able to read every run and change none, so the read-only grant exists separately. See permissions.


What a record answers

RecordSummary (platform/contracts/src/record/record-summary.ts) is deliberately small — it is what the list ships per row.

Field
outcomein_progress | completed | failed
checks{ total, checked, unchecked } over checkbox fields the run reached
keyFieldsverification values lifted out so a run is identifiable in a list
participantIdsevery non-system actor who wrote or completed, first-seen order
automationIssueCountdeliveries that failed, or sent without reaching everyone
reachedStepCount / stepCounthow far it got
startedAt updatedAt closedAt

It has a failure state the platform does not

ProcessStatus is only running | completed. RecordOutcome adds failed, derived from error and result rather than read from the document. That is a workaround, not a design — see known issues.


The three things it surfaces

Automation failures

slack_notify and telegram_notify record their outcome into context and advance. automationIssues() lifts those out:

"a signer notification that never landed leaves a clean-looking completed record. Surfacing it is the difference between 'the run completed' and 'everyone who should have been told, was'."

It prefers process.automationWarnings, which the engine now writes as deliveries happen, and falls back to reading context for runs that finished before that existed. Both paths go through lib/automation-outcome.ts, so records and the runner cannot disagree about whether something sent.

Two kinds are reported: failed (nothing sent) and partial (sent, but a mentioned user was not in the channel and so was never pinged).

Unchecked verification boxes

uncheckedChecks() tallies checkbox fields on steps the run actually reached. The audit-relevant number is unchecked — a verification box nobody ticked before the record closed.

Template drift

templateDivergence() compares the run's frozen snapshot against the live template and reports only what changes how this record reads: steps added or removed, and fields this run populated that were since removed or retitled.

Not a whole-object diff, on purpose:

"It fires on every cosmetic edit… so within weeks every record would carry the banner and nobody would read it."

⚠️ Both templates are expanded before comparison. Comparing an expanded snapshot against an unexpanded live template reports every subroutine-using template as diverged.

Surfaced as a banner on the detail page (data.template.divergence).


Timeline

buildRecordTimeline() merges two sources into one ordered sequence:

  • stepContextAudit — field writes
  • steps[].updatedUTC / updatedById — step completions
{ at, kind: "process_started" | "field_update" | "step_completed" | "process_closed",
  actorId, isSystem, stepKey?, stepTitle?, fields?, severity? }

isSystem distinguishes automation from people, via SYSTEM_STEP_CONTEXT_USER_ID. severity: "error" marks an entry whose automation outcome reported failure.


Filtering and export

The /records page exposes all five filters, which map straight onto RecordQuery:

Param
from / toinclusive ISO date or datetime; a date-only to covers the whole day
statusall | in_progress | completed | failed
templateKeyone template
qfree text over process id, template, actors and key fields — a tx hash, a nonce, an operator

Outcome is shown as a badge: Completed (green), In progress (blue), Failed (red). That red state is the one the platform itself cannot store.

GET /api/records/export returns a processos-record-bundle document for retention.


Where it meets the spine

Records read; they never write. They consume process.template (the snapshot), context, and stepContextAudit — see processes.

The related per-process audit dump lives at /api/process/{id}/audit — see audit and export.


Known gaps

  • The list derives from every process on every read, with no query layer or indexes. Details
  • RecordOutcome exists only because ProcessStatus has no failure state; it would become redundant if that were fixed. Details