diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e8e97d..67216852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ Each release of Fetchproof, newest first. The version is `KIT_VERSION` in ## Unreleased +- **Fixed: the remote watch ends at its deadline, even with a poll in flight** (independent audit 2026-11-04, + F06). `waitForRun` checked its deadline only once a poll had answered: a request that never answered - no + headers, or a body that never finished - held `collect-remote` past `--timeout` for as long as it hung, and a + completed run it released late was returned as if in time. One signal per watch, aborted at the deadline, + now goes to each poll's fetch and body reads and cuts off what is still unanswered then: the watch ends with + TIMEOUT (exit 4), naming the run, the status it last had and `--run-id`. A wait ends at the deadline at the + latest, and no poll starts after it; a failure being retried at the deadline is still reported as itself + (ADR-0201). A 403 whose body is cut off there answered in time: its TIMEOUT says so, with the refusal's own + remedy first - the rate limit's wait when a header names it, else both causes, the wait and then the token's + "Actions" permission, since a secondary limit is named only in the body that was cut off; a 403 from the + dispatch or a run read whose body could not be read names both too. An error that ends the watch is + followed by the way back to the run (`--run-id`), which may be fine, unless its remedy names it already or + the run's number is in doubt (a 404 before any poll read the run): "wait ... before retrying" sent the + operator to a command that dispatches another run. A 404 after a poll read the run is about a number that is + right - the token lost sight of the run, or it was deleted - and names the way back too, where it said to + check the number. `collect-remote` gives an error that ends the download the same way back, unless the + artifact itself is the problem (none, several, a name that is not a file name, too large, expired): "retry" + after a proxy's HTML page or a dropped download, and a folder to fix, did the same. Seven tests, and one now + expects no poll after the deadline. +- **Fixed: a ZIP whose local header disagrees with its directory, or whose bytes miss their CRC-32, is + refused** (independent audit 2026-11-04, F05). The artifact reader took each entry's name, method, sizes and + CRC-32 from the central directory alone: a package whose local header named `README-ALTER.md` where the + directory lists `README-FIRST.md`, and one whose CRC-32 fields were zeroed, validated PASS and stayed + `buildAuthorized`, while Python's `zipfile` refuses both and a streaming extractor writes another file. The + local header must now agree with the directory on the name, the method, the encryption and UTF-8 name bits + and - unless bit 3 defers them to a data descriptor - the CRC-32 and sizes, judged before anything is + inflated, and a local header giving its sizes as ZIP64 is refused as ZIP64, as the directory's already + were (such an entry was read before); each body's CRC-32 is checked when it is read, from a table on a + Node without `zlib.crc32` (22.0 and 22.1); a `manifest.json` or `manifest.sha256` that does not read is + FAIL, not BLOCKED. A GitHub wrapper whose package does not read - a CRC-32 now among the reasons - is + reported with that reason and a remedy to download again (`--run-id`), where the validator could only say + the wrapper holds no `manifest.json` and to regenerate it. All `ZIP-READ` (ADR-0200). `collect-remote` and + `artifact create` now print that remedy, and every package problem's, as a `fix:` line, through the renderer + `artifact validate` uses: collect-remote printed no remedy and its messages unescaped, and create no remedy. + collect-remote redacts each problem, and the saved file's name, before it is escaped: an escape such as + `\x09` ends in a word character, and a credential's pattern starts at a word boundary. What `artifact` and + `collect-remote` print from a package escapes, beyond C0 and DEL, every control, the characters a reader may + take for a line end (U+0085, U+2028, U+2029) and every character that hides or reorders text - each format + character, a lone surrogate and each one Unicode lets a renderer draw as nothing, the tag characters, + variation selectors and Hangul fillers among them - as `\xNN`, `\uNNNN` or `\u{NNNNN}`. A refusal + `collect-remote` prints is escaped so too, and so is the text the MCP server's `fetch_corpus` gives the + agent - a package's diagnostics, an error, the saved file's link name: an artifact named with U+2028 wrote a + line of its own in either, and a manifest key holding a line feed wrote the server's own PASS sentence on a + line of its own. The saved file's folder prints as the operator gave it: escaped with the name, one spelled + with a joiner (Persian's U+200C) named a folder that does not exist. Eighteen tests, and an older one now + expects the reason. +- **Fixed: a quote of repeated fragments is judged in bounded time** (independent audit 2026-11-04, F04). + `anchorFound` searched where a quote's fragments lie by recursion over every chain of their occurrences, so a + quote repeating one phrase over a page that repeats it, ending in a fragment the page lacks, took time + exponential in the fragment count: twenty copies of `alpha beta gamma` over 680 characters did not finish in + 3 s, and the citations check - which preflight and the commit gate run - waited on it. The leftmost + placement is now tried first, one search per fragment, and decides a quote it places within the bound and + one with a fragment that does not occur after it; otherwise the placements are carried as a set, fragment + by fragment, over only the occurrences a placement can reach, each fragment's listed once per distinct text + when the set first needs them (ADR-0202). Every placement the search + accepted is still accepted, a later occurrence of any fragment still counts (ADR-0162), and the + 300-character bound is unchanged. A crafted quote whose leftmost placement goes past the bound still costs + up to one read of every occurrence per fragment. Three tests. - **Fixed: a lost evidence table is named, and no check passes over it** (independent audit 2026-11-04, FP-011, alias B-2). `readCorpus` read a missing `research/EVIDENCE.md`, a directory in its place, or a file holding no evidence table as an empty table, with no problem recorded: `corpus-shape` said "the corpus parses cleanly", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f951179f..017465a3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -191,13 +191,13 @@ is an open decision (2026-09-30): | `r29-workbook-linkage-validator.mjs` | Cross-reference validator for the R29 reviewer-workbook linkage register: 24 task ids, six expected reference kinds (task/gold/source/calibration/warm/lock), and a status reduction that takes the worst of `PASS < INCOMPLETE < FAIL < REOPEN`. **Zero imports** — it is 58 lines of pure functions over `{ register, catalog, pointer }` passed in as arguments, so it touches no filesystem, no network and no clock, and the caller owns every input. That is also why it needed no sealed-record fixture: its tests synthesise a register into a temp directory rather than shipping one. A revoked lock pointer reduces to `REOPEN` rather than `FAIL`, which is the distinction the rank order exists for — a withdrawn approval is not the same as a broken link. | ADR-0029 | | `config.mjs` | A **compatibility shim**, nine lines, re-exporting `machine.mjs`'s config surface so an older import path keeps working. It adds nothing and decides nothing. Listed because it was the one module in `lib/` this map did not mention — found by counting files against rows during the 2026-09-20 reconciliation, which is the only way that kind of omission surfaces. | ADR-0002 | | `archive.mjs` | **One container format**: `buildZip(entries)` → bytes, `writeZip(file, entries)` → `{ file, bytes, entries }` (written whole or not at all, through `writeBytes`, since 2026-09-28), `crc32`, `entryName`. ZIP with deflate (store when deflating would only grow the payload), with UTF-8 name encoding declared by bit 11 in both local and central headers, preserving Unicode manifest paths when ordinary ZIP tools extract them (#345); written because the kit has no dependencies and cannot shell out to a `zip` binary that may not exist. Knows the container and nothing else — what goes inside a bundle is `lib/audit.mjs`'s. Refuses entry names that are not plain relative paths (an archive must not unpack outside its folder) and an archive with no entries. See ADR-0019. **Changes no name it is handed** (2026-09-28): `entryName` refuses a backslash - it translated one into a separator, which stored `a\b.md` as `a/b.md` while the package manifest still declared the original. **Refuses what the format cannot carry, by name** (#348): more than 65,534 entries before any entry is hashed or deflated, a name over 65,535 bytes or an entry at or above 0xFFFFFFFF bytes before that entry is, and an offset or central directory reaching 0xFFFFFFFF as soon as it is reached - the bounds exclude the ZIP64 sentinels `artifact-zip.mjs` refuses, so the kit never writes an archive it cannot read back - and a payload that is not a string, Buffer or Uint8Array. | ADR-0019 | -| `artifact-zip.mjs` | **Reading a ZIP somebody else wrote** — the opposite job from `archive.mjs`, and a separate file on purpose: a writer that also parses hostile input grows a trust boundary in the middle of itself. `openZip(bytes)` → `{ entries, names, directories, problems, has, read }`, plus `checkEntryName`, `pathKey`, `readCentralDirectory`, `inspectEntries`, `readEntry` and the exported `ZIP_LIMITS` (entry count, total and per-entry uncompressed bytes, ratio + floor, name length) so tests drive the refusal paths with small fixtures instead of real bombs. **The order of operations is the security property**: every structural refusal — traversal, absolute, drive letter, backslash, NUL, duplicate, case collision, file/directory-prefix collision, symlink/special entry, encryption, unknown method, counts, sizes, ratio — is decided from the **central directory before one byte is inflated**. The directory is attacker-controlled too, so it is never the only defence: `inflateRawSync` runs with `maxOutputLength`, making the ceiling the runtime's to enforce rather than a number compared against a field the attacker wrote. Names are judged as raw text, never `path.normalize`'d first — normalising would let `a/../../b` become `../b` and then be judged, when the honest answer is that `..` has no business in a research artifact. Touches no filesystem: entries come back as buffers. ZIP64 is refused rather than half-read. **A name another platform reads as a different name is refused** (2026-11-06, independent audit F03, ADR-0197): an empty segment, a character Windows does not allow in a name (`<`, `>`, `:`, `"`, `?`, `*`, the vertical bar, U+0001 to U+001F), a segment ending in a dot or a space, a Windows device name (CON, PRN, AUX, NUL, COM and LPT with a digit or a superscript 1 to 3, with any extension) and an 8.3 short-name shape (`EVIDEN~1.MD`) are `ZIP-PATH` - `research//BRIEF.md` had validated beside the reviewed brief and extracted onto it - and duplicates, case collisions and file/directory-prefix collisions are compared by `pathKey` (NFC, then upper- and lower-cased), wider than any one filesystem on purpose, so two Unicode forms of one name collide as two cases do. `directories` lists the directory entries, for the validator's never-packaged paths. | ADR-0032, ADR-0197 | -| `artifact-validator.mjs` | Ledger verification follows actual archive capture entries and parsed evidence rows, so relabelling roles or declaring zero captures cannot disable provenance checking; an empty failed-collection scaffold still passes (#358). **Is this package valid, and does it authorize a build** — two questions, kept apart. `validateArtifact({ file, expectedClientRef })` → `{ status, buildAuthorized, packageId, clientRef, workflowRunId, state, errors, warnings }`, where `status: 'PASS'` means internally consistent and says *nothing* about permission. Offline and read-only by construction: no transport imported, `process.env` never read, nothing from the package executed, no shell, and the only write is a `fs.mkdtemp` copy of `project/` removed in a `finally` — needed because the chain and handoff checks are the kit's existing code (`readCorpus`, `verifyHandoff`) and reuse beats a second implementation of provenance verification nobody tested. Stable error codes (`ZIP-*`, `MANIFEST-*`, `FILE-*`, `LEDGER-*`, `CAPTURE-*`, `AUTHORIZATION-INCONSISTENT`, `SECRET-DETECTED`, `FORMAT-UNSUPPORTED`, `CLIENT-REF-MISMATCH`) and **never a secret value in an error** — the report names the pattern and the line. `authorizationProblems()` is the one implementation of the build rule; the schema encodes it a second time and when the schema catches it first the rule is still reported in its own words. An unsupported format **major** is `INCOMPLETE`, not invalid. A crash is `BLOCKED`, never `PASS`. Its report carries `reviewedBy` (`review.by`, or `undeclared` for a 1.0.0 package) and judges nothing by it (ADR-0074). Report metadata is surfaced only after the manifest passes schema validation; malformed state and identity fields are null, and the CLI escapes control characters in every text diagnostic so hostile strings cannot forge lines. Reads format majors **1 and 2** (2026-09-30, ADR-0107): a 1.x `HUMAN_REVIEW_REQUIRED` is reported as `REVIEW_REQUIRED`, and `eraProblems` refuses a value from the other major by name - a 2.x package with the old state or `review.by: "human"`, or a 1.x one with the new state. A binary body (NUL in its first 8000 bytes) is not matched against the credential patterns, as a body over 512 KiB is not (ADR-0128, 2026-10-02): `docs/` travels whole, and one vendored DLL carrying the PEM marker it parses would have failed a package with no secret in it. Missing, ill-typed, malformed and below-oldest format versions are MANIFEST-SCHEMA/FAIL with regeneration as the remedy; only a numeric format major newer than this reader is FORMAT-UNSUPPORTED/INCOMPLETE (#361). Claimed approvals are re-derived from checked bytes with the portable unpromoted pluralist, 180-day baseline; warnings name that baseline and format-1 legacy compatibility. **The never-packaged paths** (the machine-local logs and lock under `research/raw/`, `research/overrides.log`, `research/GATE_OFF`) **are compared by `pathKey`, as a file, as a directory entry and as a folder prefix of any entry** (2026-11-06, independent audit F03, ADR-0197): `research/gate_off` and an empty `research/GATE_OFF/` folder had passed a check that compared the exact spelling, and the gate's `exists()` answers for a folder as for a file. | ADR-0032, ADR-0160, ADR-0197 | +| `artifact-zip.mjs` | **Reading a ZIP somebody else wrote** — the opposite job from `archive.mjs`, and a separate file on purpose: a writer that also parses hostile input grows a trust boundary in the middle of itself. `openZip(bytes)` → `{ entries, names, directories, problems, has, read }`, plus `checkEntryName`, `pathKey`, `readCentralDirectory`, `inspectEntries`, `readEntry` and the exported `ZIP_LIMITS` (entry count, total and per-entry uncompressed bytes, ratio + floor, name length) so tests drive the refusal paths with small fixtures instead of real bombs. **The order of operations is the security property**: every structural refusal — traversal, absolute, drive letter, backslash, NUL, duplicate, case collision, file/directory-prefix collision, symlink/special entry, encryption, unknown method, counts, sizes, ratio — is decided from the **central directory before one byte is inflated**. The directory is attacker-controlled too, so it is never the only defence: `inflateRawSync` runs with `maxOutputLength`, making the ceiling the runtime's to enforce rather than a number compared against a field the attacker wrote. Names are judged as raw text, never `path.normalize`'d first — normalising would let `a/../../b` become `../b` and then be judged, when the honest answer is that `..` has no business in a research artifact. Touches no filesystem: entries come back as buffers. ZIP64 is refused rather than half-read. **A name another platform reads as a different name is refused** (2026-11-06, independent audit F03, ADR-0197): an empty segment, a character Windows does not allow in a name (`<`, `>`, `:`, `"`, `?`, `*`, the vertical bar, U+0001 to U+001F), a segment ending in a dot or a space, a Windows device name (CON, PRN, AUX, NUL, COM and LPT with a digit or a superscript 1 to 3, with any extension) and an 8.3 short-name shape (`EVIDEN~1.MD`) are `ZIP-PATH` - `research//BRIEF.md` had validated beside the reviewed brief and extracted onto it - and duplicates, case collisions and file/directory-prefix collisions are compared by `pathKey` (NFC, then upper- and lower-cased), wider than any one filesystem on purpose, so two Unicode forms of one name collide as two cases do. `directories` lists the directory entries, for the validator's never-packaged paths. **Each entry's local header must agree with its directory record, and each body with its CRC-32** (2026-11-06, independent audit F05, ADR-0200): `inspectLocalHeaders`, run by `openZip` before anything is inflated, refuses a body the directory's compressed size puts past the end of the file and otherwise compares the stored name bytes, the method, the encryption and UTF-8 name bits and - unless the local header's bit 3 defers them to a data descriptor - the CRC-32 and both sizes, one `ZIP-READ` problem per entry naming each field that differs (a local header naming `README-ALTER.md` under the directory's `README-FIRST.md` had validated PASS), and a local header giving its sizes as ZIP64 with bit 3 clear is refused as ZIP64, not compared; `readEntry` refuses a body whose CRC-32 is not the directory's - `zlib.crc32`, or the same sum from a table on a Node without it (22.0 and 22.1, which the runtime checks admit) - also `ZIP-READ`. The data descriptor itself is not read. | ADR-0032, ADR-0197, ADR-0200 | +| `artifact-validator.mjs` | Ledger verification follows actual archive capture entries and parsed evidence rows, so relabelling roles or declaring zero captures cannot disable provenance checking; an empty failed-collection scaffold still passes (#358). **Is this package valid, and does it authorize a build** — two questions, kept apart. `validateArtifact({ file, expectedClientRef })` → `{ status, buildAuthorized, packageId, clientRef, workflowRunId, state, errors, warnings }`, where `status: 'PASS'` means internally consistent and says *nothing* about permission. Offline and read-only by construction: no transport imported, `process.env` never read, nothing from the package executed, no shell, and the only write is a `fs.mkdtemp` copy of `project/` removed in a `finally` — needed because the chain and handoff checks are the kit's existing code (`readCorpus`, `verifyHandoff`) and reuse beats a second implementation of provenance verification nobody tested. Stable error codes (`ZIP-*`, `MANIFEST-*`, `FILE-*`, `LEDGER-*`, `CAPTURE-*`, `AUTHORIZATION-INCONSISTENT`, `SECRET-DETECTED`, `FORMAT-UNSUPPORTED`, `CLIENT-REF-MISMATCH`) and **never a secret value in an error** — the report names the pattern and the line. `authorizationProblems()` is the one implementation of the build rule; the schema encodes it a second time and when the schema catches it first the rule is still reported in its own words. An unsupported format **major** is `INCOMPLETE`, not invalid. A crash is `BLOCKED`, never `PASS`. Its report carries `reviewedBy` (`review.by`, or `undeclared` for a 1.0.0 package) and judges nothing by it (ADR-0074). Report metadata is surfaced only after the manifest passes schema validation; malformed state and identity fields are null, and the CLI escapes control characters in every text diagnostic so hostile strings cannot forge lines. Reads format majors **1 and 2** (2026-09-30, ADR-0107): a 1.x `HUMAN_REVIEW_REQUIRED` is reported as `REVIEW_REQUIRED`, and `eraProblems` refuses a value from the other major by name - a 2.x package with the old state or `review.by: "human"`, or a 1.x one with the new state. A binary body (NUL in its first 8000 bytes) is not matched against the credential patterns, as a body over 512 KiB is not (ADR-0128, 2026-10-02): `docs/` travels whole, and one vendored DLL carrying the PEM marker it parses would have failed a package with no secret in it. Missing, ill-typed, malformed and below-oldest format versions are MANIFEST-SCHEMA/FAIL with regeneration as the remedy; only a numeric format major newer than this reader is FORMAT-UNSUPPORTED/INCOMPLETE (#361). Claimed approvals are re-derived from checked bytes with the portable unpromoted pluralist, 180-day baseline; warnings name that baseline and format-1 legacy compatibility. **The never-packaged paths** (the machine-local logs and lock under `research/raw/`, `research/overrides.log`, `research/GATE_OFF`) **are compared by `pathKey`, as a file, as a directory entry and as a folder prefix of any entry** (2026-11-06, independent audit F03, ADR-0197): `research/gate_off` and an empty `research/GATE_OFF/` folder had passed a check that compared the exact spelling, and the gate's `exists()` answers for a folder as for a file. **A `manifest.json` or `manifest.sha256` that does not read is FAIL with the reader's code** (2026-11-06, independent audit F05, ADR-0200): its `ZipError` had reached the outer catch as BLOCKED, where the same fault in a payload file is FAIL `ZIP-READ`. | ADR-0032, ADR-0160, ADR-0197, ADR-0200 | | `artifact.mjs` | Raw-cell review reads and explicit ledger reads use projectFile under ADR-0109; Raw reads stay inside the capture folder, and a non-file ledger is refused before reading (#351). **Packaging a project without changing it; shared authorization derivation lives in `artifact-state.mjs`.** `createArtifact` → `{ bytes, name, manifest, entries, derived }`; `writeArtifact` writes then **re-validates with the consumer's own validator** before reporting success; its bytes go through `writeBytes`, so re-creating a package at a name that already holds one cannot trade a good package for a truncated one (2026-09-28). There is no `--build-authorized` and no option that reaches an authorization field: `deriveState(root)` runs the real gate with `env` defaulting to `{}` so a packaging shell's override cannot become a property of a package that outlives it. `mapClassified` and `briefReviewed` have honest machine answers; `findingsReviewState` answers the third **negatively** — a `Finding` byte-identical to what `firstFinding` re-extracts from the capture was demonstrably never rewritten, which detects the unreviewed case without grading the reviewed one (ADR-0013's line, uncrossed). It repeats the collector's own call exactly - the page body after the front-matter, falling back to the page's `title` or `url` from that front-matter - since 2026-09-27: run over the whole file, the extractor read the front-matter as the page, and a URL-only corpus nobody had touched (one JSON capture, run 36287211468) was packaged `findingsReviewed: true`. `packageName` never carries the topic (an artifact listing is world-readable on a public repository) and falls back to `run-`; `checkClientRef` refuses rather than sanitises. Inventory sorted by ZIP path, manifest and digest appended **last**, digest over the exact manifest bytes. README-FIRST in a package that is not yet approved prints the preflight and brief commands and **one `create` line carrying the package's own identity** (`repackageArgs`, 2026-09-27): the manifest is written once, and the package gave its reviewer no way to get one that says `APPROVED_BRIEF`. Commands are spelled by `homeCommand` (ADR-0050). `createArtifact` checks the identity against the manifest schema's own `source` rules before deriving or writing anything (`sourceProblem`, 2026-09-27): `create --commit abc` wrote a package, then reported it did not validate, and left it on disk. **`review.by`** (format 1.1.0, ADR-0074) is the reviewer's declaration read from `BRIEF.md` by `reviewedBy`: `agent` or `undeclared` (`human` was retired in format 2.0.0, ADR-0107). Not an approval condition. README-FIRST no longer says "a human reviewed it"; it names the declared reviewer, or says none was declared. `collectProjectFiles` and the explicit ledger add refuse (`OUTSIDE_PROJECT`) any path whose real location is outside the project: it followed links and packed outside files, a private key included (ADR-0076). The same walk asks the consumer's own `checkEntryName` about every name it is about to store and refuses the run before a byte is written (`UNPACKAGEABLE_NAME`, 2026-09-28): `entryName` translates a backslash into a path separator, so a file named `a\b.md` travelled as `a/b.md` - a directory invented out of a name - while the manifest kept the original, and only the read-back caught the contradiction, after the package had been written. The same refusal covers two files differing only in case (2026-09-28) or only in Unicode form, and a file that is, spelled another way, the folder another file sits in (2026-11-06, independent audit F03, ADR-0197), compared by the reader's `pathKey`: the reader's ZIP-CASE-COLLISION fired only after the write. Format **2.0.0** (2026-09-30, ADR-0107): a collected package waiting for review says `REVIEW_REQUIRED`, and README-FIRST gives the review to the agent alone. collectProjectFiles refuses (`UNPACKAGEABLE_FILE`) what packaging cannot read to an end - a FIFO, socket or device, and a folder link back into its own ancestry (judged by real path, so a link to a sibling folder inside the project still packages) - before a byte is read (2026-10-01). **A reviewed brief is complete and current** (2026-10-03, output-reliability audit G1): `deriveState` approves only when every judged section is present and answered (`judgedSection`) and a stamped brief's inputs hash matches the corpus (`draftStamp`, `briefInputsHash`); `briefState` alone read a missing section as answered, so a drafted brief with no review sections derived APPROVED_BRIEF on the repository's own corpus, and a brief whose evidence changed after the draft stayed authorized beside hygiene's brief-stale warning. A brief with no stamp predates ADR-0055 - the root's own and ten decision briefs - and keeps its approval: its currency cannot be checked, `hygiene/brief-unstamped` says so (ADR-0138), a redraft with --force stamps it. **README-FIRST in every state and the collection summary preserve the producer evaluation warnings** through `renderGateWarnings` (ADR-0143). Authorization fields and machine package formats are unchanged. `bin/artifact.mjs` refuses a value flag given no value by name, exit 3, before anything is read or written (2026-10-11, #280): `create` had asked only whether a required flag was present, so a bare `--ref` went into the manifest as "true", a bare `--run-id` as 1 and a bare `--output` wrote a package named `true` that re-validated PASS; a bare `validate --file` tried to open a file called `true`. Caller references use checkClientRef before token lookup, output-folder creation or dispatch (#375, 2026-10-13); CR/LF are refused by the shared checker, and diagnostics are redacted. The artifact CLI awaits exitAfterFlush for its report, help and refusal endings, preserving queued stdout/stderr and the existing exit status on asynchronous POSIX pipes (#359). `create --output` over the ledger, a cited capture, anything in `research/raw/` or a corpus file is refused by `evidenceAt`, exit 3, before the package is built (#330): it packaged the ledger, wrote the package over it, and re-validated PASS. | ADR-0032, ADR-0031, ADR-0052, ADR-0143, ADR-0197 | | `artifact-state.mjs` | The one approval derivation shared by producer and validator: real gate and handoff, classified map, reviewed findings, authored/current brief; consumers rederive claimed approval under the explicit portable baseline and exact format-1 pre-0069 input hash (ADR-0160). Finding review resolves a blank Raw cell through the shared capture resolver and refuses unresolved captures; every capture read retains project and raw-folder path containment (#351, #357). | ADR-0032, ADR-0160 | -| `mcp.mjs` | **The collector as a Model Context Protocol server**, and the first module in this repository built from an APPROVED BRIEF rather than from a plan - every design choice cites an E-row in `docs/decisions/2026-09-21-agent-interface/`. `handle(message, deps)` is a pure function of a JSON-RPC message plus injected transport, so the whole surface is tested offline. **stdio, not Streamable HTTP**: semantics are identical on both, so the difference is operational, and remote costs the entire OAuth 2.1 chain. **Two tools, not one** - `collect` returns a run id immediately, `fetch_corpus` returns a link - because a server cannot initiate a request, so the alternative is one call held open for minutes and an agent that loses a paid run when its process dies. **A `resource_link`, never embedded bytes**: the spec defines embedding but states no maximum size, so a link is the defensible option, and the URI comes from the platform's own encoder - `pathToFileURL(path.resolve(file))` - where pasting `file:///` onto the path turned a `#` in a package name into a fragment and a `?` into a query (a link to a file that does not exist), left a space unencoded, and anchored a relative path at the filesystem root rather than the working directory (rank 13, `docs/review-2026-10-04-gap-audit.md`). **No tool takes a token** and a test asserts no parameter is credential-shaped. The served revisions are pinned (`SUPPORTED_VERSIONS`, preferring `MODERN_VERSION`; the unused alias `PROTOCOL_VERSION` was removed, #297) and a mismatch answers `-32022` with `data.supported`, the same discipline as pinning `X-GitHub-Api-Version`. `createStdioLoop` frames synchronously inside each `data` event - overlapping async drains had mutated the buffer concurrently, which a test caught - and answers on two chains: `tools/call` one at a time in order, everything else on its own chain, so `ping` and `tools/list` are answered while a call is in flight (#347, 2026-10-17); they had waited behind it on one shared chain. **DUAL-ERA**: `SUPPORTED_VERSIONS` is `['2026-07-28', '2025-11-25', '2025-06-18']` (2025-06-18 since 2026-09-27, ADR-0049: a client on the official SDK up to 1.24.0 asks for it and refuses 2025-11-25, and nothing 2025-11-25 changed touches this server; 2025-03-26 is not served, because the results carry `structuredContent` and `resource_link`) and `initialize` is implemented beside `server/discover`, because the specification is ahead of every shipped client - the official SDK at v1.30.0 declares `2025-11-25` as its latest and answered the modern-only first version with `-32601: unknown method initialize`. `initialize` **echoes** a serveable requested version and falls back to **legacy** rather than to this server's preference, because a client throws on a version it does not support. **Arguments that fail a tool's schema are a tool result** (`isError: true`, the reason as text) since 2026-09-27 - SEP-1303 in 2025-11-25 says input validation errors should be Tool Execution Errors, so the model can correct itself; they were JSON-RPC -32602, which a client raises as an exception. An unknown tool stays -32602. `collect`'s `topic` carries `minLength: 1`, and `validateArgs` enforces `minLength` after trimming (2026-09-27): an empty topic had passed and reached GitHub, which answered HTTP 422. `fetch_corpus` checks `out_dir` with `usableOutDir` before it reads the run (2026-09-27), so an unusable folder is an `OUT_DIR` tool error, not `UNKNOWN`. `fetch_corpus` returns `reviewedBy`, and its text says review is for an agent or a person (ADR-0074). `createStdioLoop` bounds a line at `MAX_LINE` (4 MiB, 2026-09-28): a line past it is refused with -32600 as soon as it passes the cap and dropped through its newline, and the next message is served - also while a call is in flight, since the check runs in the `data` event rather than behind the call (#346, 2026-10-17) - the buffer had grown without limit until a newline came, so a client that sent none could grow the server until it died. **No request is lost at end of input** (break-test PR #145, 2026-09-29): `exitWhenSettled` waits for every call already read before exiting (bounded by `END_GRACE_MS`), and a final request without a trailing newline is served on `end` - both had been dropped with exit 0. `SERVER_INFO.version` is `KIT_VERSION` (ADR-0116); it had announced a fixed 1.0.0. **The approved fetch response directs the builder to README-FIRST and the brief for warnings and known unknowns** (ADR-0143). It forwards no new machine warning field and derives no new authorization. `validateArgs` checks allowed keys by own membership and enforces the declared arrays of strings: malformed query/URL values and inherited Object property names become schema tool errors before credential lookup or dispatch. Omitted or empty arrays, and valid string arrays, keep their existing normalization. The shared `queriesInput` refusal prevents CR/LF inside individual queries or URLs from dispatching extra runner work (#376, 2026-10-12). Rejected query/URL input passes through the existing credential redactor before it becomes tool-result text (#378, 2026-10-12), including caller credentials absent from the server environment. Caller references use checkClientRef before token lookup, output-folder creation or dispatch (#375, 2026-10-13); CR/LF are refused by the shared checker, and diagnostics are redacted. Output folders resolve to absolute paths before access checks; the MCP caller passes that same path to fetchCorpus so the write and resource link identify the same file (#372, 2026-10-13). Non-PASS corpus downloads return only a redacted diagnostic with validator codes and remedies, never a corpus link or review instructions; buildAuthorized stays false (#371, 2026-10-13). | ADR-0034, ADR-0049, ADR-0143, ADR-0172 | +| `mcp.mjs` | **The collector as a Model Context Protocol server**, and the first module in this repository built from an APPROVED BRIEF rather than from a plan - every design choice cites an E-row in `docs/decisions/2026-09-21-agent-interface/`. `handle(message, deps)` is a pure function of a JSON-RPC message plus injected transport, so the whole surface is tested offline. **stdio, not Streamable HTTP**: semantics are identical on both, so the difference is operational, and remote costs the entire OAuth 2.1 chain. **Two tools, not one** - `collect` returns a run id immediately, `fetch_corpus` returns a link - because a server cannot initiate a request, so the alternative is one call held open for minutes and an agent that loses a paid run when its process dies. **A `resource_link`, never embedded bytes**: the spec defines embedding but states no maximum size, so a link is the defensible option, and the URI comes from the platform's own encoder - `pathToFileURL(path.resolve(file))` - where pasting `file:///` onto the path turned a `#` in a package name into a fragment and a `?` into a query (a link to a file that does not exist), left a space unencoded, and anchored a relative path at the filesystem root rather than the working directory (rank 13, `docs/review-2026-10-04-gap-audit.md`). **No tool takes a token** and a test asserts no parameter is credential-shaped. The served revisions are pinned (`SUPPORTED_VERSIONS`, preferring `MODERN_VERSION`; the unused alias `PROTOCOL_VERSION` was removed, #297) and a mismatch answers `-32022` with `data.supported`, the same discipline as pinning `X-GitHub-Api-Version`. `createStdioLoop` frames synchronously inside each `data` event - overlapping async drains had mutated the buffer concurrently, which a test caught - and answers on two chains: `tools/call` one at a time in order, everything else on its own chain, so `ping` and `tools/list` are answered while a call is in flight (#347, 2026-10-17); they had waited behind it on one shared chain. **DUAL-ERA**: `SUPPORTED_VERSIONS` is `['2026-07-28', '2025-11-25', '2025-06-18']` (2025-06-18 since 2026-09-27, ADR-0049: a client on the official SDK up to 1.24.0 asks for it and refuses 2025-11-25, and nothing 2025-11-25 changed touches this server; 2025-03-26 is not served, because the results carry `structuredContent` and `resource_link`) and `initialize` is implemented beside `server/discover`, because the specification is ahead of every shipped client - the official SDK at v1.30.0 declares `2025-11-25` as its latest and answered the modern-only first version with `-32601: unknown method initialize`. `initialize` **echoes** a serveable requested version and falls back to **legacy** rather than to this server's preference, because a client throws on a version it does not support. **Arguments that fail a tool's schema are a tool result** (`isError: true`, the reason as text) since 2026-09-27 - SEP-1303 in 2025-11-25 says input validation errors should be Tool Execution Errors, so the model can correct itself; they were JSON-RPC -32602, which a client raises as an exception. An unknown tool stays -32602. `collect`'s `topic` carries `minLength: 1`, and `validateArgs` enforces `minLength` after trimming (2026-09-27): an empty topic had passed and reached GitHub, which answered HTTP 422. `fetch_corpus` checks `out_dir` with `usableOutDir` before it reads the run (2026-09-27), so an unusable folder is an `OUT_DIR` tool error, not `UNKNOWN`. `fetch_corpus` returns `reviewedBy`, and its text says review is for an agent or a person (ADR-0074). `createStdioLoop` bounds a line at `MAX_LINE` (4 MiB, 2026-09-28): a line past it is refused with -32600 as soon as it passes the cap and dropped through its newline, and the next message is served - also while a call is in flight, since the check runs in the `data` event rather than behind the call (#346, 2026-10-17) - the buffer had grown without limit until a newline came, so a client that sent none could grow the server until it died. **No request is lost at end of input** (break-test PR #145, 2026-09-29): `exitWhenSettled` waits for every call already read before exiting (bounded by `END_GRACE_MS`), and a final request without a trailing newline is served on `end` - both had been dropped with exit 0. `SERVER_INFO.version` is `KIT_VERSION` (ADR-0116); it had announced a fixed 1.0.0. **The approved fetch response directs the builder to README-FIRST and the brief for warnings and known unknowns** (ADR-0143). It forwards no new machine warning field and derives no new authorization. `validateArgs` checks allowed keys by own membership and enforces the declared arrays of strings: malformed query/URL values and inherited Object property names become schema tool errors before credential lookup or dispatch. Omitted or empty arrays, and valid string arrays, keep their existing normalization. The shared `queriesInput` refusal prevents CR/LF inside individual queries or URLs from dispatching extra runner work (#376, 2026-10-12). Rejected query/URL input passes through the existing credential redactor before it becomes tool-result text (#378, 2026-10-12), including caller credentials absent from the server environment. Caller references use checkClientRef before token lookup, output-folder creation or dispatch (#375, 2026-10-13); CR/LF are refused by the shared checker, and diagnostics are redacted. Output folders resolve to absolute paths before access checks; the MCP caller passes that same path to fetchCorpus so the write and resource link identify the same file (#372, 2026-10-13). Non-PASS corpus downloads return only a redacted diagnostic with validator codes and remedies, never a corpus link or review instructions; buildAuthorized stays false (#371, 2026-10-13). `fetch_corpus`'s text - a package's diagnostics, an error, the saved file's link name - is redacted, then escaped by `printable`, field by field: a manifest key holding a line feed wrote the server's own PASS sentence on a line of its own, and an artifact named with U+2028 a line in an error (re-review of #442, second pass, 2026-11-06). | ADR-0034, ADR-0049, ADR-0143, ADR-0172, ADR-0200 | | `disclosure.mjs` | **What a public run actually exposes, measured rather than asserted.** Written because the opposite claim was documented first: the collector's header said dispatch inputs are visible to anyone with read access, and checking it found the topic in none of the readable responses. `probeRun` sends **unauthenticated** requests - there is no token parameter, and a probe that quietly authenticated would answer a different question while looking like this one. `summarise` separates `exposedNames` (true on any public repo, and the reason the topic is kept out of run and artifact names) from `exposedContent` and `exposedSubject`. `needleChecked` exists so a report can never let "false" read as "no": without a topic to search for, the rendering says **not checked**. An unreachable endpoint is a refusal to conclude, never a clean result - **enforced since 2026-09-27**: until then that sentence was true of the findings and false of the verdict, and with no network every probe was unreachable and the command exited 0, "only the shape is public". `summarise` now reports `unmeasured` (probes with status 0) and `conclusive`; `exitCode(report)` keeps a measured exposure (2, 1) whatever else failed, and otherwise needs every probe answered for 0, else 3; the rendering says UNREACHABLE and "not tried" instead of "refused", shows `not measured` for a false it could not establish, and closes NOT MEASURED instead of the shape paragraph. An unreachable probe names the cause of the failed fetch through `fetchFailure` (2026-09-30), not the bare `fetch failed`. `probeRun` takes the artifact and job ids from the listings its probes already read (2026-09-30); it had fetched both again inside one try/catch, so a transient failure on the second request left the artifact download and the log untried. `render` claims the shape only when `exposedNames` is true (#303, 2026-10-17): every probe refused says nothing was readable, and - only when the run probe answered 404 - that a private repository and a missing run both answer that; another refusal is named by its status. `summarise` lists a run probe refused with 403 or 429 (the anonymous rate limit) in `rateLimited`, which makes the report inconclusive: NOT MEASURED, exit 3. | ADR-0035 | -| `dispatch.mjs` | **Asking the collector to run, from outside GitHub** - the seam an agent or a desktop app uses. `dispatchCollection` (pins `X-GitHub-Api-Version: 2026-03-10` on the REQUEST, not merely in a comment), `waitForRun`, `listArtifacts`, `downloadArtifact`, `unwrapArtifact`, plus `tokenFromEnv` and `redact`. **No token may be passed as an argument** - `tokenFromEnv` is the only way in, because a credential on a command line reaches the shell history, `ps`, and any log that echoes its own command; a test asserts the source contains no token flag. `redact` is PATTERN-based rather than "remove the token we hold", so it also catches a credential the process was never given - one echoed back by a server, a proxy, a redirect. A **204** is a named `NO_RUN_ID` failure, never an empty success: "the run started and I cannot tell which one" is the failure a caller is least able to diagnose from a status code. **Both routes to a run id are requested**, not just the pinned version: the `X-GitHub-Api-Version: 2026-03-10` header AND `return_run_details: true`, the optional parameter GitHub added 2026-02-19 that returns the run id on `2022-11-28` as well. Measured before it was trusted - four calls on 2026-09-21 established that the old version honours the parameter and the new version accepts rather than rejects it, so sending both is safe and a server that strips or downgrades the header still answers with a run id. That retired ADR-0031's original fallback, which was to poll `/actions/runs` and correlate by `created_at` - a race the design never actually needed. `NO_RUN_ID` now means BOTH routes were ignored, which is a far narrower diagnosis. `waiting` is reported as a status, not a stall - it means a protection rule is holding the job. `unwrapArtifact` opens GitHub's outer ZIP through the same distrustful reader, and passes a hostile or ambiguous container through untouched for the validator to refuse rather than guessing at packaging depth. Every request goes through an injectable `fetch`, so the whole module is tested offline. **Pages by URL** (2026-09-27): `queriesInput` builds the one `queries` input from search queries and pages to fetch, one per line, and refuses a URL that is not http(s) before anything is dispatched. `collect.yml` fetches a line that is an http(s) URL instead of searching it, and with only URLs it searches nothing. `collect-remote --url` and the MCP `collect` tool's `urls` share this function, so all three agree. Until then the collector could only search, and a page the caller already knew had to be collected locally. **`usableOutDir` checks the folder before anything is spent** (2026-09-27): it creates the folder and tests write access, and an unusable one is a named `OUT_DIR` failure with a remedy. `fetchCorpus` calls it before it lists artifacts, the MCP `fetch_corpus` tool before it reads the run, and `collect-remote` before it dispatches. The folder had been checked only after the download, as a raw ENOTDIR the MCP server could report only as `UNKNOWN`, and for `collect-remote` that came after a paid run and up to half an hour of waiting. `fetchCorpus` writes the package it downloaded through `writeBytes` (2026-09-28, ADR-0079): `fs.writeFileSync` emptied a package already at that name when the write failed. `bin/collect-remote.mjs` checks `--max-pages` (1..25), `--depth`, `--runner`, `--search-transport` and `--timeout` against the ranges and choices `collect.yml` enforces, before it asks for a token (2026-09-29, Arena break test): `--max-pages 8o` was dispatched as typed, and the paid run failed on the runner. A dispatch that never left names the cause of the failed fetch through `fetchFailure` (2026-09-30), still passed through `redact`; so does `.github/scripts/check-action-pins.mjs` for a pin it could not check. Both had printed the bare `fetch failed`. `getRun` and `listArtifacts` read their body through `readJsonBody` (2026-09-30): a 200 whose body is not JSON (a maintenance page, a proxy) is `DispatchError` `BAD_BODY` with the status and a remedy, where it had thrown a raw `SyntaxError` that `collect-remote` could only call `UNKNOWN`. `getRun`, `listArtifacts` and `downloadArtifact` reach GitHub through `callApi` (2026-09-30), so a request that never left is `DispatchError` `NETWORK` with its cause (`fetchFailure`, redacted) and a remedy, as a dispatch already was; they had thrown the raw `fetch failed`, which `collect-remote` could only call `UNKNOWN`. fetchCorpus treats names from the API as data: a wrapper entry is flattened to its file name, an artifact name holding a separator, `:`, `..` or a control character is refused (`ARTIFACT_NAME`) and nothing is written (2026-10-01). unwrapArtifact hands on a wrapper whose inner package cannot be decompressed unchanged, for the validator to judge, instead of throwing ZIP-READ (2026-10-01). `waitForRun` retries a failed poll that may pass on its own - no connection, a 429 or 5xx, a body that is not JSON - up to `MAX_POLL_RETRIES` (5) in a row, reset by a good poll and never past the deadline; 401, 403 and 404 stay fatal (2026-10-01). Every failed poll had ended a watch of up to 30 minutes with RUN_FAILED while the run went on. `getRun` gives a 401, 403 or 404 its own remedy (a missing run, a refused token), and the timeout remedy names `--run-id` (2026-10-01). `splitRepository` admits only two nonempty `[A-Za-z0-9._-]+` components other than `.` and `..`, so dispatch, run lookup, artifact listing and download refuse route-changing delimiters and control characters before their first request (#379, 2026-10-12). `queriesInput` refuses CR/LF in either list before trimming and serialization (#376, 2026-10-12), retaining existing empty-input and one-line normalization. A declared item cannot become additional workflow lines. `dispatchCollection` also uses `readJsonBody`: a successful non-JSON response is `BAD_BODY` with the proxy/maintenance-page remedy; valid JSON missing the run ID retains `NO_RUN_ID` (#377, 2026-10-12). Output folders resolve to absolute paths before access checks; the MCP caller passes that same path to fetchCorpus so the write and resource link identify the same file (#372, 2026-10-13). Default corpus downloads use a fresh private mkdtemp folder per call; existing or dangling symbolic links at the package name are refused as OUT_DIR before the write, and MCP describes the private default (#373). Dispatch and run reads classify a rate-limited 403 from headers or body as RATE_LIMITED, retaining Retry-After seconds and the UTC reset time; permission-only 403 remedies remain unchanged (#374). `downloadArtifact` reads through `MAX_ARTIFACT_BYTES` (the package reader's per-entry ceiling plus 1 MiB for the wrapper): a `content-length` over it is `TOO_LARGE` before the body is read, a chunked body is cut when it passes it, and nothing is written; `unwrapArtifact` takes a download for GitHub's wrapper only when its one `.zip` is at the root and it holds no `manifest.json`, so a package carrying an audit bundle is passed on as itself (#329). `queriesInput` refuses a line carrying a user name or password, naming only its host (#308, 2026-10-17), in either list - `collect.yml` fetches any line that is an http(s) URL, a query included - and also one that only looks like such a URL (an unparseable port, a backslash for the slashes), which the runner would publish as a search: it would have become a workflow input anyone who can read the collector repository sees, and a ledger line nobody may edit. `usableOutDir(dir, { create: false })` creates the folder, checks it is writable and removes what it created - creating is the check, since looking only at the nearest existing folder passed a 300-character or `|`-bearing name that then failed after the paid run (review, 2026-10-18) - for the two callers that check early, `collect-remote` and the MCP server's `fetch_corpus`; `fetchCorpus` checks the same way before it lists the run's artifacts and creates the folder only once there is one package, so a run refused for its token, a 403 on the run, a run still in progress or an expired artifact leaves no empty folder behind (#295, 2026-10-11). | ADR-0033, ADR-0032 | +| `dispatch.mjs` | **Asking the collector to run, from outside GitHub** - the seam an agent or a desktop app uses. `dispatchCollection` (pins `X-GitHub-Api-Version: 2026-03-10` on the REQUEST, not merely in a comment), `waitForRun`, `listArtifacts`, `downloadArtifact`, `unwrapArtifact`, plus `tokenFromEnv` and `redact`. **No token may be passed as an argument** - `tokenFromEnv` is the only way in, because a credential on a command line reaches the shell history, `ps`, and any log that echoes its own command; a test asserts the source contains no token flag. `redact` is PATTERN-based rather than "remove the token we hold", so it also catches a credential the process was never given - one echoed back by a server, a proxy, a redirect. A **204** is a named `NO_RUN_ID` failure, never an empty success: "the run started and I cannot tell which one" is the failure a caller is least able to diagnose from a status code. **Both routes to a run id are requested**, not just the pinned version: the `X-GitHub-Api-Version: 2026-03-10` header AND `return_run_details: true`, the optional parameter GitHub added 2026-02-19 that returns the run id on `2022-11-28` as well. Measured before it was trusted - four calls on 2026-09-21 established that the old version honours the parameter and the new version accepts rather than rejects it, so sending both is safe and a server that strips or downgrades the header still answers with a run id. That retired ADR-0031's original fallback, which was to poll `/actions/runs` and correlate by `created_at` - a race the design never actually needed. `NO_RUN_ID` now means BOTH routes were ignored, which is a far narrower diagnosis. `waiting` is reported as a status, not a stall - it means a protection rule is holding the job. `unwrapArtifact` opens GitHub's outer ZIP through the same distrustful reader, and passes a hostile or ambiguous container through untouched for the validator to refuse rather than guessing at packaging depth. Every request goes through an injectable `fetch`, so the whole module is tested offline. **Pages by URL** (2026-09-27): `queriesInput` builds the one `queries` input from search queries and pages to fetch, one per line, and refuses a URL that is not http(s) before anything is dispatched. `collect.yml` fetches a line that is an http(s) URL instead of searching it, and with only URLs it searches nothing. `collect-remote --url` and the MCP `collect` tool's `urls` share this function, so all three agree. Until then the collector could only search, and a page the caller already knew had to be collected locally. **`usableOutDir` checks the folder before anything is spent** (2026-09-27): it creates the folder and tests write access, and an unusable one is a named `OUT_DIR` failure with a remedy. `fetchCorpus` calls it before it lists artifacts, the MCP `fetch_corpus` tool before it reads the run, and `collect-remote` before it dispatches. The folder had been checked only after the download, as a raw ENOTDIR the MCP server could report only as `UNKNOWN`, and for `collect-remote` that came after a paid run and up to half an hour of waiting. `fetchCorpus` writes the package it downloaded through `writeBytes` (2026-09-28, ADR-0079): `fs.writeFileSync` emptied a package already at that name when the write failed. `bin/collect-remote.mjs` checks `--max-pages` (1..25), `--depth`, `--runner`, `--search-transport` and `--timeout` against the ranges and choices `collect.yml` enforces, before it asks for a token (2026-09-29, Arena break test): `--max-pages 8o` was dispatched as typed, and the paid run failed on the runner. A dispatch that never left names the cause of the failed fetch through `fetchFailure` (2026-09-30), still passed through `redact`; so does `.github/scripts/check-action-pins.mjs` for a pin it could not check. Both had printed the bare `fetch failed`. `getRun` and `listArtifacts` read their body through `readJsonBody` (2026-09-30): a 200 whose body is not JSON (a maintenance page, a proxy) is `DispatchError` `BAD_BODY` with the status and a remedy, where it had thrown a raw `SyntaxError` that `collect-remote` could only call `UNKNOWN`. `getRun`, `listArtifacts` and `downloadArtifact` reach GitHub through `callApi` (2026-09-30), so a request that never left is `DispatchError` `NETWORK` with its cause (`fetchFailure`, redacted) and a remedy, as a dispatch already was; they had thrown the raw `fetch failed`, which `collect-remote` could only call `UNKNOWN`. fetchCorpus treats names from the API as data: a wrapper entry is flattened to its file name, an artifact name holding a separator, `:`, `..` or a control character is refused (`ARTIFACT_NAME`) and nothing is written (2026-10-01). unwrapArtifact hands on a wrapper whose inner package cannot be decompressed unchanged, for the validator to judge, instead of throwing ZIP-READ (2026-10-01). `waitForRun` retries a failed poll that may pass on its own - no connection, a 429 or 5xx, a body that is not JSON - up to `MAX_POLL_RETRIES` (5) in a row, reset by a good poll and never past the deadline; 401, 403 and 404 stay fatal (2026-10-01). Every failed poll had ended a watch of up to 30 minutes with RUN_FAILED while the run went on. `getRun` gives a 401, 403 or 404 its own remedy (a missing run, a refused token), and the timeout remedy names `--run-id` (2026-10-01). `splitRepository` admits only two nonempty `[A-Za-z0-9._-]+` components other than `.` and `..`, so dispatch, run lookup, artifact listing and download refuse route-changing delimiters and control characters before their first request (#379, 2026-10-12). `queriesInput` refuses CR/LF in either list before trimming and serialization (#376, 2026-10-12), retaining existing empty-input and one-line normalization. A declared item cannot become additional workflow lines. `dispatchCollection` also uses `readJsonBody`: a successful non-JSON response is `BAD_BODY` with the proxy/maintenance-page remedy; valid JSON missing the run ID retains `NO_RUN_ID` (#377, 2026-10-12). Output folders resolve to absolute paths before access checks; the MCP caller passes that same path to fetchCorpus so the write and resource link identify the same file (#372, 2026-10-13). Default corpus downloads use a fresh private mkdtemp folder per call; existing or dangling symbolic links at the package name are refused as OUT_DIR before the write, and MCP describes the private default (#373). Dispatch and run reads classify a rate-limited 403 from headers or body as RATE_LIMITED, retaining Retry-After seconds and the UTC reset time; permission-only 403 remedies remain unchanged (#374). `downloadArtifact` reads through `MAX_ARTIFACT_BYTES` (the package reader's per-entry ceiling plus 1 MiB for the wrapper): a `content-length` over it is `TOO_LARGE` before the body is read, a chunked body is cut when it passes it, and nothing is written; `unwrapArtifact` takes a download for GitHub's wrapper only when its one `.zip` is at the root and it holds no `manifest.json`, so a package carrying an audit bundle is passed on as itself (#329). `queriesInput` refuses a line carrying a user name or password, naming only its host (#308, 2026-10-17), in either list - `collect.yml` fetches any line that is an http(s) URL, a query included - and also one that only looks like such a URL (an unparseable port, a backslash for the slashes), which the runner would publish as a search: it would have become a workflow input anyone who can read the collector repository sees, and a ledger line nobody may edit. `usableOutDir(dir, { create: false })` creates the folder, checks it is writable and removes what it created - creating is the check, since looking only at the nearest existing folder passed a 300-character or `|`-bearing name that then failed after the paid run (review, 2026-10-18) - for the two callers that check early, `collect-remote` and the MCP server's `fetch_corpus`; `fetchCorpus` checks the same way before it lists the run's artifacts and creates the folder only once there is one package, so a run refused for its token, a 403 on the run, a run still in progress or an expired artifact leaves no empty folder behind (#295, 2026-10-11). **The watch's deadline bounds its polls, not only the waits between them** (2026-11-06, independent audit F06, ADR-0201): one AbortController per watch, aborted by one timer at `timeoutMs` (re-armed past the 2^31-1 ms a setTimeout holds) and cleared when the watch ends, passes its signal through `getRun` to `fetch`, and `unlessAborted` races the fetch, the JSON body and a 403's text against it. A poll cut off at the deadline ends the watch with TIMEOUT naming the run, its last status and `--run-id` - never NETWORK or BAD_BODY, which the watch would retry. A wait is the smaller of the interval and the time left, and no poll starts at or after the deadline. The other requests pass no signal. A 403 whose body is cut off at the deadline answered in time: its TIMEOUT names the status and the body still being read, and the refusal's own remedy comes first - the rate limit's wait when a header names it, else both causes, the wait first, since a secondary limit is named only in the body that was cut off (review of #442 and its re-review, 2026-11-06). A 403 from `dispatchCollection` or `getRun` is judged by `forbiddenError`, where a body that could not be read is `null`, not `''`: the dispatch's 403 and a run read's whose body was lost name both causes too; `listArtifacts`' and `downloadArtifact`'s name their status alone. An error that ends the watch is followed by the way back to the run (`--run-id`), unless its remedy names it already or the run's number is in doubt (a 404 before any poll read the run): a 403 ended it with "wait ... before retrying", and the command that was run dispatches another (re-review of #442's follow-up); a 404 after a poll read the run says the token lost sight of the run or it was deleted, then names the way back, where it said to check the number (second pass). `downloadRemedy(err, wayBack)` gives `collect-remote`'s download stage the same way back, after the error's own remedy, unless the error is the artifact's own (`NO_ARTIFACT`, `ARTIFACT_NAME`, `TOO_LARGE`, `EXPIRED`) or the remedy names `--run-id`: a proxy's HTML page for the artifact list, a dropped download and a folder to fix had said only to retry (re-review of #442, second pass, ADR-0201). A wrapper whose one package does not read - its CRC-32, its deflate stream or its size wrong - is still written and judged as it came, but `unwrapArtifact` also returns why (`unreadable`), and `fetchCorpus` puts that `ZIP-READ`, with a remedy to download again with `--run-id`, in place of the validator's `MANIFEST-MISSING`, whose remedy was to regenerate the package (review of #442, ADR-0200). | ADR-0033, ADR-0032, ADR-0201, ADR-0200 | | `brief.mjs` | The phase-1 → phase-2 handoff: renders `research/BRIEF.md` from the corpus, and **owns the brief's shape** — `BRIEF_SECTIONS` (six sections, the two that are judged marked), the markers that distinguish *still the scaffold* from *the drafter wrote this* (`briefState` → `template \| legacy \| draft \| authored`), and the section reader (`briefSection` / `judgedSection`) that `lib/audit.mjs` consumes instead of regexes of its own. The renderer writes headings **from** that definition, so the writer and its readers cannot drift; the scaffold ships `BRIEF_FILE_MARKER`, which the substitution pass leaves alone — the alternative, matching the template's prose, made a sentence of English load-bearing. The verified table has **one row per claim and source, naming the unknowns it closes** (2026-09-26): two unknowns leading with the same E-row used to print its finding twice, word for word - three committed briefs carried such duplicates. **Every E-row an unknown cites** gets a row, not only the first (2026-09-29): a MoonAliza closure resting on E-03 and E-06 showed E-03 alone, and the row it most depended on never reached the builder. With no KNOWN-UNKNOWN declared, the unknowns section reports that status and names any CLOSED unknown citing no `P` row: the earlier primary-source assurance was removed in 2026-09-27 and the unconditional closure assurance in ADR-0143. Its commands are spelled by `documentCommand`, with the project's recorded `kitPath` first (ADR-0084). `--force` over a draft, authored or legacy brief keeps it as `research/BRIEF.md.bak-` (numbered on a second force that day) and says so (2026-09-27): it restored the TODOs and kept nothing of the answers it replaced. **Every draft is stamped** (2026-09-27, ADR-0055) with a hash of its own text, a hash of what it was drafted from (`briefInputsHash`: topic, intent, unknowns, evidence rows, and since ADR-0069 the map rows and the ledger and capture counts), and the verdict it was drafted under (`gate=`), which `bin/preflight.mjs` compares with its own verdict and notes when they differ. `draftStamp` reads the stamp back, wherever it stands (the last one, if several), and text after it is an edit like text before it (2026-10-03, ADR-0138): anchored to the end of the file, a line appended after the stamp - a reviewer's note, the `Reviewed by: agent` line - had made the brief read as unstamped, so a stale brief re-approved the build and `brief-stale` fell silent. An unedited draft is redrafted without `--force` and without a backup, and any edit keeps the refusal. A draft written while the gate failed had kept saying "Gate: FAIL" after PASS. A known unknown's day-one step drops a leading "Day one:" before it is labelled (2026-09-27), so a cell written that way is not rendered "Day-one verification: Day one: ...". **`reviewedBy(text)`** (ADR-0074) reads the brief's `Reviewed by: agent|human` line; the drafted brief carries a placeholder that reads as `undeclared`. A line naming both roles, or two lines that disagree, also read as `undeclared`; a line inside a code block is not a declaration, and an indented one is. A known unknown's Evidence cell that states what is known and then labels its day-one step mid-sentence renders as "Known so far" plus one "Day-one verification" line, never the label twice (2026-09-29). A cell the author wrote as the brief prints it, starting "Known so far:", keeps that label once too. `reviewedBy` reads only `agent` (2026-09-30, ADR-0107): a `Reviewed by: human` line reads as `undeclared`, and the drafted placeholder names only the agent. **PASS reports configured checks, not complete closure or factual correctness** (ADR-0143). The preamble preserves the evaluated warning reasons through `renderGateWarnings`; the known-unknown section reports declared status without claiming all unknowns are closed, including on FAIL. The contradictions placeholder asks for corroboration rather than asserting agreement. On FAIL or without an evaluation, the verified section explicitly labels its CLOSED rows as declarations whose verification has not succeeded or has not been evaluated. Six sections are retained. The `--force` backup goes through `core.keepBackup` (#301). | ADR-0051, ADR-0084, ADR-0143 | | `corpus.mjs` | Capture and Raw-path checks use projectFile; capture reads stay inside the capture folder by real path, without reading an internal private-file link (#351, ADR-0109 trigger). `readCaptures(root, { known, sketches })` reads only the captures a caller does not already hold and sketches them only when asked (2026-10-02, break-test pass 3 F6): the collector's per-fetch refresh had re-read, parsed and MinHash-sketched every capture on disk inside the lock - 41% of a 2000-page run - and `rememberCapture` had pushed every entry again, n(n+1)/2 copies after n fetches; it now remembers a file once. Sketches serve one gate check (`corroboration`) and are the gate's to compute. `readCaptures` indexes every capture by exact URL (`byUrl`, with the asked-for URL of a redirected fetch as an alias) and by `urlKey` (`byKey`, 2026-10-02): `cacheDecision` asks the exact spelling first and the key second, so a plan that spells a page differently from its capture - `www.`, a trailing slash, a fragment, http for https - is a cache hit and spends nothing; `rememberCapture` keeps both indexes for a page collected mid-run. Also builds two per-capture indexes alongside the entries, both because `readCaptures` is already holding every body: `sketches` (MinHash, for `corroboration`'s mirror grouping) and `renderFailures` (`countRenderFailures`, `RENDER_FAILURE_MARKERS` — how many times a page printed a notice saying its own content failed to load). Neither rides inside `captureEntry`, which is serialised into artifact manifests. The research corpus: **one reader** (`readCorpus`, `readCaptures`, `parseTable`, `parseCapture`, `readOverrides`) and **one writer family** (`appendRow`, `upsertRow`, `appendJsonLine`, `writeRaw` lives in collect), plus the **cache decision** over the capture index (`cacheDecision`) — the one freshness predicate the collector, the run, and phase 0 consume. Table machinery: `splitRow`/`escapeCell`/`tableRow`/`repairRowArity`. Malformation is reported, never absorbed. The capture index has one constructor (`captureEntry`, used by `readCaptures` **and** by the collector when it has just written a page) and one writer (`rememberCapture`), so the index a run keeps in memory holds the same shape as the index read from disk — callers store the entry they are handed and shape nothing. The format's **joins** are here too: `traceOf` (evidence row → the fetch that produced it and the capture behind it), `captureOf` (row → capture, via the by-file index the reader builds), and `claimOf` (unknown → the claim it rests on, first cited row, falling back to the unknown's own text). Five consumers re-derived those joins, including the subtle "an empty Raw cell matches on the URL alone" clause; now a format change lands in one place. A `research/kit.json` that does not parse is a `kit-unparsed` problem, blocking in `corpus-shape` like `plan-unparsed` (2026-09-27): the gate read it as absent and fell back to its default code paths, silently unguarding the ones the operator declared. A row with more cells than its header folds the extras into the table's prose column (`Finding`, `Unknown`, `Why it matters`, `Title`), not the last one, so the structured columns to its right stay in place, and the `table-arity` warning says a `|` inside a cell splits it and to write `\|` (2026-09-27): folded into `Raw`, a Finding quoting `a || b` corrupted the capture path and preflight reported the page missing. A `Raw` cell's backslashes are always read as separators (2026-09-27): splitting on `path.sep` converted them only on Windows, so a row written there passed on Windows and failed on a Linux builder. `readCaptures` does not read a capture that links outside the project; it records `capture-outside`, which corpus-shape blocks on (ADR-0076). `CAPTURE_MAX_BYTES` (10 MB) is checked with `stat` before a capture is read: larger is `capture-too-large`, unreadable is `capture-unreadable`, both blocking - a 100 MB file crashed every reader and a 600 MB one was skipped in silence (ADR-0077). An EVIDENCE Raw cell that lands outside the project is a blocking `raw-outside` problem and is never read; it counted as present before (2026-09-28). A duplicate key in `plan.json` or `kit.json` is reported as `plan-unparsed` / `kit-unparsed` (blocking), found by the strict reader in `release/json.mjs`: `JSON.parse` kept the last value silently (2026-09-28). `duplicateKey(text)` is exported (2026-09-30) so a command that spends reads a hand-edited JSON file by the same rule the gate does. `byUrl` also answers to the URL a redirected fetch was **asked for** (`requestedUrl`, read out of the capture's command line; 2026-10-02), read as the transports' `command()` writes it - a JSON-quoted argument whole and unescaped, a quote no transport writes as no URL - and with the plan check's URL test (`planProblems`: what `new URL` parses as http(s), so a leading space, tab or control character too), so an asked URL holding whitespace is aliased under the plan's own spelling in `byUrl` and under its `urlKey` in `byKey`; the command had been split on whitespace first and the scheme tested on the raw text, and such a URL read as none, or as a corrupt one, there and in the gate's asked-for reads in `checks.mjs` (2026-11-01, PR #440 review r5, BR5-1 and its fix review): a capture records where the bytes came from, the plan keeps naming where it asked, and the index that knew only the first missed the cache on every later run - the page was fetched, paid for and appended to the ledger and the table again as a "first capture". A capture of the asked-for URL itself always outranks an alias; `rememberCapture` keeps the same rule for a page collected mid-run. **The newest capture of a URL on one date** (2026-10-03, outside audit): `newer(entry, held, rank)` decides every `byUrl`/`byKey`/alias slot - by retrieval date, then the ledger's order (`readCorpus` reads the ledger first and hands `readCaptures` a capture-file -> fetch-position map), then a higher revision of the same name, then the later name; filename order alone reopened twelve same-day revisions with `.r9.md` current and a re-titled page under its older title, while the collector's own run (`rememberCapture`) had it right. `ledgerRank(entries)` builds that map for both readers, and `rememberCapture` takes the rank and decides through `newer` too (2026-10-03, second audit): it had replaced the direct-URL slot unconditionally, so the collector's refresh under the lock undid the reopen's answer and a stale snapshot took `.r9.md` as its cache hit. **One slot rule on every path** (2026-10-03, fourth audit round): `rememberCapture` decides the current slot for a known file too - a refetch whose bytes match an earlier capture reuses that file and records it last (A -> B -> A) - with a direct capture over any redirect alias, the fetch that just happened (`latest`) over whatever was there, then `newer`; `newer` lets a fetch on record beat a file no fetch produced. Rank alone had let a later alias keep the slot from a direct capture where the reopen gives it to the direct one. **A data row whose ID is not in its table's form is recorded, never dropped** (2026-10-03, output-reliability audit G2): `readCorpus` pushes a `malformed-id` problem - artifact, the ID, the parser's line, a remedy naming the form - for every non-empty unknowns, evidence or subtopic ID the filters would discard; `U_99` had been neither an unknown nor a problem, and a blocking question had vanished on a typo. A row empty in every cell stays silent, because the templates ship empty tables; an empty ID cell on a row that holds content is recorded like a typo, since the row says its piece and no check can find it (the first version kept every empty ID silent on the premise that the templates ship empty rows; review of G2, 2026-10-03). **A row-shaped line after the table ended is recorded, never dropped** (2026-10-03, gap audit rank 1): `parseTable` scans from the table's end to the next heading and pushes a `table-split` problem for every row-shaped line there, naming its line and first cell; a blank line ends a table as GFM renders it, so GitHub showed such a row as prose while the parser dropped it in silence, and a blocking unknown written below a blank line had left the gate, which printed PASS. A header followed by its separator starts another table and is not flagged (the root `research/SOURCES.md` holds two). The row stays out of the table, as rendered; `hygiene` fails it by name. | ADR-0003 `readLedger` tolerates only a leading editor BOM outside the hashed entries, without rewriting evidence or relaxing entry/chain verification (#296). | `readCaptures` skips `*.source.html` (ADR-0140, 2026-10-03): a capture's source sibling is named and verified by the ledger and is never a capture - not indexed, cited, graded or exported. The scan runs to the end of the table's SECTION - a heading of the same or a higher level than the one above the table; a deeper `###` note does not end it and `#tag` prose is not a heading - skips code blocks and HTML comments, where GitHub shows a row as code or hides it, treats a lone separator as not a row, and names a second table whose separator is malformed once, as that (breaker review, 2026-10-04: a `###` note had hidden a stray row below it, and a row in a code fence had failed the gate). **The header search skips the same code blocks and comments** (independent audit 2026-11-04, F01): one pass, `hiddenLines(lines)`, says which lines GitHub shows as code or hides, and which headers it makes a table of, for the header search, the stray scan, the section heading above a table and the writer alike, by cmark-gfm's block parser line by line: a line ends at LF, CR LF or a carriage return alone, and a byte-order mark before the first is skipped; block quotes, list items and footnote definitions are containers, each measured from its parent's offset (a tab to the next multiple of 4), and a line that continues none, opens no block and falls in an open paragraph is a lazy line of it; the openers are cmark's, in its order - fences, the seven HTML block types (a comment to its `-->`, reopened by a `` is closed only by an HTML block's `-->`, as GitHub escapes one in text or code, and one in a paragraph's raw HTML leaves it in doubt (`closer`); nested footnote definitions mark each line once (the re-review of 88fe19a, 2026-11-07, F2-F6). Comments and footnotes are read in GitHub's order, which shows the footnotes below the body: a comment the body leaves open hides them too, and the first a footnote definition leaves open hides the rest of it and the definitions shown after it, never the body; a line of nested definitions is the innermost one's, in that one's order; a `[^label]` is a reference only where GitHub reads one - in a paragraph's, a heading's or a cell's text, not in a code span, an autolink or raw HTML, not escaped, and not link text - followed by an inline link or a `[ref]` the file defines, or around a link until a bracket not an image's opens after it (cmark-gfm's `no_link_openers`) - (`footnoteRefs`, one pass; the re-review of 50b18d1, 2026-11-08, G-2, G-4 and G-6); and the body can end inside an HTML block of type 1 or 3 to 5 as well (the re-review of 116a2a3, 2026-11-07, findings 1 and 3-5). The body and each footnote definition read the raw lines of their HTML blocks with a browser tokenizer of their own (`feedHtml`), a comment or a tag any of them leaves open hiding what follows; a reference is what cmark-gfm's inline parse makes one, once every link reference definition is known (`refmap`, `footnoteRefs`); a label is case-folded by GitHub's fold tables, older than Node's Unicode - a character outside the blocks they cover is kept as written (`normalizeLabel`, G-3) - and one holding an entity name the kit does not know is `uncertain`; and `doubtOf` says where GitHub may show a line the kit hides, or hide one it shows - a tag GitHub wrote a block after, a comment past a `-->` that may close it, a line past an HTML block the browser may read otherwise (`unsure`, its second reading `alt`), or past raw HTML GitHub writes as it stands in a paragraph, a heading or a cell that leaves the browser in a comment or a quoted value, or a `