Plan 02-01 Summary: Tracer slice (state machine, events, port, pinned root, canonicalizer, atomic replace, session store, lifecycle create)
Status: implemented; gates not run in this lane (orchestrator runs them after the wave). Requirements claimed: CORE-01, CORE-04, FS-01, FS-05, RT-07. CORE-06 is partly covered here (create retry, idempotent create, attach and detach); the full seven-operation set lands in plan 02-05.
Files
Created or changed (repo-relative):
src/core/state-machine.ts:RealmStateMachineandStateSnapshot. Wraps the frozencheckTransition; commits, then publishes.src/core/events.ts:createEventBus,StateTransitionEvent,RealmEvent,EventBus. D-26 delivery and rethrow rule.src/core/ports.ts:ExecutionService(D-39 methods),CreateRequest,ProbeResult. No environment field.src/fs/root.ts:bindRoot,PinnedRoot,isInsideRoot.src/fs/canonicalize.ts:canonicalize(D-29 walk,mode: "read" | "write"),openVerified,verifyDescriptor,openCanonical(with theonResolveBoundaryhook and aprocFdDiroverride),lstatIfPresent,errnoOf,MAX_SYMLINK_HOPS.src/fs/atomic-write.ts:atomicWriteFile(D-35,expectedPriorSha256,stageHook),observeSha256,sha256Hex,WriteNotAtomicError(withreplaced).src/session/paths.ts:resolveStateDir(env),sessionsDirOf,controlSocketPath,recordFileName.src/session/store.ts:SessionStore(save,load,delete), D-14 frozen fields, modes 0700 and 0600.src/lifecycle/controller.ts:LifecycleControllercreate path,attach,detach,attachments(),snapshot(),events.test/support/fake-execution.ts:FakeExecutionService(idempotency-keyed resources, call log,callCount,setDelay,failNext,beforeCall,setHealth,resources).test/core/state-machine.test.ts,test/core/events.test.ts,test/fs/canonicalize.test.ts,test/session/store.test.ts,test/lifecycle/controller.test.ts.
No frozen file was edited (src/core/states.ts, src/core/errors.ts, src/contract/*, src/core/policy.schema.json). No dependency was added.
Must-haves and the tests that evidence them
- A rejected transition, including every self-transition, throws
REALM_INVALID_TRANSITIONand leaves state, reason, time, and events unchanged:every disallowed pair, including all six self-transitions, throws REALM_INVALID_TRANSITION with nothing changed (CORE-01); 13 allowed and 23 rejected pairs counted inthe 36 ordered pairs split into the 13 D-11 edges and 23 rejected pairs (CORE-01). - Every committed transition publishes exactly one event, in subscription order, surviving a throwing listener:
every committed transition publishes exactly one state.transition event ... (CORE-04),listeners run synchronously in subscription order (CORE-04, D-26),a throwing listener does not stop later listeners ... (CORE-04, D-26),when several listeners throw, the first listener error is the one rethrown (D-26),a transition whose listener throws stays committed ... (CORE-04, D-25). - A relative path inside the root canonicalizes; an escaping symlink, a more-than-40-hop chain, or an unverifiable descriptor fails with
PATH_ESCAPE:a relative path inside the root resolves to its real path (FS-01),a symlink that escapes the root is rejected with PATH_ESCAPE, relative or absolute (FS-01),a chain of exactly 40 symlink hops resolves; 41 hops fail with PATH_ESCAPE (FS-01),openVerified refuses a symlink at the final component with PATH_ESCAPE (FS-02),without /proc/self/fd the descriptor check fails closed with PATH_ESCAPE and is never skipped (FS-02). - Session create writes the record with
containerIdnull before the fake create runs, then stores the returned id (RT-07):create writes the record with containerId null before the fake create runs (RT-07),create stores the returned containerId before start, and the record holds it afterwards (RT-07),a failed port create leaves containerId null in the record and the session in error (RT-07). - The fake implements the exact D-39 method set with no environment parameter:
the port request carries only the idempotency key and the workspace root (AUD-02)(runtime key-set check on the recorded request).
Requirement evidence
- CORE-01:
test/core/state-machine.test.ts, all five tests. - CORE-04:
test/core/events.test.ts, all six tests. - FS-01:
test/fs/canonicalize.test.ts, bind, resolution, escape, segment containment, hop limit, loop, absent results, dot-dot, write mode. - FS-05 (partial in this plan):
test/session/store.test.tsan update replaces the record atomically and leaves no temporary file behind (FS-05, D-37). The stage-hook failure matrix (after-temp-write,after-fsync,before-rename) is owned by plan 02-03 (test/fs/atomic-write.test.ts). - RT-07:
test/lifecycle/controller.test.tscreate-ordering tests andtest/session/store.test.tsa first save writes exactly the frozen fields ... (RT-07, D-14). - CORE-06 (partial): create retry through error, no second create for a stored containerId, repeated create returns the same snapshot, attach and detach idempotent.
Test titles use the (CORE-xx) and (FS-xx) markers, so the taxonomy and requirement checks can find them.
Deviations and interpretations (each with the default applied)
- Port is async. D-39 writes the signatures without
Promise. The port returns Promises because D-28 races every call withwithTimeout, which takes a Promise. A synchronous call could not be bounded. Method names, argument shapes, and the absence of an environment field are exactly D-39. - D-20 layering amendment (recorded here, not edited in
.planning/).sessionandlifecycleimportcontractfor validation only (F-2).lifecyclealso importsfsfor the root bind.coreimports nothing from other modules. The policy loader is not in this plan (plan 02-02 owns it), so nocoretocontractcycle is introduced here. - Canonicalize result shapes.
foundis{status, realPath}.absentis{status, nearest, missing}, wheremissinglists the unresolved segments. Write mode rejects a symlink at the final segment (D-34 step 1); symlinks in intermediate segments are followed. - Symlink containment is checked on the final resolved path. Absolute symlink targets may pass through ancestors of the root during the walk. The final real path must lie inside the root, and the descriptor check confirms the object that is opened. An escape that ends outside the root is
PATH_ESCAPE. - Any open failure in
openVerifiedisPATH_ESCAPE(includingELOOPfrom a swapped symlink andENOENT). A path that cannot be verified is treated as escaping, per F-8 and D-30. Anlstatfailure other thanENOENTorENOTDIRis alsoPATH_ESCAPE. - Verification failures inside
atomicWriteFilekeep their own code. A temp-file descriptor mismatch isPATH_ESCAPE. Other pre-rename failures areWRITE_NOT_ATOMICwithreplaced: false. A re-hash mismatch isLOCK_CONFLICT. A parent fsync failure after a successful rename isWRITE_NOT_ATOMICwithreplaced: true, so the audit layer can record that durability is unconfirmed. - Stage order in
atomicWriteFile: write,after-temp-write, fsync,after-fsync,before-rename, re-hash, rename, parent fsync. Thebefore-renamehook runs before the re-hash, so a test can change the target and observeLOCK_CONFLICT. - Session ids are restricted to
^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$because they become file names. A bad id fails withSCHEMA_INVALID. The frozen schema only requiresminLength: 1, so this is a code check. resolveStateDirthrows a plainErrorwhen neither an absoluteXDG_STATE_HOMEnor an absoluteHOMEis set. The catalogue has no configuration code and the catalogue is frozen. Callers see a non-taxonomy error. Phase 6 CLI should surface it.- Controller error reasons. A
RealmErrorfrom the port moves the session toerrorwith the code as the reason, and the snapshot is returned. A non-RealmErrormoves it toerrorwith reasonunexpected port failureand rethrows. The fake'sfailNextaccepts either form. - Workspace policy.
createcanonicalizes the requested root and everypolicy.workspaceRootsentry. The root must lie inside a policy root. An unresolvable policy root, or an emptyworkspaceRoots, fails withPOLICY_DENIED. A stored container bound to another workspace is never reused (POLICY_DENIED). - Stopping state.
createfromstoppingthrowsREALM_STOPPING. Stop and destroy semantics are plan 02-05's. - Store writes are inside the guarded block. The first record write runs inside the
try, so a failed write moves the session toerrorrather than leaving it stuck inconnecting. - No timeouts in create.
startupBudgetMswrapping is plan 02-05's (D-28). The create path calls the port directly. - The store has
delete(listed in the plan's artifacts). Plan 02-05 uses it when it removes the record last. - Helper directories. Temporary-directory helpers are inlined in each test file, because
files_modifieddoes not list a shared helper.
Residual risks and open questions
- Q-2 (rename window, accepted). Node 22 has no
openat. A parent directory swapped between verification andrename(2)is narrowed by the parent re-check, not closed. Recorded for the Phase 7 security review. This plan adds no native helper. - Q-3 (orphaned temp files, default applied). A temp file left by a killed process is not cleaned up here. The name pattern is
.<basename>.realm-tmp-<hex>. - Q-1 (teardown timeout, default applied). No teardown code in this plan. Plan 02-05 uses the single
TEARDOWN_TIMEOUT_MSconstant withREALM_STOPPING. - Q-4 (MCP and ACP contract tests, deferred to Phase 5). No MCP or ACP test in this plan. QUAL-02 is covered only through the taxonomy and category tests (plan 02-06).
- Not verified in this lane. Per the shared rules, I did not run
tsc,eslint, ornode --test. The orchestrator must run the gates after the wave. Watch in particular: definite assignment offdinatomicWriteFileandopenVerified; the optional-chain narrowing incontroller.test.ts(created?.request); and thelet containerIdnarrowing across thetryin the controller.
Checkpoint decision (D-14, approved)
The record matches the approved one-way shape: <stateDir>/sessions/<sessionId>.json, directory mode 0700, record mode 0600, the six frozen fields in fixed order, containerId null until created, controlSocketPath recorded but not created, and writes through the atomic replace with no lock. The store.test.ts tests check these properties.