SRD-082 — Checkpoint fidelity for composite constructs and durable Call Activity children¶
| Field | Value |
|---|---|
| Status | Accepted (2026-08-08) |
| Date | 2026-08-06 |
| Owner | Ruslan Gabitov |
| Implements | ADR-033 v.4 §2.1 (items 5–7), §2.2 (re-enter applies to steps, not recorded composites), §2.10 (composite fidelity; durable, symmetrically linked children), ADR-023 v.3 §2.7 (the restart contract) |
| Upstream | ADR-025 v.2 §2.12 (the iteration decorator whose position this persists), ADR-026 v.1 §2.1/§2.4 (the ledger and the sequential sweep) |
| Related | SRD-070 (the capture/restore machinery this grows; its FR-4 deferral posture retires here), SRD-071 v.2.5 (the wake/continuation paths), SRD-079 (schema 3, which this bumps to 4) |
| Tracking | #277 |
An instance can today be checkpointed in a state it cannot be faithfully restored into — or not checkpointed at all while a construct is in flight. This SRD retires both failure modes: every composite construct records its position in the checkpoint document (schema 4), restore rebuilds it at that position, and Call Activity children become durable instances re-linked to their parent on recovery. The issue names three constructs; the evidence below shows the silent half of the problem is in three unguarded siblings, which land here too ("no pre-existing errors").
§1 Background (verified)¶
- The deferral guards — one switch in
captureDocument(internal/instance/checkpoint_capture.go:138-145): non-emptyls.calls,ls.miGroupsorls.sweeps→ no write, aPhaseCheckpointDeferredfact at Warn (checkpoint_capture.go:339-349). The mirror guard blocks dehydration for the same three maps (loop.go:1131-1134). This was SRD-070 FR-4's explicit stopgap ("Full-fidelity capture of those constructs is SRD-071+ territory"). - The guard set is narrower than the gap. Sequential MI and
Standard Loop keep their position in
miState{collection, staging, inputItem, outputRef, outputItem, numberOfInstances, completed}— a track field owned by the runner goroutine (mi.go:62-71), which no guard inspects;evScopeOpenis a persist point (checkpoint_capture.go:42), so a sequential MI checkpoints mid-iteration today and restores with the iteration restarted at pass 0 while the in-flight pass's inner tracks also respawn. OnlyTrackRecord.LoopCountersurvives (checkpoint_capture.go:287). - Composite scope entries are never restored.
Restorerebuilds scopes' data, keys, ledgers, tracks, incidents — and nothing else (internal/instance/restore.go:75-131);ls.scopesentries (scopeEntry{host, node, parent, active, ordinal, aborting},scope_runtime.go:74-95) are absent after restore, sodecScope/decScopePinnedearly-return on the missing key (scope_runtime.go:355-359,:337-341), a drained child scope never resumes its host, and the host track (stateTrackExecutingStep, inliveTrackStates) re-enters the composite from the top — double-executing the body. - The sweep consumes the ledger as it runs.
applyCompensatemoves ledger entries out ofls.ledgersinto the sweep (compensation_watch.go:107-137) — capture cannot interleave inside one loop-event application (the consistent-cut premise), but from the first handler spawn onward the compensable state lives incompSweep{thrower, txHost, path, queue, wait}+ the running handler'ssweepRun{sweep, entry}(compensation_watch.go:47-54,:260-263) — none of it in the document, so a mid-sweep capture records a ledger that no longer holds the swept entries. - A Call Activity child is not persisted at all.
WithCheckpointingis applied at exactly three sites —instanceOptions(pkg/thresher/thresher.go:1320), recovery (recovery.go:97), wake (wake.go:136) — andThresher.InvokeProcessuses none of them (pkg/thresher/invoker.go:65-68), despiteinstanceOptions' doc comment claiming it covers "a Call Activity child" (thresher.go:1301-1311). After a crash the child is gone and nothing in the store names it. The parent side isloopState.calls map[string]*callEntry{track, node, child}keyed by child instance id (loop.go:72-77,calls.go:33-39), completion via a watcher onchild.Done()(calls.go:113-126); on the restore sideDocument.ParentID/CallNodeIDare informational only (checkpoint/document.go:54-57) —Restorere-applieswithCallLinkagefor facts (restore.go:99-101) and nothing rebuildsls.calls. - The document is at
CurrentSchema = 3(internal/instance/checkpoint/document.go:26; 1→2 boundaries SRD-071 FR-9a, 2→3 incidents SRD-079 FR-5); restore reads older schemas and refuses future ones loud (document.go:219-225). - The re-link substrate exists:
Thresher.settledFor(id)mints the per-instance settled channel on demand (locked.go:250-258) — order-independent, so a restored parent can await a child that has not itself been recovered yet; recovery lists both records in the same group scan (recovery.go:24).InvokeProcess's locally minted settled channel IS registered under the child's id (trackInstanceLocked→handleForLocked,locked.go:241), so the registry channel converges across launch and recovery — the re-link's substrate; what is missing is everything else: the checkpointing options, the call records, the re-link itself.
§2 Requirements¶
§2.1 Functional¶
- FR-1 — schema 4: the position records.
checkpoint.Documentgrows, additively (schema 3 documents still restore): Calls []CallRecord{ChildID, NodeID, TrackID string}— one per in-flight Call Activity (ADR-033 v.4 §2.1 item 7).TrackRecordgainsMI *MIRecord{N, Completed int, Staging json.RawMessage, ConditionMet bool}— the sequential MI / Standard Loop position attached to its host track (item 5;Stagingis the collected-outputs array in the canonical value encoding; names likeinputItemare derived from the node, never stored).MIGroups []MIGroupRecord{HostTrack string, N, Pending int, Staging json.RawMessage, Open []OpenScope{Path string, Ordinal int}}— the parallel group's open set (item 5).Sweeps []SweepRecord{ThrowerTrack, TxHostTrack string, ScopePath string, Wait bool, Queue []LedgerRecord, Running *LedgerRecord}— the resolving compensation's remaining queue and the entry being undone (item 6).LedgerRecordgrowsHandlerEventSub(behavioral: the restored handler's seed mode — child scope vs frame) and the display names.- The value codec gains an explicit nil kind: a parallel staging is pre-sized to N, so unfilled and canceled slots are nil — in the record and in the published output alike.
CurrentSchema3 → 4;Marshalstamps 4; the future-schema refusal is untouched.- FR-2 — the loop mirrors off-loop iteration position. The
decorator protocol already round-trips every pass through the loop
(
scopeRequest{op: scopeOpen|scopeFanOut|scopeReArm|scopeComplete},scope_decorator.go:47-55); the loop records what the protocol shows it — per-host iteration position in loop-owned state — so capture reads a consistent cut without touching the runner's goroutine-ownedmiState(ADR-025 §2.12's "the decorator must not mutate loop state" holds; the loop observing the protocol is not the decorator mutating). The runner remains authoritative at runtime; the mirror exists for capture only. - FR-3 — sequential MI and Standard Loop restore at position. A
restored host track carrying
MIre-enters its composite with the decorator seeded: completed passes are not re-run,stagingreturns the collected outputs, and a firedcompletionConditionis honored — recorded when the runner's protocol note reached the loop before the capture, otherwise re-evaluated forward over the restored data (deterministic, never backwards), and the in-flight pass restarts from its restored scope data (re-enter applies to the pass, not the construct — ADR-033 v.4 §2.2). The §2.9 counters (numberOfInstances,loopCounter, …) re-publish from the record. - FR-4 — parallel MI restores its open set. A restored
MIGroupRecordrebuildsls.miGroups[host]: the still-open per-instance scopes re-open at their recorded ordinals over their restored scope data, their inner tracks respawn (they are in the track table), the runner re-arms awaiting the remaining drains, and completed instances stay completed (their outputs are inStaging).numberOfTerminatedInstancesremains a cancel-time computation (never stored — unchanged). - FR-5 — composite scope entries rebuild (the double-execution
fix). Restore derives
ls.scopesfrom what the document already carries — for every open non-rootScopeRecordpath, the host track (theTrackExecutingSteptrack whose node hosts that scope), the parent path, and the live-inner-track count from the restored track table — so a drained scope resumes its host exactly once and the host never re-enters a composite whose body is mid-flight. Pure derivation: no new document field (ADR-033 §2.1's minimality). - FR-6 — the sweep is captured and restored; the window closes.
Capture writes
SweepRecords fromls.sweeps+ the activesweepRun; restore rebuilds the sweep — the thrower re-parks, the remaining queue (including aRunningentry, which re-runs: a handler is at-least-once per ADR-033 §2.3) resumes in order, aTxHostTracksweep re-drivesfinalizeTransactionon drain. The running handler's own track is NOT recorded in the track table — the sweep record is its whole state, and restoring both would run the handler twice. At every capture instant a compensable entry is either in the recorded ledger or in a recorded sweep — the invariant the records themselves establish (capture is loop-atomic, so no register-before-consume reordering is needed). - FR-7 — Call Activity children are durable and re-linked.
Thresher.InvokeProcessapplies the same checkpointing options as every other launch site (fixing theinstanceOptionscomment's claim); the child's record carries its own lease/group plusParentID/CallNodeID(already in the document); the parent's capture writesCallRecords fromls.calls, andevCallWaitingbecomes a persist point — the parent's document must carry the call the moment the child exists, or a crash in that window restores a parent that re-invokes. On recovery both records list in the same group scan; the restored parent rebuildsls.callsand re-establishes the completion watch through the engine (settledFor(childID)is mint-on-demand, so parent/child recovery order is irrelevant); the recovered child runs under the normal claim discipline and its terminal outcome re-enters the restored parent. Loud failure modes (ADR-033 v.4 §2.10): a restored parent whoseCallRecord.ChildIDhas no repository record → that parent's restore fails (the per-instance recovery failure); a recovered child whoseParentIDrecord is absent → fails loud, never runs orphaned. The cancel cascade (cleanupCall,calls.go:249-257) works on the re-linked pair unchanged. Discovery separates roots from called children: todayThresher.Instances(filter)(pkg/thresher/discovery.go:30, SRD-019) lists both indistinguishably. The registry records the parent linkage;InstancesgainsInstancesRoots/InstancesChildrenfilters (existing filters keep their meaning), and theInstanceHandleexposesParentID()/CallNodeID()— a host listing "processes" shows roots only, with children reachable through their parent. (Multi-Instance iterations are scopes inside ONE instance and never appear in this registry — the separation concerns Call Activity children only.) - FR-8 — the capture guards retire per milestone; the dehydration
guard stays, re-justified.
captureDocument's three-construct switch retires one case per milestone, exactly when the construct's position becomes part of the document — the guard exists precisely while the construct is uncapturable (parallel MI in M2, the sweep in M3, the call in M4). After M4 capture always writes (deferral remains only for the encode/save error paths, still loud). The dehydration gate (loop.go:1131-1134) keeps refusing while these constructs are in flight, for the true reason now recorded in its comment: an in-flight construct is active work (a running child, a running handler, an iterating body), not a passive wait — releasing the goroutines would strand it. Dehydrating a parent parked solely on a durable call is future work, out of scope here. - FR-9 — schema-3 compatibility. A schema-3 document (no position records) restores exactly as today — such a document was only ever written with no construct in flight (the old guards guaranteed it), so absent records mean "nothing to rebuild", not data loss. A fixture-based test proves it.
§2.2 Non-functional¶
- NFR-1 — no new dependencies; all new state serializes through the existing canonical value/JSON encodings.
- NFR-2 — every restore-side failure is loud and classified (the errs idiom); no silent construct drop remains anywhere in capture/restore.
- NFR-3 —
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%.
§3 Models¶
// internal/instance/checkpoint — schema 4 (additive over 3)
const CurrentSchema = 4
type CallRecord struct {
ChildID string // the awaited child instance
NodeID string // the Call Activity node
TrackID string // the parked caller track
}
type MIRecord struct { // sequential MI / Standard Loop, on its host track
Staging json.RawMessage // collected outputs (canonical array)
N int // frozen numberOfInstances (0 for Standard Loop)
Completed int // passes fully completed
ConditionMet bool // completionCondition already fired
}
type OpenScope struct {
Path string // the per-instance scope path (its data is in Scopes)
Ordinal int // the 0-based instance ordinal
}
type MIGroupRecord struct { // parallel MI
HostTrack string
Staging json.RawMessage
Open []OpenScope
N int
Pending int
}
type SweepRecord struct { // a resolving compensation throw
ThrowerTrack string
TxHostTrack string // "" unless a Transaction abort drives it
ScopePath string
Queue []LedgerRecord // remaining, in run order
Running *LedgerRecord // the entry being undone (re-runs on restore)
Wait bool
}
type Document struct {
// … unchanged fields …
Calls []CallRecord
MIGroups []MIGroupRecord
Sweeps []SweepRecord
}
type TrackRecord struct {
// … unchanged fields …
MI *MIRecord
}
Worked trace (T-8's shape): a process with a Call Activity inside a
sequential 3-instance MI parks mid-pass-2 while the pass's body waits
on a called child; the checkpoint (schema 4) carries the MI position
(Completed: 1, staging with pass-1's output), the call record, the
child's own record exists beside it. The engine dies. Recovery
restores the child (it resumes its own timer wait) and the parent: the
MI decorator seeds at pass 2, the pass's track re-parks on the call,
ls.calls re-links via the minted settled channel. The child
completes → outputs bind → pass 2 completes → pass 3 runs → the MI
assembles all three outputs → the instance completes. Nothing
re-executed pass 1; no second child was launched.
§4 Analysis & decisions¶
- §4.1 Faithful capture for all five constructs; refusal only for the unserializable. Decided at ADR level (ADR-033 v.4 §2.10). The per-construct outcome the issue asked to record: restore faithfully for composite scopes, sequential/parallel MI, Standard Loop, the compensation sweep, and the Call Activity (via child durability). "Fail loudly at capture" was weighed per construct and rejected as the steady state for all five: the deferral does not compose (a construct-dense process may never find a capturable instant, silently widening the loss window), and two of the five were never guarded at all — the "refuse" posture demonstrably leaks.
- §4.2 The loop mirrors the decorator's position — the runner stays
authoritative. The alternative (capture reads
miStatedirectly) races the runner goroutine; the alternative (move iteration state wholly into the loop) reverts ADR-025 §2.12's off-loop decision. The mirror is the minimal consistent-cut-preserving shape: the loop records only what the existing protocol already shows it. - §4.3 A
Runningsweep entry re-runs. A compensation handler is an effect; ADR-033 §2.3's at-least-once posture covers it (handlers read an immutable snapshot, ADR-026 §2.5, so a re-run is well-defined). Recording sub-handler progress would be mid-step state, which §2.2 forbids. - §4.4 Child re-link through the settled registry, not a new seam.
settledForalready mints per-instance channels on demand andwatchCallalready consumes aDone()signal; recovery re-links by reconnecting those existing pieces. The rejected alternative — a dedicatedReattachProcessAPI on the invoker seam — adds public surface for what is engine-internal recovery wiring. - §4.5 Scope entries are derived, not stored. Host track, parent
path and live-inner count are all recoverable from the document's
existing track table + scope paths; storing
scopeEntrywould duplicate derivable state against ADR-033 §2.1's minimality rule. MI ordinals are NOT derivable (the open set is runner state) — they rideMIGroupRecord. - §4.6 No child-side "orphan adoption". A recovered child whose parent record is gone fails loud (ADR-033 v.4 §2.10). The alternative — let it run to completion and discard the outputs — hides a broken process half-silently; the parent's absence means the caller's state is already lost, and loud beats plausible.
§5 API deltas¶
| Surface | Change | Compat |
|---|---|---|
checkpoint.Document |
+ Calls, MIGroups, Sweeps; TrackRecord.MI; schema 3→4 |
additive; older documents restore |
internal/instance capture/restore |
guards retired; position capture; seeded restores | internal |
pkg/thresher invoker |
children get checkpointing options | behavioral (children now persist) |
instance.Restore / recovery |
call re-link, loud missing-counterpart failures | internal |
| Public API | — none — |
§6 Test scenarios¶
| # | Test | Verifies |
|---|---|---|
| T-1 | schema-4 round-trip (internal/instance/checkpoint) |
FR-1: new records marshal/unmarshal; schema stamps 4; future refusal intact |
| T-2 | schema-3 fixture restore (internal/instance) |
FR-9: a pre-fidelity document restores as today; absent records rebuild nothing |
| T-3 | composite double-execution regression (internal/instance) |
FR-5: restore mid-composite → drained child scope resumes host exactly once; body not re-run |
| T-4 | sequential MI / Standard Loop position (internal/instance) |
FR-2/FR-3: capture mid-pass-k; restore resumes at pass k with staging intact; fired condition honored |
| T-5 | parallel MI open set (internal/instance) |
FR-2/FR-4: capture with j of n open; restore re-opens exactly the open ordinals; completed outputs preserved |
| T-6 | sweep capture/restore + window (internal/instance) |
FR-6: mid-sweep restore resumes the queue in order (Running re-runs); no capture instant loses consumed entries |
| T-7 | durable child + re-link (pkg/thresher) |
FR-7: kill mid-call → both records exist; recovery re-links; child completes → parent resumes; no duplicate child; InstancesRoots/InstancesChildren separate the registry and the handle exposes the linkage |
| T-8 | missing-counterpart refusals (pkg/thresher) |
FR-7: child record deleted → parent restore fails loud; parent record deleted → child fails loud; engine starts regardless |
| T-9 | guard retirement (internal/instance) |
FR-8: capture succeeds with each construct in flight; dehydration still refuses with the new reason |
| T-10 | e2e kill-and-resume, MI+call composite (pkg/thresher) |
the §3 worked trace end-to-end |
§7 Milestones¶
- M1 — schema 4 + the silent-sibling fixes. FR-1, FR-5, FR-2/FR-3
(sequential); T-1/T-2/T-3/T-4.
feat(instance): schema-4 position records; composite scopes and sequential iteration restore at position (SRD-082 M1). - M2 — parallel MI. FR-4 (incl. its guard's retirement and the
codec's explicit nil kind — a pre-sized staging carries holes); T-5.
feat(instance): parallel multi-instance groups capture and restore their open set (SRD-082 M2). - M3 — the compensation sweep. FR-6; T-6.
feat(instance): a resolving compensation sweep survives the checkpoint (SRD-082 M3). - M4 — durable children + the last guard's retirement. FR-7, FR-8;
T-7/T-8/T-9.
feat(thresher): Call Activity children are durable and re-linked; the capture deferral retires (SRD-082 M4). - M5 — the proof + docs. T-10; guides
(
operating/persistence.md"Current limits" rewritten,extending/repository.mduntouched), README, CHANGELOG.feat(instance): the composite kill-and-resume e2e; docs (SRD-082 M5). - M6 — the independent-review round. The
/pr-reviewpass (three lenses over the branch diff) returned nine notes: five agreed and landed here — deterministic re-attach completion order (sort pending drains by frozen ordinal), handle linkage cached atadopt, refusal tests assert the expected cause (exposing the two refusal shapes: adoption failures land terminal, decorator-seed refusals raise a durable incident), post-require.Neversemantic anchors, deferral reason assertions. Two agreed-but-out-of-scope → issues #305 (shared catch-node payload routing in parallel MI) and #306 (compositional discovery query API); one rejected (goroutine-leak claim — the instance context cancels every track on fail).fix: land the agreed independent-review findings (SRD-082 M6).
§8 Cross-doc¶
- Implements ADR-033 v.4 §2.1/§2.2/§2.10 (bumped in this branch) and ADR-023 v.3 §2.7's restart contract (Draft, amended in this branch).
- Upstream: ADR-025 v.2 §2.12, ADR-026 v.1 §2.1/§2.4/§2.5.
- Related: SRD-070 (whose FR-4 deferral posture this retires), SRD-071 v.2.5, SRD-079 (schema 3 → 4).
- #277: closes all three checkboxes, with the recorded outcome "restore faithfully" for each (§4.1).
§9 Definition of Done¶
- [x] FR-1…FR-9 implemented; every §6 test exists and passes.
- [x]
make cigreen; diff-coverage ≥95% (aim 100%); touched functions ≥80%. - [x] SRD-070's retired FR-4 posture is cross-noted HERE (§1; SRD-070 itself is a frozen one-shot and is not retro-edited); the persistence guide's "Current limits" rewritten.
- [x] §10 filled.
§10 Implementation summary¶
Landed on feat/checkpoint-fidelity in seven milestones — M1
6507e0b, M2 717e756, M3 a1ef725, M4 3b68b66, M5 36ea396
(rebased onto master after #304; the re-attach seam adapted to the
engine-group context accessor in d6f6132), M6 fc0266d (the
independent-review round, §7), plus a coverage-gate milestone adding
the re-attach seam's shape tests (resident child, not-running refusal,
repository load failure, the lazy handle's resident delegation, the
settled child's root-scope filter and decode refusal).
Verification: make ci green end to end — mock/link/example checks,
tidy, lint (0 issues incl. tests), build, consumer smoke, the full
-race suite, govulncheck — with diff-coverage 96.8% of 787 changed
coverable lines (min 95%).
Deviations from the plan: none in scope; two review findings were agreed but out of scope and filed as issues — #305 (per-iteration payload routing on shared catch nodes) and #306 (compositional discovery queries). The known limits documented in the persistence guide are filed as #307 (Ad-Hoc routing state) and #308 (cross-engine call re-link).
Open questions¶
None — §4 records the resolved design points (per-construct outcome, the position mirror, at-least-once handlers, the re-link seam, derived scope entries, orphan refusal).