<objective> Move the zero-orphan sweep to its own module, add the session-record list primitive, and build the startup reaper with crash-safety ordering.
Purpose: Deliver D-84, D-85, and D-86, the full RT-08 unit surface, in one wave-1 plan with no cross-plan file conflicts. Output: Sweep module, reaper module, store list method, and three test files. </objective>
<execution_context> @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md </execution_context>
<context> @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-CONTEXT.md @.planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-PATTERNS.md </context>
<tasks>
<task type="tdd"> <name>Task 1: Sweep move with removed count (D-84)</name> <files>src/runtime/sweep.ts, src/runtime/session-execution.ts, test/runtime/down-sweep.test.ts</files> <read_first> - src/runtime/session-execution.ts lines 189-205 (removeAllRealmContainers and the SweepClient type to move with no behavior change) - src/runtime/session-execution.ts line 34 (MANAGED_LABEL_VALUE, which moves into src/runtime/openshell.ts beside MANAGED_LABEL_KEY per the pattern map) - src/runtime/openshell.ts line 28 (MANAGED_LABEL_KEY, the constant's new neighbor) - test/runtime/down-sweep.test.ts lines 13-36 (startFixture, requestsOn, assertCode helpers) and line 8 (the import to migrate) </read_first> <behavior> - Test (RT-08): three labelled containers plus one foreign container; after the sweep no labelled container remains, the foreign one is untouched, and the returned count is 3 - Test (RT-08): a surviving container yields REALM_STOPPING; a repeated sweep is a no-op returning 0 - Test titles cite RT-08 </behavior> <action>Write the failing tests first per behavior, then move removeAllRealmContainers and the SweepClient type unchanged in behavior into src/runtime/sweep.ts per D-84, extending the function to return the number removed while keeping the double-list zero-survivor proof (list, force-remove each realm-labelled id, list again, throw REALM_STOPPING via makeRealmError if any remain). Move MANAGED_LABEL_VALUE into src/runtime/openshell.ts beside MANAGED_LABEL_KEY. Remove the sweep and the constant from src/runtime/session-execution.ts and keep existing callers working through that module (delegation, not a silent second copy). Open src/runtime/sweep.ts with a header comment naming D-84 in the src/core/events.ts style. (Implements D-84 sweep half; OQ-P4-06 default: document the D-87 single-owner precondition in the reap task, not here.)</action> <acceptance_criteria> - test/runtime/down-sweep.test.ts imports from src/runtime/sweep.js and asserts the removed count, foreign preservation, repeat no-op, and REALM_STOPPING on a survivor - The sweep lists twice on every run and still touches no unlabelled container </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/runtime/down-sweep.test.js</automated> </verify> <done>Sweep moved with count return and zero-orphan proof green.</done> </task>
<task type="tdd"> <name>Task 2: SessionStore.list (D-85)</name> <files>src/session/store.ts, test/session/store.test.ts</files> <read_first> - src/session/store.ts lines 21-63 (SessionStore save/load/delete and the guard shape of load to copy) - src/session/paths.ts lines 37-42 (recordFileName validation used inside a try/catch to filter names) - test/session/store.test.ts lines 98-117 (existing store fixture and cleanup style) </read_first> <behavior> - Test (RT-08): list() returns ids for session record files and ignores non-matching names - Test (RT-08): list() returns the id for a record whose contents are corrupt (no parse) - Test (RT-08): list() on an absent sessions directory returns an empty array - Test titles cite RT-08 </behavior> <action>Write the failing tests first per behavior, then add list() to SessionStore per D-85: enumerate with readdirSync, keep names ending in .json whose stem passes recordFileName inside a try/catch, parse no contents so a corrupt record is still deletable, and return an empty array (not null) when the sessions directory is absent. Copy the guard shape of load and import only node:fs plus session modules. (Implements D-85.)</action> <acceptance_criteria> - test/session/store.test.ts asserts id listing, non-matching-name exclusion, corrupt-content inclusion, and absent-directory empty array - list() parses no file contents </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/session/store.test.js</automated> </verify> <done>SessionStore.list green with name-only enumeration.</done> </task>
<task type="tdd"> <name>Task 3: reapStartup with containers-before-records order (D-84, D-86, D-87)</name> <files>src/session/reap.ts, test/session/reap.test.ts</files> <read_first> - src/runtime/sweep.ts (from Task 1 of this plan; the sweep signature to inject) - src/session/store.ts (from Task 2 of this plan; list and delete used after the sweep) - src/lifecycle/controller.ts line 347 (two-phase destroy that keeps the record until teardown finishes; the reaper mirrors that resume rule) - test/runtime/down-sweep.test.ts lines 13-36 (fake-libpod fixture style to copy for the sweep half) </read_first> <behavior> - Test (RT-08): order check first — list containers, remove containers, then delete records (asserted on fake call logs, per the CONTEXT specifics note) - Test (RT-08): a record with a null container id (crash during realm up, D-86) is reaped via the label sweep - Test (RT-08): a record with a container id is reaped; a foreign unlabelled container is untouched - Test (RT-08): a second reap is a no-op; a surviving container yields REALM_STOPPING and keeps every record - Test titles cite RT-08 </behavior> <action>Write the failing tests first per behavior, then implement reapStartup({ store, sweep }) in src/session/reap.ts per D-84: await sweep() first and prove zero survivors; only then store.list() and store.delete(id) for each id, so a crash during reap leaves the records the next start needs; a failed sweep (REALM_STOPPING) deletes no record. Import only session modules and types; the sweep arrives as a parameter so there is no runtime import (D-50). Open the module with a header comment naming D-84 and D-86. State the D-87 single-owner precondition (no LifecycleController or ToolDispatcher exists yet in the process; one host daemon owns the state directory) in the function doc comment and note the OQ-P4-06 residual risk there; Phase 6 owns the cross-process check. (Implements D-84 reaper half, D-86, D-87.)</action> <acceptance_criteria> - test/session/reap.test.ts asserts the containers-before-records order first, both crash-during-up record states, foreign preservation, repeat no-op, and sweep-failure record retention - src/session/reap.ts contains no import of any src/runtime/ module </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/session/reap.test.js</automated> </verify> <done>Reaper green with crash-safe ordering and both record states covered.</done> </task>
</tasks>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| sweep list -> remover | Label query results cross into force-remove; only realm-labelled ids are removed |
| record names -> deleter | File names cross into session-record deletion; only recordFileName-passing stems are deleted |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-04-03 | T (Tampering) | src/session/reap.ts | high | mitigate | Sweep keys off realm labels only; the foreign-unlabelled-container test pins that nothing else is touched (D-86) |
| T-04-04 | D (Denial of Service) | src/session/reap.ts | high | mitigate | Reap deletes records only after the sweep proves zero survivors; a failed sweep keeps every record so the next start retries instead of losing state (D-84); the single-owner precondition is documented per D-87 |
| </threat_model> |
<verification> npx tsc -p tsconfig.json && node --test dist/test/runtime/down-sweep.test.js dist/test/session/store.test.js dist/test/session/reap.test.js; grep the three test files for RT-08 in test titles. </verification>
<success_criteria> Sweep move, store list, and startup reaper all green with containers-before-records ordering proven. </success_criteria>
Artifacts this phase produces (contribution of this plan)
src/runtime/sweep.ts(new): movedremoveAllRealmContainersplusSweepClient, returning the removed count (D-84).src/session/reap.ts(new): exportsreapStartup({ store, sweep })with containers-before-records order (D-84, D-86, D-87).SessionStore.list()insrc/session/store.ts(extended): name-only id enumeration (D-85).test/session/reap.test.ts(new): RT-08 order, crash states, foreign preservation, repeat no-op, failure retention.test/session/store.test.ts(extended): RT-08list()cases.test/runtime/down-sweep.test.ts(migrated): imports fromsrc/runtime/sweep.js, asserts the removed count.
<output> Create .planning/phases/04-reaper-egress-guard-and-shared-dispatcher/04-02-SUMMARY.md when done </output>