Files
kandev/docs/specs/workflow/task-step-transition-ledger/spec.md
T
2026-08-15 16:59:40 +01:00

36 KiB

status, created, owner
status created owner
draft 2026-08-12 nova28

Task step transition ledger

Why

Kandev records when a card was worked on but not which workflow step it was in at the time. Cost, token, and duration events can be joined to a task and a session, but attributing them to a step requires reconstructing step boundaries from the message timeline. On the reference store that reconstruction leaves 47.0% of spend cleanly inside one step, 30.5% merely "dominant of two steps in the window", and 22.5% unattributable — so "what does Review cost" cannot be answered without a caveat larger than the answer.

The one table that looks like it should answer this, session_step_history, is keyed by session while Kandev tracks step state on the task (base_migrations.go removed task_sessions.workflow_step_id for exactly that reason). A card can change step with no active session, or with several, so attributing a task-level transition to one session manufactures ownership that does not exist.

What this feature is, and is not

It is a forward-looking record of step boundaries. From its activation point onward, every durable change to a task's workflow step is recorded with its timestamp, its cause, and its actor, so downstream analysis can bound a step interval from data rather than infer it from chat.

It cannot repair the past. No historical transition is reconstructed or backfilled. Rows before the activation point do not exist and MUST NOT be synthesised.

It does not make per-step cost exact. Cost events bill windows that cross step boundaries; better boundaries improve attribution but do not decide how one window's cost divides between two steps. The "dominant of two steps" bucket shrinks; it does not disappear.

It is not workflow_step_decisions. That table has a live writer on the Office approval path; its zero rows mean an unused feature, not a missing one. The two mechanisms MUST NOT be merged.

It is not a replacement for session_step_history. This spec neither wires, removes, nor changes that table or the GET /api/v1/sessions/:id/workflow/history endpoint that reads it. See Relationship to session_step_history.

What

The feature ships as two independently valuable slices. Slice 1 SHALL ship and be measured before Slice 2 is built (see Gate between slices).

Slice 1 — turn-start step stamp

  • A turn created for a session records the workflow step its task was in at the moment the turn started.
  • The stamp is present on every turn created after activation whose task has a workflow step, and absent — never empty, never 0 — when the task has none.
  • The stamp is immutable once written. A step change during the turn does not rewrite it.
  • No column is added to task_session_turns.

Slice 2 — task-level transition ledger

  • Every committed change to a task's workflow step produces exactly one ledger row, written in the same database transaction as the change.
  • A row that is not committed with its transition does not exist: a rolled-back or WIP-rejected move leaves no row.
  • A move that does not change the step (position-only reorder, re-issued move to the current step) produces no row.
  • Each row names the task, the step it left and the step it entered, when, why (trigger), what kind of actor caused it, and that actor's identifier — never a display name.
  • The initiating session is recorded when one exists and is genuinely the initiator; it is NULL otherwise. A transition with no session, or with several candidate sessions and no single initiator, MUST record NULL rather than pick one.
  • Each row carries the collection-contract version it was written under.

Cross-cutting

  • Legacy rows and unstamped turns get NULL / absent — never 0, never "".
  • Both slices publish a machine-readable activation point, because the downstream extract is a point-in-time snapshot with no schema versioning: a column whose meaning changes mid-series is silently discontinuous.
  • Both writers are observable at runtime, and the set of code paths that may mutate a task's workflow step is pinned by a test (see Writer health).

Data model

task_step_transitions (new)

task_step_transitions
  id                        int64      PK, monotonic per install
                                       (SQLite AUTOINCREMENT / Postgres BIGSERIAL)
  task_id                   string     FK -> tasks.id ON DELETE CASCADE
  session_id                string     FK -> task_sessions.id ON DELETE SET NULL,
                                       nullable — the initiating session, when one exists
  from_workflow_id          string     nullable — NULL only on the genesis row
  from_workflow_step_id     string     nullable — NULL on genesis, and when the task
                                       held no step (detached from a workflow)
  to_workflow_id            string     nullable — NULL when the task is detached
  to_workflow_step_id       string     nullable — NULL when the task is detached
  trigger                   enum       see Trigger below, NOT NULL
  actor_kind                enum       see Actor kind below, NOT NULL
  actor_id                  string     nullable — an opaque identifier, never a name
  contract_version          int        NOT NULL, starts at 1
  occurred_at               timestamp  NOT NULL, UTC

Indexes: (task_id, occurred_at, id) for interval reconstruction; (occurred_at) for extract windowing.

from_* and to_* are never both NULL — a row with neither side would record nothing. from_workflow_step_id equals to_workflow_step_id on no row; that combination is the no-op case, which writes nothing.

Empty string normalizes to NULL. tasks.workflow_step_id uses '' for "no step" and tasks.workflow_id likewise. The ledger stores NULL, never '', for the same condition, in both the from_* and to_* pairs. A consumer that sees '' in this table is looking at a bug.

There is deliberately no foreign key to workflow_steps or workflows. Steps and workflows are deleted; the historical fact that a card was in a now-deleted step must survive that deletion. Consumers MUST tolerate a step ID with no matching row.

Trigger — why the step changed. Unrecognised or unattributable causes use unknown; the enum is closed and new causes are added to it deliberately.

Value Meaning
task_created The genesis row: the task was created into a step.
manual_move A user moved the card (HTTP/WS move, drag, board action).
task_update A task update changed workflow_step_id without going through the move path.
mcp_move An agent moved the card via move_task_kandev, applied immediately.
mcp_deferred_move An agent's move_task_kandev applied at turn end.
engine_transition The workflow engine applied a step action (on_enter, on_exit, on_turn_start, on_turn_complete).
wip_pull A WIP/queue reconciler pulled or promoted the card into a vacated step.
bulk_move A bulk move of a selection or of a whole step.
unarchive_restore A restore/rollback returned the card to a recorded step.
workflow_attached The task was attached to a workflow.
workflow_detached The task was removed from a workflow (to_* NULL).
unknown The path did not declare a trigger.

Each production path that mutates a task's workflow step maps to exactly one trigger. The mapping is fixed so that no implementer has to choose:

Path Trigger
Task creation admission placement task_created
Service.MoveTask / MoveTaskWithOptions from an HTTP/WS board action manual_move
Service.MoveTask reached from move_task_kandev, applied immediately mcp_move
A move_task_kandev pending move applied at turn end mcp_deferred_move
Service.UpdateTask where the request carries a new workflow_step_id task_update
executeStepTransition and the engine's ApplyTransition engine_transition
An explicit user cancellation completing a turn (turnCompletionCauseUserCancellation), reached through the same on_turn_complete code path as engine_transition user_cancellation
Feeder pull and queued-task promotion on step vacate wip_pull
BulkMoveTasks / BulkMoveSelectedTasks bulk_move
RestoreTaskMessageRollbackIfSessionState unarchive_restore
AddTaskToWorkflow workflow_attached
RemoveTaskFromWorkflow workflow_detached
Anything not in this table unknown

A move that reaches MoveTask through more than one of these is attributed to the outermost caller: mcp_move beats manual_move, because the agent, not a board click, is what caused it.

Actor kind — what kind of thing caused it.

Value Meaning
human An authenticated identity, or the synthetic single-user identity when auth is disabled. actor_id is the user ID.
agent An agent session acting through MCP, or a session whose turn the engine reacted to. actor_id is the session ID.
system The workflow engine reacting to a non-session trigger, a queue reconciler, or a scheduler, with no initiating identity. actor_id is NULL.
integration An external watcher or automation (GitHub, GitLab, Jira, Linear, Sentry, Azure DevOps, webhook automation). actor_id is the watch/automation ID.
unknown Not determinable. actor_id is NULL.

actor_id MUST be an identifier. Display names, titles, emails, and prompts MUST NOT be written to this table.

For actor kind agent, actor_id and session_id hold the same session ID. The duplication is intentional: actor_id then has one uniform meaning ("the identifier of whoever caused this") across every actor kind, and consumers do not branch on kind to find the actor.

An engine_transition is agent when the trigger came from a session's turn (on_turn_start, on_turn_complete, on_exit/on_enter reached through one) and system when it came from a non-session trigger such as a children-completed rollup or a scheduled evaluation.

A user_cancellation is never agent, even though it reaches the engine through the same on_turn_complete code path an engine_transition does: the turn didn't run to completion on its own, a caller forced it closed. Actor kind for user_cancellation follows the same identity-on-context rule as manual_move — human with the cancelling request's user ID when an authenticated identity is present, system with no identifier when it is not (for example, an automated caller that cancels on a session's behalf with no request-scoped identity). This is a deliberate carve-out from the engine_transition rule above, not a relaxation of it: the trigger value itself (user_cancellation vs. engine_transition) is what distinguishes a forced completion from a natural one, so a consumer never has to inspect actor_kind to tell them apart.

telemetry_activations (new)

A one-row-per-contract registry that makes each telemetry contract's activation point readable from the snapshot itself.

telemetry_activations
  contract_key      string     PK part 1 — e.g. "turn.workflow_step_id_at_start",
                               "task_step_transitions"
  contract_version  int        PK part 2
  activated_at      timestamp  NOT NULL, UTC — first boot on which this version of
                               the contract became active on this install

The primary key is (contract_key, contract_version), not contract_key alone, so a future version bump appends a row rather than overwriting the first activation. That is the whole point: a series that changed meaning mid-stream must show two activation points, not one.

Written once per (key, version), on the boot that first makes it active; never rewritten. It lives in the same schema as the data it describes so that a database reset clears both together — an activation marker that outlives its data would be worse than none.

This spec registers exactly two keys: turn.workflow_step_id_at_start (Slice 1) and task_step_transitions (Slice 2), each at version 1.

task_session_turns.metadata (existing column, new key)

metadata is a JSON object; today its only key is runtime_config_snapshot (1,722 of 1,722 turns in the reference store). Slice 1 adds a sibling key:

{
  "runtime_config_snapshot": { "model": "opus[1m]", "mode": "auto" },
  "workflow_step_id_at_start": "e6f925e0-de99-4a29-88d7-1843d033e141"
}
  • The key is present only when the task held a workflow step when the turn started. It is otherwise absent — not "", not null, not 0.
  • Adding the key MUST NOT depend on runtime_config_snapshot being present. A turn whose runtime snapshot is empty still carries the stamp.
  • The value is a workflow step ID. It is not resolved to a step name; step names are mutable and the ID is the join key.

API surface

None. This feature adds no HTTP route, no WebSocket event, no MCP tool, and no frontend surface. Its only consumers read the database.

Existing surfaces are unchanged, including GET /api/v1/sessions/:id/workflow/history.

The Go-side contract is the invariant in What, Slice 2: the repository transaction that commits a change to tasks.workflow_step_id also commits the ledger row. Callers supply trigger and actor; a caller that supplies neither gets unknown / unknown, which is a recorded fact and not an error.

Determinism, ordering, and concurrency

Ordering. Ledger rows for one task are ordered by occurred_at ASC, tiebroken by id ASC. id is the authoritative sequence — two transitions can share a timestamp, and occurred_at alone is not a total order. There is no third tiebreak because id is unique.

Clock. occurred_at is the host wall clock at transaction time, in UTC. It can move backwards across an NTP correction. When occurred_at and id disagree about the order of two rows for the same task, id is correct; occurred_at is a measurement, id is the record. Consumers computing durations from a backwards-stepping pair get a non-positive interval and MUST treat it as unknown rather than clamping it to zero.

id is not a global watermark. Postgres BIGSERIAL allocates from a sequence before commit, so ids can commit out of order across concurrent transactions. An incremental extract MUST window on occurred_at with an overlap wide enough to cover its longest transaction, never on MAX(id) seen last time. Within a single task the chain invariant below makes gaps detectable; across tasks it does not.

Chain invariant. For any single task, reading its rows in that order, each row's from_workflow_step_id equals the previous row's to_workflow_step_id, and the last row's to_workflow_step_id equals the task's current tasks.workflow_step_id. This holds under concurrent moves; it therefore requires that the read of the old step and the write of the new one be serialized against other writers of the same task row, not read outside the write transaction.

Concurrency. Two callers moving the same task to different steps produce two rows with an intact chain, in the order the transactions committed. This spec does not change which move wins — last committed writer still wins, as today. Neither move is rejected merely because the other raced it.

No-op writes. A committed update whose workflow_step_id is unchanged writes no row, whatever else it changed. This is the whole of the feature's idempotency: a client retrying a move that already applied produces a second committed update, a no-op step change, and therefore no second row.

Engine retries. Engine transitions are already deduplicated by the engine's OperationID applied-operations store, so a retried trigger does not re-apply the transition and writes no second row. No additional idempotency key is added, and there is no unique constraint on the ledger — a card legitimately moving A→B→A→B produces four rows.

Genesis rows. Task creation writes one row with from_* NULL and trigger task_created, including when WIP capacity places the task in a feeder step rather than the requested target. Without it, the first interval of every task created after activation would have no start boundary in the ledger.

A task created with no workflow at all writes no genesis row — there is no step to record, and a row with both sides NULL is forbidden. Its first row is the workflow_attached row written when it later joins a workflow.

Which tasks are recorded. All of them, including ephemeral (quick-chat) and config tasks, and including tasks whose origin excludes them from WIP counts. Filtering by task kind is the consumer's job; excluding a class of task here would put invisible holes in the chain invariant, and the invariant is what makes the ledger self-checking.

Archiving is not a transition. Archive, unarchive-in-place, cascade archive, and delete do not change tasks.workflow_step_id and therefore write no row. Only unarchive_restore, which does set a step, writes one.

Ledger vs. tasks.updated_at. occurred_at is the transaction's timestamp, so a row's occurred_at and its task's updated_at agree at write time. The task's updated_at moves on later unrelated writes; the ledger row never does.

Permissions

The ledger is written by the same transactions that already perform authorized step mutations; it grants no new capability and performs no authorization of its own. If a caller was not permitted to move the task, no move and therefore no row occurs.

There is no read surface, so there is no read authorization. Anyone with database access already has it.

Failure modes

Condition Behaviour
Ledger insert fails inside the transaction The whole transaction rolls back: the step change does not commit either. Telemetry never silently diverges from the state it describes.
Migration fails at boot Kandev's migration runner swallows migration errors (db.MigrateLogger.Apply logs and continues). A failed CREATE TABLE is therefore invisible at boot. The writer MUST detect the missing table at first write and fail the transaction rather than skip the row — a step change that cannot be recorded is preferable to a ledger that is quietly partial. The boot health line (below) reports the table's absence.
Trigger or actor cannot be determined Written as unknown, which is a recorded fact. It is never guessed.
Task has several live sessions and no single initiator session_id is NULL.
Task has no workflow step (detached) to_* NULL with trigger workflow_detached; the stamp key is absent from new turns.
Task is deleted Rows cascade away with the task. Kandev prefers archive over delete, and archived tasks retain their rows; a deleted task's step history is gone by design and is not recoverable.
Session is deleted session_id becomes NULL; the row survives, because the transition was a task-level fact.
Slice 1: the task row cannot be read when a turn starts The turn is still created; the stamp key is absent. Turn creation MUST NOT fail because telemetry could not be resolved.

Persistence guarantees

  • Ledger rows and turn stamps survive restart, upgrade, and backup/restore. They are ordinary rows in the primary database.
  • Nothing about this feature is held in memory across a restart. There is no queue, no buffer, and no deferred flush; a row exists exactly when its transaction committed.
  • telemetry_activations rows survive restart and upgrade, and are cleared by a database reset together with the data they describe.
  • No retention policy, no TTL, no pruning. The ledger is bounded by the number of step changes (72 tasks and 1,722 turns on the reference store); it needs none.
  • Postgres parity is required per ADR 0027.

Migration and activation

Kandev has no migration framework: schema is Go string DDL applied idempotently at boot, and the runner swallows errors. Therefore:

  • New columns are added nullable and backfilled by a separate idempotent statement, following the task_session_messages.updated_at pattern (ALTER TABLE … ADD COLUMN then UPDATE … WHERE … IS NULL). Anything referencing a new column — index, backfill, partial predicate — runs after the ADD COLUMN, never in the CREATE TABLE init block.
  • Nothing is backfilled with a substituted value. Pre-activation state is absent, and absent is the correct reading.
  • The migration is replay-safe: running it twice on the same database is a no-op, and dialect-specific replay classification uses internal/db (IsDuplicateColumnError / IsAlreadyExistsError), never local string matching.
  • Coverage mirrors task_external_id_migration_test.go: seed a pre-migration row, run migrations, assert the pre-existing row reads NULL and the new objects exist, then call the migration runner twice more and assert idempotence — on SQLite and, env-gated, on Postgres.
  • On the boot that first activates each contract, a telemetry_activations row is written with that boot's UTC time and contract version 1. It is written once and never rewritten.

Writer health

A telemetry writer that silently stops is worse than one that was never built, because its output still looks like data. Three controls, all required:

  1. A pinning test over the mutation set. The production statements that may change tasks.workflow_step_id are enumerated in a test. A new statement that is not registered fails the build. This is the control that matters: the realistic failure is not the writer breaking, it is a new code path bypassing it. The set as of this spec is UpdateTask, UpdateTaskIfWorkflowStepHasCapacity, PromoteQueuedTaskIfWorkflowStepHasCapacity, RestoreTaskMessageRollbackIfSessionState, AddTaskToWorkflow, RemoveTaskFromWorkflow, and task creation's admission placement.
  2. Runtime counters, following the routing.metric.* precedent: an expvar counter of rows written keyed by trigger, and of turns created keyed by whether the stamp was present. Mirrored as structured logs under a telemetry.metric.* namespace so a log-aggregation rule can match the event name without scanning free text.
  3. A boot health line reporting, for each registered contract key: whether its objects exist, its activation timestamp, the current row count, and the most recent occurred_at. An install whose ledger stopped growing is visible in one line.

Relationship to session_step_history

session_step_history exists, has a CreateStepTransition writer with zero production callers on main (verified at db4fc039a), and is read by GET /api/v1/sessions/:id/workflow/history, which therefore returns {"history":[]} on every install. Wiring that writer is separate work and lands first; it and this ledger touch the same code region and cannot proceed in parallel.

This spec does not delete, deprecate, migrate, or dual-write that table. The two answer different questions — "what did this session do" versus "what happened to this card" — and a card can change step with no session or with several, which is why the second question needs its own grain.

Downstream consumer contract

Normative for the analysis extract, not for this repository — recorded here because the producer's obligations only make sense against it.

Every published per-step figure MUST carry an attribution_basis per row and coverage counts by basis. Basis is resolved in this precedence:

Basis Source Precedence
task_history A task_step_transitions interval containing the event 1 (strongest)
turn_start task_session_turns.metadata.workflow_step_id_at_start 2
message_reconstruction task_session_messages.metadata.workflow_step_id on workflow-automation messages (the mechanism in use today) 3
unknown No source resolves the event 4

A partly-populated ledger MUST NOT silently replace message-timeline reconstruction. Both remain live; the ledger raises precedence for events it covers and changes nothing for events it does not. A figure published without its coverage-by-basis breakdown is a figure whose meaning changed when the ledger activated, and that is exactly the discontinuity this contract exists to prevent.

Kandev's obligations that make this possible: publish the activation point (telemetry_activations), version each row (contract_version), and leave pre-activation state absent rather than defaulted.

Honest limits

  • Attribution improves forward only. History is not repaired.
  • A cost event whose window crosses a step boundary is still not divided between the two steps. The 30.5% "dominant of two steps" bucket shrinks as boundaries sharpen; it does not go to zero, and this feature does not attempt it.
  • Turn-level stamping records the step at turn start. A turn that spans a step change is attributed to where it began, which is a choice, not a measurement.
  • Step names are not captured. A renamed step is the same step; a deleted step leaves rows pointing at an ID with no row to join, and the consumer must tolerate that.

Gate between slices

Slice 1 ships alone and is measured before Slice 2 is built. The measurement that opens the gate: on a store with at least two weeks of post-activation turns, report the share of post-activation turns carrying the stamp, and the resulting change in the 47.0 / 30.5 / 22.5 attribution split when turn_start is admitted as a basis. If Slice 1 alone moves the unattributable bucket enough, Slice 2's scope is a decision made against that number rather than against this spec's assumption.

If the measurement is inconclusive — too few post-activation turns, or a change inside noise — the default is that Slice 2 proceeds as specified. The gate exists to let evidence shrink Slice 2, not to let missing evidence stall it.

The two slices can disagree: a turn's stamp reads the task's step at turn start and can be superseded by a move that commits moments later, which the ledger records and the stamp does not. That is expected, not an inconsistency, and is why task_history outranks turn_start in the basis precedence. Neither writer corrects the other.

Scenarios

Slice 1 — turn-start step stamp

  • GIVEN a session whose task sits in workflow step S, WHEN a turn starts, THEN the turn's metadata contains workflow_step_id_at_start = S.
  • GIVEN a session whose task has no workflow step, WHEN a turn starts, THEN the turn's metadata contains no workflow_step_id_at_start key — not an empty string, not null, not 0.
  • GIVEN a session with no runtime config to snapshot, WHEN a turn starts while its task sits in step S, THEN the metadata contains workflow_step_id_at_start = S and no runtime_config_snapshot.
  • GIVEN a turn stamped with step S, WHEN the task moves to step T before the turn completes, THEN the turn's stamp is still S.
  • GIVEN a turn created before activation, WHEN it is read after activation, THEN it carries no stamp and is not backfilled.
  • GIVEN the task row cannot be read, WHEN a turn starts, THEN the turn is created without a stamp and turn creation does not fail.
  • GIVEN a synthetic completed turn created for a lifecycle message, WHEN it is written while its task sits in step S, THEN it carries the same stamp as a normal turn.

Slice 2 — ledger writes

  • GIVEN a task in step A, WHEN a user moves it to step B, THEN exactly one ledger row exists with from_workflow_step_id = A, to_workflow_step_id = B, trigger manual_move, actor kind human, and actor_id equal to the acting user's ID.
  • GIVEN a task in step A with no session at all, WHEN a bulk move sends it to step B, THEN one row is written with session_id NULL and trigger bulk_move.
  • GIVEN a task in step A with two live sessions, WHEN a WIP pull promotes it into step B, THEN one row is written with session_id NULL, trigger wip_pull, and actor kind system.
  • GIVEN an agent session on a task in step A, WHEN the agent calls move_task_kandev and it applies immediately, THEN one row is written with trigger mcp_move, actor kind agent, and actor_id equal to that session's ID.
  • GIVEN an agent's deferred move_task_kandev, WHEN it applies at turn end, THEN one row is written with trigger mcp_deferred_move.
  • GIVEN a workflow step whose on_turn_complete transitions to step B, WHEN the turn completes, THEN one row is written with trigger engine_transition.
  • GIVEN a task in step A, WHEN a move to step A is issued, THEN no row is written.
  • GIVEN a task in step A, WHEN only its position within A changes, THEN no row is written.
  • GIVEN a WIP-limited target step at capacity, WHEN a move into it is rejected, THEN no row is written and the task's step is unchanged.
  • GIVEN the transaction that changes the step is rolled back for any reason, WHEN it completes, THEN no row exists for it.
  • GIVEN a task created into step A, WHEN creation commits, THEN one row exists with from_workflow_id and from_workflow_step_id NULL and trigger task_created.
  • GIVEN a task created targeting a full step B with feeder step A, WHEN creation commits and places it in A, THEN the genesis row's to_workflow_step_id is A, the step it was actually placed in.
  • GIVEN a task attached to a workflow, WHEN it is later removed from that workflow, THEN a row is written with to_workflow_id and to_workflow_step_id NULL and trigger workflow_detached.
  • GIVEN a task whose step changed via a task update rather than the move path, WHEN the update commits, THEN one row is written with trigger task_update.
  • GIVEN a caller that declares no trigger or actor, WHEN its step change commits, THEN the row records trigger unknown, actor kind unknown, and actor_id NULL.
  • GIVEN a task created with no workflow, WHEN creation commits, THEN no row is written; WHEN it is later attached to a workflow, THEN its first row has trigger workflow_attached and from_* NULL.
  • GIVEN an ephemeral (quick-chat) task in step A, WHEN it moves to step B, THEN a row is written exactly as for any other task.
  • GIVEN a task in step A, WHEN it is archived, unarchived in place, cascade-archived, or deleted without its step changing, THEN no row is written for that action.
  • GIVEN an engine action that switches a task to a step in a different workflow, WHEN the transition commits, THEN one row is written whose from_workflow_id and to_workflow_id differ, with trigger engine_transition.
  • GIVEN a task whose step is stored as the empty string, WHEN it moves into step B, THEN the row's from_workflow_step_id is NULL, not "".
  • GIVEN a row referencing a workflow step that is later deleted, WHEN the step is deleted, THEN the row survives unchanged with its step ID intact.

Ordering, chain, and concurrency

  • GIVEN a task moved A→B→A→B, WHEN its rows are read ordered by (occurred_at, id), THEN four rows appear and each row's from_workflow_step_id equals the previous row's to_workflow_step_id.
  • GIVEN two rows for one task sharing an identical occurred_at, WHEN they are ordered by (occurred_at, id), THEN the order is total and matches the order the transitions committed.
  • GIVEN two callers concurrently moving the same task, one to B and one to C, WHEN both transactions commit, THEN exactly two rows exist, their chain is intact, and the later row's to_workflow_step_id equals the task's final workflow_step_id.
  • GIVEN an engine trigger that is retried with the same OperationID, WHEN the retry is handled, THEN no second row is written.
  • GIVEN any task, WHEN its last row is read, THEN its to_workflow_step_id equals tasks.workflow_step_id.
  • GIVEN two rows for one task whose occurred_at values run backwards because the host clock was corrected, WHEN they are ordered by id, THEN the chain invariant still holds and the ledger is not repaired or reordered by timestamp.
  • GIVEN a turn stamped with step S and a ledger row moving the task to T a moment after that turn started, WHEN both are read, THEN they disagree, and neither is treated as an error.

Actor and privacy

  • GIVEN authentication is disabled, WHEN the single synthetic user moves a card, THEN actor kind is human and actor_id is that identity's user ID.
  • GIVEN a Jira or Linear watcher that moves a card, WHEN the move commits, THEN actor kind is integration and actor_id is the watch ID.
  • GIVEN any row, WHEN it is inspected, THEN actor_id holds an identifier and no display name, email, title, or prompt text appears anywhere in the row.

Migration and activation

  • GIVEN a database created before this feature, WHEN the backend boots, THEN the ledger table and telemetry_activations exist, pre-existing tasks have no ledger rows, and pre-existing turns carry no stamp.
  • GIVEN a database already migrated, WHEN the migration runner is run twice more, THEN it succeeds both times and changes nothing.
  • GIVEN a Postgres deployment, WHEN the same fresh-then-replay sequence runs, THEN it behaves identically to SQLite.
  • GIVEN a first boot on which a contract activates, WHEN boot completes, THEN telemetry_activations holds one row for that key with contract_version = 1 and that boot's UTC time.
  • GIVEN a subsequent boot, WHEN it completes, THEN the existing activation row is unchanged.
  • GIVEN an install where the ledger table is absent because its migration silently failed, WHEN a step change is attempted, THEN the transaction fails rather than committing a step change with no row, and the boot health line reports the table as absent.

Writer health

  • GIVEN a new production statement that mutates tasks.workflow_step_id, WHEN the test suite runs, THEN the pinning test fails until that statement is registered.
  • GIVEN a ledger row is written, WHEN metrics are read, THEN the expvar counter for that trigger has incremented and a telemetry.metric.* log line names the event.
  • GIVEN any boot, WHEN startup completes, THEN one health line per registered contract key reports object existence, activation time, row count, and most recent occurred_at.

Out of scope

  • Backfilling history. No pre-activation transition is reconstructed, from the message timeline or otherwise.
  • Dividing a cost event across a mid-turn step change. Explicitly not attempted; the "dominant of two steps" bucket remains.
  • workflow_step_decisions. A different mechanism with a live writer. Not merged, not extended, not read here.
  • Wiring, changing, or removing session_step_history and its endpoint. Separate work, lands first.
  • Any read API. No HTTP route, no WS event, no MCP tool, no UI. Consumers read the database.
  • The downstream extract and dashboard. The consumer contract above is recorded for the analysis side; implementing it is not part of this feature.
  • Step-name capture or step-rename history. IDs only.
  • Retention, pruning, or archival of ledger rows.
  • A parent_session_id / subagent grain. Separate card.
  • Per-turn cost columns. Separate card.