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 filetest/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 intest/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
baseruns the base tree B, materialised byte-exactly from the repository snapshot (materialiseBase); a check of stagecandidateruns the exported Candidate M's files. Either tree is only bytes handed toConfinedExecutor.runas its program, with the check'sentryandargs; the input is the one fixture snapshot the check names. Nothing else is given to the sandbox: the expected stdout exists only asstdoutDigestin 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 whoserunIdis the pinned Product; the pinnedrecipe.collector.closureiscollectorClosure(), so records are never attributed to other code; the sealed Candidate's canonical manifest hashes topins.candidateDigestand 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 bindingsjudgeRepositoryauthenticates, 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;placementProblemfinds 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 (elseoversized); and at least 1 ms remains before the deadline. Each failure is a not-started run with its reason. - One run.
startedAtandendedAtcome from the collector's clock immediately aroundexecutor.run. The effective timeout ismin(recipe timeoutMs, time remaining), and the run's signal also aborts when the deadline passes (AbortSignal.any([signal, AbortSignal.timeout(remaining)])). An executorTypeErroris recorded as not-started, relying on the executor's documented contract that it throwsTypeErroronly before anything is touched; any other throw isunresolved. - One record per run. Every check, started or not, becomes one
ObservationFactsdocument (check, stage, Candidate, status, attributability, problems, the executor's identities, process facts, namespace disposition, output lengths) encoded with its stdout and stderr, and oneEvidenceStore.putwithpins = evidencePinsOf(pins)andobservation = 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;judgeRepositorycan 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/liband the directory of every runtime.dylib(files and aliases), and the individual paths of every runtime file and alias, the executable,/,/dev/null,/dev/randomand/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/Volumesor/.vol, whose paths are second names for directories elsewhere. The collector checks itsprotectedPathsthis way at construction (a problem is aTypeError) 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,/.voland..rows).
- it cannot be placed: that prefix cannot be examined or resolved with
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 eightsrc/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}):protectedPathsis 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 listplacementProblemfinds 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}: oneCollectedRun {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 (nullwhen 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 adescribeRuntime()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 betreeatcommit.listingDigest(listing.files)equalssnapshotDigest(files).collectorClosure(): string:"sha256:"of the canonical{collector: "sf-repository-scenario-collector/1", files: [{path: "src/<name>", sha256}]}overCOLLECTOR_SOURCESas they are now.SnapshotStore(root):put(files, expected): SnapshotManifestandread(digest): SnapshotFile[];SnapshotError {code: "missing" | "corrupt" | "conflict" | "unavailable"}. See the invariants.
Invariants and guarantees
- 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).
- Only the pinned trees run. The base run's program digest is
pins.baseSnapshot, the Candidate'spins.candidateSnapshot, both checked on private copies before the first run and recorded by the executor. - 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 givesInconclusive, neverVerifiedorFailed. - 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 andcollectrejects withFenceErrorreleased; records stored earlier stand and are inert (theirattemptIdis this epoch's). - 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 evidencenull. The composition must then issue no Verdict for this epoch (spec V6/V8). - Deadline. Checked before each launch; the recorded
timeoutMsis the effective one, which the executor enforced (its identities carry it), so a capped run still qualifies; a run whoseendedAtby the collector's clock is past the deadline never does. - 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. - Snapshot store (
<root>=<snapshots>/<productId>, created0700):putrefuses, withTypeErrorbefore any write, files that do not giveexpected, 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-replacinglinkto<root>/<hex>, removes the temporary name and fsyncs the directory. An equal entry replays; different bytes under the name areconflict. The file holdssf-delivery-snapshot/1\n<length>\n<canonical SnapshotManifest>and the files' bytes in path order; the manifest is exactly whatDeliveryLedger.recordSnapshotManifestaccepts.readopens without following links, re-hashes every file and recomputes the digest; anything else iscorruptand is never repaired. A root that is not a canonical private directory of this user isunavailable.
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
TypeErrorafter 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 aftercollect, pass the gate's current state as theCollectGate, judge{pins, evidence: runs with a record as {ref, check}}withjudgeRepository, and recordnot-startedrather than a Verdict when any run isstorage-failedor the environment or Candidate changed.
Changing it safely
- Run
node --test test/repository-collector.test.ts test/repository-collector-adversarial.test.ts, thentest/repository-assurance.test.tsandtest/delivery-boundaries.test.ts, thennpm run check. - Any change to a file in
COLLECTOR_SOURCESchangescollectorClosure(): 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.
