Files
kandev/docs/specs/workflow-evaluate-only-operation-marking/verification.md
T

250 lines
20 KiB
Markdown

# Verification plan — `EvaluateOnly` operation marking
Companion to [`spec.md`](spec.md). Provenance and test mapping, not contract. Where the two
disagree, `spec.md` wins.
## Prior art receipts
The unabridged receipts; `spec.md` § Prior art carries the same three legs in short form and points
here.
- **Leg 1 (wiki) — DID NOT RUN.** Resolved `OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki`
via `~/.obsidian-wiki/config` → `config.henry`, `QMD_WIKI_COLLECTION="wiki"`. Neither
`obsidian-wiki` nor `qmd` is on `PATH`; the documented grep fallback returns
`Operation not permitted` on the vault and a direct read of `index.md` returns `EPERM`. Sandbox
restriction, not an empty vault.
- **Leg 2 (`saas-kb` / `search_fsm_docs`) — DID NOT RUN.** MCP server absent from the session tool
list; no query issued with or without `category: "ai_sdlc"`.
- **Leg 3 (in-repo) — RAN.** `grep -rn "markOperationApplied\|IsOperationApplied"` and
`grep -rn "OperationID:"` over `apps/backend` (non-test) at `ddcc4fc6a`. It produced the two
precedents `spec.md` § Why dissects.
**What we do differently** (displaced from `spec.md` § Prior art, which is at its size ceiling):
neither precedent generalizes — `reevaluateGuardedTransitions` commits in-call, while
`processOnChildrenCompleted` uses the explicit caller-owned deferral path (`OperationID` plus
`DeferOperationMark`). This spec makes the result signal part of `HandleTrigger`, so both current
callers carry a visible commit-to-mark obligation.
## Measurement receipts
Displaced from `spec.md`, which is at its size ceiling. Contract statements stay there; the raw
measurements that back them live here.
### Defect receipt
The tail of `handleTrigger` at `ddcc4fc6a`, `apps/backend/internal/workflow/engine/engine.go`:
```go
result, err := e.processActions(ctx, in, state, step, actions, filter)
if err != nil {
return HandleResult{}, err
}
return result, e.markOperationApplied(ctx, in.OperationID) // unconditional
```
The commit this mark claims sits inside `processActions`, behind `if !in.EvaluateOnly`.
### Blast radius receipts
`grep -rn "applied_operations\|operation_id" --include=*.sql apps/backend` returns nothing — there is
no `applied_operations` table and no operation-id column anywhere in the schema, so the marker lives
only in `appliedOps sync.Map` (`internal/orchestrator/workflow_store.go:104`, read/written at
`:886-899`) and suppression is process-scoped. `agentErrorOperationID`
(`internal/orchestrator/event_handlers_agent_error.go:58-63`) is where the
`agent_error:session:<sessionID>` fallback is built when `AgentEventData.AgentExecutionID` is empty.
### The fifth return path
Named for the path AC-EO-11's four-case table excludes, but the accounting here is over **every**
`return false` in `applyEngineTransitionWithCommitMode`. There are **six**, not five, and the sixth
is not a non-committing return at all. Measured at `ddcc4fc6a`,
`internal/orchestrator/event_handlers_workflow.go`:
| line | path | disposition |
|---|---|---|
| 5411 | target step lookup error | AC-EO-11 case 1 |
| 5419 | credential preflight failure | AC-EO-11 case 2 |
| 5433 | source-step load failure | AC-EO-11 case 3 |
| 5458 | commit error | AC-EO-11 case 4 |
| 5461 | `applied == false` (CAS closure) | never committed; unreachable, see below |
| 5524 | `maybySwitchSessionForProfile` failure | **post-commit**; unreachable, see below |
**The fifth, `applied == false`, is unreachable from `applyEngineTransition`.** The commit closure
`applyEngineTransitionWithMode` supplies returns `(true, nil)` or `(false, err)` and never
`(false, nil)`. `(false, nil)` originates only from the guarded-decision CAS closure over
`applyTransitionIfAtStepRaw`, which AC-EO-9 carves out and which never runs in `EvaluateOnly` mode.
**The sixth is a different kind of return, and is the one to read carefully.** It sits *after* the
commit at `:5458` has already succeeded, inside the `mode == transitionLifecycleOnTurnStart` branch
opened at `:5517`, so it is a `return false` on a **committed** transition — not a non-committing
path, and therefore outside AC-EO-11's framing rather than a fifth-and-sixth pair with the CAS
closure. It is unreachable from this caller by construction, on the same "mode is fixed at the sole
live call site" argument: `applyEngineTransition` (`:5343-5353`) sets
`mode = transitionLifecycleWithOnEnter` whenever `triggerOnEnter` is true, and
`dispatchKanbanAgentErrorTrigger` passes `true` (`event_handlers_agent_error.go:253`), so the
`transitionLifecycleOnTurnStart` branch cannot execute for it.
Why this matters beyond the count: a `return false` after a successful commit is a **second
structural opener** of AC-EO-17's commit-succeeded-but-unmarked window, alongside the panic that AC
names and the mark failure AC-EO-12 permits. Under AC-EO-10's `if and only if` it leaves the marker
absent on a committed transition. A future `EvaluateOnly` caller whose commit helper reaches
`applyEngineTransitionWithCommitMode` in `transitionLifecycleOnTurnStart` mode would hit it on every
profile-switch failure — the same stranding this spec exists to close, re-entered through the caller
obligations the spec hands out. That caller therefore owes a **third** obligation beyond AC-EO-10 and
AC-EO-13: confirm which lifecycle mode its commit helper selects, because the mode is what closes
this today and nothing in the engine does.
## Why the contract changed rather than the call site
Three options were on the table. The originating comment named two.
**(b) Each `EvaluateOnly` caller re-opens the marker when its own commit fails.** Rejected on
mechanism, not on taste. `TransitionStore` has no clearing verb, so this means adding one to an
interface with production and test implementations, and the clear itself can fail. Worse, it leaves
a crash window between the premature mark and the clear that reproduces the original bug exactly —
the failure it is meant to fix is the failure it reintroduces. It also has to be re-applied at every
future call site, which is the property AC-EO-15 exists to prevent.
**(a-full) A caller-driven two-phase commit protocol on `HandleInput`/`HandleResult`.** Rejected as
more machinery than the problem needs. The engine does not need to learn the caller's commit
outcome; it only needs to stop asserting one it never had.
**(a-narrow) — adopted.** The engine declines to mark exactly the work it deferred, and reports that
it did so. Two conditions in the engine, one caller change, no interface growth, no new failure
window. It also generalizes: the reason `on_children_completed` is already correct is that it does
this by hand.
The invariant is not novel, and `spec.md` § Why states it in one sentence because the detail lives
here. `Engine.reevaluateGuardedTransitions` (`internal/workflow/engine/quorum.go:667`) already states
the rule in its own doc comment — the operation id is marked applied "only once this function has
fully determined the outcome (transitioned, abandoned, or nothing satisfied) — never before, so a
crash mid-evaluation does not leave a decision's re-evaluation permanently skipped" — and it commits
through the CAS *inside* the call before marking. `EvaluateOnly` was the one mode where the outcome
is determined outside the call, and the one mode that marked anyway. This spec closes that hole; it
does not invent a new rule.
## Test mapping
TDD: every test below is written failing first. `TestHandleTrigger_EvaluateOnlySkipsPersistence`
(`engine_test.go:298`) passes no `OperationID` and so does not cover any of this — do not mistake it
for existing coverage.
### `apps/backend/internal/workflow/engine`
Extend `fakeStore` to record `MarkOperationApplied` calls (it already has an `applied` map; the
tests need call-order or a call log, not just the final set).
| AC | Test |
|---|---|
| AC-EO-1 | `EvaluateOnly: true` + non-empty `OperationID` + a step whose trigger fires `move_to_next` → store records **no** `MarkOperationApplied`, and `Transitioned` is true |
| AC-EO-2 | same, but the step declares only a non-transition action → `MarkOperationApplied` **is** called |
| AC-EO-2 | same, non-transition action returning a `DataPatch` → `MarkOperationApplied` is called. Assert **only** that a patch does not change marker ownership. Do NOT assert or imply the patch was persisted — on this path it is dropped, which `spec.md` § The contract carves out and § Out of scope tracks. Name the test for marker ownership, not for patch handling, so the deferred fix does not have to reinterpret it |
| AC-EO-3 | `EvaluateOnly: false` + transition → `MarkOperationApplied` called, `ApplyTransition` called, mark after commit |
| AC-EO-4 | `HandleResult.OperationMarkDeferred` is `true` for the AC-EO-1 case, and `false` for each of: idempotent short-circuit, no-actions step, `EvaluateOnly: false`, deferred transition with empty `OperationID`, and a `processActions` error |
| AC-EO-5 | callback returns an error → no mark, zero `HandleResult` |
| AC-EO-6 | step declares no actions for the trigger, `EvaluateOnly: true` → mark called |
| AC-EO-7 | empty `OperationID` → neither `IsOperationApplied` nor `MarkOperationApplied` called |
| AC-EO-8 | AC-EO-2, AC-EO-4-`false` and AC-EO-7 re-run through `HandleTriggerSessionShapedOnly` with the same expectations, **plus** a structural assertion that `sessionShapedActionKinds` and the kinds `isTransitionAction` admits are disjoint sets. There is deliberately **no** AC-EO-1 case here: the filter admits no transition kind and `evaluateActions` applies it before the transition branch, so a deferred transition is unreachable through this entry point and AC-EO-1 holds vacuously (`spec.md` AC-EO-8). The disjointness assertion is the thing that fails if a later change widens the filter. Do **not** make an AC-EO-1 case pass here by adding a transition kind to the session-shaped set — that double-dispatches step entry against AC-OFFICE-STEP-ENTRY-001 |
| AC-EO-9 | existing quorum re-evaluation coverage still marks `decision:<task>:<step>:<decision>`; add an explicit assertion if none pins the ordering today |
### `apps/backend/internal/orchestrator`
New file — `event_handlers_agent_error*_test.go` is already at six test files and the
800-effective-line revive limit applies to test files.
| AC | Test |
|---|---|
| AC-EO-10 | `OperationMarkDeferred` result + `applyEngineTransition` succeeds → operation id present in the store afterwards |
| AC-EO-11 | one case per **reachable** non-committing path: target step missing, credential preflight failure, source-step load failure, commit error. **Four cases, not five** — `applied == false` is unreachable from `applyEngineTransition` by construction (its commit closure never returns `(false, nil)`), so there is no fifth test and its absence is deliberate. Each case asserts the operation id is **absent** afterwards, then re-dispatches the same `AgentEventData` and asserts the engine re-evaluated rather than returning `Idempotent` |
| AC-EO-12 | **No test.** The branch is unreachable: `agentErrorDispatchDeps.store` is the concrete `*workflowStore`, whose `MarkOperationApplied` is `return nil` unconditionally, and no fake substitutes without widening a production field (deferred — `spec.md` § Out of scope). Enforcement is `errcheck` — active via golangci-lint v2's **default** linter set, not via `apps/backend/.golangci.yml`'s `enable:` list (that file sets no `linters.default`); confirm with `golangci-lint linters` rather than by grepping it — plus code review of the warn-and-continue handler. Do not manufacture a seam, and do not drop the handler |
| AC-EO-13 | two concurrent `dispatchKanbanAgentErrorTrigger` calls with the same `(SessionID, AgentExecutionID)` → exactly one commit. Run under `-race`. Assert against the **new** per-operation-id helper, not `lockChildCompletionOperation` — AC-EO-13 requires a separate map, so a test that reaches into `childCompletionLocks` is testing the wrong thing |
| AC-EO-13 | **lock SPAN through the commit, the case "exactly one commit" cannot see.** `TestDispatchKanbanAgentErrorTrigger_ConcurrentSameOperationLockSpansThroughCommit` (`event_handlers_agent_error_evaluate_only_test.go`) blocks the first dispatch **inside** its `UpdateTaskWithWorkflowStepAdmission` commit call (via `agentErrorBlockingCommitRepo`) and asserts the second, concurrent dispatch has not yet re-read `GetTaskSession` or observed the operation as applied while the first is still inside its own commit — pinning that the lock is held from before state load through the commit→mark window, not just up to the engine call. Mutation-verified: releasing the lock right after `HandleTrigger` returns (before `applyEngineTransition`/mark) made the *old* one-commit-only assertion fail just 1-in-20 runs at `-count=20`; this test fails deterministically (5/5) under the same mutation, closing the gap a scheduler-dependent assertion could not |
| AC-EO-14 | after a failed commit (task still on the **source** step), re-delivery re-executes that step's callbacks — assert the callback ran twice. Pins at-least-once as intended, so a later reader does not "fix" it |
| AC-EO-16 | `TestProcessOnChildrenCompleted_StillIdempotentOnRedelivery` (`event_handlers_children_completed_test.go`): a 3-step chain where the target step **also** declares an `OnChildrenCompleted` action, so the marker check is load-bearing — without it, redelivery would re-fire that action and advance a second time. Asserts `IsOperationApplied` is true after the first delivery and that redelivery neither transitions again nor moves the parent past its post-first-delivery step. Mutation-verified: deleting `childCompletionAlreadyApplied`'s short-circuit makes this test fail. The child-completion caller passes `OperationID` with `DeferOperationMark: true` and remains in the AC-EO-15 registered set; the `on_turn_start` / `on_turn_complete` callers pass no `OperationID`, so the pin test flags either if it later pairs the two fields. |
| AC-EO-17 | commit succeeds, marker left absent → re-delivery evaluates the **target** step's actions, not the source step's. Assert the step id the second evaluation loaded, and that a mark lands by the end of it (via AC-EO-10 or AC-EO-2) so a third delivery short-circuits. Drive the missing mark from the test rather than by panicking, and do not assert a specific second transition — the AC permits one, it does not require one |
The AC-EO-11 rows are the ones that would have caught the original defect. Prefer the credential
preflight case as the primary regression test: it is the most ordinary of the four and the least
likely to be dismissed as a synthetic DB fault.
### Regression guard (AC-EO-15)
`spec.md` AC-EO-15 fixes the mechanism, the detection predicate and the remediation; this section only
says how to build it. Model on `agent_error_fire_site_pin_test.go`, which is the same kind of artifact:
a `go/parser` walk rooted at `apps/backend`, skipping `_test.go`, keyed
`"pkgRelDir/ReceiverType.FuncName"`, failing on any key absent from a registered set.
- **It is a closed-set allowlist, not a data-flow check.** Do not try to verify that
`OperationMarkDeferred` is read — that is undecidable and false-fires on a caller that delegates the
check into a helper.
- **Detection predicate:** a `HandleInput` composite literal with both an `EvaluateOnly:` key whose
value is the literal `true` and an `OperationID:` key whose value is not the empty-string literal.
Nothing more; runtime non-emptiness is not inferable and must not be attempted.
- **Seed the set with two entries:** `internal/orchestrator/Service.dispatchKanbanAgentErrorTrigger` and
`internal/orchestrator/Service.evaluateChildrenCompleted` (the explicit `DeferOperationMark` path).
- **Put it in a new file** rather than extending `agent_error_fire_site_pin_test.go`. Either satisfies
every AC-EO-15 observable, so this is a steer, not a requirement: the two guards pin unrelated
predicates, and a shared walk helper couples their lifetimes for no gain.
- **Editing the set IS the remediation**, not a workaround — it is the review gate, exactly as in the
fire-site pin. Point the failure message at AC-EO-10, AC-EO-13 and AC-EO-15 together, so the next
author reads all three caller obligations before adding their entry rather than after: honour the
flag, serialize the window, and check which lifecycle mode their commit helper selects (§ The fifth
return path — in `transitionLifecycleOnTurnStart` mode the helper can return `false` on a committed
transition, which AC-EO-10's `if and only if` would then leave unmarked).
## Commands
```bash
# focused, during TDD
go test ./internal/workflow/engine/... -run 'EvaluateOnly|OperationApplied|MarkOperation' -race -count=1
go test ./internal/orchestrator/... -run 'AgentError' -race -count=1
# before commit — internal/orchestrator carries goleak.VerifyTestMain; the engine package does not
go test ./internal/workflow/engine/... ./internal/orchestrator/... -race -count=1
# changed-file lint, per apps/backend/CLAUDE.md
golangci-lint run ./... --new-from-rev="$(git merge-base HEAD origin/main)" --timeout=5m
```
All from `apps/backend`. `internal/orchestrator` carries `goleak.VerifyTestMain`, so the AC-EO-13
concurrency test must join its goroutines rather than leaving them to the race detector.
Specification lint, from the repo root:
```bash
python3 scripts/lint-spec-files.py --all
```
`docs/specs/<slug>/` classifies as `legacy` for the linter, whose only check on it is the 32768-byte
ceiling plus the size ratchet. **`spec.md` is at 32689 / 32768 bytes — 79 bytes of headroom, which is
effectively full.** A future edit must DISPLACE prose into this file, which has no ceiling, rather than
append to `spec.md`; § Measurement receipts above exists because three rounds of edits already had to.
Re-measure with `wc -c` after every edit and re-run the lint. Do not add a
`docs/specs/spec-lint-exceptions.tsv` entry: exceptions only ratchet downward and a new file has no
business needing one.
## Risks
- **The AC-EO-14 / AC-EO-17 trade is the one a reviewer will push back on.** Deferring the mark means
callbacks can run twice when a commit fails (AC-EO-14), and — in the narrower window where the
commit landed but the mark did not — that the re-delivery evaluates the *target* step and may
transition again (AC-EO-17). The alternative is the current behavior, where neither happens and the
task is stranded. Both are stated as ACs rather than left implicit precisely so the push-back lands
on the decision instead of on the implementation.
- **AC-EO-13's lock must span load → engine → commit → mark, not engine → commit → mark.** This is
the trap of the whole spec. The engine consumes `HandleInput.PreloadedState` verbatim and never
re-reads the store, and the live caller builds that state *before* it even computes the operation
id — so a lock that starts at the engine call lets a blocked racer wake holding pre-commit state
and re-evaluate the **source** step, which AC-EO-17 forbids. Because `ApplyTransition` is a plain
write with no compare-and-set, that second commit re-runs `processOnExit` for a step the task has
already left and re-enters the target: a step-entry double-dispatch. A naive concurrency test that
only counts commits or marks passes anyway, which is why the second AC-EO-13 row above asserts the
step id instead. The lock must also be a *separate* map from `childCompletionLocks`, per AC-EO-13.
- **AC-EO-12 has no test, on purpose.** `sync.Map` makes `MarkOperationApplied` infallible and the
caller's dependency is a concrete `*workflowStore`, so the error branch is neither reachable nor
injectable without a production change this spec defers. `errcheck` keeps the error from being
dropped; the handler ships untested and becomes testable when the marker is persisted (`spec.md`
§ Out of scope). Reviewers should not read the missing test as an omission — but should reject any
attempt to delete the handler because it lacks one.