44 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/tasks/requirements/workflow-task-step-transition-ledger.md | 2026-08-13 | draft |
Implementation Plan: Task step transition ledger
Overview
Two independently valuable slices, separated by a measurement gate the spec
mandates. Both need one shared prerequisite — a telemetry_activations registry
that publishes each contract's activation point — so that lands first. Slice 1
then adds the turn-start step stamp (one new key in an existing JSON column, no
schema change). After the gate, Slice 2 adds the task_step_transitions table,
wires the ledger write into the seven repository transactions that can mutate
tasks.workflow_step_id, gives real triggers and actors to the callers of those
transactions, and adds the three writer-health controls.
The order is dictated by dependency, not preference: the activation registry is read by both writers; the ledger table must exist before the writer references it; the writer must exist before callers can attribute to it; the pinning test must run against a finished writer set.
Prerequisite outside this feature. The spec records that wiring
session_step_history's dormant CreateStepTransition writer is separate work
that "lands first" because it touches the same code region
(internal/orchestrator/event_handlers_workflow.go,
internal/orchestrator/workflow_store.go). Confirm its state before starting
Task 05, which edits those files.
Backend
Area 1 — Telemetry contract activation registry (new package)
apps/backend/internal/telemetrycontract/ — a small leaf package owning the
telemetry_activations table and the boot-time activation + health report. It is
deliberately generic: the spec frames this table as a registry for any
telemetry contract, and registers exactly two keys today.
contract.go:
package telemetrycontract
// Contract describes one telemetry collection contract whose activation point
// and liveness must be readable from the database snapshot itself.
type Contract struct {
Key string // e.g. "turn.workflow_step_id_at_start"
Version int // starts at 1; a bump APPENDS an activation row
ExistsQuery string // SELECT returning 1 when the backing objects exist
StatsQuery string // SELECT (count, max_occurred_at) — max may be NULL
}
// Registry is the closed set of contracts this binary writes.
func Registry() []Contract
The two registrations:
| Key | Version | Backing objects | Stats source |
|---|---|---|---|
turn.workflow_step_id_at_start |
1 | task_session_turns |
rows whose metadata contains the stamp key; started_at as the recency column |
task_step_transitions |
1 | task_step_transitions |
COUNT(*), MAX(occurred_at) |
store.go:
// NewWithDB creates telemetry_activations idempotently and returns a Store.
func NewWithDB(writer, reader *sqlx.DB) (*Store, error)
// Activate writes one row per registered (key, version) that has none yet,
// stamped with this boot's UTC time. Never rewrites an existing row.
func (s *Store) Activate(ctx context.Context) error
// LogHealth emits one line per registered contract: object existence,
// activation time, row count, most recent occurred_at.
func (s *Store) LogHealth(ctx context.Context, log *logger.Logger)
DDL (dialect-split on the timestamp only; both dialects accept the rest):
CREATE TABLE IF NOT EXISTS telemetry_activations (
contract_key TEXT NOT NULL,
contract_version INTEGER NOT NULL,
activated_at TIMESTAMP NOT NULL,
PRIMARY KEY (contract_key, contract_version)
);
The composite primary key is load-bearing: a future version bump appends a
second activation row rather than overwriting the first. Activate uses
INSERT ... ON CONFLICT DO NOTHING (supported by both SQLite and pgx) so a
concurrent or repeated boot is a no-op.
Wiring: apps/backend/internal/backendapp/storage.go, immediately before the
existing recordSchemaVersion(writer, ...) call at line 101 — the comment there
already marks the point where every repository has finished initSchema, which
is exactly when a contract's backing objects are known to exist. Activate then
LogHealth. Neither is fatal: a failure logs and boot continues, matching
recordSchemaVersion's contract.
Area 2 — Turn-start step stamp (Slice 1)
apps/backend/internal/task/models/models.go — add beside
TurnMetaKeyRuntimeConfigSnapshot (line 232):
// TurnMetaKeyWorkflowStepIDAtStart records the workflow step the turn's task
// was in when the turn started. Absent when the task held no step.
const TurnMetaKeyWorkflowStepIDAtStart = "workflow_step_id_at_start"
apps/backend/internal/task/service/service_turns.go — the whole of Slice 1's
write path is the two call sites of runtimeConfigSnapshotMetadata(session):
StartTurn (line 44) and createCompletedTurn (line 79). Replace both with a
new method:
// turnStartMetadata composes the turn's immutable start-of-turn metadata: the
// runtime config snapshot and the workflow step the task was in. The step stamp
// is independent of the runtime snapshot — a turn with nothing to snapshot still
// carries the stamp. A task read failure logs and omits the stamp; turn creation
// must never fail because telemetry could not be resolved.
func (s *Service) turnStartMetadata(ctx context.Context, session *models.TaskSession) map[string]interface{}
Behaviour, in order:
- Start from
runtimeConfigSnapshotMetadata(session); if it returnsnil, start from an empty map (do not return early — this is the spec scenario "no runtime config to snapshot, still stamped"). s.tasks.GetTask(ctx, session.TaskID). On error:s.logger.Debug, record the absent-stamp metric, return the map as-is.- If
task.WorkflowStepID != "", set the stamp key. Empty string sets nothing — never"", nevernil, never0. - Return
nilwhen the map ended up empty, preserving today's "no metadata" shape for turns that carry neither piece.
runtimeConfigSnapshotMetadata keeps its current signature and semantics; it
becomes an input to the composer rather than the whole answer.
Immutability needs no code: nothing rewrites task_session_turns.metadata after
creation. Task 02 adds a regression test that pins that.
Area 3 — Ledger schema (Slice 2)
apps/backend/internal/task/repository/sqlite/base_schema.go — a new
initStepTransitionsSchema step appended to the initSchema step list, placed
after initSessionSchema (the FK targets task_sessions) and before
runMigrations. The id column is dialect-split exactly as
session_step_history does it (internal/workflow/repository/sqlite.go:88-96):
idCol := "id INTEGER PRIMARY KEY AUTOINCREMENT"
if dialect.IsPostgres(r.db.DriverName()) {
idCol = "id BIGSERIAL PRIMARY KEY"
}
CREATE TABLE IF NOT EXISTS task_step_transitions (
<idCol>,
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL,
from_workflow_id TEXT,
from_workflow_step_id TEXT,
to_workflow_id TEXT,
to_workflow_step_id TEXT,
trigger TEXT NOT NULL,
actor_kind TEXT NOT NULL,
actor_id TEXT,
contract_version INTEGER NOT NULL,
occurred_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_task_step_transitions_task
ON task_step_transitions(task_id, occurred_at, id);
CREATE INDEX IF NOT EXISTS idx_task_step_transitions_occurred
ON task_step_transitions(occurred_at);
No foreign key to workflow_steps or workflows — deliberate, per the spec: a
deleted step must not delete the historical fact that a card was in it.
This is a new table, not a new column, so CREATE TABLE IF NOT EXISTS in the
init block is correct and complete; the root guide's "columns only via
runMigrations" rule does not apply. Nothing is backfilled.
Area 4 — Attribution contract (new package, Slice 2)
apps/backend/internal/steptelemetry/ — a leaf package holding the closed
enums, the ambient attribution, the row type, and the metrics. Imported by the
sqlite repository, the task service, the orchestrator, and the MCP handlers; it
imports none of them, so no cycle is possible.
package steptelemetry
const ContractVersion = 1
const ContractKey = "task_step_transitions"
type Trigger string
const (
TriggerTaskCreated Trigger = "task_created"
TriggerManualMove Trigger = "manual_move"
TriggerTaskUpdate Trigger = "task_update"
TriggerMCPMove Trigger = "mcp_move"
TriggerMCPDeferredMove Trigger = "mcp_deferred_move"
TriggerEngineTransition Trigger = "engine_transition"
TriggerWIPPull Trigger = "wip_pull"
TriggerBulkMove Trigger = "bulk_move"
TriggerUnarchiveRestore Trigger = "unarchive_restore"
TriggerWorkflowAttached Trigger = "workflow_attached"
TriggerWorkflowDetached Trigger = "workflow_detached"
TriggerUnknown Trigger = "unknown"
)
type ActorKind string
const (
ActorHuman ActorKind = "human"
ActorAgent ActorKind = "agent"
ActorSystem ActorKind = "system"
ActorIntegration ActorKind = "integration"
ActorUnknown ActorKind = "unknown"
)
// Attribution is who and why. The zero value is the spec's recorded fact for a
// caller that declares nothing: unknown trigger, unknown actor, no IDs.
type Attribution struct {
Trigger Trigger
ActorKind ActorKind
ActorID string
SessionID string
}
func WithAttribution(ctx context.Context, a Attribution) context.Context
func FromContext(ctx context.Context) Attribution // normalizes zero -> unknown/unknown
Why the attribution travels on the context and not in method signatures. The
seven repository entry points are reached from dozens of call sites across the
service, orchestrator, MCP handlers, and integration watchers. The spec requires
that a caller who supplies nothing gets unknown/unknown recorded as a fact
rather than an error — that is ambient-default semantics, not a required
parameter. The repository already receives the acting identity this way
(authn.IdentityFromContext), which is the same value that becomes actor_kind = human, so this reuses an established seam rather than inventing one. Adding
four parameters to seven signatures and threading them through every caller
would produce a much larger diff for a value most callers pass through untouched.
Also in the package, mirroring internal/office/scheduler/metrics_vars.go:
var (
stepTransitionsTotal = expvar.NewMap("telemetry_step_transitions_total")
turnStampsTotal = expvar.NewMap("telemetry_turn_stamps_total")
)
// RecordLedgerRow bumps the expvar counter keyed by trigger AND emits a
// telemetry.metric.step_transition_written log line.
func RecordLedgerRow(log *logger.Logger, trigger Trigger)
// RecordTurnStamp bumps the counter keyed by present/absent AND emits
// telemetry.metric.turn_stamped.
func RecordTurnStamp(log *logger.Logger, present bool)
Log event names are the first field of the line so a log-aggregation rule matches
on the name without scanning free text — the routing.metric.* precedent.
Area 5 — Ledger writer inside the mutating transactions (Slice 2)
apps/backend/internal/task/repository/sqlite/step_transitions.go (new):
// stepTransitionTx is satisfied by both *sql.Tx and *sqlx.Tx, the two
// transaction types the mutation paths in this package use.
type stepTransitionTx interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// readTaskStepInTx reads the task's current (workflow_id, workflow_step_id)
// inside the write transaction, taking a row lock on Postgres. Reading outside
// the transaction would break the chain invariant under concurrent moves.
func (r *Repository) readTaskStepInTx(ctx context.Context, tx stepTransitionTx, taskID string) (workflowID, stepID string, found bool, err error)
// recordStepTransition writes exactly one ledger row when the step actually
// changed. It is a no-op when fromStepID == toStepID (position-only reorder,
// re-issued move to the current step) and when both sides are empty.
func (r *Repository) recordStepTransition(ctx context.Context, tx stepTransitionTx, in stepTransitionInput) error
readTaskStepInTx issues SELECT workflow_id, workflow_step_id FROM tasks WHERE id = ? with FOR UPDATE appended on Postgres (dialect.IsPostgres). On SQLite
the writer pool already serializes writers, so the plain read inside the write
transaction is sufficient.
recordStepTransition normalizes "" to NULL on all four from_*/to_*
columns, reads steptelemetry.FromContext(ctx) for trigger/actor/session,
stamps occurred_at = time.Now().UTC() and contract_version = steptelemetry.ContractVersion, and returns the INSERT error unchanged.
The missing-table case needs no detection code and must not get any. If the
CREATE TABLE was silently swallowed by the migration runner, the INSERT fails
with no such table / relation does not exist, that error returns, and the
enclosing transaction rolls back — which is precisely the spec's required
behaviour ("the transaction fails rather than committing a step change with no
row"). The rule for the implementer is therefore a negative one: never log-and-
continue on recordStepTransition's error, and never wrap it in a table-exists
probe that would let the step change commit alone.
The seven chokepoints, each already holding a transaction except where noted:
| Function | File:line | Change |
|---|---|---|
insertTaskTx (task creation) |
task.go:319 |
Genesis row after the INSERT when task.WorkflowStepID != "", from_* NULL. Reads the task struct, which by this point already carries the actual admission placement (applyAdmissionPlacement, task.go:235), so the feeder-placement scenario is satisfied for free. A task with no workflow writes nothing. |
UpdateTask |
task.go:469 |
readTaskStepInTx before the UPDATE; recordStepTransition after the RowsAffected check. |
UpdateTaskIfWorkflowStepHasCapacity |
task.go:778 |
Same. A WIP rejection returns before the UPDATE, so no row — as required. |
PromoteQueuedTaskIfWorkflowStepHasCapacity |
task.go:830 |
Same, but the UPDATE is conditional: record only when rows > 0. |
RestoreTaskMessageRollbackIfSessionState |
task.go:2043 |
Same conditional shape — the UPDATE is guarded by an EXISTS on session state; record only when rows > 0. |
AddTaskToWorkflow |
workflow.go:17 |
Currently has no transaction. Wrap in r.db.BeginTx, read-then-update-then-record. |
RemoveTaskFromWorkflow |
workflow.go:25 |
Same. Its UPDATE is guarded by AND workflow_id = ?; record only when rows > 0. to_* normalize to NULL from the '' the statement writes. |
Archive, unarchive-in-place, cascade archive, and delete are untouched: none of
them write workflow_step_id, so none writes a row, which is the specified
behaviour rather than an omission.
Area 6 — Caller attribution (Slice 2)
Each caller wraps its context once, as close to the entry point as possible.
Actor resolution is uniform: authn.IdentityFromContext(ctx) present →
ActorHuman with Identity.UserID (including the synthetic single-user identity
when auth is disabled); an agent session → ActorAgent with the session ID in
both ActorID and SessionID; no initiating identity → ActorSystem with
empty ActorID.
| Site | File | Attribution |
|---|---|---|
MoveTaskWithOptions |
service/service_workflow.go:428 |
manual_move + identity actor — only if the context carries no trigger already, so an mcp_move set by the MCP handler wins over the inner board-move default (the spec's outermost-caller rule). |
BulkMoveSelectedTasks, BulkMoveTasks |
service/service_workflow.go:947,1049 |
bulk_move, SessionID empty |
Service.UpdateTask (request carries a new step) |
service/service_tasks.go:1362 |
task_update |
promoteNextQueuedTask / pullNextTaskOnVacate |
service/service_workflow.go:558,612 |
wip_pull, ActorSystem, SessionID empty — even when the task has live sessions, since no one session is the initiator |
applyMoveTaskImmediate |
mcp/handlers/config_task_handlers.go:176 |
mcp_move, ActorAgent, actor+session = session.ID |
applyPendingMove |
orchestrator/event_handlers_workflow.go:1243 |
mcp_deferred_move, ActorAgent |
executeStepTransition |
orchestrator/event_handlers_workflow.go:296 |
engine_transition; ActorAgent when reached from a session turn, ActorSystem otherwise |
workflowStore.ApplyTransition |
orchestrator/workflow_store.go:154 |
Same, using its sessionID argument to decide agent vs system |
| Watcher-driven moves | orchestrator/watcher_dispatch.go + source_jira.go / source_linear.go |
ActorIntegration, ActorID = watch ID |
Task creation's genesis row needs no wrapping: the writer hard-codes
TriggerTaskCreated, and the actor comes from the identity already on the
context.
Anything not wrapped records unknown/unknown, which is a recorded fact.
Frontend
None. The spec's API surface section is explicit: no HTTP route, no WebSocket event, no MCP tool, no frontend surface. Consumers read the database. No store slice, hook, component, or API client changes.
Tests
Slice 1 — turn-start step stamp
| What | File | How |
|---|---|---|
Task in step S → turn metadata carries workflow_step_id_at_start = S |
internal/task/service/service_turns_step_stamp_test.go (new file — service_turns_test.go is near the 800-line revive limit) |
Service test with a stub task repo; assert the persisted turn's metadata |
Task with no step → key absent, not ""/null/0 |
same | Assert key membership with the two-value map read, not a value comparison |
No runtime config to snapshot → stamp present, runtime_config_snapshot absent |
same | Session with empty profile snapshot and no runtime metadata |
Task moves to T mid-turn → stamp still S |
same | Move the task after StartTurn, re-read the turn |
| Task read fails → turn still created, no stamp | same | Task repo stub returning an error; assert StartTurn returns a turn and err == nil |
| Synthetic completed turn carries the same stamp | same | Exercise createCompletedTurn through its lifecycle-message caller |
| Pre-activation turn is not backfilled | internal/task/repository/sqlite/step_stamp_migration_test.go (new) |
Seed a turn with metadata lacking the key, boot, assert unchanged |
Slice 2 — ledger writes
| What | File | How |
|---|---|---|
A→B manual move writes one row with correct from/to/trigger/actor |
internal/task/repository/sqlite/step_transitions_test.go (new) |
Table-driven over the trigger matrix against a real SQLite DB |
| No-op move to the current step writes no row | same | Assert row count unchanged |
| Position-only reorder writes no row | same | Same step, new position |
| WIP-rejected move writes no row and leaves the step unchanged | same | UpdateTaskIfWorkflowStepHasCapacity at capacity |
| Rolled-back transaction leaves no row | same | Force a failure after the UPDATE inside the tx |
Genesis row on create: from_* NULL, trigger task_created |
same | CreateTask into a step |
| Genesis row records the feeder step when WIP diverted the placement | same | CreateTaskWithWorkflowStepAdmission with a full target and a feeder |
Task created with no workflow writes no row; later attach writes the first row with workflow_attached |
same | Create bare, then AddTaskToWorkflow |
Detach writes to_* NULL with workflow_detached |
same | RemoveTaskFromWorkflow |
| Empty-string step normalizes to NULL on both sides | same | Seed workflow_step_id = '', move in |
No trigger/actor declared → unknown/unknown/NULL |
same | Call the repo with a bare context |
| Ephemeral (quick-chat) task records exactly as any other | same | IsEphemeral: true |
| Archive / unarchive-in-place / cascade / delete write no row | internal/task/repository/sqlite/step_transitions_lifecycle_test.go (new) |
Row count unchanged across each action |
| Row survives deletion of the workflow step it names | same | Delete the step, assert the row and its step ID |
| Missing ledger table fails the transaction rather than committing the step change alone | same | DROP TABLE task_step_transitions, attempt a move, assert error and unchanged tasks.workflow_step_id |
Ledger row deleted with its task; survives session deletion with session_id NULL |
same | Exercise both cascades |
Ordering, chain, and concurrency
| What | File | How |
|---|---|---|
A→B→A→B yields four rows with an intact chain |
internal/task/repository/sqlite/step_transitions_chain_test.go (new) |
Read ordered by (occurred_at, id), assert each from equals the previous to |
Rows sharing an occurred_at are totally ordered by id |
same | Freeze the clock across two moves |
Two concurrent moves → two rows, chain intact, last to equals the task's step |
same | Two goroutines, errgroup, real DB |
Last row's to_workflow_step_id equals tasks.workflow_step_id |
same | Invariant assertion helper reused by every case above |
Retried engine trigger with the same OperationID writes no second row |
internal/orchestrator/workflow_step_ledger_test.go (new) |
Replay the trigger through the engine's applied-operations store |
Actor and privacy
| What | File | How |
|---|---|---|
Auth disabled → human + synthetic identity's user ID |
internal/task/repository/sqlite/step_transitions_actor_test.go (new) |
Synthetic identity on the context |
Jira/Linear watcher move → integration + watch ID |
same | Attribution built by the watcher source |
| No display name, email, title, or prompt text in any column | same | Scan every text column of every row produced by the full trigger matrix against a fixture whose title/description/prompt are unique sentinels |
Migration and activation
| What | File | How |
|---|---|---|
| Pre-feature DB gains both tables; existing tasks have no rows; existing turns have no stamp | internal/task/repository/sqlite/step_transitions_migration_test.go (new) |
Mirrors task_external_id_migration_test.go: seed pre-migration rows, run migrations, assert |
| Migration runner replays twice more with no change | same | Call NewWithDB three times total |
| Postgres behaves identically | same, env-gated on KANDEV_TEST_POSTGRES_DSN |
Same fresh-then-replay sequence |
| First boot writes one activation row per key at version 1 with that boot's UTC time | internal/telemetrycontract/store_test.go (new) |
Activate on a fresh DB |
| Subsequent boot leaves the activation row unchanged | same | Activate twice, compare activated_at |
| A version bump appends a second row rather than overwriting | same | Register version 2, assert two rows |
Writer health
| What | File | How |
|---|---|---|
A new production statement mutating tasks.workflow_step_id fails the build until registered |
internal/task/repository/sqlite/step_transition_writers_pin_test.go (new) |
go/parser scans apps/backend/internal recursively; resolve package constants and fmt.Sprintf column setters, qualify identities by package and receiver, and assert set equality against the registered names |
A ledger row bumps the expvar counter for its trigger and logs telemetry.metric.step_transition_written |
internal/steptelemetry/metrics_test.go (new) |
Read the expvar map; assert with zaptest's observed logs |
A stamped/unstamped turn bumps the turn counter and logs telemetry.metric.turn_stamped |
same | Both branches |
| Boot emits one health line per registered contract with existence, activation, count, recency | internal/telemetrycontract/health_test.go (new) |
Observed logs against a seeded DB |
E2E Tests
None. The spec has zero user-visible changes: no UI, no API, no WebSocket event. There is no observable outcome for Playwright to assert. Coverage is entirely Go-side, including the real-database integration tests above.
Verification Results
All six tasks (01–06) implemented and committed together in
15beb3a003d214b8110e48f0a1b254b2c02daf9d on feature/tel-task-grain-step-bhh
(37 files, +2,478/−4). One commit rather than one per task, given the
interlocking nature of the seven chokepoints and their callers — see the
per-task decision notes below for what would otherwise have been split.
Slice 1 gate. No production deployment exists to measure two weeks of post-activation turns against. Per the spec's own default ("If the measurement is inconclusive... Slice 2 proceeds as specified"), this counts as inconclusive and Slice 2 proceeded as specified in the same session.
Commands run (backend, apps/backend), all green:
go build ./...go test ./internal/telemetrycontract/... ./internal/steptelemetry/... ./internal/task/repository/sqlite/... ./internal/task/service/... ./internal/task/... ./internal/orchestrator/... ./internal/mcp/... ./internal/office/engine_adapters/... ./internal/backendapp/...go test -raceon the same set (chain/concurrency tests specifically require-race)go test -run 'Postgres|Activation|StepTransition' ...— env-gated legs all skipped (KANDEV_TEST_POSTGRES_DSNunset in this environment); the Postgres-parity test files exist and compile (store_postgres_test.go,step_transitions_postgres_test.go,step_transitions_writer_postgres_test.go) and must be run in CI's postgres-boot job.golangci-lint run ./...(backend) — 0 issuesgolangci-lint run ./... --new-from-rev=db4fc039ac637766d6ec30e996918c538303021d— 0 issues- Pinning-test bite check: temporarily added an unregistered
UPDATE tasks SET workflow_step_id = ?statement totask.go, confirmedTestStepTransitionWritersArePinnedfailed naming it, removed the statement, confirmedgit diff --checkwas clean and the test passed again.
Root gauntlet, all green:
make fmt(backend + web; caught and fixed one stray smart-quote corruption in a comment during a fmt pass — reverted to plain"")make typecheck— required generatingapps/web/lib/generated/{changelog,release-notes}.jsonvianode scripts/generate-release-notes.mjs && node scripts/generate-changelog.mjsfirst (apredev/prebuildstep this checkout had never run); clean after.make lint(backend + web + harness + architecture) — 0 issuesmake lint-format— cleancd apps/web && pnpm run i18n:ratchet— clean (no UI source touched)make test(test-backend + test-web + test-cli + test-scripts) — 5 failures, all proven pre-existing/environmental by reproducing identically against merge-basedb4fc039ain scratch worktrees, then removed:internal/repocloneTestEnsureWorkspaceClonedWithBasicAuthKeepsCredentialScopedToGitChild/context_cancellation— flaky at merge-base too (3 reruns, mixed pass/fail both there and on this branch)internal/system/storage/workspaces(3 tests) — reproduces identically at merge-base; macOS temp-dir "not a directory" environment issueapps/weblib/http-git-server.test.ts(3 tests) — no Docker daemon in this sandbox; reproduces identically at merge-baseapps/webstorage-maintenance-settings.test.tsx(3 tests) — 5s timeout under this host's heavy concurrent-session CPU load; reproduces at merge-basescripts/pr-state"threads count" — file is byte-identical to merge-base (git diffempty), so trivially pre-existing (bash-version array-expansion issue)
Deliberate scope decisions made during Build (beyond the ones recorded during Spec), for review:
- Genesis-row and three other chokepoints hardcode their trigger at the
writer, not via caller wiring.
insertTaskTx(task_created),RestoreTaskMessageRollbackIfSessionState(unarchive_restore),AddTaskToWorkflow(workflow_attached) — via its sole caller,WorkflowSwitcherAdapter.SwitchTaskWorkflow— and the twopullNextTaskOnVacateimplementations (service and orchestrator; wip_pull) each have exactly one semantic meaning regardless of caller, so Task 05's plan (which only enumerated caller-side wiring) was extended to cover these four gaps against the spec's own Path→Trigger table, which lists all eleven mappings as fixed and caller-independent for these paths. RemoveTaskFromWorkflowhas zero production callers on this branch (confirmed by repo-wide grep) — same situation the spec notes forsession_step_history'sCreateStepTransition. It is wired identically toAddTaskToWorkflow(workflow_detached) so it is correct the moment a caller appears; there was nothing to wire attribution to.- Watcher-driven Jira/Linear activity in this codebase only creates
tasks, never moves an existing one (no
MoveTaskcall site found underinternal/jira,internal/linear, or the watcher dispatch/source files). The spec's "watcher moves a card → actor integration" scenario is satisfied by wiring the genesis row's actor toActorIntegration+WatchIDfor watcher-originated creates instead, via a small extension togenesisAttributionthat prefers a caller-set actor over the identity-derived default. - The ledger-row expvar/log counter fires at INSERT time inside the
transaction, not after commit. A later statement in the same
caller-owned transaction failing after the ledger INSERT (rare — e.g. the
runner sync in
UpdateTask) rolls the row back but the counter still incremented. Documented instep_transitions.go; treated as acceptable because the counter is a liveness signal ("is the writer alive"), not a row-for-row audit trail — the ledger table itself is that audit trail. e1cbdf22(wiresession_step_history) had not landed on this branch at Build time (CreateStepTransitionstill had zero production callers, reconfirmed before editingevent_handlers_workflow.go/workflow_store.go), so this card proceeded as the sole writer into those two files.
Testing Step — QA Pass And Scenario Audit
Committed as 4cbcc8f47 ("test: close ledger coverage gaps for cross-workflow
and cascade-archive"), 4 files, +212.
Proof-level determination. git diff --name-only origin/main...HEAD lists
41 changed files, all under apps/backend/, docs/plans/, docs/specs/ —
zero apps/web/ files. Per the mechanical rule (E2E required only for
non-exempt apps/web/ changes), no E2E is required; backend-only proof.
Attack pass. Traced the full watcher → genesis-row-actor context
attribution pipeline line by line (watcher_dispatch.go →
backendapp/orchestrator.go → service_tasks.go; no context.Background()
reset points found). Verified the SQLite-writer-serialization claim against
internal/db/sqlite.go's SetMaxOpenConns(1). Reconfirmed
RemoveTaskFromWorkflow still has zero production callers. Scanned the full
diff for debug/TODO/panic red flags (none found). Identified one dead-code
no-op branch in recordStepTransition (the empty-string/empty-string check
is subsumed by the equal-values check above it) — harmless, left as-is rather
than churning a working file for a no-op cleanup.
Test-honesty audit against all ~47 GIVEN/WHEN/THEN scenarios in
docs/specs/tasks/requirements/workflow-task-step-transition-ledger.md § Scenarios, by
category:
| Category | Scenarios | Result |
|---|---|---|
| Slice 1 — turn-start step stamp | 7 | fully covered |
| Slice 2 — ledger writes | 19 | 2 gaps found + closed |
| Ordering, chain, and concurrency | 7 | 2 gaps found + closed |
| Actor and privacy | 3 | fully covered |
| Migration and activation | 6 | fully covered |
| Writer health | 3 | fully covered |
Migration/activation and writer-health scenarios are each satisfied by more
than one existing test working together (e.g. the "ledger table absent"
scenario is proven jointly by
TestMissingLedgerTableFailsTransactionRatherThanCommittingStepChangeAlone
and TestLogHealthEmitsOneLinePerRegisteredContract) — no new tests were
needed there.
4 coverage gaps found and closed, zero production defects found:
- Cross-workflow engine transition had no ledger-row-level test. The
only existing test,
TestWorkflowStore_ApplyTransitionSyncsWorkflowIDAcrossWorkflows, asserts ontask.WorkflowID/task.WorkflowStepIDbut never queriestask_step_transitions, so the spec's "one row is written whosefrom_workflow_idandto_workflow_iddiffer" claim was unverified. Closed byTestApplyTransitionCrossWorkflowRecordsDifferingWorkflowIDs(internal/orchestrator/workflow_step_ledger_test.go), which mirrors that test'swf2/step-wf2setup and additionally reads the last ledger row. - Cascade-archive (
ArchiveTaskIfActive) had no direct behavioral test asserting it writes no ledger row — structurally guaranteed by the pinning test's exclusion (its SQL never touchesworkflow_step_id), but untested at the ledger level. Closed byTestArchiveTaskIfActiveWritesNoRow(internal/task/repository/sqlite/step_transitions_lifecycle_test.go). - Backwards host-clock correction was untested. No test proved the
chain invariant (
from == previous row's to) holds when rows are read inidorder afteroccurred_atruns backwards, or that the ledger is not "repaired"/reordered by timestamp. Closed byTestChainSurvivesBackwardsClockCorrectionWhenOrderedByID(internal/task/repository/sqlite/step_transitions_chain_test.go), which forces a row'soccurred_atearlier than the genesis row's and confirms both the chain and the raw (unrepaired) value survive underidordering. - Turn-stamp vs. ledger-row disagreement was untested. The spec's
explicit cross-slice scenario ("they disagree, and neither is treated as
an error") had no test reading both the turn's stamp and the ledger's
last row after a mid-turn move. Closed by
TestTurnStampAndLedgerRowCanDisagreeWithoutError(internal/task/service/service_turns_step_stamp_test.go).
All 4 new tests pass fresh (non-cached); no existing test was weakened, skipped, or had its assertions loosened.
Commands run (backend, apps/backend), fresh receipts:
go clean -testcache && go build ./...— clean.go test ./internal/orchestrator/... ./internal/task/repository/sqlite/... ./internal/task/service/... ./internal/steptelemetry/... ./internal/telemetrycontract/... ./internal/mcp/handlers/...— allok, fresh.go test -race ./internal/orchestrator/... ./internal/task/repository/sqlite/... ./internal/task/service/...— oneinternal/orchestratorgoroutine-leak failure inworkflow_e2e_test.go'srunWorkflowTestCase(a file this card never touches); re-rango test -race ./internal/orchestrator/...alone immediately after and it passed clean — confirmed timing flake, not a regression.make -C apps/backend lint—0 issues.make -C apps/backend test lint—testtarget failed, which blocksmake's sequentiallintprerequisite (same behavior seen during Build), solintwas re-run standalone (above, clean).testfailures, both pre-existing:internal/repoclone(1 test) — re-rango test ./internal/repoclone/... -vstandalone and it passed clean; same known timing flake documented in the Build step's Verification Results above.internal/system/storage/workspaces(same 3 tests, sameopen dependency workspace root: not a directoryerror text as the Build step) — already proven pre-existing against merge-basedb4fc039avia scratch worktree during Build; not re-proven again since it is the identical failure mode with receipts already on file above.
E2E: none, confirmed twice — the spec states zero user-visible surfaces,
and this step's mechanical file-diff check independently confirms zero
apps/web/ changes on the branch.
Review Round 1 / Round 2 — test-rigor follow-up on the writer-health pinning gate
A later Kandev task (fix-mcp-deferred-mov_428h3jqo) opened a follow-up card
strengthening TestStepTransitionWritersArePinned and its supporting
detector (findWorkflowStepIDMutators in
step_transition_writers_pin_test.go) against seven mutation-proven false
negatives, then ran two adversarial review rounds against the fix (a
Kandev-internal review leg plus an independent cross-vendor codex leg each
round). This entry is the durable receipt Review Round 1 required and Review
Round 2 confirmed was still missing from this file after Build round 2's
commit message (84a466d44) claimed — inaccurately — that it had already
been recorded here. Recording it now closes that gap.
What Round 1 found and Round 2 confirmed fixed:
- The production scan was rooted at this package's own directory (
"."), not the whole backend source tree, so recursion into subdirectories bought the gate nothing against a writer added in a sibling package. Fixed byfindBackendSourceRoot, which walks up toapps/backend/internal. - The Postgres concurrency test relied on a
sync.WaitGroupbarrier, which guarantees simultaneous start but not actual lock contention — reintroducing the historical bug it exists to catch produced only 7/20 failures (~35%). Fixed byTestPostgresReadTaskStepInTxBlocksOnConcurrentRowLock, a channel-gated deterministic lock-hold test mirroringturn_step_stamp_postgres_test.go's existing pattern. The test now pollspg_stat_activityto confirm that the competing backend is waiting on the row lock, and it uses the repository clock seam to prove the transition timestamp is sampled after lock release; the same reintroduced bug fails 5/5. - (Round 2, new)
funcIdentity()keyed registered writers as"ReceiverType.FuncName"with no package qualification. Since this codebase declares a type literally namedRepositoryin 11 different backend packages, a same-named method in any of them collided with an already-registered entry and the gate missed it. Fixed by qualifying the identity with the writer's package directory relative to the scan root ("task/repository/sqlite/Repository.updateTaskTx"), plus a permanent regression fixture (TestFindWorkflowStepIDMutatorsQualifiesByPackage) proving two packages with colliding receiver-type-and-method names are now reported as two distinct writers, not collapsed into one.
Both-ways (fail-then-pass) receipt for the evasive-shapes fixture test, run against the actual pre-fix detector, not reconstructed from memory:
$ git show 9fe867f9f:apps/backend/internal/task/repository/sqlite/step_transition_writers_pin_test.go \
> /tmp/pin_old_detector_source.go
# extracted the pre-fix functionMutatesWorkflowStepID/literalMutatesWorkflowStepID
# (BasicLit-only — no Sprintf handling, no const resolution, bare-name keys —
# i.e. exactly origin/main's shape before this card's fixes) into a standalone
# `go run` program and ran it against the current three fixture files.
$ go run /tmp/pin_old_detector_repro/main.go
found["constHoistedStepMutator"] = false # (a) unreachable: literal is const-hoisted, old detector only walked BasicLit
found["sprintfInterpolatedStepMutator"] = false # (b) unreachable: old detector had no Sprintf handling at all
found["updateTaskTx"] = true # (c) RepoA and RepoB collapse into one bare-name key — a duplicate masked as a single hit, not a miss
3 of the 5 fixture shapes were genuinely undetectable (or miscounted) under
the pre-fix detector, confirming the fixtures encode real false negatives,
not hypothetical ones. Post-fix, all 5 pass —
TestFindWorkflowStepIDMutatorsCatchesEvasiveShapes, run fresh:
$ go test ./internal/task/repository/sqlite/ -run TestFindWorkflowStepIDMutatorsCatchesEvasiveShapes -v -count=1
--- PASS: TestFindWorkflowStepIDMutatorsCatchesEvasiveShapes (0.00s)
Package-qualification receipt (Round 2, Finding A), live decoy in a real backend package, not a synthetic fixture:
$ cat > internal/office/repository/sqlite/zz_review_collision_tmp.go # package sqlite, a DIFFERENT
package sqlite # backend package than the real
# writer's internal/task/repository/sqlite
func (r *Repository) updateTaskTx() string {
return "UPDATE tasks SET workflow_step_id = ? WHERE id = ?"
}
$ go build ./internal/office/... # clean
$ go test ./internal/task/repository/sqlite/ -run TestStepTransitionWritersArePinned -v -count=1
--- FAIL: TestStepTransitionWritersArePinned
function(s) [office/repository/sqlite/Repository.updateTaskTx] contain a
statement that mutates tasks.workflow_step_id but are not registered...
$ rm internal/office/repository/sqlite/zz_review_collision_tmp.go
$ git status --porcelain internal/office/ internal/task/ # empty — clean revert
Before the Round 2 fix this exact decoy passed silently (the collision with
the already-registered "Repository.updateTaskTx" entry masked it); after
the fix it is reported under its own package-qualified identity and fails
the gate as intended.
Process note, disclosed rather than omitted: while drafting this card's own Kandev task plan during Round 2, the reviewer wrote a sentence claiming the evasive-shapes both-ways receipt above had already been re-verified before that verification had actually been run — the same class of unsubstantiated claim this section exists to prevent. It was caught before being reported anywhere external, and the real verification (shown above) was performed immediately after. Recorded here so the fix's provenance is honest rather than convenient.
Implementation Waves And Parallel Candidates
Sequential by default. The two parallel-safe labels below are the only tasks
whose files are genuinely disjoint from a concurrent sibling; every other task
edits internal/task/repository/sqlite/ or a file an earlier task created.
Wave 1 — shared foundation:
- [ ] [task-01-telemetry-activation-registry](task-01-telemetry-activation-registry.md)
Wave 2 — Slice 1 (ships and is measured alone):
- [ ] [task-02-turn-start-step-stamp](task-02-turn-start-step-stamp.md)
=== GATE: measure Slice 1 before building Slice 2 (see below) ===
Wave 3 — Slice 2 persistence:
- [ ] [task-03-ledger-schema-migration](task-03-ledger-schema-migration.md)
Wave 4 — Slice 2 writer:
- [ ] [task-04-ledger-writer-chokepoints](task-04-ledger-writer-chokepoints.md)
Wave 5 — Slice 2 attribution:
- [ ] [task-05-caller-attribution-wiring](task-05-caller-attribution-wiring.md)
Wave 6 — Slice 2 health controls:
- [ ] [task-06-writer-health-controls](task-06-writer-health-controls.md)
The gate is a real stop, not a formality. Per the spec, Slice 1 ships alone
and is measured on a store with at least two weeks of post-activation turns:
report the share of post-activation turns carrying the stamp, and the change in
the 47.0 / 30.5 / 22.5 attribution split when turn_start is admitted as a
basis. If the measurement is inconclusive — too few turns, or a change inside
noise — the recorded default is that Slice 2 proceeds as specified. The gate
exists to let evidence shrink Slice 2, not to let missing evidence stall it.
Task 04 is the only task in the plan that changes committed behaviour on every
step change in the product. It is deliberately scoped to leave the ledger fully
working with unknown triggers, so that Task 05 — which touches the orchestrator
and MCP handlers — can be reviewed as attribution quality rather than as
correctness of the write path.
Open Questions
session_step_historywiring order. The spec records that wiring that table's dormant writer is separate work that lands first, because it editsinternal/orchestrator/event_handlers_workflow.goandinternal/orchestrator/workflow_store.go— the same two files Task 05 edits. Confirm whether that work has landed before starting Task 05; if it has not, Task 05 is the second writer into those files and should expect conflicts.