From aa1d673f66f364eab0230b1bfdc9c0a9ca02d3c0 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 17:46:34 +0300 Subject: [PATCH] fix(evidence): restrict recorded capture reads to the raw folder what changed: Evidence context validates recorded Raw paths before reading, refuses private project files/traversal/links leaving research/raw, and distinguishes directories from missing captures. ADR-0109's defect trigger also migrates corpus capture/Raw checks and artifact Raw/ledger readers to projectFile; a non-file ledger is refused before reading. why: A Raw cell could print a project's private file into an agent context. The accepted ADR-0109 explicitly requires the other recorded-path readers to move when this deferral's defect trigger fires. No alternative design was set aside. what it touched: evidence-context, corpus and artifact libraries; evidence-context and artifact-producer tests; all three architecture rows; ADR-0109 implementation note, changelog and research-kit README inventory. what you verified: verified - red-first 90 passes, three expected failures and four unsupported file-symlink tests; then 93 passes, zero failures and the same four unsupported capabilities across evidence-context/corpus/artifact-producer. Direct/traversal private paths cause zero private reads and no text/JSON disclosure; directories and non-file ledgers are named. Handoff OK, preflight PASS, doctor READY collector/firecrawl-cli in this checkout. Untested locally - real file links; the CI matrix exercises supported symlink capabilities. The required full offline suite runs in the commit gate before acceptance. what you got wrong and fixed: mistake - the documentation updater assumed every architecture row ended with an ADR column. where - the local architecture-update step. impact - its first attempt stopped before writing the map; no delivered PR was affected. cause - the existing corpus row spans lines and the evidence-context row has an empty ADR cell. fix - update each module's responsibility prefix and record the expired deferral. verified - all three rows changed and git diff --check passed; the targeted code checks remain 93 passes, zero failures and four unsupported capabilities. --- CHANGELOG.md | 2 + docs/ARCHITECTURE.md | 6 +- ...des-whether-a-recorded-path-may-be-read.md | 5 ++ research-kit/README.md | 2 +- research-kit/lib/artifact.mjs | 21 +++++-- research-kit/lib/corpus.mjs | 17 +++-- research-kit/lib/evidence-context.mjs | 20 ++++-- research-kit/test/artifact-producer.test.mjs | 8 +++ research-kit/test/evidence-context.test.mjs | 63 ++++++++++++++++++- 9 files changed, 123 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..d0c4c9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Each release of Research-Kit, newest first. The version is `KIT_VERSION` in `research-kit/lib/core.mjs`; a release is tagged `v` (ADR-0116). ## Unreleased + +- Evidence context refuses Raw cells outside research/raw and names directories before reading; the ADR-0109 trigger also moves corpus and artifact recorded-path readers to projectFile (#351). - Fixed `AGENTS.md` corpus description to use evergreen relative references rather than stale hardcoded entry counts (#299). - The suite's "every CLI refuses a flag it does not know" table now covers `measure`, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca0813fa..b193c7a3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -179,12 +179,12 @@ is an open decision (2026-09-30): | `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), 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. | 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, problems, has, read }`, plus `checkEntryName`, `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, 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. | ADR-0032 | | `artifact-validator.mjs` | **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). 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. | ADR-0032 | -| `artifact.mjs` | **Packaging a project without changing it, and deriving every authorization field.** `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), with the reader's lowercase comparison: 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`. | ADR-0032, ADR-0031, ADR-0052, ADR-0143 | +| `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, and deriving every authorization field.** `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), with the reader's lowercase comparison: 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`. | ADR-0032, ADR-0031, ADR-0052, ADR-0143 | | `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. **No tool takes a token** and a test asserts no parameter is credential-shaped. `PROTOCOL_VERSION` is pinned and a mismatch answers `-32022` with `data.supported`, the same discipline as pinning `X-GitHub-Api-Version`. `createStdioLoop` serialises chunk processing through one promise chain - overlapping `data` events were mutating the buffer concurrently, which a test caught. **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 - 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. | ADR-0034, ADR-0049, ADR-0143 | | `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. | 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). | ADR-0033, ADR-0032 | | `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. | ADR-0051, ADR-0084, ADR-0143 | -| `corpus.mjs` | `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): 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 | `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 fenced code 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). A `*.source.html` no ledger entry names is a `source-unnamed` problem: a sibling is never indexed, so nothing else would see a planted one. +| `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): 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 | `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 fenced code 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). A `*.source.html` no ledger entry names is a `source-unnamed` problem: a sibling is never indexed, so nothing else would see a planted one. | `transport.mjs` | **Adapter selection**, and the one reader of the operator's transport choice — now for **two sides** (ADR-0027). The *fetch* ladder is unchanged: `--transport`, `RESEARCH_KIT_TRANSPORT`, the config's `transport`, then auto-detection (`probeFirecrawl` — authenticated or anonymous CLI wins, otherwise keyless). The *search* ladder mirrors it: `--search-transport`, `RESEARCH_KIT_SEARCH_TRANSPORT`, the config's `searchTransport`, then one auto-detect rule — a SerpAPI key wins, otherwise **the search side IS the fetch provider**, so a machine with no key behaves exactly as before. `selectTransport` returns `{ name, adapter, why }` still meaning the FETCH side, with `search` added alongside; that is what let six call sites stay unedited. Exposes `TRANSPORTS`/`TRANSPORT_NAMES` (whole-shape adapters), `SEARCH_PROVIDERS`/`SEARCH_PROVIDER_NAMES` (a **separate** map — a search-only module in the first would make `ADAPTER_SHAPE` a claim it no longer keeps), `FETCH_SHAPE`, `SEARCH_SHAPE`, `satisfies`, `isSearchOnly`, `selectSearch`. `isSearchOnly` reads a module's declared `CONTRACTS` rather than duck-typing, because a search-only module exports `scrape` on purpose — to refuse by name. **`unusedKeyNote(chosen, env)`** (2026-09-27) is the line `research.mjs` and `decompose.mjs` print under the transport line when `FIRECRAWL_API_KEY` is set but auto-detection fell back to keyless because the CLI is not on PATH: the key is unused, and the line gives the install command. Until then only doctor said so. An explicit `--transport http-keyless` gets no note. **`browser` is the third transport** (2026-09-28, ADR-0088), chosen only by name; `SEARCH_PROVIDERS` holds only the transports that search, and `selectSearch` gives a fetch-only transport the keyless search, saying so. With a SerpAPI key the merged search pairs SerpAPI with the fetch side only if it searches; a fetch-only transport (the browser) hands its share to `http-keyless` (2026-09-29, found by the first live browser run). `SEARCH_PROVIDERS` includes `searxng` (2026-09-30, ADR-0104), selected only explicitly; `selectSearch` reports it `notReady` without an instance URL, as it does SerpAPI without a key. `selectSearch` hands the searches of a Firecrawl CLI with no key to the keyless route, as it does a fetch-only transport's: Firecrawl still scrapes for that CLI but refuses its searches ("Alexandria requires a Firecrawl API key", CLI 1.24.6, 2026-10-01). **`canSearch`** sits beside `isSearchOnly` because both are a transport's own capability, asked of any provider: whether it can search at all — the browser fetches only (ADR-0088), and a Firecrawl CLI with no key has a search function and no search. **`SEARCH_KEY`** (2026-10-07, ADR-0147) is the metered search provider's credential for a surface that must not name the vendor - its env and config names, `read`, `state` (set, and from where; never the key) and `redact` - consumed by the desktop panel. | ADR-0005, ADR-0027, ADR-0088, ADR-0147 | | `serpapi.mjs` | A **search-only provider**: the first module here that satisfies one contract instead of the whole adapter shape, declared as `CONTRACTS = ['search']`. Produces no capture, so nothing it returns reaches the fetch ledger and `transport-provenance` is untouched. Sends exactly `engine`, `q`, `api_key` — **no result-count parameter**, because none is documented and a response costs the same whatever it carries, so `limit` is applied client-side; `no_cache` and `async` are never sent, which keeps the vendor's free 1-hour cache. The key is read from `SERPAPI_API_KEY` or the config's `serpapiKey`, **never from a repository**, and travels to the child on **stdin** rather than argv so it never enters the process table. The request URL — the one string that contains the key — is built in the child and never returned; `redact()` scrubs it from vendor error text on the way back, and `command()` renders it pre-redacted. **A query that contains the key is refused before transmission**, because `cmd` is written into the hash-chained ledger (a committed file) and because sending it would store the credential as a search term on the vendor's systems for 31 days; redacting the rendering alone would have fixed the smaller half. Async `fetch` behind a synchronous shape via the same self-exec rendezvous `http-transport.mjs` uses. `status()` answers from configuration alone and **declines to report a remaining allowance**, because no endpoint this project has captured reports one. `requestUrl(query, key, endpoint)` is exported so the exact parameter set is testable rather than buried in the child — the guarantee worth pinning is negative, and negative guarantees rot silently. The **endpoint is overridable and guarded**: `allowedEndpoint` permits the vendor's own host over https, or loopback, and nothing else. That exists so the whole path — parent, `spawnSync`, child, real `fetch`, real socket — can be driven against a local stand-in instead of being exercised once by hand and called verified; an overridable endpoint on a request carrying an API key is otherwise an exfiltration route, so **both halves check it**, the parent before spawning and the child before fetching, because the child reads its job off a pipe and does not get to trust it. The one thing no stand-in can prove — that the two meters really are separate — is covered by `test/live-collect.test.mjs`, an **opt-in** test (`RESEARCH_KIT_LIVE=1` plus a key) that drives `bin/research.mjs` through a real collecting run in a temp project and asserts the Firecrawl spend against the pages fetched. Opt-in because running it on every suite would cost every developer credits forever; existing because "it costs money" is a reason to gate a test, not to skip it. **It also says what the meter's numbers mean** (`METER_NOTES`, 2026-09-26): the 50/hour and 250/month `searchUsage` reports are the *documented* Free Plan (E-25, E-27), while SerpAPI's own Account API reported 250/hour for this account (U-9); and SerpAPI's month is a billing cycle, not the calendar month the kit counts (U-10). `research.mjs --status` prints the selected search adapter's notes, so the vendor stays named only here (NFR-3) and a machine without a key hears nothing about it (FR-5) **The key it searches with is the key that selected it**: `search()` reads the machine config when none is passed, as `selectSearch` does. It defaulted to `null` until 2026-09-26, so a key held only in `~/.agents/research-kit.config.json` selected SerpAPI and then failed every search "no SerpAPI key" - a merged run degraded silently to the other provider on every query **It reads the vendor's own meter** (`account()`, ADR-0040): `serpapi.com/account.json`, free and uncounted (E-26), through the same child-process path, endpoint lock and redaction as `search()`, copying only whitelisted fields (the payload carries the email, the account id and the key). `research.mjs --status` asks the selected search adapter for it and prints a `vendor meter` line; no key, no call. `status()` stays offline. **Its child uses a configured proxy** (2026-09-27, ADR-0047): `runJob` starts it with `fetchEnv(env)` - it had been started without, after the keyless child was fixed, so behind a proxy every search went around it and failed "fetch failed"; `search()` and `account()` hand their `env` to the child. A child past its timeout is reported as "SerpAPI did not answer within Ns", not `spawnSync ... ETIMEDOUT` (2026-09-27, seen on a real run) The child runs with `maxBuffer: CHILD_OUTPUT_LIMIT` and names an overflow (2026-09-28, ADR-0082): under the 1 MiB default a large answer was lost to ENOBUFS. `normalizeSearch` drops a result row that is not an object and reads a field of the wrong type as empty (2026-09-28): one null row threw and discarded every valid result beside it. The child reads the answer through `boundedText` (2026-09-28, ADR-0082): with `response.text()` a 17 MiB answer was accepted, since the output limit bounds only what reaches the parent. | ADR-0027, ADR-0040, ADR-0047 | | `searxng.mjs` | The second **search-only provider** (2026-09-30), with the contract `serpapi.mjs` defines: `GET /search?q=...&format=json` against a SearXNG instance the operator runs, named by `SEARXNG_URL` or `searxngUrl`. No key and no meter. JSON is off in a default instance, so an HTTP 403 is named as "enable json in `search.formats`", never "no results"; a row with no string `url` is dropped (the docs type it `str \| None`). Chosen only explicitly. An instance behind basic auth (`http://user:pass@host`, 2026-09-30) has its credentials taken out of the URL - fetch refuses one that carries them - and sent as an `Authorization` header (`basicAuth`); every message that names the instance shows it through `shownInstance`, password as `***`. Every search had failed, and the error printed the password. `shownInstance` redacts a password from any URL that parses, whatever its scheme, and by pattern from one that does not (2026-09-30): a mistyped `htp://user:pass@host` - exactly when the instance is shown in an error - had printed the password. `basicAuth` sends a literal `%` as typed instead of throwing `URIError`. A password holding an unencoded / ? # or @ - which no URL parser accepts - is redacted by pattern up to the last @ (2026-09-30). requestUrl builds the endpoint on the pathname, so a query or fragment on the instance URL no longer drops its sub-path (2026-09-30). | ADR-0104 | @@ -194,7 +194,7 @@ is an open decision (2026-09-30): | `browser-guard.mjs` | **The guard proxy Chromium renders through, and the child that runs both** (ADR-0118). `startGuard` listens on a loopback port and judges every plain request and CONNECT with `judge`: the keyless rule (`isInternal`), a name by the addresses it resolves to, the connection pinned to the address judged; only the `host:port` asked for is exempt, `allowInternal` is the operator's own decision, and a configured proxy is the upstream (absolute URLs, CONNECT passed through, `Proxy-Authorization` from the URL). A refusal is a 403 carrying `REFUSAL_MARKER` and is recorded. `renderThroughGuard` runs the browser with `--proxy-server` for the guard and `--proxy-bypass-list=<-loopback>`, its own process group on POSIX (killed whole on a timeout), the DOM capped at `MAX_PAGE_BYTES`, the exit reported after a short grace even when a forked helper holds stdout open. Run as a process it takes one job on stdin and prints one JSON line. The guard keeps a record of every request it carried - `seen`, each with its outcome (a status, `refused`, `tunnel`, an error, or `unanswered` once the guard closes) - and `pending()` lists the ones still unanswered, with how long they have waited; the child reports `requests`, `pending` and `elapsedMs` beside the dump, and `seen` as a timeline from the launch - each request's offset, duration and outcome - because a Windows run then stalled 28 s with nothing pending at the guard, so where the time went became the question (2026-10-01). The child keeps the first 30 lines of the browser's stderr as well as the last 60, with the omission counted between them, and stamps the launch (`startedAt`): a hung Windows launch's tail showed profile creation 43 s after the launch, so the wait was in the head the tail could not show (2026-10-01). The render timeout starts at the browser's first request through the guard, and the launch is allowed as long again (ADR-0119): the child arms the kill at launch and re-arms it on the first request, and reports `startupMs`: three Windows CI runs in a row ran the first launch to its deadline and nothing said on what, and the guard is the one place every load passes through. **The browser's own service traffic is dropped** (2026-10-02, ADR-0124): `BROWSER_SERVICE_HOSTS` names Chromium's service hosts - `clients*.google.com`, `*.clients.google.com`, `mtalk.google.com`, `accounts.google.com`, `www.google.com`, the update, safe-browsing, variations and autofill endpoints on `googleapis.com`, `*.gvt1.com`, `dl.google.com` - and `isBrowserService` answers for one; a request there is answered 403 with `DROP_MARKER`, logged `dropped`, and is neither judged, carried nor counted as a refusal, the page asked for exempt by host and port. Rendering a plain loopback page, Chromium 141 had made nine such connections per render, carried as external, and on a no-network host the last of them - `www.google.com`, unresolvable - became the verdict of the LIVE test about an internal address. **A request before the page's own is the browser's** (ADR-0125): with `pageFirst`, which `renderThroughGuard` passes, every request is dropped until the host and port asked for has been requested - CI's Chrome stable preconnected to `www.gstatic.com` 256 ms into a render, before the page, and that host is one real pages use too; the order settles what a list cannot. The CONNECT socket gets its error listener before any answer is written (2026-10-02): a browser resets a CONNECT it has given up on with the 403's body unread, and an ECONNRESET on the handed-over socket with no listener was an uncaught exception that killed the guard child mid-render - an empty report, read by the transport as "the browser exited null", on three CI legs at random points of the render. **The child has Chromium write its net log** (2026-10-03, ADR-0137): `renderThroughGuard` makes a private folder (`rk-netlog-`), adds `--log-net-log=/net-log.json` before `--dump-dom`, reads the file after the render (a log over `MAX_PAGE_BYTES` is dropped as empty) and deletes the folder on every way out; a folder that cannot be made renders without the flag, which the transport grades as status not observed. The child also reports `allowInternal`, the internal-address decision it made by resolving the requested URL, so the transport judges the final URL by the same rule. The log travels in full through the child's JSON stdout and is never written to the corpus. **A name asked for is judged by what it resolves to, exempt or not** (ADR-0155, 2026-10-11): `judge` refuses a name resolving inward, or `localhost`, unless the caller allowed this machine's network, whether or not it is the host and port asked for; the exemption covers only the literal-address judgment. Under `allowInternalRedirects: false` the child judges a name asked for before launching and answers `EINTERNAL` with no render. | ADR-0118, ADR-0124, ADR-0125 | | `collect.mjs` | One URL's journey: `collectOne` (cache, force, fail entries, evidence/source rows) and `writeRaw` (the capture writer, which hands back the corpus's own index entry). Consumes the `runScrape` seam; stamps `transport` and `completeness` from what the adapter declared — invents neither. The Finding cell a row is born with is `lib/finding.mjs`'s: the collector **imports** `firstFinding` and does not re-export it, so the extractor has one address. A fetch reports **why it happened in words** (`whyFetched`: first capture, refreshed with the age, or --force), not `cacheDecision`'s code - verbatim, a fresh fetch logged as "collected - not-collected" (found in a live-collection log, 2026-09-26). **`writeRaw` never overwrites a capture with different bytes** (2026-09-27, ADR-0058): a same-day re-collection of an unchanged page reuses the file, and a changed page is written as `.r2.md` (then `.r3`...), which sorts after the original so `readCaptures` still takes it as the day's latest. The earlier reading had been replaced, and its ledger hash broken. **An error page is a failed fetch** (2026-09-28): a result whose `statusCode` is 400 or more is recorded as a `fail` ledger entry and writes no capture and no EVIDENCE row - Firecrawl answered ok with Google's "Error 503" page and the kit had filed it as evidence; the keyless transport already refused non-2xx answers. `recentlyGone` (ADR-0100) answers `gone` without fetching when the ledger's last entry for the URL is a 404 or 410 inside the refresh window. writeRaw refuses (`OUTSIDE_PROJECT`, a named write refusal in core) a link at the capture's generated name - dangling or not - and a capture folder whose real path leaves the project, before it reads or writes; a project living under a link is judged by real path and still writes (2026-10-01). A front-matter value with a line break is written on one line, so an adapter title or error cannot add a field; the body is written verbatim (2026-10-01). A page whose text exceeds `CAPTURE_MAX_BYTES` is a **failed fetch**, named with its size and the limit, before anything is written: the reader refuses such a file as `capture-too-large` and the gate blocks on it, so a 40 MB answer had become a capture, a ledger entry and a row the corpus could not hold (2026-10-02, break-test pass 3). `refreshCaptures`, the re-read inside the lock before every fetch, asks `readCaptures` only for files the snapshot does not hold and for no sketches (2026-10-02): per-capture cost at 400 rows went from 74 ms to 13 ms. A forced re-fetch that comes back byte-identical reuses the capture (`writeRaw` never overwrites) and lets the row that cites it stand - the fetch is in the ledger, the reason says `the page is unchanged, so E-01 stands`; it had appended a second row that superseded the first, so every unknown citing unchanged content had to move (break-test pass 3, 2026-10-02). A changed page still goes to `.r2` with a row of its own. `writeRaw` writes the body with LF line endings only, whatever the transport received (2026-10-02): the scaffold's `.gitattributes` makes git store a capture as LF, and a keyless capture written with the CR bytes copyright.gov served hashed one way on the collector and checked out another way everywhere else, failing handoff on a capture nobody had touched. The one such capture already committed (a licence decision project, E-18) is pinned byte for byte with a `-text` attribute in that project. `refreshCaptures` reads the new captures with the ledger's rank (2026-10-03): one ordering rule with the reopen, so a capture another collector wrote since the snapshot is current only if its fetch came later. The collector remembers its own fetch as `latest`, and `refreshCaptures` decides every known capture the ledger names again with the rank, since a reused file's position moves while its bytes do not (2026-10-03). | ADR-0016 | **The text a capture was converted from is kept beside it** (2026-10-03, ADR-0140, gap audit rank 3): `writeSource(root, captureFile, source, markdown)` writes `.source.html` (`sourcePathFor`), LF-folded like the body, and returns its path and hash for `collectOne` to hand to `appendFetch`; nothing is kept for a verbatim answer (the source is the capture), a source over `CAPTURE_MAX_BYTES`, or a sibling already on disk with other content, which is never overwritten. The converted Markdown was all the corpus kept, so G3's conversion defect was permanent and the ledger certified it. When a source was given and is not kept, `writeSource` returns `{ omitted }` with the reason - over `CAPTURE_MAX_BYTES`, a sibling already on disk with other content, a sibling name that cannot be written - and the ledger entry carries it as `sourceOmitted` (breaker review, 2026-10-04): a link planted at the sibling's name had made `assertCapturePath` throw after the capture was written, leaving an orphan capture with no ledger entry that the next run took for a cache hit; the write is now inside the try, and the fetch is ledgered without a source. | `finding.mjs` | The auto-extracted **Finding** cell: `firstFinding(markdown, fallback)` — page markdown in, one line of prose out — over the heuristics behind it (boilerplate/tracking/nav/campaign regex families, the sentence splitter, the table reader, the tier-hero block skip, the graded score, the 220-character cap). Two arguments over ~460 lines: a deep module that was buried in the middle of another one until ADR-0016. Pure by construction — no filesystem, no corpus, no adapter — the one module a caller can exercise with a string. **`findingWithContext` is the real entry point** and returns `{ finding, excerpt, heading, signals, score }`: the same selection, plus where on the page it came from, the surrounding paragraph bounded to `EXCERPT_CHARS`, and which signals fired. `firstFinding` is a thin wrapper over it, so the sentence the collector writes and the excerpt a reviewer reads cannot be chosen by different code. **Ranking is one declared table, `RULES`** — 21 named rules, each with a weight and the reason for it, and `explainScore` returns the rules that fired alongside the total. Weights used to be two separate runs of `value += n`, one in `score()` and one in the selector, so the priority order could not be stated without executing the code and the number a reviewer saw could differ from the number that chose the sentence; a test now asserts no weight is applied outside `RULES`, and that the reported signals sum to the score. Heading context is an **ancestry stack**, not the last heading seen, so a claim under `#### Capacity` still knows it sits inside `## Pricing`. The policy is evidence-first: a measured quantity or a metering rule (**how** usage is counted, which often carries no digit) is strongest; where a sentence sits counts as much as what it says, so a terms-bearing heading and an FAQ answer both score while blog and careers copy is pushed down; and things that only look concrete — one row of a pricing comparison matrix, a number inside a sign-up prompt — are penalised enough to lose to plain prose. The `comparison-row` rule's tier test is three linear scans (`tierThenPeriodThenDigit`, per line, since `.` never crossed a newline) instead of `\b(plan|tier)\b.*\b(month|year|user)\b.*\d`, which was quadratic in the row - every tier word on a non-matching row scanned to its end before failing - and cost 15 s on a 309 KB row, the size of a line this corpus already holds, twice per row (break-test 2026-10-01). | ADR-0016 (superseding ADR-0009) An underscore inside a word is the word (2026-10-02): `stripMarkdown` dropped every `_` as emphasis, so a drafted finding named `return_run_details` as "returnrundetails" and `check_suite_id` as "checksuiteid" - parameters no page carries, and a quote copied from the draft could never be found; emphasis marks at a word's edge still go. | -| `evidence-context.mjs` | One unknown, everything it rests on, in one read-only answer: the unknown's wording and why it blocks, every evidence row it cites, each row's source title / URL / type / retrieval date, the Finding, a bounded excerpt of the capture chosen for overlap with that Finding (and, in a block longer than the bound, windowed where the most distinct tokens of the Finding fall together - 2026-09-27: a JSON capture is one block, and the excerpt was its first 420 characters whatever the Finding quoted), rows that supersede it, and other rows for the same URLs. **It decides nothing**: it writes no status, offers no verdict, and reports a gap rather than filling it — a test asserts the rendering contains no conclusion and says plainly that the judgement is the reader's, and structural tests assert it imports no verdict machinery and contains no write call at all - the architectural property, which a phrase-based check cannot give. The recorded status is printed LAST and labelled as a recording, because a verdict shown before the evidence anchors the reader. Where the extractor would now pick a different sentence than the row records, that is shown beside it, labelled a suggestion. Reads no environment variable and spawns nothing, so it cannot use a credential. A Raw cell outside the project is reported ("is outside the project, so it is not read") and never read: it printed the outside file into an agent's context (2026-09-28, ADR-0076). `bin/evidence-context.mjs --all` over a contract with no unknowns yet answers "no unknowns" (or `[]` with `--json`) and exits 0 (2026-09-29, break-test): it exited 2 asking for the `--all` it was given. | — | +| `evidence-context.mjs` | Raw cells pass projectFile for both project and capture-folder boundaries before any content is read; private project files, traversal and links leaving research/raw are refused, and directories are named as non-captures (#351, ADR-0109 trigger). One unknown, everything it rests on, in one read-only answer: the unknown's wording and why it blocks, every evidence row it cites, each row's source title / URL / type / retrieval date, the Finding, a bounded excerpt of the capture chosen for overlap with that Finding (and, in a block longer than the bound, windowed where the most distinct tokens of the Finding fall together - 2026-09-27: a JSON capture is one block, and the excerpt was its first 420 characters whatever the Finding quoted), rows that supersede it, and other rows for the same URLs. **It decides nothing**: it writes no status, offers no verdict, and reports a gap rather than filling it — a test asserts the rendering contains no conclusion and says plainly that the judgement is the reader's, and structural tests assert it imports no verdict machinery and contains no write call at all - the architectural property, which a phrase-based check cannot give. The recorded status is printed LAST and labelled as a recording, because a verdict shown before the evidence anchors the reader. Where the extractor would now pick a different sentence than the row records, that is shown beside it, labelled a suggestion. Reads no environment variable and spawns nothing, so it cannot use a credential. A Raw cell outside the project is reported ("is outside the project, so it is not read") and never read: it printed the outside file into an agent's context (2026-09-28, ADR-0076). `bin/evidence-context.mjs --all` over a contract with no unknowns yet answers "no unknowns" (or `[]` with `--json`) and exits 0 (2026-09-29, break-test): it exited 2 asking for the `--all` it was given. | — | | `credits.mjs` | **What a run does when the paying provider runs dry** — ADR-0086's one switch to the fallback, ADR-0129's stop-and-report. The one owner of a run's exhaustion state: `fellBack` / `stopped`, the `uncollected` and `skippedSearches` accounting, and the single `credits-exhausted` row the failure log keeps per run. The coordinator sequences and asks it which provider serves each call (`exhausted`, `live`, `onExhaustion`, `askLive`) and reads the state back for the summary; before the extraction the state lived as closures inside `runResearch` and `stopped.uncollected` was incremented from three loop sites. | ADR-0086, ADR-0129 | | `search-session.mjs` | **How a query is put to the search providers** (ADR-0135), for both coordinators - `runResearch` and `decompose` each carried a copy, and read side by side on 2026-10-03 the copies had drifted in six places (a failed first ask's usage counted in one, a plain failure in `searchFailures` in the other, `searchTransport` spelled `serpapi` by one and `serpapi+firecrawl-cli` by the other under a merge, the `notReady` refusal in one only, three log lines in one only, `covered` in the other only). `searchSession({ adapter, searchAdapter, searchAdapters, limit, maxRateLimitRetries, sleep, log, record, credits, askOne })` returns `name` (the paying provider, what the usage row's `searchTransport` says), `meter` (the dry run's label, every provider under a merge), `providers`, `assertReady()` (a `searchAdapter` reporting `notReady` throws `SEARCH_PROVIDER_NOT_READY` before any ask), `ask(text)` and `meters()`. `ask` is the loop: every provider under a merge, interleaved by `mergeByRank` and attributed, else the one provider with one bounded degrade to the fetch provider when it `canSearch` and is not the provider that failed; it answers `{ ok, query, results, provider, providers, merged, searchId, degraded }` or `{ ok: false, ..., error }`. **One accounting rule**: what an adapter reports is counted on its meter for every ask, succeeded or failed, and every failed ask is one failure on its provider - a query that failed on both providers is two. **The failure row is the session's, the sink the coordinator's**: `record` gets `{ query, error, provider }`, with `degraded: true, fellBackTo` on a degrade and `covered: true` when another provider of a merge answered; it is handed over at once (so a failure log keeps its chronology beside a `credits-exhausted` row) and gains `covered` afterwards. Provider-level lines (`waiting`, `search failed on`, `degrading to`, `searched`, `search found N`, `search failed`, `search failed on every provider`) are the session's; what a coordinator makes of an answer stays its own. `searchPatiently` and `mergeByRank` live here, re-exported by `research-run.mjs`. With a `credits` policy the policy's `askLive`/`live` decide which provider serves a call (ADR-0133); `askOne` is the one patient call a coordinator shares with that policy. A session built with no provider at all answers the failed search `searchPatiently` already names, charged to `this transport`, rather than throwing on the missing name (2026-10-03, probing after the module landed; only a library caller can reach it). `resultRows` keeps the shape every reader of a row assumes - an array, each row an object - on both paths (2026-10-03, probing): a provider's `null` row had ended both coordinators on `row.url`, and results that were not an array had ended research's `noteOutcome` on `.some`; whether a row's URL is usable stays the coordinator's question. `assertReady` reads `notReady` off every provider the session may ask, the merge's partners included, not the search side alone (2026-10-03, probing). **A provider that throws is a failed search that says so** (`searchPatiently`, 2026-10-03, probing): every adapter returns its failures by contract, so a throw is an adapter's bug the kit cannot prevent; re-thrown it ended both coordinators, and the searches paid for before it never reached the usage row, written at the end of a run - the meter under-recorded real spend. Named ` threw: `, not retried, degraded and recorded like any failure. An adapter that returns a Promise - an `async search()` - is a failed search too, saying adapters are synchronous (review of that guard, the same day): it had reached the `ok` test as a failure with no error text, and its rejection, awaited by nobody, ended the process after all; the Promise is given a catch so it cannot. A thrown value whose own rendering throws (a Symbol, a poisoned `message` getter) is named `a value that cannot be rendered as text` (`thrownText`). | ADR-0027, ADR-0133, ADR-0135 | | `research-run.mjs` | Many URLs, one run: plan/query/url fan-out, discovery, candidate selection, budget tiers (`DEPTH_SCRAPES`), cache policy. The coordinator; collectOne does the writing, and what a run does when its credits run out is `credits.mjs`'s, asked through one policy object whose state is read back here. A query whose `why` names unknowns that are all CLOSED in the contract is not searched (2026-10-02, ADR-0127): a search has no cache and was paid on every run of a finished project; the skip is said on the terminal and in `discovered[].note`, the dry run previews it, and `--force` runs it. The plan's own URLs are compared with each other by `urlKey` (2026-10-02, break-test pass 3): a page written down in two spellings - `www.`, a trailing slash, a fragment - is queued once, and each other spelling is a `skipped` result naming the spelling that was fetched; until then only search results were compared with the plan, and such a plan paid for the page once per spelling. Since ADR-0027 it drives **two** providers: `searchAdapter` defaults to the fetch adapter, so an un-updated caller is unaffected, and one bounded fallback covers a search-provider outage — reported, never absorbed, because it moves spend back onto the fetch budget. `searchUsage(root)` counts the search meter from the usage log per hour and per month; it **reports and does not block**, because the count is this machine's only and cannot tell a free cached repeat from a billed search, so a guard built on it would refuse work on a number wrong in the blocking direction. **The funnel is `limit` then `perQuery`, and it only works when they differ**: `limit` is how many results the search is asked for, `perQuery` how many survive `selectCandidates`' ranked slice. The collector workflow set both to `max_pages`, so the slice kept everything the search returned and `rankCandidate` - the preferred-domain bonus, the docs/terms bonuses, the blog and forum penalty - could not affect a dispatched collection at all. Measured on job-0922h: 8 results, 8 kept, 7 off topic. Ranking had been inert since `perQuery` was raised to `max_pages` to fix a breadth bug, a change that widened the intake and destroyed the selection in one line. Widening `limit` costs nothing, because a search is billed per search and not per result. **Failures are counted per provider** (`searchFailuresOn`) as well as in total, because the total mixes meters: `searchSummaryLine` prints `searches 0 on serpapi` with "N attempts failed" beside it when the reported provider was asked and failed - on 2026-09-22 the bare zero read as "not used" after a SerpAPI timeout the merge had survived. A clean run prints the line it always did. **`prefer` can name a path on a shared host** (2026-09-26, ADR-0044): `parsePreference` reads an entry as host plus optional path; a bare host covers the host and its subdomains as before, and a host with a path covers that path and below, on exactly that host, at a segment boundary. `prefer: github.com` had given every GitHub page the owner's bonus, and collect run 36279879229 captured a stranger's run page with it. The input descriptions in `collect.yml`, `collect-remote --help` and the MCP tool show the path form, pinned by test. A `prefer` value may be a list or a string (`preferList`, 2026-09-27), at the top level and per query: a string is split on commas and whitespace, as `collect.yml` splits its input. It had been dropped at the top level and spread into single characters per query - silently, both times. **One page, one fetch** (2026-09-27, `urlKey`): "already have this page" compares identities - host case, `www.`, scheme, a trailing slash and the fragment dropped; path case and query kept - in the seen set, in `selectCandidates` (which also keeps one spelling per list) and in decompose's candidate list. Plan URLs enter the seen set before any search runs, and the single-provider path now records its picks as the merged path did. Collect run 36283114657 had paid twice for `.../ui-and-api` and `.../ui-and-api/`. **A page the budget leaves out is said, not only recorded** (2026-09-27): each is logged `skipped ... budget exhausted`, the result carries `overBudget`, and `research.mjs` ends its summary with `left N over the budget`; `--status` states the budget the run will use, min(maxScrapes, the depth's cap), where it quoted the depth's cap alone. A plan naming five pages at maxScrapes 2 had printed two lines and nothing about the other three. The result also carries `maxScrapes`, and the summary's next line says **which cap bound the run** and what lifts it (2026-09-28): the depth's (naming the `--depth` that allows more), the plan's `maxScrapes`, or both at once. A scaffolded plan says depth quick with maxScrapes 10, and a MoonAliza run stopped at 4 without saying the 10 needs a deeper tier; the template's `notes` now state the tiers too. A **dry run names each query** it would search and the provider(s) whose meter it would spend - it had previewed nothing for a plan made of queries. `bin/research.mjs` refuses a plan with no queries and no urls before choosing a transport - exit 2, naming the plan and the shape of a query and a url (2026-09-27): the scaffold's plan is empty, and new-project's step 3 ran on it, printed `collected 0` and exited 0. Before that it refuses a plan file that is missing or does not parse, naming which (the parse error used to read as "no queries and no urls"), and a `--only` that matches no query. Then `planProblems(plan)` (research-run) names every value it cannot use - a depth outside `DEPTHS`, a non-integer count, a query without `q`, a URL that is not http(s) - and research refuses them all at once (2026-09-27): `readPlan` defaulted each in silence, so a query written `{"query": ...}` ran nothing and exited 0. Search credits an adapter estimates are summed as `searchCreditsEstimate` into the usage row and the run's summary line, and `searchUsage` reports `creditsEstimate` for `--status`, labelled as an estimate (2026-09-27, ADR-0065). Vendor-neutral: it sums what adapters report. Every search logs its outcome (`found N` / `search found nothing`), and an empty one is recorded as `op: search-empty` in `.failures.jsonl` (2026-09-27, ADR-0066). One `noteOutcome` reports empty and off-topic searches for both the single-provider and the merged path; the merged path had reported neither. **A result must carry the query before it costs a scrape** (2026-09-27, ADR-0067): `selectCandidates` takes the query and keeps a result only if `matchesQuery` finds `min(2, half)` of its distinctive terms (`queryTerms`; ADR-0068 replaced ADR-0067's "half", which kept 2 of 38 real search results) in the title, snippet or URL, or `isPreferred` puts it on a `prefer` domain. A search whose results all miss scrapes nothing, logs `no result matched`, and records `op: search-off-topic`. A possessive counts as its word (2026-09-29): `Ollama's` had become the term `ollama s`, which no page carries, so a product's own name stopped counting toward the floor. Searches are counted **per paying provider** (`searchesOn`, 2026-09-28): a merged query was logged as two searches under the SerpAPI name and charged to its free-plan meter. The usage row carries `searchesOn`, `searchUsage` counts only the named provider's share of a row that has it (older rows as before), and the summary line names each meter. **A run survives exhausted credits** (2026-09-28, ADR-0086): given a `fallbackAdapter`, the first search or fetch whose failure the adapter's `creditsExhausted` recognises switches every later call that would have gone to it - merged searches and the degraded path included - to the fallback, once, and re-fetches the refused page; the refusal stays a `fail` ledger entry but not a failed page, and the switch is logged, recorded as `op: credits-exhausted`, and returned as `fellBack`. **A page that is gone stays gone** (2026-09-29, ADR-0100): a URL whose last ledger entry is a 404 or 410 inside the refresh window is set aside before the budget is counted and logged `gone`; `--force` retries. `bin/research.mjs` passes `http-keyless` when the chosen adapter can say what exhaustion looks like, announces it before the run, reports `fell back` in the summary, and takes `--no-fallback`. A dry run counts the searches it would make (`wouldSearch`), and `bin/research.mjs` prints the most the plan could spend - page fetches up to the budget, the searches, and on Firecrawl the credit rate - as one `at most` line (2026-09-29): it listed each search and page but never the total. `bin/research.mjs` and `bin/decompose.mjs` refuse, exit 2, when the selection reports the search provider `notReady` and the run would spend (2026-09-30): research passed the provider module rather than the selection, so the `searchAdapter?.notReady` check here never fired, and decompose had no check. An explicit provider with no key or instance failed each search, and decompose degraded to the fetch provider's search, which on Firecrawl spends credits. **The opt-in witness** (2026-09-30, ADR-0106): with `witness`, `witnessCapture` asks `witness.mjs` (or the injected `witnessLookup`) once per newly collected page - never for a cached page, a failed fetch or a dry run - and appends `{ at, url, raw, ...record }` to `research/witnesses.jsonl` (`PATHS.witnesses`), outside `raw/` and the ledger; a throwing lookup is recorded as `witnessed: false` and the capture stands. `bin/research.mjs` passes `--witness`. `rankCandidate` matches the first two words of a reason as plain substrings (`mentionsWhy`, 2026-09-30); it had compiled them into a regular expression, so `a(b` threw a `SyntaxError` and a blank reason built `|`, which matched every URL and gave every candidate the bonus. `bin/research.mjs` refuses, exit 2, a plan with a duplicate object key (`duplicateKey`, 2026-09-30) before it plans or spends: `JSON.parse` keeps the last value, so the url the operator pasted was not the one collected, and the gate that names the duplicate runs only after collection. `canSearch` (in `transport.mjs`, beside `isSearchOnly`): a fetch-only transport is never asked to search - `searchPatiently` names it a failed search, a failed search degrades only to a provider that can search, and an out-of-credits switch hands searches only to a fallback that can (2026-10-01). `isWebUrl`: `selectCandidates` queues only an http(s) search result with a host - a vendor's `javascript:` or `file:` row never reaches a fetch adapter (2026-10-01). `canSearch` is false for a Firecrawl CLI with no key, so no fallback asks it to search (2026-10-01). **Refuses a ledger it cannot record into before the first fetch** (ADR-0122): `assertAppendable(root)` runs beside the vendor-CLI and search-provider refusals, dry runs included, so a broken chain costs nothing - the F12 refusal at the record had let the first fetch be paid for. Without a `fallbackAdapter` (the default on a local run, ADR-0129, 2026-10-02) exhaustion STOPS the run: the first refusal is recognised either way now, every later search is not run and every later page is reported `uncollected` (never failed, never spent), `stopped` carries `{ reason, provider, during, uncollected, skippedSearches }`, and the failure log's `credits-exhausted` row says `action: "stopped"`. `bin/research.mjs` then prints the two ways forward and exits 2; `--fallback` opts into the ADR-0086 switch, which `collect.yml` and `live-collection.yml` pass because nobody is there to decide. `canSearch` moved to `transport.mjs` beside `isSearchOnly` (2026-10-03, architecture pass): a transport's own capability, asked of any provider, had lived in the coordinator; `decompose.mjs` and one test import it from its new home. **The asking of the search providers moved to `search-session.mjs`** (2026-10-03, ADR-0135): `runResearch` builds one session beside its credits policy, refuses through `session.assertReady()`, asks `session.ask(text)` per query, maps the session's failure rows to the failure log's `op: "search"` rows, and reads `session.meters()` into the usage row and the result; what it does with an answer - `noteOutcome`, `selectCandidates`, the target row (`from: 'search'` under a merge, `from: 'query: ... (via ...)'` alone) - stays here. Two counts changed with the move, deliberately: a failed first ask's reported usage is counted whether or not the degrade answers, and a plain failure counts in `searchFailures` as it already did in `searchFailuresOn`. Search-found pages are filtered through the existing candidate-specific `cacheDecision` before ranking and the `perQuery` slice: fresh hits remain free, stale or forced captures remain eligible, and `seen` holds only targets queued in this run so plan/search aliases and later queries cannot queue the same page twice (2026-10-08). | ADR-0027, ADR-0044, ADR-0086, ADR-0100, ADR-0135 | diff --git a/docs/adr/0109-one-helper-decides-whether-a-recorded-path-may-be-read.md b/docs/adr/0109-one-helper-decides-whether-a-recorded-path-may-be-read.md index 3cf3313f..ce1b680a 100644 --- a/docs/adr/0109-one-helper-decides-whether-a-recorded-path-may-be-read.md +++ b/docs/adr/0109-one-helper-decides-whether-a-recorded-path-may-be-read.md @@ -35,6 +35,11 @@ its own tests. Moving them is a refactor of working, tested code with no defect **Trigger that ends the deferral:** a new reader of recorded paths, or a defect in any of those three, is written against `projectFile`, and the other two move with it. +Implementation (2026-10-13, #351): the evidence-context private-file defect fired the +deferral's trigger. Its Raw reader, corpus capture/Raw checks, and the artifact Raw/ledger +readers now use `projectFile`. Capture reads also stay inside the capture folder by real +path, and missing or non-file inputs are named before any content read. + ## Rejected alternatives - **Move every reader now.** Three working readers rewritten in one change, with no defect diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..292dc092 100644 --- a/research-kit/README.md +++ b/research-kit/README.md @@ -563,7 +563,7 @@ node research-kit/bin/selftest.mjs # all of it node research-kit/bin/selftest.mjs gate hook # just these files ``` -1789 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means +1793 tests, offline, no key and no network. The runner **awaits** every test, so `ok` means the assertions settled (ADR-0021), and each test is raced against a watchdog (`RESEARCH_KIT_TEST_TIMEOUT`, default 60s). diff --git a/research-kit/lib/artifact.mjs b/research-kit/lib/artifact.mjs index b03796f8..c6050ba8 100644 --- a/research-kit/lib/artifact.mjs +++ b/research-kit/lib/artifact.mjs @@ -29,7 +29,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { - PATHS, resolve, exists, isDirectory, isRegularFile, readText, sha256, canonicalJson, nowIso, listFiles, homeCommand, realInside, writeBytes, + PATHS, resolve, relative, exists, isDirectory, isRegularFile, readText, sha256, canonicalJson, nowIso, listFiles, homeCommand, realInside, writeBytes, projectFile, } from './core.mjs'; import { buildZip } from './archive.mjs'; import { checkEntryName } from './artifact-zip.mjs'; @@ -308,8 +308,11 @@ export function findingsReviewState(root, corpus) { const unrewritten = []; for (const row of rows) { if (!row.raw) continue; - if (!realInside(root, resolve(root, row.raw))) continue; // never read from outside (ADR-0076) - const text = readText(resolve(root, row.raw)); + const named = projectFile(root, row.raw); + const rawRoot = resolve(root, PATHS.raw); + const raw = projectFile(rawRoot, relative(rawRoot, named.abs)); + if (named.problem || raw.problem) continue; + const text = readText(raw.abs); if (text === null) continue; // The collector's own call, over what it extracted from: the page body after the // front-matter, falling back to the page's title or URL (collect.mjs). Found 2026-09-27 @@ -556,13 +559,19 @@ export function createArtifact({ add(`project/${rel}`, fs.readFileSync(resolve(root, rel))); } - const ledgerAbs = resolve(root, PATHS.ledger); - if (exists(ledgerAbs) && !realInside(root, ledgerAbs)) { + const ledger = projectFile(root, PATHS.ledger); + const ledgerAbs = ledger.abs; + if (ledger.problem === 'outside') { const err = new Error(`${PATHS.ledger} is a link to somewhere outside the project; packaging would copy what is there into the archive.`); err.code = 'OUTSIDE_PROJECT'; throw err; } - if (exists(ledgerAbs) && !entries.some((e) => e.name === `project/${PATHS.ledger}`)) { + if (ledger.problem === 'not-file') { + const err = new Error(`${PATHS.ledger} is not a regular file and cannot travel as a provenance ledger.`); + err.code = 'UNPACKAGEABLE_FILE'; + throw err; + } + if (!ledger.problem && !entries.some((e) => e.name === `project/${PATHS.ledger}`)) { // The ledger is a dotfile inside research/raw/ and every hand-rolled copy in this // project's history has lost exactly this file. Added explicitly rather than trusted // to the walk. diff --git a/research-kit/lib/corpus.mjs b/research-kit/lib/corpus.mjs index 3ff2e9d3..08d88e78 100644 --- a/research-kit/lib/corpus.mjs +++ b/research-kit/lib/corpus.mjs @@ -11,7 +11,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { PATHS, HEADERS, resolve, relative, exists, isDirectory, readText, readJson, - writeText, appendLine, listFiles, sha256File, ageInDays, hostOf, parseJson, realInside, urlKey, + writeText, appendLine, listFiles, sha256File, ageInDays, hostOf, parseJson, urlKey, projectFile, } from './core.mjs'; import { sketch } from './similarity.mjs'; import { parseJsonNoDuplicates } from './release/json.mjs'; @@ -315,11 +315,17 @@ export function readCaptures(root, { known = null, sketches: wantSketches = true if (known?.has(rel)) continue; // A link that lands outside the project is not read (ADR-0076): git stores symlinks, // and research/raw/x.md -> ~/.ssh/id_rsa would otherwise be read as a capture. - if (!realInside(root, abs)) { - problems.push({ kind: 'capture-outside', file: rel, detail: `${rel} is a link to somewhere outside the project - not read` }); + const named = projectFile(root, rel); + const raw = projectFile(dir, name); + if (named.problem === 'outside' || raw.problem === 'outside') { + problems.push({ kind: 'capture-outside', file: rel, detail: `${rel} is a link outside the project or its capture folder - not read` }); continue; } if (isDirectory(abs)) continue; + if (named.problem || raw.problem) { + problems.push({ kind: 'capture-unreadable', file: rel, detail: `${rel} is missing or not a regular file - not read` }); + continue; + } // Checked BEFORE reading: every file here is either read or named, never dropped. let size = 0; try { size = fs.statSync(abs).size; } catch { /* unreadable - named below */ } @@ -643,14 +649,15 @@ export function readCorpus(root) { // A cited capture that is not on disk is a corpus problem, not a check's discovery. for (const row of evidence) { if (!row.raw) continue; - if (exists(at(row.raw)) && !realInside(root, at(row.raw))) { + const named = projectFile(root, row.raw); + if (named.problem === 'outside') { // A Raw cell that lands outside the project (a "../" spelling, or a link) is never // read: evidence-context printed such a file into an agent's context (2026-09-28). problems.push({ kind: 'raw-outside', artifact: PATHS.evidence, row: row.id, file: row.raw, line: row.line, detail: `${row.raw} (row ${row.id}) is outside the project - a Raw cell names a capture in ${PATHS.raw}/` }); continue; } - if (!exists(at(row.raw))) { + if (named.problem === 'missing') { problems.push({ kind: 'raw-dangling', artifact: PATHS.evidence, row: row.id, file: row.raw, line: row.line }); } } diff --git a/research-kit/lib/evidence-context.mjs b/research-kit/lib/evidence-context.mjs index 8b1bc767..ae557e5f 100644 --- a/research-kit/lib/evidence-context.mjs +++ b/research-kit/lib/evidence-context.mjs @@ -10,7 +10,7 @@ // evidence closes the unknown stays a person's call — that is the whole protocol, and a // tool that quietly rendered a verdict here would be the most dangerous thing in the kit. -import { PATHS, resolve, readText, realInside } from './core.mjs'; +import { PATHS, resolve, relative, readText, projectFile, isDirectory } from './core.mjs'; import { captureOf, traceOf, parseCapture } from './corpus.mjs'; import { supersededRows } from './checks.mjs'; import { findingWithContext } from './finding.mjs'; @@ -106,14 +106,24 @@ export function evidenceContext(corpus, unknownId, { limit = 420 } = {}) { let excerpt = ''; let excerptProblem = ''; let extractor = null; - const rawOutside = row.raw && !realInside(corpus.root, resolve(corpus.root, row.raw)); - if (rawOutside && readText(resolve(corpus.root, row.raw)) !== null) { + const named = row.raw ? projectFile(corpus.root, row.raw) : null; + const rawRoot = resolve(corpus.root, PATHS.raw); + const raw = named ? projectFile(rawRoot, relative(rawRoot, named.abs)) : null; + if (named?.problem === 'outside') { // Never read: this output lands in an agent's context (ADR-0076, 2026-09-28). excerptProblem = `${row.raw} is outside the project, so it is not read`; + } else if (raw?.problem === 'outside') { + excerptProblem = `${row.raw} names a file outside research/raw, so it is not read`; + } else if (raw?.problem === 'missing') { + excerptProblem = `${row.raw} is named by the row and is not on disk`; + } else if (raw?.problem === 'not-file') { + excerptProblem = isDirectory(raw.abs) + ? `${row.raw} is a directory, not a capture` + : `${row.raw} is not a regular file, not a capture`; } else if (row.raw) { - const text = readText(resolve(corpus.root, row.raw)); + const text = readText(raw.abs); if (text === null) { - excerptProblem = `${row.raw} is named by the row and is not on disk`; + excerptProblem = `${row.raw} could not be read - check its permissions`; } else { const body = parseCapture(text).body; excerpt = excerptFor(body, row.finding, { limit }); diff --git a/research-kit/test/artifact-producer.test.mjs b/research-kit/test/artifact-producer.test.mjs index b576eb9e..ea7ea25c 100644 --- a/research-kit/test/artifact-producer.test.mjs +++ b/research-kit/test/artifact-producer.test.mjs @@ -29,6 +29,14 @@ function build(root, overrides = {}) { return createArtifact({ root, ...IDENTITY, ...overrides }); } +test('a directory at the explicit ledger path is refused as an unpackageable file', () => { + const root = collectedProject(); + const ledger = path.join(root, PATHS.ledger); + fs.unlinkSync(ledger); + fs.mkdirSync(ledger); + assert.throws(() => build(root), (error) => error.code === 'UNPACKAGEABLE_FILE' && /ledger|fetches/.test(error.message)); +}); + test('package handoff prose retains gate warnings without changing build authorization', () => { for (const [label, root, authorized] of [['collected', collectedProject(), false], ['approved', approvedProject(), true]]) { const built = build(root); diff --git a/research-kit/test/evidence-context.test.mjs b/research-kit/test/evidence-context.test.mjs index f08591be..6c5a1576 100644 --- a/research-kit/test/evidence-context.test.mjs +++ b/research-kit/test/evidence-context.test.mjs @@ -7,7 +7,8 @@ // concludes nothing. import { spawnSync } from 'node:child_process'; -import { test, describe, assert, assertEqual, fs, path, os, KIT_ROOT, tempDir, cleanup, makePassingProject } from './harness.mjs'; +import { test, describe, assert, assertEqual, fs, path, os, KIT_ROOT, tempDir, cleanup, makePassingProject, requireSymlink } from './harness.mjs'; +import { findingsReviewState } from '../lib/artifact.mjs'; import { resolve, PATHS } from '../lib/core.mjs'; import { runPreflight } from '../lib/preflight.mjs'; import { readCorpus } from '../lib/corpus.mjs'; @@ -15,6 +16,66 @@ import { evidenceContext, renderContext, excerptFor } from '../lib/evidence-cont describe('evidence-context'); +test('Raw cells cannot read or print a private project file directly or by traversal', () => { + const root = fixture(); + const privateFile = path.join(root, '.env'); + const sentinel = 'PRIVATE_SENTINEL_FROM_ENV'; + fs.writeFileSync(privateFile, sentinel); + const evidenceFile = path.join(root, PATHS.evidence); + const original = fs.readFileSync(evidenceFile, 'utf8'); + for (const raw of ['.env', 'research/raw/../../.env']) { + fs.writeFileSync(evidenceFile, original.replace('research/raw/page.md', raw)); + const corpus = readCorpus(root); + const real = fs.readFileSync; + let privateReads = 0; + fs.readFileSync = (file, ...args) => { if (String(file) === privateFile) privateReads++; return real(file, ...args); }; + let context; + try { context = evidenceContext(corpus, 'U-1'); findingsReviewState(root, corpus); } + finally { fs.readFileSync = real; } + assert.equal(privateReads, 0); + assert.equal(context.cited[0].excerpt, ''); + assert.equal(context.cited[0].extractor, null); + assert.match(context.cited[0].excerptProblem, /outside research\/raw/); + for (const args of [[], ['--json']]) { + const result = spawnSync(process.execPath, [CLI, 'U-1', ...args], { cwd: root, encoding: 'utf8', windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + assert.ok(!result.stdout.includes(sentinel)); + if (args.length) JSON.parse(result.stdout); + } + } +}); + +test('a directory Raw cell is named as a directory instead of a missing capture', () => { + const root = fixture(); + fs.mkdirSync(path.join(root, PATHS.raw, 'folder')); + const corpus = readCorpus(root); + corpus.evidence[0].raw = `${PATHS.raw}/folder`; + const row = evidenceContext(corpus, 'U-1').cited[0]; + assert.equal(row.excerpt, ''); + assert.match(row.excerptProblem, /directory.*not a capture/); + assert.doesNotMatch(row.excerptProblem, /not on disk/); +}); + +test('a capture link to a private file inside the project is unread by all capture consumers', () => { + const root = fixture(); + const privateFile = path.join(root, '.env'); + fs.writeFileSync(privateFile, 'PRIVATE_SENTINEL_FROM_LINK'); + const link = path.join(root, PATHS.raw, 'link.md'); + requireSymlink(privateFile, link, 'a capture link to a private project file'); + const real = fs.readFileSync; + let privateReads = 0; + fs.readFileSync = (file, ...args) => { if ([link, privateFile].includes(String(file))) privateReads++; return real(file, ...args); }; + try { + const corpus = readCorpus(root); + corpus.evidence[0].raw = `${PATHS.raw}/link.md`; + const context = evidenceContext(corpus, 'U-1'); + findingsReviewState(root, corpus); + assert.equal(context.cited[0].excerpt, ''); + assert.match(context.cited[0].excerptProblem, /outside research\/raw/); + } finally { fs.readFileSync = real; } + assert.equal(privateReads, 0); +}); + const REPO = path.resolve(KIT_ROOT, '..'); const CLI = path.join(KIT_ROOT, 'bin', 'evidence-context.mjs');