Factory docs, home
Page navigation

Runs each frozen process check of one sealed Candidate once under the accepted Confined executor and stores exactly one sf-repository-observation/1 Evidence record per run under the repository-scope pins Repository Assurance judges. It observes; it never judges, never runs Candidate or base code in its own process, never reads or writes Git and never writes the journal. Beside it, a write-once snapshot store keeps a Slice's frozen fixture inputs.

Status: Accepted as step 1, increment 3, of the step-1 core (handover) on branch factory/delivery. Before review, an independent adversarial pass found four defects (the Data-volume and /.vol spellings of a readable directory accepted as protected paths, .. after a link resolved as text, and pins not bound to the sealed Candidate's own store, Slice and frozen-record digests), fixed test-first; its six tests are kept in the adversarial file. Independent implementation review by Astra (gpt-6-astra, high effort): round 1 FAIL (1 finding: placement not checked at setup), round 2 PASS on 29 September 2026 (<local evidence archive>). At that review the focused tests (collector, adversarial, delivery boundaries, Repository Assurance) passed 50/50, none skipped (astra-inc3-tests.log in the same directory), and the full npm run check then passed 881/881, none skipped. No receipt exists yet, and this reference has no separate reference-review report. Not composed: nothing in src/ imports it; the delivery composition that will call it (step 1, increment 4) is not built. Local development only: no delivery, release or customer claim, and all 27 epics remain open. Human page: guide.

  • Source: src/repository-collector.ts
  • Tests: test/repository-collector.test.ts (20; every executor run is a real sandboxed process, except that two rows wrap the real executor to report a relabelled or unresolved run and one replaces it with an executor that throws) and the independent adversarial file test/repository-collector-adversarial.test.ts (6: other spellings of a readable directory, pins the sealed Candidate was not sealed for, and what the sandbox receives); its import boundary is asserted in test/delivery-boundaries.test.ts.
  • Helper: test/helpers/collector.ts (an owned bare store with a stub tool, a Candidate sealed from an authored program, fixture inputs, pins, a real Evidence store, executor root and fence hold; the expected stdout is derived on the host and appears in the acceptance only as a digest).

Intelligence: none — Runs each frozen check once in the sandbox and stores what the host saw, with write-once input snapshots; it observes and never judges, so nothing is inferred.

What it hides

  • Candidate-as-program runs. A check of stage base runs the base tree B, materialised byte-exactly from the repository snapshot (materialiseBase); a check of stage candidate runs the exported Candidate M's files. Either tree is only bytes handed to ConfinedExecutor.run as its program, with the check's entry and args; the input is the one fixture snapshot the check names. Nothing else is given to the sandbox: the expected stdout exists only as stdoutDigest in the frozen acceptance, held by the host.
  • Validation before anything starts (TypeError, nothing started or stored): the pins parse; the hold is one this process holds (validatedHoldIdentity) on a fence whose runId is the pinned Product; the pinned recipe.collector.closure is collectorClosure(), so records are never attributed to other code; the sealed Candidate's canonical manifest hashes to pins.candidateDigest and names the pinned store (canonically equal) and Product, Slice id and revision, base and commit, and the digests of the pinned acceptance, recipe and environment records (the bindings judgeRepository authenticates, so nothing runs under records the Candidate was not sealed for); the Candidate files, the base (Git tree id recomputed) and every input a process check names give their pinned snapshot digests; the deadline is an ISO timestamp. All bytes are copied at this point, so later changes to the caller's objects change nothing that runs (test "Candidate files changed after sealing…").
  • Before each executor process, in this order: the signal is not aborted; gate.current() says proceed (a refusal, an unreadable gate or a malformed answer makes that check and every later one not-started, and the gate is not asked again; a read still unanswered when the signal aborts or the deadline passes is abandoned, with the same effect and the reason "aborted before the run" or "the deadline … passed while the gate was read"); the hold is still active; describeRuntime() succeeds and the environment record (runtime, profile, platform, node) equals the pinned one; placementProblem finds no protected path the profile can read; the entry is a file of the stage's tree; program and input fit the executor's 16 MiB (else oversized); and at least 1 ms remains before the deadline. Each failure is a not-started run with its reason.
  • One run. startedAt and endedAt come from the collector's clock immediately around executor.run. The effective timeout is min(recipe timeoutMs, time remaining), and the run's signal also aborts when the deadline passes (AbortSignal.any([signal, AbortSignal.timeout(remaining)])). An executor TypeError is recorded as not-started, relying on the executor's documented contract that it throws TypeError only before anything is touched; any other throw is unresolved.
  • One record per run. Every check, started or not, becomes one ObservationFacts document (check, stage, Candidate, status, attributability, problems, the executor's identities, process facts, namespace disposition, output lengths) encoded with its stdout and stderr, and one EvidenceStore.put with pins = evidencePinsOf(pins) and observation = observationOf(facts). A run that did not start, was unresolved, was not attributable, or whose identities differ from the pinned program, input, runtime, profile or bounds (with the effective timeout) carries a collector error; judgeRepository can then only find it Inconclusive. In each problem text, control characters U+0000–U+001F and U+007F become ? (other characters are kept); each is at most 1,024 characters and there are at most 16.
  • Placement. profileReadable(runtime) is what the pinned profile lets a program read: the subtrees /System/Library, /usr/lib and the directory of every runtime .dylib (files and aliases), and the individual paths of every runtime file and alias, the executable, /, /dev/null, /dev/random and /dev/urandom, each as written and as it resolves now. placementProblem(protectedPaths, runtime) takes each protected path as given (the collector keeps it unnormalised, so a .. after a symbolic link is resolved by the file system, not removed as text), finds its longest existing prefix, and refuses it when:
    • it cannot be placed: that prefix cannot be examined or resolved with realpath (a /.vol/<device>/<inode> path exists but does not resolve), the next component is a link that does not resolve now (dangling, or into /.vol), or a readable path that exists cannot be placed;
    • by device and inode: the prefix, or any directory above it as the kernel resolves .. (from a Data-volume firmlink such as /System/Volumes/Data/System/Library/Caches, .. leads to /System/Library), is a readable subtree; or the whole path exists and is a readable subtree or file, or a directory above one;
    • by name: as written (normalised) or as the prefix resolves with the rest appended, it equals, lies below or contains a readable subtree, or equals or contains a readable file, compared without regard to case; or it lies under /System/Volumes or /.vol, whose paths are second names for directories elsewhere. The collector checks its protectedPaths this way at construction (a problem is a TypeError) and again before every executor process, so a path that becomes readable after setup, or during a collection, stops the next launch (tests "a protected path the profile can read…" and "placement compares device and inode…", and the adversarial file's Data-volume, /.vol and .. rows).

Public interface

Runtime imports: node:buffer, node:crypto, node:fs, node:path; validatedHoldIdentity from ./attempt-fence.ts; canonicalJson from ./canonical-json.ts; CONFINED_PROFILE, ConfinedExecutor, MAX_SNAPSHOT_BYTES, confinedProfile, describeRuntime and snapshotDigest from ./confined-executor.ts; EvidenceStore from ./evidence.ts; ENVIRONMENT_PROTOCOL, OBSERVATION_FORMAT, REPOSITORY_COLLECTOR, acceptanceDigest, encodeObservation, environmentDigest, evidencePinsOf, listingDigest, observationOf, parseRepositoryPins and recipeDigest from ./repository-assurance.ts. Types only from ./repository.ts and ./delivery-records.ts. The test asserts these exact imports, so the collector itself imports no journal, Git, node:child_process or network module; its module graph still loads node:child_process through confined-executor.ts (the sandboxed spawn) and repository.ts through repository-assurance.ts, whose Git functions the collector never calls.

  • Constants: SNAPSHOT_LIMITS = {maxFiles: 256, maxBytes: 15 MiB} (frozen), SNAPSHOT_FORMAT = "sf-delivery-snapshot/1", MAX_PROTECTED_PATHS = 256, COLLECTOR_SOURCES (the eight src/ files loaded with the collector: attempt-fence.ts, canonical-json.ts, confined-executor.ts, errors.ts, evidence.ts, repository-assurance.ts, repository-collector.ts, repository.ts; closed under runtime relative imports, asserted by test).
  • new RepositoryCollector({store: EvidenceStore, executor: ConfinedExecutor, protectedPaths: string[], now?: () => Date}): protectedPaths is 1–256 absolute paths the Candidate must never read (the journal, every delivery directory, registered checkouts and their common directories, fixture input directories, the operator's answer key), kept as given. The constructor refuses (TypeError) a list placementProblem finds a problem with now, or any list when the runtime cannot be described to place it; placement is decided again before every launch.
  • collect(request: CollectRequest, gate: CollectGate, hold: FenceHold, signal: AbortSignal): Promise<CollectResult>:
    • CollectRequest {pins, sealed: SealedCandidate, base: SourceSnapshot, inputs: ReadonlyMap<listing digest, SnapshotFile[]>, deadline}; CollectGate {current(): Promise<{proceed: true} | {proceed: false; reason}>}.
    • CollectResult {runs, evidence, environmentBefore, environmentAfter}: one CollectedRun {check, evidence: string | null, status: "observed" | "unresolved" | "not-started" | "storage-failed", identities, problems} per process check in acceptance order (before, after, guardrails; review checks are reserved and never run), the stored ids in run order, and the environment record at the start and end (null when the runtime could not be described).
  • currentEnvironment(): EnvironmentRecord: {protocol: "sf-confined-environment/1", runtime: describeRuntime().digest, profile: confinedProfile().digest, platform: "<platform>/<arch>", node: <version>}; throws when the runtime cannot be described.
  • profileReadable(runtime), placementProblem(protectedPaths, runtime): string | undefined: as above; both take a describeRuntime() result.
  • materialiseBase(snapshot, {commit, tree}): {files, listing: BaseListing}: byte-exact, frozen; each file's UTF-8 bytes must give its recorded size, SHA-256 and Git blob id, paths must be in order, and the Git tree built from them must be tree at commit. listingDigest(listing.files) equals snapshotDigest(files).
  • collectorClosure(): string: "sha256:" of the canonical {collector: "sf-repository-scenario-collector/1", files: [{path: "src/<name>", sha256}]} over COLLECTOR_SOURCES as they are now.
  • SnapshotStore(root): put(files, expected): SnapshotManifest and read(digest): SnapshotFile[]; SnapshotError {code: "missing" | "corrupt" | "conflict" | "unavailable"}. See the invariants.

Invariants and guarantees

  1. At most one process per check, one record per run. No retry, no second attempt in the same collection, and no fallback to anything but the pinned executor. The stored ids are exactly the Evidence store's new objects (tests count them).
  2. Only the pinned trees run. The base run's program digest is pins.baseSnapshot, the Candidate's pins.candidateSnapshot, both checked on private copies before the first run and recorded by the executor.
  3. Collector errors are never outcomes. Every refusal before launch, executor refusal (a full root is retained-limit), namespace change, identity difference, unresolved or thrown executor, stopped run (timeout, output limit, abort) and deadline overrun is a record the judge rejects or finds inconclusive; tests show each gives Inconclusive, never Verified or Failed.
  4. The hold bounds storage. The hold is asserted before every executor process (after the gate read) and before every put. Once it has ended, nothing further is started or stored and collect rejects with FenceError released; records stored earlier stand and are inert (their attemptId is this epoch's).
  5. Storage failure stops the collection. A record that cannot be encoded or stored makes that run storage-failed; nothing further is started or stored and later checks are reported not-started with evidence null. The composition must then issue no Verdict for this epoch (spec V6/V8).
  6. Deadline. Checked before each launch; the recorded timeoutMs is the effective one, which the executor enforced (its identities carry it), so a capped run still qualifies; a run whose endedAt by the collector's clock is past the deadline never does.
  7. Hostile Candidates (test "a hostile Candidate…"): reads of the answer key, the Evidence store, the repository's config, the fence and a canary beside them; listings of those directories, the executor root, the home directory and its own namespace's parent; and writes into the Evidence store, the executor root and the scenario directory are all denied (EPERM); no canary bytes reach any record; a forged observation header printed on stdout stays inside the stdout bytes, after the collector's own facts. A runtime library directory is readable, which is why protected paths may not be placed there.
  8. Snapshot store (<root> = <snapshots>/<productId>, created 0700): put refuses, with TypeError before any write, files that do not give expected, more than 256 files or 15 MiB, or anything the executor's snapshot rules refuse. It writes <root>/tmp-<hex> (O_EXCL, 0400, fsynced; a leftover temporary from an interrupted put is replaced, never adopted), publishes it with a non-replacing link to <root>/<hex>, removes the temporary name and fsyncs the directory. An equal entry replays; different bytes under the name are conflict. The file holds sf-delivery-snapshot/1\n<length>\n<canonical SnapshotManifest> and the files' bytes in path order; the manifest is exactly what DeliveryLedger.recordSnapshotManifest accepts. read opens without following links, re-hashes every file and recomputes the digest; anything else is corrupt and is never repaired. A root that is not a canonical private directory of this user is unavailable.

Failure semantics

TypeError for invalid options (protected paths the profile can read at construction included), requests, pins, pins the sealed Candidate was not sealed under (another store, Product, Slice, acceptance, recipe or environment), holds of another fence, a pinned closure that is not this code, snapshot files or base snapshots, before anything is started or stored. FenceError released once the hold has ended. Everything else about a run is its CollectedRun status and problems, and its record. SnapshotError for the snapshot store's storage faults.

Trust scope

  • Established locally: the rules above on this Mac (darwin/arm64, Node 26.8.1, the pinned profile), with real owned bare stores, real sealed Candidates, the real executor and Evidence store, and real fence holds.
  • Not established: any composition (the gate reads, re-exports around a collection and the Verdict are increment 4); protection from a hard link to a protected file placed where the profile can read (identities are compared for directories above a path and for the path itself, not searched for other names), from a component created between one placement check and the launch it guards (the next check catches it), or from a writer with access to the protected paths (the collector's facts are attribution, not authentication); an executor that throws TypeError after starting something (recorded as not-started, still Inconclusive); stopping a run when the hold ends mid-run (the run completes, then nothing is stored); a check after an await undoing a write already in progress when the hold ended; stopping later checks after an unresolved run (each launches under the same pre-launch rules); a snapshot store shared by concurrent writers (one writer, the owner, at a time).

Composition

  • Depends on: Confined executor (run, describeRuntime, confinedProfile, snapshotDigest), Evidence / Assurance (EvidenceStore.put), Repository Assurance (pins, observation encoding, listingDigest), Attempt fence (validatedHoldIdentity, FenceHold), Command journal's canonical JSON, and the types of Repository and the delivery records, all unchanged.
  • Used by: nothing in src/ yet. The delivery composition (step 1, increment 4) is to re-export the Candidate before and after collect, pass the gate's current state as the CollectGate, judge {pins, evidence: runs with a record as {ref, check}} with judgeRepository, and record not-started rather than a Verdict when any run is storage-failed or the environment or Candidate changed.

Changing it safely

  • Run node --test test/repository-collector.test.ts test/repository-collector-adversarial.test.ts, then test/repository-assurance.test.ts and test/delivery-boundaries.test.ts, then npm run check.
  • Any change to a file in COLLECTOR_SOURCES changes collectorClosure(): every recipe pinned with the old closure is refused (TypeError) and every Verdict over it must be re-established under a new Slice revision.
  • Keep the placement subtrees in step with the executor's PROFILE_TEMPLATE; never narrow the rule without a new boundary review.

Source: docs/agents/repository-collector.md