<objective> Complete the lifecycle controller (all seven operations with timeout budgets and idempotency), prove no host environment reaches the sandbox, and land the doctor core check runner. Depends on all wave 1-2 plans.
Purpose: Close CORE-06, RT-07 teardown semantics, AUD-02, and the doctor core so the phase goal holds with no container present. Output: Controller completion, fake scriptability, doctor runner, 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/02-policy-state-and-filesystem-lockdown/02-CONTEXT.md @.planning/phases/02-policy-state-and-filesystem-lockdown/02-PATTERNS.md </context>
<tasks>
<task type="tdd"> <name>Task 1: Full lifecycle operations with budgets and idempotency</name> <files>src/lifecycle/controller.ts, test/support/fake-execution.ts, test/lifecycle/idempotent.test.ts, test/lifecycle/no-host-env.test.ts</files> <read_first> - src/lifecycle/controller.ts (from plan 02-01; extend the create path, do not rewrite it) - src/core/timeout.ts (from plan 02-02; wrap every ExecutionService call) - src/contract/validate.ts (Policy type for startupBudgetMs; no new codes needed per Q-1) - test/support/fake-execution.ts (from plan 02-01; add scripted delay and failure setters here) </read_first> <behavior> - Test: every operation repeated returns the same snapshot with no duplicate fake resources and no second record write - Test: startup overrun gives STARTUP_BUDGET_EXCEEDED with state error; teardown overrun returns REALM_STOPPING, session stays stopping, repeated destroy resumes - Test: probe healthy changes nothing, probe unhealthy in active moves to error with reason probe failed, probe elsewhere returns the snapshot; attach and detach idempotent with no state or record change; stop of unbound or stopping is a no-op; destroy of absent session is a no-op; destroy releases the session locks and deletes the record - Test: sentinel env value set in the test process never appears in fake inputs, the session record file, or any audit line; a sample port request object has no environment key by runtime key-set check - Test titles cite CORE-06, RT-07, and AUD-02 </behavior> <action>Write the failing tests first per behavior, then extend the controller per D-38 and D-28: start valid in connecting and error via create; startup create, start, and probe wrapped with policy.startupBudgetMs mapping expiry to STARTUP_BUDGET_EXCEEDED and state error; stop valid in connecting, active, degraded, and error moving to stopping, calling exec.stop wrapped with the single TEARDOWN_TIMEOUT_MS 5000 constant owned in controller.ts, mapping expiry to a REALM_STOPPING return with the session staying in stopping so repeated destroy resumes teardown (Q-1 carried: no new catalogue code); destroy stopping first when needed then exec.destroy, session lock release via the D-33 table, and record delete, with absent-session no-op; probe only in active or degraded with unhealthy moving active to error reason probe failed and never producing degraded (ANDA-03 is Phase 3); attach and detach idempotent against the in-memory host set; controller calls the state machine only with checked transitions. Extend the fake with per-operation delay and failure setters the timeout tests drive. Keep F-7: the fake stores request objects visibly so the no-env assertion is a runtime key check, never a suppression comment.</action> <acceptance_criteria> - test/lifecycle/idempotent.test.ts repeats all seven operations asserting identical snapshots and single fake resources - test/lifecycle/no-host-env.test.ts asserts sentinel absence in fake inputs, record file, and audit lines plus the no-environment-key runtime check - Destroy of an absent session and stop of unbound or stopping are verified no-ops </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/lifecycle/idempotent.test.js dist/test/lifecycle/no-host-env.test.js dist/test/lifecycle/controller.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>All operations complete, idempotent, budgeted, and env-free with tests green.</done> </task>
<task type="auto"> <name>Task 2: Doctor core check runner</name> <files>src/doctor/checks.ts, test/doctor/checks.test.ts</files> <read_first> - src/contract/validate.ts (the policy-file check validates through validate policy; never hand-rolls schema rules) - src/session/paths.ts (from plan 02-01; state-dir default resolution shared with the store) </read_first> <action>Implement runChecks in src/doctor/checks.ts returning CheckResult objects with id, status of pass, warn, or fail, detail, and fix per D-44: policy-file absent yields warn with defaults-in-effect detail, invalid yields fail with the schema problem named as fix input, policy inside a workspace root yields fail with a move fix; state-dir missing yields warn created-on-first-use, wrong mode yields fail with chmod 700 fix; session-store not writable yields fail with ownership fix using access W_OK and no probe file. Register only these three checks (flag D-44 as costly per the phase brief so later phases extend the same runner deliberately); Podman, OpenShell, AgentOS, Anda, and control-channel items stay unregistered so the runner never reports on components it cannot inspect. Keep the F-10 status shape: pass, warn, fail, distinct from the validate ok shape.</action> <acceptance_criteria> - test/doctor/checks.test.ts asserts pass, warn, and fail outcomes for each of the three checks, each with its fix - No check references Podman, OpenShell, AgentOS, Anda, or control-channel state </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/doctor/checks.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Doctor core runner green with only the three Phase 2 checks registered.</done> </task>
<task type="auto"> <name>Task 3: Startup-budget overrun and teardown resume spot coverage</name> <files>test/lifecycle/controller.test.ts</files> <read_first> - src/lifecycle/controller.ts (as extended in Task 1 of this plan; append only the budget cases here) </read_first> <action>Append to test/lifecycle/controller.test.ts from plan 02-01 only the timeout-mapping cases: create with a scripted fake delay beyond startupBudgetMs yields STARTUP_BUDGET_EXCEEDED with state error; destroy with a scripted stop delay beyond the teardown bound returns REALM_STOPPING with the session in stopping and a repeated destroy resuming teardown. No other file changes in this task.</action> <acceptance_criteria> - Both budget cases assert the exact code and the resulting state - No duplicate coverage of the Task 1 idempotency cases </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/lifecycle/controller.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Budget and teardown-resume cases green in the controller suite.</done> </task>
</tasks>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| host env -> ExecutionService | Host secrets cross into the sandbox boundary; the port must carry no env by type |
| hung runtime -> controller | An unresponsive runtime crosses into teardown; budgets keep it bounded |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-02-10 | I (Information Disclosure) | src/core/ports.ts | high | mitigate | Port carries no environment field by type; no-host-env test sets a sentinel and asserts absence in fake inputs, record, and audit lines |
| T-02-11 | D (Denial of Service) | src/lifecycle/controller.ts | medium | mitigate | Every ExecutionService call wrapped: startupBudgetMs for startup, single 5000 teardown constant; expiry maps to STARTUP_BUDGET_EXCEEDED or REALM_STOPPING, never hangs |
| T-02-12 | D (Denial of Service) | src/fs/locks.ts | low | mitigate | Destroy releases the session locks; stale locks vanish with the in-memory table, never persisted |
| </threat_model> |
<verification> npx tsc -p tsconfig.json && node --test dist/test/lifecycle/idempotent.test.js dist/test/lifecycle/no-host-env.test.js dist/test/lifecycle/controller.test.js dist/test/doctor/checks.test.js; grep the test files for their requirement IDs. </verification>
<success_criteria> All seven operations idempotent and budgeted, sentinel env absent everywhere, and doctor core reporting only what it can inspect. </success_criteria>
<output> Create .planning/phases/02-policy-state-and-filesystem-lockdown/02-05-SUMMARY.md when done </output>
Artifacts this phase produces
- src/lifecycle/controller.ts (extended: start, probe, attach, detach, stop, destroy; TEARDOWN_TIMEOUT_MS 5000; budget wraps per D-28)
- src/doctor/checks.ts (runChecks with policy-file, state-dir, session-store checks per D-44)
- test/support/fake-execution.ts (extended: scripted per-operation delay and failure)
- test/lifecycle/idempotent.test.ts (CORE-06: all seven operations repeated without duplication)
- test/lifecycle/no-host-env.test.ts (AUD-02: sentinel absence in fake, record, and audit; no-environment-key runtime check)
- test/doctor/checks.test.ts (D-44: pass, warn, fail outcomes with fixes; Phase 3 and 6 items unregistered)