<objective> Land the phase tracer: the thinnest production-quality path from binding a workspace root through canonicalization and atomic write to a persisted session record and lifecycle create against the in-memory fake, with state-machine transitions and event publication. Later plans expand outward from this proven slice.
Purpose: Prove the core/fs/session/lifecycle layering and the RT-07 record-before-create ordering on real temp-directory fixtures before the lock, policy, audit, and doctor layers land. Output: Nine source/support files, five test files, and a passing end-to-end create ordering test.
Decisions implemented: D-19: module ownership and layout (spec n2 with the lifecycle amendment); D-20: dependency direction; D-25: state machine behavior; D-26: typed event bus; D-29: canonicalization walk; D-30: resolve-then-verify race checks; D-35: atomic replace; D-37: session record persistence. </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="checkpoint:decision"> <name>Checkpoint: confirm the session-record on-disk format (D-14 one-way)</name> <files></files> <read_first> - .planning/phases/02-policy-state-and-filesystem-lockdown/02-CONTEXT.md (D-14 record fields, D-37 paths and modes) - src/contract/session-record.schema.json (frozen required fields; containerId null until created) </read_first> <action>Confirm before the store writer lands: records live at <stateDir>/sessions/<sessionId>.json with exactly the frozen schema fields (realmId random UUID, sessionId, workspaceRoot canonical, state, controlSocketPath <stateDir>/run/control.sock recorded but not created, containerId null until created), directory mode 0700, record mode 0600, writes via the D-35 atomic replace without a lock, and no token, secret, or environment field ever written. Undo after Phase 3 reads these files needs a migration, which is why this gate exists.</action> <verify> <automated>MISSING — human decision required before the store writer lands</automated> </verify> <done>Human confirms the D-14 record shape, location, and modes, or records an amendment.</done> </task>
<task type="tdd"> <name>Task 1: RealmStateMachine, typed event bus, and ExecutionService port</name> <files>src/core/state-machine.ts, src/core/events.ts, src/core/ports.ts, test/core/state-machine.test.ts, test/core/events.test.ts</files> <read_first> - src/core/states.ts (frozen STATES, ALLOWED_TRANSITIONS, checkTransition; wrap it, never redefine it) - src/core/errors.ts (makeRealmError; REALM_INVALID_TRANSITION is the only code this task uses) - test/core/states.test.ts (node:test style, literal pins, behavior-sentence titles) </read_first> <behavior> - Test: every one of the 13 allowed edges commits and publishes; every disallowed ordered pair (36 minus 13, including all 6 self-transitions) throws REALM_INVALID_TRANSITION with state, reason, time, and event log unchanged - Test: machine starts in unbound with reason no workspace; transition records ISO 8601 UTC time - Test: every committed transition publishes one state.transition event carrying sessionId, from, to, reason, at; delivery is synchronous in subscription order; a throwing listener does not stop later listeners and its error is rethrown after all listeners run - Test titles cite CORE-01 (machine) and CORE-04 (events) </behavior> <action>Write the failing tests first per behavior, then implement: RealmStateMachine in src/core/state-machine.ts holding current state, last reason, and last-transition time, whose transition(to, reason) calls checkTransition and throws the returned RealmError before any mutation, then commits and publishes; the typed bus in src/core/events.ts as createEventBus with subscribe and publish implementing the D-26 delivery and rethrow rule and the StateTransitionEvent interface; the ExecutionService port in src/core/ports.ts as an interface only with exactly the D-39 create, start, probe, stop, destroy signatures and no environment field (flag as costly per D-39: Phase 3 implements it and lifecycle depends on its shape). ESM .js import suffixes, no new dependencies.</action> <acceptance_criteria> - test/core/state-machine.test.ts covers all 13 allowed edges and all 23 rejected pairs with code REALM_INVALID_TRANSITION and unchanged state after rejection - test/core/events.test.ts asserts single publish per commit with from, to, reason, at, plus the throwing-listener delivery rule - src/core/ports.ts contains no environment field; a runtime key-set check on a sample request object shows no env key (no ts-expect-error per F-7) </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/core/state-machine.test.js dist/test/core/events.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Machine, bus, and port exist with tests green; the frozen tables are untouched.</done> </task>
<task type="tdd"> <name>Task 2: Pinned root, canonicalizer with race verification, atomic replace, and the fake</name> <files>src/fs/root.ts, src/fs/canonicalize.ts, src/fs/atomic-write.ts, test/support/fake-execution.ts, test/fs/canonicalize.test.ts</files> <read_first> - src/core/errors.ts (PATH_ESCAPE and WRITE_NOT_ATOMIC catalogue entries; makeRealmError is the only construction path) - test/core/states.test.ts (test style to mirror; temp-directory fixtures are real directories, never mocks, per CONTEXT specifics) </read_first> <behavior> - Test: relative resolution inside the root; symlink inside root followed; symlink escaping root rejected with PATH_ESCAPE; absolute symlink targets resolving outside rejected; >40 symlink hops rejected with PATH_ESCAPE; missing final segment returns absent with the verified parent; missing intermediate segment is absent for the whole path - Test: where /proc/self/fd is unavailable, verification fails closed with PATH_ESCAPE (no silent skip per D-30 and F-8) - Test titles cite FS-01; atomic-write failure cases live in test/fs/atomic-write.test.ts (plan 02-03) </behavior> <action>Write the failing tests first per behavior, then implement per D-29 and D-30: bindRoot(path) in src/fs/root.ts does realpath, checks the result is a directory, and returns the canonical root object every other fs function takes (F-9 scope decision: root.ts owns only this binding); canonicalize(root, relPath, opts?) in src/fs/canonicalize.ts walks one segment at a time with lstat, resolves symlink targets relative to the current directory with a 40-hop limit, and checks every resolved path stays inside the root on segments, returning found or absent shapes; openVerified(realPath) opens with O_NOFOLLOW then compares fstat dev and ino with lstat and requires readlink /proc/self/fd/<fd> to equal the real path, closing and failing PATH_ESCAPE on any mismatch, and failing closed with PATH_ESCAPE where /proc/self/fd is unavailable (Linux only; macOS is V2-01); expose an onResolveBoundary hook option invoked between resolve and open so plan 02-03 drives the swapped-symlink race without touching production logic; atomicWriteFile in src/fs/atomic-write.ts implements the D-35 steps with .<basename>.realm-tmp-<random hex> temp names via crypto randomBytes, mode 0600 replaced by the target mode when present, temp real-path verification per D-30, write plus fsync, expected-prior-hash re-check yielding LOCK_CONFLICT, rename, parent fsync, temp cleanup on pre-rename failure, WRITE_NOT_ATOMIC on failure at or after rename with a reason note for post-rename fsync failure, and the stageHook parameter called at after-temp-write, after-fsync, before-rename for plan 02-03 tests while production callers never pass it; implement FakeExecutionService in test/support/fake-execution.ts against the D-39 interface with an in-memory resource table keyed by idempotency key, per-operation call counters, and scripted delay and failure setters for later timeout tests. Keep the ok versus status result-shape split per F-10: canonicalize returns the D-29 found or absent shape, not the validate ok shape.</action> <acceptance_criteria> - test/fs/canonicalize.test.ts covers inside-root resolution, inside-follow, outside-escape rejection, hop limit, absent final and intermediate segments, and fail-closed behavior without any skip - src/fs/canonicalize.ts exposes the resolve-to-open hook used by plan 02-03; production open path always verifies - test/support/fake-execution.ts implements all five D-39 methods with idempotency-keyed resources and no environment parameter </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/fs/canonicalize.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>Root binding, canonicalizer with verification and hook seam, atomic replace with stageHook, and the fake exist with canonicalize tests green.</done> </task>
<task type="tracer"> <name>Task 3: Tracer slice — session store plus lifecycle create ordering against the fake</name> <files>src/session/paths.ts, src/session/store.ts, src/lifecycle/controller.ts, test/session/store.test.ts, test/lifecycle/controller.test.ts</files> <read_first> - src/contract/validate.ts (Policy and SessionRecord types; validate sessionRecord on every store write; never import ajv elsewhere) - src/fs/atomic-write.ts (from Task 2 of this plan; store writes go through it without a lock per D-37) - test/support/fake-execution.ts (from Task 2 of this plan; assert on its call log for ordering) - src/core/state-machine.ts (from Task 1 of this plan; controller calls only checked transitions) </read_first> <action>Starting red with a failing end-to-end create test, then implement the slice: resolveStateDir(env) in src/session/paths.ts returns $XDG_STATE_HOME/realm or $HOME/.local/state/realm from the passed env argument (never reads process.env directly, per the pattern map, so tests stay hermetic); SessionStore in src/session/store.ts persists records at <stateDir>/sessions/<sessionId>.json creating the directory at mode 0700 and records at 0600, validates through validate sessionRecord on write, writes via atomicWriteFile without a lock, and carries the D-14 one-way reversibility rationale (later phases read these files, so shape changes need a migration); LifecycleController in src/lifecycle/controller.ts takes constructor deps store, exec, and clock, exposes create, start, probe, attach, detach, stop, destroy returning the current snapshot, and this tracer implements only the create path: policy value check (Policy type imported from contract validate; no import of src/core/policy.ts, which plan 02-02 owns, so this plan stays file-disjoint), bind by canonicalizing each workspaceRoot with realpath and failing the bind with POLICY_DENIED for an unresolvable root, unbound to connecting, store write with containerId null, then exec.create with the session id as idempotency key, then record update with the returned containerId, then start with connecting to active on success or connecting to error on failure, retryable from error through error to connecting; attach and detach manage an in-memory host set with no state change and both idempotent; remaining operations arrive in plan 02-05. Amend and record the D-20 layering per F-1 and F-2 inside this task: session and lifecycle import contract for validation only, and core policy.ts (plan 02-02) imports contract validate with no module-eval-time use, so the ESM cycle stays safe. Test titles cite RT-07 and CORE-06. Assumption Q-1 carried: no teardown-timeout code exists yet; stop and destroy arrive in plan 02-05.</action> <acceptance_criteria> - test/lifecycle/controller.test.ts asserts the fake sees the record with containerId null at the moment of create, then sees the stored containerId after update, and that a pre-existing record with a containerId causes no second create - test/session/store.test.ts asserts exact frozen fields, modes 0600 and 0700, containerId null on first write, no secret field, and atomic replace on update - Every lifecycle method returns the same snapshot shape with state, reason, and last-transition time </acceptance_criteria> <verify> <automated>npx tsc -p tsconfig.json && node --test dist/test/session/store.test.js dist/test/lifecycle/controller.test.js</automated> <fails_when>non-zero exit, or the summary shows 0 passed</fails_when> </verify> <done>The single happy path binds, writes the record, creates through the fake, transitions with events, and is committed with ordering tests green.</done> </task>
</tasks>
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| agent path -> canonicalizer | Workspace-relative strings cross into real-path resolution; symlink escapes are the attack |
| controller -> fake port | Idempotency key crosses; duplicate create must not duplicate resources |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-02-01 | S (Spoofing) | src/fs/canonicalize.ts | high | mitigate | Segment-walk with lstat, 40-hop limit, inside-root segment check, post-open fstat and fd readlink verification; toctou.test.ts in plan 02-03 drives the swapped-symlink case |
| T-02-02 | D (Denial of Service) | src/lifecycle/controller.ts | medium | mitigate | create validates the record it writes; pre-existing containerId short-circuits so retries never duplicate; budget wrapping arrives in plan 02-05 |
| T-02-SC | T (Tampering) | npm/pip/cargo installs | high | mitigate | No package installs in this plan or any Phase 2 plan per D-32; Node built-ins only; new-dependency gate never triggers |
| </threat_model> |
<verification> npx tsc -p tsconfig.json && node --test dist/test/core/state-machine.test.js dist/test/core/events.test.js dist/test/fs/canonicalize.test.js dist/test/session/store.test.js dist/test/lifecycle/controller.test.js; grep the five test files for their requirement IDs. </verification>
<success_criteria> Tracer slice green end to end: machine rejects with REALM_INVALID_TRANSITION without mutation, events publish once per commit, escapes fail PATH_ESCAPE, and create writes containerId null before the fake runs. </success_criteria>
<output> Create .planning/phases/02-policy-state-and-filesystem-lockdown/02-01-SUMMARY.md when done </output>
Artifacts this phase produces
- src/core/state-machine.ts (RealmStateMachine: snapshot, transition; wraps checkTransition, publishes after commit)
- src/core/events.ts (createEventBus, StateTransitionEvent, subscribe and publish with the D-26 rethrow rule)
- src/core/ports.ts (ExecutionService interface, exact D-39 signatures, no environment field)
- src/fs/root.ts (bindRoot: realpath plus directory check, returns the canonical root)
- src/fs/canonicalize.ts (canonicalize, openVerified, onResolveBoundary hook seam)
- src/fs/atomic-write.ts (atomicWriteFile with expected-prior-hash check, stageHook, WRITE_NOT_ATOMIC mapping)
- src/session/paths.ts (resolveStateDir from a passed env argument)
- src/session/store.ts (SessionStore: save, load, delete at <stateDir>/sessions/<sessionId>.json, modes 0700 and 0600)
- src/lifecycle/controller.ts (LifecycleController create path with RT-07 ordering; remaining ops in plan 02-05)
- test/support/fake-execution.ts (FakeExecutionService: idempotency-keyed resources, call counters, scripted delay and failure)
- test/core/state-machine.test.ts (CORE-01: 13 allowed, 23 rejected incl. self-transitions)
- test/core/events.test.ts (CORE-04: publish-once, order, throwing-listener rule)
- test/fs/canonicalize.test.ts (FS-01: resolution, escapes, hop limit, absent handling, fail-closed)
- test/session/store.test.ts (RT-07: null containerId first write, frozen fields, modes, no secret)
- test/lifecycle/controller.test.ts (RT-07 ordering against the fake; CORE-06 create-retry case)