Factory docs, home
Page navigation

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 the collectorError text.
  • 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), where LocalFixtureWorkerOptions = { store: EvidenceStore; workspaceRoot?: string }. workspaceRoot defaults to os.tmpdir().
    • run(request: FixtureRequest, signal?: AbortSignal): Promise<FixtureRunResult> is the legacy path, with collector sf-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 FixtureRunResult is 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 GuardedRunResult is one of:
    • observed or storage-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, outputExceeded and aborted;
    • signalsSent: readonly ("SIGTERM" | "SIGKILL")[];
    • durationMs, outputBytes (every byte received) and capturedBytes (at most the budget);
    • workspace: WorkspaceDisposition.
  • type WorkspaceDisposition = { state: "removed" } | { state: "quarantined"; path: string; reason: string }
  • Re-exports: FIXTURES, GUARDED_FIXTURE_COLLECTOR and type FixtureName.

Exported constants (:104–122):

NameValueMeaning
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_MS1 / 60_000timeoutMs range (integer)
MAX_OUTPUT_BYTES1_048_576maxOutputBytes range is 1 to this; stdout and stderr together
TERMINATE_GRACE_MS250SIGTERM to SIGKILL
KILL_CONFIRM_MS5_000SIGKILL until "not confirmed gone"
CLOSE_GRACE_MS1_000Exit 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), collectorError to 1024 characters (MAX_COLLECTOR_ERROR_LENGTH) and every error text that describe() embeds to 256 characters.
  • The child runs with --max-old-space-size=64.
  • A spawn that assigns no pid is reported as spawn-failed after Node's error event, or after CLOSE_GRACE_MS without 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 FixtureName and isFixtureName(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 exits EXIT.usage.
  • EXIT = { pass: 0, fail: 1, usage: 64, outputError: 74, watchdog: 124, parentLost: 125 }.
  • hang ignores SIGTERM. noisy writes indefinitely and honours backpressure. pass and fail exit once their output is flushed, or after FLUSH_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): string returns "sf-fixture-gate/1 admitted <nonce>\n".
  • payloadRecord(nonce: string): string returns "sf-fixture-gate/1 payload <nonce>\n".

src/fixture-manifest.ts:

  • GUARDED_FIXTURE_PROTOCOL = "sf-guarded-fixture/1" and GUARDED_FIXTURE_COLLECTOR.
  • GUARDED_FIXTURE_SOURCES is 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 each path is src/<name> in GUARDED_FIXTURE_SOURCES order and each sha256 is "sha256:" plus 64 lowercase hex characters.
  • guardedFixtureManifest(): ExecutionManifest hashes the files as they are now, and throws if one cannot be read.
  • parseExecutionManifest(value: unknown): ExecutionManifest validates exactly and recomputes the digest. It throws TypeError and 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), and package.json requires Node >=26.8.1.

Invariants and guarantees

  1. 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 in GATE_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…".
  2. Environment. The child's environment is exactly CHILD_ENVIRONMENT. A guarded run adds only GATE_ENV, which the gate deletes before any fixture code runs. macOS itself adds __CF_USER_TEXT_ENCODING to every process; the environment tests allow that one name and nothing else.
  3. 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".
  4. Pins must match the path. pins.collector must equal the path's collector. For runGuarded, the pins must also name the fence's runId, its epoch and attemptId <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…".
  5. Bounded time and output.
    • At timeoutMs the worker sends SIGTERM, then SIGKILL after TERMINATE_GRACE_MS, then settles after KILL_CONFIRM_MS if there is still no exit.
    • Output over maxOutputBytes stops the process, and capturedBytes never exceeds the budget.
    • The child also exits by itself: with EXIT.watchdog when its lifetime ends, and with EXIT.parentLost on 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…".
  6. Signals only through the live handle. Signals go only through this call's ChildProcess handle, 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".
  7. 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 is quarantined with 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…".
  8. One record, no Verdict. Each process that starts gets at most one EvidenceStore.put: exactly one on the run() 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".
  9. Never a false clean exit. A timeout, output overflow, abort, lost contact or unconfirmed exit sets timedOut or a non-null collectorError. Assurance's classify treats either as inconclusive.
  10. Guarded admission. The exit status is a fixture outcome only if the control pipe carried exactly admissionRecord(nonce) + payloadRecord(nonce). Otherwise collectorError names 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.
  11. Guarded ownership. controllerHoldProblem is 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 then ownership-lost carrying that write's evidenceId. 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…".
  12. No await in the launch window. Nothing is awaited between checkWorkspace, isWorkspaceEmpty, checkDedicatedFenceDirectory and the last ownership check, and the synchronous spawn. The fence directory may hold only <fence> and <fence>-journal. Test: guarded-worker-boundaries.test.ts.
  13. Drift refusal. The sources are re-hashed before launch, and a mismatch with executionDigest returns not-started / drift.
  14. 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 in fixture-manifest.test.ts.

Failure semantics

  • Throws.
    • The constructor throws TypeError synchronously for bad options: a non-object, an extra field, a non-EvidenceStore store, or a workspaceRoot that 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 is not-started / setup-failed at run time (test "a missing workspace root is a typed setup failure with no process").
    • run and runGuarded reject with TypeError before anything is created or touched: invalid fixture request: … (an EvidenceError from parsePins/parseCheckName is re-wrapped), invalid guard: … (a FenceError/WorkspaceError is re-wrapped) or signal 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 … or guard.<field> is required.
    • Every runtime outcome is a result, not a throw.
  • Result by status:
Status / reasonProcessEvidenceWorkspace
not-started / aborted, setup-failednonenonerun(): its own temporary directory is removed, or quarantined with a note in message. runGuarded(): left for the caller
not-started / drift, ownership-lost (guarded)nonenoneLeft for the caller
observedranevidenceIdtermination.workspace
storage-failedranno identity returned (put rejected); metadata is returned. The object may already exist, because put links it before its final directory synctermination.workspace
ownership-lost (guarded)ranevidenceId if the one write, started only while the hold was live, returned an identity (the hold may have ended during it); otherwise nullQuarantined for recovery if the hold was lost before disposal
  • Unknown vs failed. The worker never decides pass or fail.
    • exitCode is null when no exit was observed, and never defaults to 0.
    • exited: false means 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 a null exit 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 most CLOSE_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 collectedAt sampled 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 a storage-failed result.

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.write and net) is defence in depth only.
  • Confinement of the fence: node:sqlite ignores --permission file 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.environment describes 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. removed means gone at that moment; the worker drops disposeWorkspace's durable flag.
  • 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 the EvidencePins, EvidenceMetadata and ProcessObservation types.
  • src/attempt-fence.ts: parseFenceIdentity, validatedHoldIdentity and FenceError in the worker, and holdFence in the gate.
  • src/attempt-workspace.ts: checkWorkspace, isWorkspaceEmpty, disposeWorkspace, parseWorkspaceRef, controllerHoldProblem and WorkspaceError.

Used by:

  • src/guarded-verification.ts:
    • dispatchOne calls new LocalFixtureWorker({ store: ctx.store }).runGuarded(…) with the Expectation's pins and request.candidate.execution.digest.
    • buildGuardedRequest uses parseExecutionManifest, isFixtureName, FIXTURES, MAX_TIMEOUT_MS, MAX_OUTPUT_BYTES and GUARDED_FIXTURE_COLLECTOR.
    • currentExecution uses guardedFixtureManifest().digest, before the launch and after the run. verdictOf judges only when both equal the pin.
  • src/local-factory.ts:
    • verifyFixture pins guardedFixtureManifest().
    • buildLegacyRequest uses LOCAL_FIXTURE_COLLECTOR and the bounds to re-check stored /1 records.
    • /1 Runs are no longer dispatched, so run() has no src/ caller today; only the tests call it.
  • src/factory-support.ts: the ExecutionManifest, FixtureName and TerminationSummary types. isTermination re-validates every TerminationSummary field by name, type and range (TERMINATION_FIELDS, exact set) when stored reports are read back, and unsafeCleanup reads exited, stdioClosed and workspace.

Changing it safely

  • Any edit to a manifest source changes the digest. That applies to all seven GUARDED_FIXTURE_SOURCES. Runs pinned under Recipe sf-local-fixture-verify/2 then dispatch nothing (drift), which is by design; never re-pin silently. Editing fixture-child.ts also invalidates /1 Candidates.
  • Any change to a TerminationSummary field must also change TERMINATION_FIELDS and isTermination in factory-support.ts, or the stored /1 and /2 reports that local-factory.ts and guarded-verification.ts read 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.ts checks only static import/export … from "./…" declarations; a side-effect import (import "./x.ts"), a dynamic import() 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.ts and fixture-gate.ts. A guarded-worker test checks it.
  • Keep GATE_REFUSED_EXIT distinct from every EXIT value.
  • Never:
    • add an await to the runGuarded launch 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.
  • 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.
  • 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.

Source: docs/agents/local-fixture-worker.md