Runs one built-in synthetic fixture as a real, bounded child process and stores what it observed as Evidence. It observes; it never judges.
Status: "Accepted for fixed trusted fixtures" (build review): real bounded processes, environment isolation, cancellation, controller loss and safe quarantine. Receipts: build receipt records the original run() path (commit 0eff5b2, independent worker review by gpt-6-astra at xhigh). Its final_sources hashes for the two local-worker test files still match; the hashes for local-worker.ts and fixture-child.ts do not. Continuation receipt records the guarded path, the gate and the manifest (introduced in commit 5c5addc, accepted together with guarded verification). Its files hashes match all four current source files and the three guarded and manifest test files; the two legacy test files match the build receipt's final_sources. This is local scope only: it is not a release and not customer value, and all 27 epics remain open. Narrative docs: local development, sections "Local fixture worker" and "Guarded runs". Human page: guide.
Source: src/local-worker.ts, src/fixture-child.ts, src/fixture-gate.ts, src/fixture-manifest.ts. Tests: test/local-worker.test.ts (14), test/local-worker-boundaries.test.ts (2), test/guarded-worker.test.ts (23), test/guarded-worker-boundaries.test.ts (1), test/fixture-manifest.test.ts (3). Helpers: test/helpers/guarded-worker-child.ts (a killable controller with stopAtSpawn and the start-up faults drop-fence-grant, missing-program and broken-entry, plus a pending SQLite writer), test/helpers/fence-child.ts, test/helpers/json-lines.ts and test/helpers/broken-entry-preload.mjs.
Intelligence: none — Runs fixed fixtures as real bounded processes and records what each did; an observation must be recorded exactly, never interpreted.
What it hides
- Process lifecycle. The worker spawns the fixed program with
process.execPath, an explicit environment, no shell and a private working directory. Termination is SIGTERM, then SIGKILL. The worker waits for the process to be reaped and its pipes to close, and reports a bounded wait that ends without that as unconfirmed. - Capture and framing. It keeps stdout and stderr within one combined byte budget in a fixed Evidence body layout. It maps what happened to a
ProcessObservation, including thecollectorErrortext. - Cleanup decisions. It removes the working directory non-recursively, and only when the process is confirmed gone. Otherwise it quarantines the directory.
- Guarded protocol (
runGuarded). A preload gate makes the child hold the Attempt fence before any fixture code runs. It reports admission and program start on a separate control pipe. The worker re-checks the caller's ownership and re-hashes the sources before launch.
Public interface
src/local-worker.ts (all fields readonly; results are frozen):
class LocalFixtureWorker(local-worker.ts:276):constructor(options: LocalFixtureWorkerOptions), whereLocalFixtureWorkerOptions = { store: EvidenceStore; workspaceRoot?: string }.workspaceRootdefaults toos.tmpdir().run(request: FixtureRequest, signal?: AbortSignal): Promise<FixtureRunResult>is the legacy path, with collectorsf-local-fixture-worker/1(:306).runGuarded(request: FixtureRequest, guard: FixtureGuard, signal?: AbortSignal): Promise<GuardedRunResult>runs under an Attempt fence (:368).
interface FixtureRequest { fixture: FixtureName; pins: EvidencePins; check: string; timeoutMs: number; maxOutputBytes: number }interface FixtureGuard { fence: FenceIdentity; hold: FenceHold; workspace: WorkspaceRef; executionDigest: string }type FixtureRunResultis one of:{ status: "observed"; scope: "local-fixture"; evidenceId: string; metadata: EvidenceMetadata; termination: TerminationSummary }{ status: "storage-failed"; scope; message: string; metadata; termination }{ status: "not-started"; scope; reason: "aborted" | "setup-failed"; message: string }
type GuardedRunResultis one of:observedorstorage-failed, as above;{ status: "not-started"; scope; reason: "aborted" | "setup-failed" | "ownership-lost" | "drift"; message };{ status: "ownership-lost"; scope; message; evidenceId: string | null; termination }.
interface TerminationSummary:pid(diagnostic only),exitCode: number | null,signal: string | null;- booleans
exited,stdioClosed,timedOut,outputExceededandaborted; signalsSent: readonly ("SIGTERM" | "SIGKILL")[];durationMs,outputBytes(every byte received) andcapturedBytes(at most the budget);workspace: WorkspaceDisposition.
type WorkspaceDisposition = { state: "removed" } | { state: "quarantined"; path: string; reason: string }- Re-exports:
FIXTURES,GUARDED_FIXTURE_COLLECTORandtype FixtureName.
Exported constants (:104–122):
| Name | Value | Meaning |
|---|---|---|
LOCAL_FIXTURE_COLLECTOR | "sf-local-fixture-worker/1" | Required pins.collector for run() |
GUARDED_FIXTURE_COLLECTOR | "sf-guarded-fixture-worker/1" | Required pins.collector for runGuarded() |
MIN_TIMEOUT_MS / MAX_TIMEOUT_MS | 1 / 60_000 | timeoutMs range (integer) |
MAX_OUTPUT_BYTES | 1_048_576 | maxOutputBytes range is 1 to this; stdout and stderr together |
TERMINATE_GRACE_MS | 250 | SIGTERM to SIGKILL |
KILL_CONFIRM_MS | 5_000 | SIGKILL until "not confirmed gone" |
CLOSE_GRACE_MS | 1_000 | Exit until open pipes are reported as held |
CHILD_ENVIRONMENT | { LC_ALL: "C", TZ: "UTC" } | The child's complete environment |
Internal limits:
- The child's lifetime is
timeoutMs + 5_000(WATCHDOG_MARGIN_MS), so the worker's timeout acts first. - Control records are limited to 256 bytes (
MAX_CONTROL_BYTES),collectorErrorto 1024 characters (MAX_COLLECTOR_ERROR_LENGTH) and every error text thatdescribe()embeds to 256 characters. - The child runs with
--max-old-space-size=64. - A spawn that assigns no pid is reported as
spawn-failedafter Node's error event, or afterCLOSE_GRACE_MSwithout one.
Evidence body: sf-local-fixture/1 fixture=<name>\n== stdout: N bytes ==\n…\n== stderr: N bytes ==\n…\n, then either == end ==\n or == output truncated at <maxOutputBytes> bytes ==\n.
src/fixture-child.ts has no side effects on import and acts only as the entry point: node fixture-child.ts <fixture> <lifetime ms>.
FIXTURES = ["pass", "fail", "hang", "noisy"](frozen),type FixtureNameandisFixtureName(value: unknown): value is FixtureName.MAX_CHILD_LIFETIME_MS = 300_000. The lifetime argument must match/^[1-9][0-9]{0,8}$/; the child caps it at this value. Anything else, an unknown fixture or a wrong argument count exitsEXIT.usage.EXIT = { pass: 0, fail: 1, usage: 64, outputError: 74, watchdog: 124, parentLost: 125 }.hangignores SIGTERM.noisywrites indefinitely and honours backpressure.passandfailexit once their output is flushed, or afterFLUSH_LIMIT_MS(2000, internal) regardless. The output lists environment variable names (never their values) and the denied capabilities.
src/fixture-gate.ts acts only when loaded as --import <url>?enter.
GATE_ENV = "SF_FIXTURE_GATE_FENCE",GATE_REFUSED_EXIT = 123,GATE_WAIT_MS = 2_000.GATE_ENTRY_QUERY = "enter",GATE_REFUSAL_PREFIX = "sf-fixture-gate: refused: ",GATE_CONTROL_FD = 3.GATE_START_CALLBACK = Symbol.for("sf-fixture-gate/1 start").admissionRecord(nonce: string): stringreturns"sf-fixture-gate/1 admitted <nonce>\n".payloadRecord(nonce: string): stringreturns"sf-fixture-gate/1 payload <nonce>\n".
src/fixture-manifest.ts:
GUARDED_FIXTURE_PROTOCOL = "sf-guarded-fixture/1"andGUARDED_FIXTURE_COLLECTOR.GUARDED_FIXTURE_SOURCESis sorted:attempt-fence.ts,attempt-workspace.ts,evidence.ts,fixture-child.ts,fixture-gate.ts,fixture-manifest.ts,local-worker.ts.interface ExecutionManifest { protocol; collector; files: readonly { path: string; sha256: string }[]; digest: string }, where eachpathissrc/<name>inGUARDED_FIXTURE_SOURCESorder and eachsha256is"sha256:"plus 64 lowercase hex characters.guardedFixtureManifest(): ExecutionManifesthashes the files as they are now, and throws if one cannot be read.parseExecutionManifest(value: unknown): ExecutionManifestvalidates exactly and recomputes the digest. It throwsTypeErrorand says nothing about the current files.- The digest covers source bytes, the protocol and the collector only. The Node version and platform are not in it: the scenario pins the runtime separately (
pins.environment), the worker never compares that pin with the runtime it actually uses (process.execPath), andpackage.jsonrequires Node>=26.8.1.
Invariants and guarantees
- Fixed program only. The worker calls
spawn(process.execPath, …, { shell: false }). Callers never supply the executable, the argument list, fixture source, a shell or the environment.run()builds its arguments from the validated fixture name and lifetime only, and creates its own working directory.runGuarded()also derives arguments from the validated guard: a read grant for the fence's directory and the fence identity inGATE_ENV; it runs in the provisioned workspace's path once that directory's identity and emptiness are checked. Tests:local-worker.test.ts"only the fixed fixture scope is accepted…" and "the child gets only the explicit environment and fixed arguments…". - Environment. The child's environment is exactly
CHILD_ENVIRONMENT. A guarded run adds onlyGATE_ENV, which the gate deletes before any fixture code runs. macOS itself adds__CF_USER_TEXT_ENCODINGto every process; the environment tests allow that one name and nothing else. - Snapshot before the first await. Input is validated and snapshotted synchronously, and each field is read once. Later caller mutation cannot change what runs or what is recorded. Test: "pins and request values are snapshotted before the process starts".
- Pins must match the path.
pins.collectormust equal the path's collector. ForrunGuarded, the pins must also name the fence'srunId, itsepochandattemptId<runId>/<epoch>.validatedHoldIdentity(hold)must equal the fence exactly, and the workspace ref must match the fence's Run, epoch, worker and nonce (parseGuard, :768). Tests:guarded-worker.test.ts"invalid or mismatched guards…" and "a relabelled fence identity on the same file…". - Bounded time and output.
- At
timeoutMsthe worker sends SIGTERM, then SIGKILL afterTERMINATE_GRACE_MS, then settles afterKILL_CONFIRM_MSif there is still no exit. - Output over
maxOutputBytesstops the process, andcapturedBytesnever exceeds the budget. - The child also exits by itself: with
EXIT.watchdogwhen its lifetime ends, and withEXIT.parentLoston stdin end-of-file. - When the worker settles, it destroys the child's stdin (never written; end-of-file is the child's cue to exit) and, if no exit was seen,
unrefs the handle so an unconfirmed process cannot keep the controller alive. - Tests: the hang and noisy tests, "minimum timeout, hang and noisy output never yield Verified…" and "the fixture program bounds its own lifetime…".
- At
- Signals only through the live handle. Signals go only through this call's
ChildProcesshandle, and only before an exit has been seen (deliver, :580). There are no PID files, leases, restarts or takeover. Test: "a controller killed abruptly leaves no fixture running and its directory is not taken over". - Cleanup only when confirmed. The directory is removed only when
exited && stdioClosed(and, for a guarded run, while ownership is still held). Removal is non-recursive (rmdir/disposeWorkspace). Otherwise the directory isquarantinedwith its path and reason, and its contents are preserved. Tests: "unexpected workspace content is preserved…", plus guarded "workspace swapped or written into…" and "unexpected workspace content left during a run…". - One record, no Verdict. Each process that starts gets at most one
EvidenceStore.put: exactly one on therun()path, and one on the guarded path unless ownership was lost after the exit, when nothing is stored. A run that starts no process stores nothing. The caller's pins and check are stored verbatim. The worker never calls Assurance. Tests: "pass runs a real process … without a verdict" and "an abort before the process starts spawns nothing and stores nothing". - Never a false clean exit. A timeout, output overflow, abort, lost contact or unconfirmed exit sets
timedOutor a non-nullcollectorError. Assurance'sclassifytreats either as inconclusive. - Guarded admission. The exit status is a fixture outcome only if the control pipe carried exactly
admissionRecord(nonce) + payloadRecord(nonce). OtherwisecollectorErrornames one of: a refusal (with its reason), a death before admission, a program never confirmed to start, or malformed records. Control records never reach Evidence output or the budget. Tests: guarded-worker cases for gate death, never-loaded and compile-failing programs, a timeout before admission, and gate refusals. - Guarded ownership.
controllerHoldProblemis checked at call start, right before the spawn, after the exit (before disposal and storage) and before return. The worker never releases the caller's hold. Once a check finds ownership lost, it starts no further removal or write. These are checks, not a borrow: the caller must keep its hold for the whole awaited call and through its own report and settlement. A check cannot cancel a write already in progress when the hold ended; the result is thenownership-lostcarrying that write'sevidenceId. Tests: "a healthy guarded pass… leaves the controller hold with the caller", "without a live controller hold…", "the running child holds the fence itself…" and "a hold that ends while Evidence is stored…". - No await in the launch window. Nothing is awaited between
checkWorkspace,isWorkspaceEmpty,checkDedicatedFenceDirectoryand the last ownership check, and the synchronous spawn. The fence directory may hold only<fence>and<fence>-journal. Test:guarded-worker-boundaries.test.ts. - Drift refusal. The sources are re-hashed before launch, and a mismatch with
executionDigestreturnsnot-started/drift. - Manifest closure. The manifest list is sorted and closed under the static
./imports that the test scans (see "Changing it safely" for what the scan misses). The digest is the SHA-256 of canonical JSON of[protocol, collector, [[path, sha256]…]], so a change to any listed file, including a dependency alone, changes it. Tests: all three cases infixture-manifest.test.ts.
Failure semantics
- Throws.
- The constructor throws
TypeErrorsynchronously for bad options: a non-object, an extra field, a non-EvidenceStorestore, or aworkspaceRootthat is not a string, not absolute, contains NUL or is longer than 4096 characters. It does not check that the directory exists: a missing root isnot-started/setup-failedat run time (test "a missing workspace root is a typed setup failure with no process"). runandrunGuardedreject withTypeErrorbefore anything is created or touched:invalid fixture request: …(anEvidenceErrorfromparsePins/parseCheckNameis re-wrapped),invalid guard: …(aFenceError/WorkspaceErroris re-wrapped) orsignal must be an AbortSignal. A guard of the wrong shape fails first and unprefixed:guard must be an object,guard must be a plain object,guard has unexpected field …orguard.<field> is required.- Every runtime outcome is a result, not a throw.
- The constructor throws
- Result by status:
| Status / reason | Process | Evidence | Workspace |
|---|---|---|---|
not-started / aborted, setup-failed | none | none | run(): its own temporary directory is removed, or quarantined with a note in message. runGuarded(): left for the caller |
not-started / drift, ownership-lost (guarded) | none | none | Left for the caller |
observed | ran | evidenceId | termination.workspace |
storage-failed | ran | no identity returned (put rejected); metadata is returned. The object may already exist, because put links it before its final directory sync | termination.workspace |
ownership-lost (guarded) | ran | evidenceId if the one write, started only while the hold was live, returned an identity (the hold may have ended during it); otherwise null | Quarantined for recovery if the hold was lost before disposal |
- Unknown vs failed. The worker never decides pass or fail.
exitCodeisnullwhen no exit was observed, and never defaults to0.exited: falsemeans the process was not confirmed gone after SIGKILL.- A gate refusal (
GATE_REFUSED_EXIT) is the gate's exit, never a fixture outcome; a held or closed fence proves exclusion, not that a process exited. - Assurance gives Failed only for a known unexpected exit code. A
collectorError, a timeout, a signal or anullexit is Inconclusive.
- Abort. An abort before the spawn starts nothing. An abort during the run stops the process and records
aborted. An abort after the exit has no effect: what remains is a fixed set of steps. Pipe collection waits at mostCLOSE_GRACE_MS; cleanup and the one store write await filesystem operations with no deadline of their own. - No retries and no idempotency. Every call that starts a process launches a new one and makes at most one storage attempt. Evidence identity is content-derived, so identical metadata and bytes (including a
collectedAtsampled in the same millisecond) share one record. There are no command IDs and no replay. Retries, Budget and replay belong to Execution and guarded verification. The worker does not retry astorage-failedresult.
Trust scope
Established (local, per the receipts above):
- Real bounded child processes for the four fixtures, isolated by an explicit environment.
- Cancellation.
- Controller loss: the child exits on stdin end-of-file or on its own watchdog.
- Quarantine instead of claimed cleanup.
- Guarded path: fence-held admission, exact ownership checks and drift refusal. Collector errors never become fixture outcomes.
- Tested on macOS with Node 26.8.1.
Not established:
- Safe execution of arbitrary code, or an OS sandbox. Node's permission model (it denies
child,worker,fs.writeandnet) is defence in depth only. - Confinement of the fence:
node:sqliteignores--permissionfile flags, and a directory read grant covers everything inside that directory. - Protection against a hostile concurrent writer in the workspace root, the fence directory or the Evidence store. These are trusted local storage. The collector pin is attribution, not authentication.
- That a process exited. A fence proves exclusion, not process death.
- That
pins.environmentdescribes the runtime that actually ran; the worker records the pin verbatim and does not check it. - Tracking of process groups or descendants. None should exist, and none are tracked.
- Durable removal.
removedmeans gone at that moment; the worker dropsdisposeWorkspace'sdurableflag. - Reclaiming a dead controller's directory, or killing a saved PID.
- Other platforms, power loss, CI or production.
- Current coverage. The build receipt's coverage percentages do not measure the current suite. The coverage run also omitted the environment assertion, which passes in the normal suite.
Composition
Depends on:
src/evidence.ts:EvidenceStore#put,parsePins,parseCheckName,EvidenceError, and theEvidencePins,EvidenceMetadataandProcessObservationtypes.src/attempt-fence.ts:parseFenceIdentity,validatedHoldIdentityandFenceErrorin the worker, andholdFencein the gate.src/attempt-workspace.ts:checkWorkspace,isWorkspaceEmpty,disposeWorkspace,parseWorkspaceRef,controllerHoldProblemandWorkspaceError.
Used by:
src/guarded-verification.ts:dispatchOnecallsnew LocalFixtureWorker({ store: ctx.store }).runGuarded(…)with the Expectation's pins andrequest.candidate.execution.digest.buildGuardedRequestusesparseExecutionManifest,isFixtureName,FIXTURES,MAX_TIMEOUT_MS,MAX_OUTPUT_BYTESandGUARDED_FIXTURE_COLLECTOR.currentExecutionusesguardedFixtureManifest().digest, before the launch and after the run.verdictOfjudges only when both equal the pin.
src/local-factory.ts:verifyFixturepinsguardedFixtureManifest().buildLegacyRequestusesLOCAL_FIXTURE_COLLECTORand the bounds to re-check stored/1records./1Runs are no longer dispatched, sorun()has nosrc/caller today; only the tests call it.
src/factory-support.ts: theExecutionManifest,FixtureNameandTerminationSummarytypes.isTerminationre-validates everyTerminationSummaryfield by name, type and range (TERMINATION_FIELDS, exact set) when stored reports are read back, andunsafeCleanupreadsexited,stdioClosedandworkspace.
Changing it safely
- Any edit to a manifest source changes the digest. That applies to all seven
GUARDED_FIXTURE_SOURCES. Runs pinned under Recipesf-local-fixture-verify/2then dispatch nothing (drift), which is by design; never re-pin silently. Editingfixture-child.tsalso invalidates/1Candidates. - Any change to a
TerminationSummaryfield must also changeTERMINATION_FIELDSandisTerminationinfactory-support.ts, or the stored/1and/2reports thatlocal-factory.tsandguarded-verification.tsread back stop validating. - Add every new relative dependency of a listed file to
GUARDED_FIXTURE_SOURCES, keeping the list sorted and transitively closed.fixture-manifest.test.tschecks only staticimport/export … from "./…"declarations; a side-effect import (import "./x.ts"), a dynamicimport()or a parent-relative (../) import would pass the test unlisted, and a later change to that file would then leave the digest unchanged. Check closure by hand for those, or extend the scan. - Keep the start-callback key identical in
fixture-child.tsandfixture-gate.ts. A guarded-worker test checks it. - Keep
GATE_REFUSED_EXITdistinct from everyEXITvalue. - Never:
- add an await to the
runGuardedlaunch window; - inherit the controller's environment, accept caller arguments, or run in any directory other than one the worker created or the validated provisioned workspace;
- signal by PID;
- make removal recursive;
- release the caller's hold;
- store anything after ownership is lost.
- add an await to the
- Proof by area:
- Legacy bounds, abort and environment:
local-worker*.test.ts. The hang test accepts SIGTERM or SIGKILL as the ending signal, because SIGTERM stops the child before its handler is installed; keep that tolerance. - Admission, ownership, drift and quarantine:
guarded-worker*.test.ts. - Manifest closure and drift:
fixture-manifest.test.ts.
- Legacy bounds, abort and environment:
- After a change:
- Run
npm run check. - Get an independent review.
- Record a new receipt with the source hashes, and update this module's row in build-review.md.
- Keep the disposition wording within what the tests show.
- Run
