Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
32 KiB
status, created, updated, owner
| status | created | updated | owner |
|---|---|---|---|
| draft | 2026-09-05 | 2026-09-06 | Kandev |
Workflow Engine Operation Ledger Lifetime
Decisions:
- ADR-0004 task-model-unification — makes the
workflow engine the coordinator for task-scoped agent runs and gives every Phase 2 trigger an
OperationID-keyed idempotency contract.
Related spec (states the contract this card makes true):
docs/specs/workflow-on-agent-error-dispatch/spec.md— AC-C5: exactly-once lasts one process. The service ledger now survives engine rebuilds.
Why
Engine.HandleTrigger's idempotency check is the only thing between a redelivered workflow trigger
and a second execution of its actions — a second queue_run, a second step transition, a second
clear_decisions. The ledger it consults is Service.operationLedger, an in-memory sync.Map.
Service.initWorkflowEngine builds a brand-new workflowStore on every call and assigns it to
s.workflowStore. The new store receives &s.operationLedger, so rebuilding the store does not
discard the ledger.
Thirteen Service methods reach initWorkflowEngine — two directly (SetWorkflowStepGetter
service.go:1813, SetStepHistoryRecorder :1823) and eleven through reinitWorkflowEngine (every
Set* between :1888 and :1971, SetReviewRunner among them).
The previous implementation scoped the guarantee to "since the last Set* call". It now lasts
one backend process lifetime, as the related specification requires.
The reachable window, measured
Verified by reading apps/backend/internal/backendapp/main.go and helpers.go.
| # | Boot site | Effect |
|---|---|---|
| 1 | orchestrator.go:210-211 — SetStepHistoryRecorder, SetWorkflowStepGetter |
first store built, before Start |
| 2 | main.go:1000 — orchestratorSvc.Start(ctx) |
reconcilers run, then watcher.Start / scheduler.Start: live event delivery begins |
| 3 | main.go:1343 — services.Office.RegisterEventSubscribers(eventBus) |
office subscribers go live |
| 4 | main.go:1656-1671 — ten SetEngine* / SetPrimaryAgentResolver calls in wireWorkflowEngineForOffice |
ten wipes |
| 5 | helpers.go:1841 — SetReviewRunner, from registerRoutes → registerMCPAndDebugRoutes (main.go:2286) |
eleventh wipe |
Two exposure windows follow, both open while triggers can fire:
- Orchestrator-sourced triggers — step 2 to step 5, spanning eleven wipes.
- Office-sourced triggers — step 3 to step 5, spanning one wipe. Before step 3 the office
dispatcher is nil (assigned at the end of
wireWorkflowEngineForOffice) and office triggers are dropped, so nothing earlier is at risk on that route.
The startup reconcilers named in the brief — reconcileUnpublishedPromptTurnsOnStartup and
reconcileExecutorSessionsOnStartup (service.go:2696, :2708) — run before watcher.Start and
mark no operations, so they are not a source of exposure. Answering the brief's first question:
the reachable redelivery is not in the reconcilers, it is in the live post-Start window above.
A redelivery that is structurally guaranteed, not theoretical
markTaskCompletedForTerminalStep (event_handlers_children_completed.go:59) persists the task, then
does both of: (1) publishTaskStateChanged (:89), consumed by the orchestrator's own watcher
(service.go:1434 wires OnTaskStateChanged: s.handleTaskStateChanged; watcher/watcher.go:279
subscribes it), which calls processParentChildrenCompletedForTaskState; and (2) that same call
directly (:90).
Both deliveries reach processOnChildrenCompleted for the same parent and read the same persisted
child rows, so childCompletionOperationID (:320) hashes to the identical operation id — the
row's UpdatedAt was written before either delivery. lockChildCompletionOperation serializes them
and childCompletionAlreadyApplied (:179) is expected to reject the second. It does, unless a
Set* call lands between them: then the second re-evaluates, and a transition that already committed
commits again, re-running the target step's on_enter sequence.
What is not affected, contrary to the task brief
The brief states the ledger backs "every trigger (on_enter, on_turn_start, on_turn_complete,
on_children_completed, and now on_agent_error)". Verified by grepping every non-test
OperationID: producer and every HandleTrigger call site:
on_turn_startandon_turn_completepass noOperationID(event_handlers_workflow.go:5620and:5039);isOperationAlreadyAppliedshort-circuits on the empty string (engine.go:313-315), so neither touches the ledger. The brief's suggested regression test — "at least one trigger already in production use (e.g.on_turn_complete)" — would be vacuous. AC-T2 names the triggers that do use it.on_enter's step-entry path has its own durable ledger.processOnEnter(event_handlers_workflow.go:3135) keys onstep_entry:<entryID>:<position>and claims it throughRepository.ClaimStepEntryMarker, a database row;Engine.DispatchStepEntrynever callsIsOperationApplied. Only theswitch_workflowon_exit/on_enter route (phase8_callbacks.go:111,133, viaworkflow_callbacks.go:104) uses the in-memory ledger foron_enter.
The operations that do depend on the in-memory operation ledger, with their key shapes:
| Operation id shape | Producer | Consumer |
|---|---|---|
on_children_completed:<parent>:<sha256 of rows> |
event_handlers_children_completed.go:320 |
orchestrator |
wake:<parent>:<childSetKey> |
office/service/event_subscribers.go:1099 (edge), scheduler_wake_reconciler.go:124 (sweep) |
engine (office) |
agent_error:<runID> |
office/service/event_subscribers.go:676 |
engine |
agentErrorOperationID(<session>,<agentExec>) |
orchestrator/event_handlers_agent_error.go:58 |
engine |
<commentID> via commentkeys.TaskComment |
office/service/event_subscribers.go:1156, office/dashboard/service_tasks.go:1024 |
engine |
blockers_resolved:<taskID> |
office/service/event_subscribers.go:1027 |
engine |
approval_resolved:<approvalID> |
office/service/event_subscribers.go:1179 |
engine |
heartbeat:<task>:<step>:<unix> |
scheduler/cron/heartbeat.go:229 |
engine |
budget:<ws>:<scope>:<id>:<pct>:<periodStart> |
scheduler/cron/budget.go:174 |
engine |
decision:<task>:<step>:<decision> |
workflow/engine/quorum.go:673 |
engine (direct) |
| `:switch_workflow:on_(exit | enter)` | workflow/engine/phase8_callbacks.go:111,133 |
Every key except heartbeat is level-triggered — derived from durable state, not the moment of
delivery — so a redelivery reproduces it exactly. That is what makes the ledger load-bearing rather
than decorative.
What
Design decision
The set of operation ids marked applied is a property of the orchestrator service, not of any
workflowStore instance, and rebuilding the engine does not disturb it.
The ledger is a field of Service whose zero value is usable. It needs no construction step,
so it exists and works for every Service however that Service was built; it is never replaced;
and its address is handed to every workflowStore that initWorkflowEngine builds.
workflowStore.IsOperationApplied / MarkOperationApplied delegate to it, so
engine.TransitionStore is unchanged. For operation-bearing triggers, the engine validates every
non-transition action before execution. A missing callback returns ErrActionNotYetWired and leaves
the id retryable. processOnChildrenCompleted sets HandleInput.DeferOperationMark and marks only
after its outer transition lifecycle commits.
The zero value is the load-bearing choice, not an implementation detail: a constructed ledger only
exists on a Service some constructor initialised, making correctness depend on how each Service
was built, whereas a zero value is correct for every Service that exists at all. It removes the nil
case rather than defining behaviour for it.
Three alternatives were considered and rejected:
- Reorder boot so every
Set*call precedesStart. Rejected on evidence.SetReviewRunneris reached fromregisterMCPAndDebugRoutesinsideregisterRoutes, called frombuildHTTPServer(main.go:1183) deliberately afterStart, because the bootstrap listener must be bound and the recovery sweeps must run before the real router is built (main.go:1174-1182, anddocs/specs/startup-listener-before-recovery/spec.md). Moving it fights an existing contract. More decisively, boot ordering is an invariant nothing enforces: the nextSet*call added anywhere reintroduces the defect silently. - A constructed ledger passed as a nilable dependency, with a ledger-less store failing open.
Rejected after review: it makes the guarantee conditional on
Serviceconstruction. A bare&Service{...}literal that never ran the constructor reachesinitWorkflowEngineviaSetWorkflowStepGetterand hands the store a nil ledger, which type-checks with no diagnostic. The store then never dedups — indistinguishable from a correct one until a duplicate arrives. A fail-open branch turns a wiring mistake into silence, worse than the boot-window bug this card fixes, which at least ends when boot ends. - Copy the old
appliedOpsmap forward insideinitWorkflowEngine. Rejected:sync.Maphas no atomic snapshot, so a copy racing a concurrentStoredrops entries — a partial wipe is no better than a full one, only harder to reproduce. It also leaves a store captured before the reinit (theagentErrorDepssnapshot atservice.go:1875) writing into a map nothing reads afterwards.
Acceptance criteria
L. Ledger lifetime
- AC-L1 WHEN an operation id has been marked applied, THEN
IsOperationAppliedshall report it applied for the remainder of the process, across any number ofinitWorkflowEngineandreinitWorkflowEnginecalls. The service-owned ledger and its store delegates implement this. - AC-L2 A
Serviceshall hold exactly one ledger — the field AC-L3 describes. EveryworkflowStorethatinitWorkflowEngineconstructs shall resolve to that same instance; a store built before a reinit and a store built after it shall observe each other's writes in both directions. AworkflowStoreconstructed directly, outsideinitWorkflowEngine, is outside this criterion: it resolves to whichever ledger its caller passed, which for an isolated unit test is normally a ledger of its own. That is not a second ledger on aService; it is a store that noServiceowns. - AC-L3 The ledger shall be a direct field of
Servicewhose zero value is usable: no constructor call, no initialisation statement and no assignment shall be needed to make it work, so aServiceobtained by ANY means —NewService, or a bare&Service{...}literal naming none of its fields — has a working ledger from the moment the struct exists, beforeStartcan run. The field shall never be reassigned, andServiceshall never be copied by value — the orchestrator uses*Servicethroughout andgo vet'scopylocksfails a build that copies it — which is what lets the field hold async.Mapor mutex directly rather than behind a pointer. Taking its address is not a write, so nothing needs synchronising around the field itself; AC-L6 is the ledger's own guarantee. Lazily creating a ledger insideinitWorkflowEnginedoes not satisfy AC-L2: it returns early whenworkflowStepGetteris nil (service.go:1861), so a lazily-created ledger has the same conditional lifetime as the bug. No lock, atomic, or once-guard shall be added around the ledger reference itself — twoSet*calls racing each other read the same immutable field. (They still race ons.workflowStoreands.workflowEngine; that race is out of scope and AC-L1 does not depend on it.) - AC-L3a A ledger at its zero value shall report every operation id as not applied. It shall not be pre-seeded, warmed, or hydrated from anything.
- AC-L4 A
Serviceon whichinitWorkflowEnginenever ran —workflowStepGetterstill nil — shall still have a usable ledger: an id marked on it shall be reported applied by a later check on the sameService, and neither call shall panic or return an error — this follows from AC-L3's zero value and needs no wiring. No production reader is reachable in that state, and this card changes none of the guards that make it unreachable:processOnChildrenCompletedreturns before bothchildCompletionAlreadyApplied(event_handlers_children_completed.go:180) andmarkChildCompletionApplied(:247) wheneverworkflowEngine,workflowStoreorworkflowStepGetteris nil (:94);switchWorkflowDispatcherreturns when the engine is nil (workflow_callbacks.go:106) and skips its check when the store is nil (:113). AC-L4 is therefore observed directly on the ledger (AC-T3(a)); reachability through a dispatch path is explicitly not claimed, and no guard shall be relaxed to manufacture one. - AC-L5 The ledger shall remain in-memory and process-scoped. A backend restart clears it.
This restates
workflow-on-agent-error-dispatchAC-C5 and is now true rather than aspirational; durability is out of scope. - AC-L6 The ledger shall be safe for concurrent use by multiple goroutines without external
locking, matching the
sync.Mapsemantics its callers assume.
S. Semantics preserved exactly
- AC-S1 IF
operationIDis the empty string, THENIsOperationAppliedshall return(false, nil)andMarkOperationAppliedshall be a no-op returningnil— the behaviour atworkflow_store.go:886-901today. An empty id shall never be stored, and shall never be reported applied on a later call. - AC-S2
MarkOperationAppliedshall be idempotent: marking an already-marked id succeeds and leaves it marked. There shall be no un-mark, delete, clear, or reset operation on the ledger's production surface. Tests may construct a fresh ledger; they shall not clear a live one. - AC-S3 Neither method shall return a non-nil error under any input. Callers' fail-closed
handling of an error (
childCompletionAlreadyAppliedtreats an error as "applied",event_handlers_children_completed.go:181-187) stays in place but remains unreachable. - AC-S3a No
workflowStorereached by production code shall ever hold a nil ledger, and the guarantee shall be structural, not conventional — it shall not depend on how the owningServicewas constructed. The ledger shall reachworkflowStoreas a required positional parameter ofnewWorkflowStore, last before its existingpublishers ...interface{}variadic tail (workflow_store.go:108-114). It shall not be carried in that tail, which is dispatched by a type switch, so an omitted entry stays nil with no diagnostic. Positional placement also means a futureSet*-plus-reinit method cannot obtain a store without naming a ledger, which is what AC-B2 relies on.initWorkflowEngineshall pass the address of theService's own ledger field. That expression cannot be nil for any receiver, so the single production call site (service.go:1864) is non-nil by construction for everyServicehowever it was built — including the bare&Service{...}literals that bypassNewServiceand still reach it throughSetWorkflowStepGetter. With AC-L3's zero value there is therefore noService, and no productionworkflowStore, without a working ledger. A nil ledger is not a supported input and no defensive branch shall be added for it: no nil-check in either method, no fail-open and no fail-closed path. A caller passing nil explicitly panics at first access — intended, and loud. Silently reporting "not applied" for a missing ledger is what this criterion forbids: a store that never dedups is indistinguishable from a correct one until a duplicate arrives in production, whereas a panic shows up on the first run. The production call site and all 35 direct test call sites shall pass a ledger explicitly. That fallout is cheap because the zero value is usable: a test uninterested in sharing passes a zero-valued ledger and gets exactly today's deduping behaviour, which is what AC-S4 relies on. - AC-S4 No operation-id derivation shall change. Every producer in the table above keeps its exact key shape. This card changes where the ledger lives, not what is written into it. The existing operation-id tests shall keep passing with unchanged deduping behaviour, not merely green: every store they construct still has a working ledger (AC-S3a). Making one pass by removing its deduping would violate this criterion.
- AC-S5 The normal engine path shall mark an operation only after
processActionsreturns without error, and shall mark it when no actions are declared. A caller that setsHandleInput.DeferOperationMarkowns the later mark and shall use it only when an outer side effect must commit first. A ledger that marks earlier would swallow the retryworkflow-on-agent-error-dispatchAC-C6 requires. - AC-S6 The ledger shall not be a mutual-exclusion primitive. WHEN two goroutines check the same
unmarked id concurrently, THEN both may observe "not applied" and both may proceed; serialization
remains the caller's job through the mechanisms that already do it —
lockChildCompletionOperation(event_handlers_children_completed.go:196) andacquireTurnCompletionLock(service.go:2476). Adding check-and-set to the ledger is out of scope. - AC-S7 Operation ids shall be compared by exact byte equality. The ledger shall not trim,
case-fold, normalize, hash, truncate, or otherwise transform a key, and shall impose no length
limit or character restriction. Keys are opaque to it:
commentkeys.TaskCommentemits a prefixed comment id thatphase2_callbacks.go:422inspects withHasTaskCommentPrefix, andphase8_callbacks.goderives<parentOpID>:switch_workflow:on_enterby concatenation, so any normalization would silently split or merge keys those sites construct.
R. Reader parity
- AC-R1 The engine's
TransitionStore.IsOperationApplied/MarkOperationAppliedpath shall read and write the shared ledger.Engine.reevaluateGuardedTransitions, which callse.store.IsOperationApplieddirectly (quorum.go:673), is covered and needs no separate change. - AC-R2 The two orchestrator-level readers that bypass the engine shall observe the same ledger,
and their result shall not depend on which
workflowStoreinstances.workflowStorepoints at:childCompletionAlreadyApplied/markChildCompletionApplied(event_handlers_children_completed.go:179,:246);switchWorkflowDispatcher's pre-dispatch check (workflow_callbacks.go:114).
- AC-R3 A dispatch holding a store captured before a reinit shall write to the same ledger a
dispatch started after that reinit reads. This covers the
agentErrorDepsatomic snapshot (service.go:1875), which pins one engine/registry/store triple for a dispatch's duration and today pins a ledger a concurrent reinit orphans. - AC-R4 WHEN an operation is marked through one reader class and checked through another — say
marked by the engine under
on_agent_errorand checked byswitchWorkflowDispatcher— the check shall see it. There is one ledger, not one per reader.
B. Observable boot behaviour
- AC-B1 WHEN a trigger operation is marked applied at any point after
watcher.Startand the identical operation id is redelivered later in the same process, THEN the redelivery shall be deduped, regardless of how manySet*calls executed in between. The structurally guaranteed redelivery described above is the concrete case this must cover. - AC-B2 The contract shall hold without depending on boot ordering. Adding a new
Set*-plus-reinitWorkflowEnginemethod, or moving an existing call site, shall not be able to reintroduce the defect. A fix expressed only as a reordering ofmain.godoes not satisfy this criterion. - AC-B3
initWorkflowEnginemay continue to rebuild everything else — the store's publishers, the callback registry, the engine, theagentErrorDepssnapshot, andstepCache(workflow_store.go:150), a TTL cache with an explicit invalidation path whose loss costs one reload per key and is not a correctness change. Only the ledger is exempt from the rebuild.
T. Regression coverage
-
AC-T1 A test shall prove AC-L1 directly: mark an operation id, call a
Set*method that triggersreinitWorkflowEngine, and assert the id still reads applied through the new store. Asserting only that the old store still remembers it does not satisfy this — the old store is exactly what production stops reading. -
AC-T2 A test shall prove end-to-end dedup across a reinit for both reader classes, using a trigger already in production use:
- the engine class —
on_agent_errorthroughHandleTriggerwith a fixedagentErrorOperationID, asserting the second delivery returnsIdempotent: trueand runs no action; - the orchestrator class —
on_children_completedthroughprocessOnChildrenCompletedwith an unchanged child-row set, asserting the second delivery applies no second transition.
on_turn_completeshall not be used: it passes noOperationIDand would assert nothing. - the engine class —
-
AC-T3 A test shall prove the two wiring cases the mechanism turns on. Both are positive assertions about a working ledger, not "does not panic" checks, and both are in-package tests reading the
Service's unexported ledger field directly — no exported accessor shall be added:- (a) AC-L4 — a
ServicewhoseworkflowStepGetterwas never set, soinitWorkflowEnginenever ran andworkflowStoreis nil: mark an id on its ledger field, assert a later check reports it applied. It shall not route throughprocessOnChildrenCompletedorswitchWorkflowDispatcher, whose guards make those paths unreachable here (AC-L4), and shall not require any guard to be relaxed. - (b) AC-S3a — a
Servicebuilt as a bare&Service{...}literal, bypassingNewService, then wired withSetWorkflowStepGetter: assert theworkflowStoreit produces resolves to thatService's ledger in both directions. This is the regression guard against aService-construction-dependent mechanism;createEngineService(workflow_e2e_test.go) has exactly that shape.
- (a) AC-L4 — a
-
AC-T4 A test shall pin AC-L3 structurally — that
initWorkflowEngineneither declares nor assigns a ledger of its own, and reaches the ledger only as the address of theService's existing field — so a later refactor that gives the rebuild path a ledger of its own fails loudly rather than silently restoring "since the lastSet*call".
Concurrency and ordering
- Two callers, same unmarked id. Both may proceed (AC-S6). Unchanged, and deliberately so: the ledger is a memo, the existing per-operation locks are the mutex. Collapsing them would change serialization semantics this card does not touch.
- Reinit concurrent with an in-flight dispatch. The dispatch's check and its later mark land in
the same ledger (AC-R3), so a reinit cannot split one dispatch across two ledgers. The dispatch may
still use a pre-reinit engine and registry — pre-existing behaviour, and the
agentErrorDepssnapshot's stated design. - Mark-to-commit ordering is unchanged (AC-S5): the normal engine path marks after actions
succeed.
processOnChildrenCompleteddefers its mark untilapplyEngineTransitionWithModecommits, so a failed outer transition remains retryable. - No ordering is defined between two distinct operation ids. The ledger is an unordered set: no iteration order, no observable insertion sequence, nothing may be derived from one.
Failure modes
- Restart. The ledger is empty; every level-triggered operation is redelivered once and
re-executes. That is today's behaviour and AC-L5's stated limit, and is why the paths that cannot
tolerate it carry durable receipts of their own:
ClaimStepEntryMarkerfor step entry,UpsertWakeReceiptTxfor the parent-wake reconciler (office/service/scheduler_wake_reconciler.go:211). - Unbounded growth. Never pruned; one entry per distinct operation id for the process lifetime. This card does not change the growth rate — after boot there are no further reinits today, so entries already accumulate for the whole process. The wipes it removes all occur during boot, when the ledger holds at most a handful of entries. Bounding it is out of scope.
- A ledger read that could not answer. Not reachable (AC-S3). The fail-closed branches treating an error as "already applied" stay as written, so if a future ledger can error the conservative outcome is a skipped duplicate, not a double execution.
Out of scope
Each entry is a named exclusion and part of the contract.
- Durability across restart. Surviving a restart means a table, a retention policy and a
migration.
workflow-on-agent-error-dispatchAC-C5 already declares the process-lifetime bound andworkflow-on-enter-action-dispatch's step-entry record owns the durable-marker question. This card makes the declared bound true; it does not widen it. - Eviction, TTL, or any bound on ledger size. Requires deciding what a safe eviction is for a
level-triggered key like
blockers_resolved:<taskID>, which has no natural expiry — evicting it re-arms a duplicate wake. A separate design with its own risk. - Defensive nil-ledger handling. AC-S3a makes a nil ledger unreachable from production by
construction rather than tolerated, so no nil check, fail-open branch, fail-closed branch, or
substitute-a-private-ledger fallback shall be added to either ledger method or to
newWorkflowStore. Explicit nil panics on first access; that is the specified behaviour. A fallback would restore the silent divergence this card was re-scoped to remove: a store deduping against nothing, or against a private ledger no other store can see, reporting success either way. - Synchronizing
s.workflowStoreands.workflowEnginethemselves. Both are plain fields, written byinitWorkflowEngine(service.go:1867,:1874) while the watcher is live and read from bus goroutines. That is a pre-existing data race, was already excluded byworkflow-on-agent-error-dispatchAC-E13, and is excluded here too. The fix for this card must not depend on it being fixed: a shared ledger reached through whichever store instance a racing reader happens to observe is still the same ledger, so AC-L1 holds either way. A separate card should still close the race. engineOptionsaccumulation.s.engineOptionsis appended to on eachSet*call and never reset (service.go:1887and siblings), so it grows across reinits. Bounded by the number ofSet*methods and harmless (later options win), but the same wiring and worth a look someday. Not here.- Giving
on_turn_start/on_turn_completeanOperationID. They deliberately have none; the turn-completion path has its own duplicate-suppression (acquireTurnCompletionLock,acquireTurnCompletionCriticalSection), and an engine-level key would add a second, differently-scoped dedup over the same event. That is a contract change to those triggers, not a ledger change. - Boot reordering. Rejected as the fix (Design decision), and excluded as an
addition: this card shall not move
orchestratorSvc.Start,wireWorkflowEngineForOffice,RegisterEventSubscribersorregisterRoutesrelative to one another. - Anything specific to
on_agent_error's own dispatch.agentErrorOperationID, theagentErrorDepssnapshot's contents and the fire-site pin test are unchanged; AC-R3 constrains only which ledger that snapshot's store resolves to. - Reducing the number of
reinitWorkflowEnginecalls. ThirteenSet*methods rebuilding the engine is its own smell, but collapsing them is a wiring refactor acrossmain.go,helpers.goandorchestrator.go, and it is not needed for AC-B2, which holds by construction once the ledger is exempt from the rebuild.
Prior art
Two legs were attempted. Both were unavailable; neither result means "there is no prior thinking here."
- Wiki (
wiki-query @henry). Receipt: config~/.obsidian-wiki/config.henry,OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki, collectionswiki/sources. The vault could not be read —ls,headand a sandbox-disabled retry all returnedOperation not permitted(macOS TCC on~/Documents, not a missing vault;ls -don the parent resolves). Neitherobsidian-wikinorqmdis onPATHhere and nomcp__qmd__*tool is exposed, so the QMD and graph paths were also unavailable. Zero pages consulted. saas-kb(search_fsm_docs,category: "ai_sdlc"). Tool not available — the only MCP servers exposed arekandevandcodex. No query run, no vendor comparison made.
In-repo prior art, available and the substantive input here: this codebase has already solved "an
idempotency marker must outlive the thing that consumes it" twice, both times by giving the marker a
lifetime independent of the engine — ClaimStepEntryMarker / CompleteStepEntryMarker for
step-entry actions (event_handlers_workflow.go:3136), and UpsertWakeReceiptTx for the parent-wake
reconciler (office/service/scheduler_wake_reconciler.go:211). This card is the same move at a
smaller radius: the ledger keeps in-memory storage (AC-L5) and gains only an independent
lifetime.
Verification
No user-visible surface changes: no route, no WS payload, no DTO field, no copy. No E2E coverage is
required or added. Every criterion is observable from Go unit tests in
apps/backend/internal/orchestrator.
cd apps/backend
go test -race ./internal/orchestrator/... ./internal/workflow/...
golangci-lint run ./... --new-from-rev="$(git merge-base HEAD origin/main)" --timeout=5m
Criteria and their observation points:
| Criterion | Observed by |
|---|---|
| AC-L1, AC-L2 | mark → Set* → read through the new s.workflowStore (AC-T1) |
| AC-L3, AC-B2 | initWorkflowEngine neither declares nor assigns a ledger, reaching it only as the address of the Service field (AC-T4) |
| AC-L4 | nil workflowStepGetter service: mark then check on its ledger field reports applied, no production guard changed (AC-T3(a)) |
| AC-L6 | -race on a concurrent mark/check test |
| AC-L3a | a zero-valued ledger reports nothing applied |
| AC-L5 | a new Service's ledger reports nothing applied (AC-L3a's mechanism; a restart is a new process and a new Service), and no persistence is added: no table, no migration, no repo write |
| AC-S1, AC-S2, AC-S3, AC-S7 | direct ledger unit tests |
| AC-S3a | bare &Service{} literal wired via SetWorkflowStepGetter yields a store resolving to that Service's ledger, both directions (AC-T3(b)) |
| AC-S4 | unchanged producers; existing operation-id tests keep passing with unchanged deduping behaviour, not merely green |
| AC-S5 | engine idempotency and deferred-mark tests (engine_test.go) |
| AC-S6 | the same -race mark/check test as AC-L6, asserting both goroutines may observe "not applied" and proceed; the ledger exposes no check-and-set, compare-and-swap or LoadOrStore-style method usable as a mutex |
| AC-R1, AC-R4 | mark via engine, check via switchWorkflowDispatcher's path |
| AC-R2 | on_children_completed dedup across a reinit (AC-T2) |
| AC-R3 | on_agent_error dedup across a reinit (AC-T2) |
| AC-B1 | AC-T2, both classes |
| AC-B3 | stepCache still rebuilt; no assertion that it survives |