From ced1912f5b3981f6085a71d9af126faeee269b75 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 00:57:20 +0300 Subject: [PATCH 01/27] fix(dispatch): validate repository path components what changed: Fix #379; dispatch.mjs, dispatch regression coverage, architecture map, changelog and test inventory. why: Restore the existing collection input and credential-handling contract. This is a bug fix with one remedy; no alternative design was set aside. what it touched: Shared remote collection seams; existing ADR-0033/0034 behavior. No new domain term or public interface. what you verified: verified - dispatch/MCP red-first reproduction and passing combined regression run (83 tests); CLI integration regressions pass in the broader command run. The commit gate runs the required complete offline suite before accepting this commit. what you got wrong and fixed: mistake - running six full suites concurrently exceeded the default 3900-second budget; where - local commit validation on this Windows host; impact - affected hooks refused commits before publication; cause - excess concurrent subprocess and filesystem work; fix - retry one commit at a time with the ADR-0142 timeout extension (RESEARCH_KIT_GATE_TIMEOUT=7920); verified - the required full-suite commit gate accepted the commit. --- CHANGELOG.md | 5 +++++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/dispatch.mjs | 2 +- research-kit/test/dispatch.test.mjs | 15 ++++++++++++--- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..e17981f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 +- GitHub request helpers refuse repository components outside `[A-Za-z0-9._-]+`, + including `.` and `..`, before any request is made (#379). Query/fragment + delimiters, encoded separators, whitespace and control characters cannot alter + the API path. + - 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..0a8584f8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -182,7 +182,7 @@ is an open decision (2026-09-30): | `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 | | `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 | +| `dispatch.mjs` | **Asking the collector to run, from outside GitHub** - the seam an agent or a desktop app uses. `dispatchCollection` (pins `X-GitHub-Api-Version: 2026-03-10` on the REQUEST, not merely in a comment), `waitForRun`, `listArtifacts`, `downloadArtifact`, `unwrapArtifact`, plus `tokenFromEnv` and `redact`. **No token may be passed as an argument** - `tokenFromEnv` is the only way in, because a credential on a command line reaches the shell history, `ps`, and any log that echoes its own command; a test asserts the source contains no token flag. `redact` is PATTERN-based rather than "remove the token we hold", so it also catches a credential the process was never given - one echoed back by a server, a proxy, a redirect. A **204** is a named `NO_RUN_ID` failure, never an empty success: "the run started and I cannot tell which one" is the failure a caller is least able to diagnose from a status code. **Both routes to a run id are requested**, not just the pinned version: the `X-GitHub-Api-Version: 2026-03-10` header AND `return_run_details: true`, the optional parameter GitHub added 2026-02-19 that returns the run id on `2022-11-28` as well. Measured before it was trusted - four calls on 2026-09-21 established that the old version honours the parameter and the new version accepts rather than rejects it, so sending both is safe and a server that strips or downgrades the header still answers with a run id. That retired ADR-0031's original fallback, which was to poll `/actions/runs` and correlate by `created_at` - a race the design never actually needed. `NO_RUN_ID` now means BOTH routes were ignored, which is a far narrower diagnosis. `waiting` is reported as a status, not a stall - it means a protection rule is holding the job. `unwrapArtifact` opens GitHub's outer ZIP through the same distrustful reader, and passes a hostile or ambiguous container through untouched for the validator to refuse rather than guessing at packaging depth. Every request goes through an injectable `fetch`, so the whole module is tested offline. **Pages by URL** (2026-09-27): `queriesInput` builds the one `queries` input from search queries and pages to fetch, one per line, and refuses a URL that is not http(s) before anything is dispatched. `collect.yml` fetches a line that is an http(s) URL instead of searching it, and with only URLs it searches nothing. `collect-remote --url` and the MCP `collect` tool's `urls` share this function, so all three agree. Until then the collector could only search, and a page the caller already knew had to be collected locally. **`usableOutDir` checks the folder before anything is spent** (2026-09-27): it creates the folder and tests write access, and an unusable one is a named `OUT_DIR` failure with a remedy. `fetchCorpus` calls it before it lists artifacts, the MCP `fetch_corpus` tool before it reads the run, and `collect-remote` before it dispatches. The folder had been checked only after the download, as a raw ENOTDIR the MCP server could report only as `UNKNOWN`, and for `collect-remote` that came after a paid run and up to half an hour of waiting. `fetchCorpus` writes the package it downloaded through `writeBytes` (2026-09-28, ADR-0079): `fs.writeFileSync` emptied a package already at that name when the write failed. `bin/collect-remote.mjs` checks `--max-pages` (1..25), `--depth`, `--runner`, `--search-transport` and `--timeout` against the ranges and choices `collect.yml` enforces, before it asks for a token (2026-09-29, Arena break test): `--max-pages 8o` was dispatched as typed, and the paid run failed on the runner. A dispatch that never left names the cause of the failed fetch through `fetchFailure` (2026-09-30), still passed through `redact`; so does `.github/scripts/check-action-pins.mjs` for a pin it could not check. Both had printed the bare `fetch failed`. `getRun` and `listArtifacts` read their body through `readJsonBody` (2026-09-30): a 200 whose body is not JSON (a maintenance page, a proxy) is `DispatchError` `BAD_BODY` with the status and a remedy, where it had thrown a raw `SyntaxError` that `collect-remote` could only call `UNKNOWN`. `getRun`, `listArtifacts` and `downloadArtifact` reach GitHub through `callApi` (2026-09-30), so a request that never left is `DispatchError` `NETWORK` with its cause (`fetchFailure`, redacted) and a remedy, as a dispatch already was; they had thrown the raw `fetch failed`, which `collect-remote` could only call `UNKNOWN`. fetchCorpus treats names from the API as data: a wrapper entry is flattened to its file name, an artifact name holding a separator, `:`, `..` or a control character is refused (`ARTIFACT_NAME`) and nothing is written (2026-10-01). unwrapArtifact hands on a wrapper whose inner package cannot be decompressed unchanged, for the validator to judge, instead of throwing ZIP-READ (2026-10-01). `waitForRun` retries a failed poll that may pass on its own - no connection, a 429 or 5xx, a body that is not JSON - up to `MAX_POLL_RETRIES` (5) in a row, reset by a good poll and never past the deadline; 401, 403 and 404 stay fatal (2026-10-01). Every failed poll had ended a watch of up to 30 minutes with RUN_FAILED while the run went on. `getRun` gives a 401, 403 or 404 its own remedy (a missing run, a refused token), and the timeout remedy names `--run-id` (2026-10-01). `splitRepository` admits only two nonempty `[A-Za-z0-9._-]+` components other than `.` and `..`, so dispatch, run lookup, artifact listing and download refuse route-changing delimiters and control characters before their first request (#379, 2026-10-12). | 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. | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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/dispatch.mjs b/research-kit/lib/dispatch.mjs index 9d4dd8b3..624ebe72 100644 --- a/research-kit/lib/dispatch.mjs +++ b/research-kit/lib/dispatch.mjs @@ -106,7 +106,7 @@ function headers(token) { function splitRepository(repository) { const parts = String(repository ?? '').split('/'); - if (parts.length !== 2 || !parts[0] || !parts[1]) { + if (parts.length !== 2 || parts.some((part) => !part || /[^A-Za-z0-9._-]/.test(part) || part === '.' || part === '..')) { throw new DispatchError('REPOSITORY', `repository must be "owner/name", got ${JSON.stringify(String(repository))}`); } return parts; diff --git a/research-kit/test/dispatch.test.mjs b/research-kit/test/dispatch.test.mjs index 1c4aa50d..fc2f51b3 100644 --- a/research-kit/test/dispatch.test.mjs +++ b/research-kit/test/dispatch.test.mjs @@ -225,9 +225,12 @@ test('a 200 whose body is not JSON is a dispatch error, not a SyntaxError', asyn test('a malformed repository is refused before any request is made', async () => { const doFetch = stubFetch([jsonResponse(200, { workflow_run_id: 1 })]); - for (const bad of ['no-slash', '/leading', 'trailing/', 'a/b/c']) { - await assert.rejects(() => dispatchCollection({ repository: bad, token: TOKEN, fetch: doFetch }), - (err) => err.code === 'REPOSITORY'); + for (const bad of ['no-slash', '/leading', 'trailing/', 'a/b/c', '../x', 'o/..', './r', 'o/.', + 'o/r?x=1', 'o/r#f', 'o/r%2fother', 'o/r\\other', 'o/r name', 'o/r\nname', 'o/r\n', 'o/r\r', 'o/répo']) { + for (const request of [dispatchCollection, getRun, listArtifacts, downloadArtifact]) { + await assert.rejects(() => request({ repository: bad, token: TOKEN, runId: 1, artifactId: 1, fetch: doFetch }), + (err) => err.code === 'REPOSITORY', `${request.name} accepted ${JSON.stringify(bad)}`); + } } assert.equal(doFetch.calls.length, 0, 'a bad repository must not produce a request'); }); @@ -521,3 +524,9 @@ function require_read(bytes) { // openZip is what unwrapArtifact uses; reading the one entry is where the throw came from. return importedOpenZip(bytes).read('package.zip'); } + +test('repository names with dots, underscores and hyphens retain their API path', async () => { + const doFetch = stubFetch([jsonResponse(200, { workflow_run_id: 1 })]); + await dispatchCollection({ repository: 'Example-org/project.name_v2-1', token: TOKEN, fetch: doFetch }); + assert.match(doFetch.calls[0].url, /\/repos\/Example-org\/project\.name_v2-1\/actions\//); +}); From 9a1459c77441c0cb6f06adb059b76f5260994748 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 01:30:03 +0300 Subject: [PATCH 02/27] fix(dispatch): refuse multiline collection items what changed: Fix #376; dispatch.mjs, dispatch/MCP/CLI regression coverage, architecture map, changelog and test inventory. why: Restore the existing collection input and credential-handling contract. This is a bug fix with one remedy; no alternative design was set aside. what it touched: Shared remote collection seams; existing ADR-0033/0034 behavior. No new domain term or public interface. what you verified: verified - dispatch/MCP red-first reproduction and passing combined regression run (83 tests); CLI integration regressions pass in the broader command run. The commit gate runs the required complete offline suite before accepting this commit. what you got wrong and fixed: mistake - running six full suites concurrently exceeded the default 3900-second budget; where - local commit validation on this Windows host; impact - affected hooks refused commits before publication; cause - excess concurrent subprocess and filesystem work; fix - retry one commit at a time with the ADR-0142 timeout extension (RESEARCH_KIT_GATE_TIMEOUT=7920); verified - the required full-suite commit gate accepted the commit. --- CHANGELOG.md | 4 ++++ docs/ARCHITECTURE.md | 6 +++--- research-kit/README.md | 2 +- research-kit/lib/dispatch.mjs | 14 +++++++++++--- research-kit/test/cli.test.mjs | 12 ++++++++++++ research-kit/test/dispatch.test.mjs | 11 +++++++++++ research-kit/test/mcp.test.mjs | 12 ++++++++++++ 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..600c9940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 +- Each collection query and URL is refused when it contains CR or LF before trimming + or joining into workflow input (#376). MCP and `collect-remote` cannot turn one + declared item into extra searches or page fetches on the runner. + - 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..8182b87c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -180,9 +180,9 @@ is an open decision (2026-09-30): | `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 | -| `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 | +| `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. The shared `queriesInput` refusal prevents CR/LF inside individual queries or URLs from dispatching extra runner work (#376, 2026-10-12). | 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 | +| `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). `queriesInput` refuses CR/LF in either list before trimming and serialization (#376, 2026-10-12), retaining existing empty-input and one-line normalization. A declared item cannot become additional workflow lines. | 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. | `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 | @@ -203,7 +203,7 @@ is an open decision (2026-09-30): | `bin/measure.mjs` | The judge-free citation report for the current project (ADR-0089); `--json` for machines. A report, never a gate; exit 2 outside a research project. | ADR-0089 | | `bin/export-warc.mjs` | The corpus as one WARC 1.1 `.warc.gz` for archive tools (ADR-0090); `--out` (default `research-corpus.warc.gz`), written whole with `writeBytes` after `readWarc` re-reads it; a refused write is named by `writeFailure`, exit 2 (2026-09-29). A copy, never a gate; exit 2 outside a research project. | ADR-0090 | | `bin/disclosure.mjs` | Probes a run and reports what a stranger can read. Calls `honourEnvProxy()` before its first request (ADR-0047). Exit codes ARE the finding, so it can gate something later: `0` shape only, `1` content readable, `2` subject leaked, `3` could not measure - which includes any probe that never reached GitHub, unless an exposure was measured anyway (`exitCode` in the lib). An unknown flag matching token/auth/key/secret is refused with the reason - authenticating this probe would answer a different question. | ADR-0035 | -| `bin/collect-remote.mjs` | The one command an agent or a desktop app needs: dispatch the collector on GitHub, learn which run it started, wait, download, unwrap, and validate with the same code a human would run. Calls `honourEnvProxy()` before its first request, so a proxy-only network can dispatch at all (ADR-0047). Prints the run id **before** it waits, so a caller that dies mid-wait can still find its run - and a caller that never sees that line knows the dispatch itself did not land. **No `--token` flag**, and an unknown flag matching token/auth/key/secret/pat is refused with the reason rather than the spelling. Exit codes separate the four things a caller does differently about: `0` valid (still not authorized to build), `1` invalid package, `2` run failed or incomplete, `3` could not start, `4` dispatched and still running with the run id on stdout. An `--out` it cannot write is refused (exit 3, `OUT_DIR`) before the token is read or a run dispatched (2026-09-27); `--no-wait` downloads nothing and skips the check. Each failed poll the watch survives is printed with its number (2026-10-01). `--run-id ` picks up a run already dispatched - after `--no-wait`, a timeout (exit 4) or a lost connection - waits, downloads and judges it, and refuses `--topic` beside it (2026-10-01). The remedies and exit 4 had pointed at that flag while it did not exist, so the only way back to a run was to pay for another. A failure that names no way out now says how to pick the run up. A bare `--out`, `--workflow` or `--ref` is refused as `-- needs a value` (exit 3) beside the five checked values (2026-10-11, #270): the parser reads a bare flag as `true`, so `--out` had become the folder `true`, created before the token was asked for, and `--workflow` or `--ref` would have dispatched against the literal name "true". | ADR-0033 | +| `bin/collect-remote.mjs` | The one command an agent or a desktop app needs: dispatch the collector on GitHub, learn which run it started, wait, download, unwrap, and validate with the same code a human would run. Calls `honourEnvProxy()` before its first request, so a proxy-only network can dispatch at all (ADR-0047). Prints the run id **before** it waits, so a caller that dies mid-wait can still find its run - and a caller that never sees that line knows the dispatch itself did not land. **No `--token` flag**, and an unknown flag matching token/auth/key/secret/pat is refused with the reason rather than the spelling. Exit codes separate the four things a caller does differently about: `0` valid (still not authorized to build), `1` invalid package, `2` run failed or incomplete, `3` could not start, `4` dispatched and still running with the run id on stdout. An `--out` it cannot write is refused (exit 3, `OUT_DIR`) before the token is read or a run dispatched (2026-09-27); `--no-wait` downloads nothing and skips the check. Each failed poll the watch survives is printed with its number (2026-10-01). `--run-id ` picks up a run already dispatched - after `--no-wait`, a timeout (exit 4) or a lost connection - waits, downloads and judges it, and refuses `--topic` beside it (2026-10-01). The remedies and exit 4 had pointed at that flag while it did not exist, so the only way back to a run was to pay for another. A failure that names no way out now says how to pick the run up. A bare `--out`, `--workflow` or `--ref` is refused as `-- needs a value` (exit 3) beside the five checked values (2026-10-11, #270): the parser reads a bare flag as `true`, so `--out` had become the folder `true`, created before the token was asked for, and `--workflow` or `--ref` would have dispatched against the literal name "true". The shared query/URL validator refuses CR/LF inside an individual item before output-folder checks, token lookup or dispatch (#376, 2026-10-12). | ADR-0033 | | `bin/researcher-release.mjs` | The release CLI: `validate`, `conform`, `fi-validate`. **A usage error returns 2 from every subcommand**, and a status maps to its own code — `PASS` 0, `FAIL`/`REOPEN` 1, `INCOMPLETE` 2, `BLOCKED` 3. `4` is the unrecognised-status fallback and never a usage error, so a caller can distinguish "did not run" from "ran and produced something unknown". `--help` prints the usage to stdout and exits 0 (2026-09-27), as do `path-authority`, `ledger-conformance`, `fi-sidecar-conformance` and `artifact`. All five had exited 2. `--report` is written through `writeText` (2026-09-28, ADR-0079), whole or not at all. `fi-validate --report` names a report path the environment refuses through `writeFailure` and exits 2 (2026-09-30); it had crashed with a stack and exit 1, its code for FAIL. `test/hardening.test.mjs` now requires every `bin/` command that imports a writer from `lib/` to call `writeFailure`, or to name the handler that makes it unnecessary. | — | | `bin/panel.mjs` | Starts the desktop panel (`lib/panel.mjs`) and opens the browser at its address (`explorer.exe` on Windows, `open`, `xdg-open`; `--no-open` prints it only). Flags: `--project `, `--port `, `--no-open`. Stops on Ctrl+C. A Windows desktop shortcut is a target the operator writes (README), not an installed launcher (ADR-0050). | ADR-0147 | | `release-validator.mjs` | Read-only R28–R32 release evidence validation: envelope/predecessor/role/visibility/promotion checks, ledger anchoring, and deterministic `PASS`/`INCOMPLETE`/`FAIL`/`REOPEN` reduction. It never rewrites records, pointers, registries, or role rosters. **Also the facade** over the four primitives below: all 21 of its original exports are re-exported unchanged, so the eighteen files importing from it were not touched. 1324 → ~1010 lines. Record ids and file lists sort by `compareText` (2026-10-01), the order the Python runner uses. `validateRelease` hands an explicit `genesisPath` and `checkpointsDir` to the ledger check, holds them to the ledger's below-the-root rule, and refuses either without a ledger (2026-10-11): until then both were dropped unread. | R28–R32 validator contract | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..27f8d04f 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 +1792 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/dispatch.mjs b/research-kit/lib/dispatch.mjs index 9d4dd8b3..4ec29cde 100644 --- a/research-kit/lib/dispatch.mjs +++ b/research-kit/lib/dispatch.mjs @@ -71,14 +71,22 @@ export function redact(text) { * looks exactly like one from before these options existed. */ export function queriesInput({ queries = [], urls = [] } = {}) { - const clean = (list) => (Array.isArray(list) ? list : [list]).map((item) => String(item ?? '').trim()).filter(Boolean); - const pages = clean(urls); + const items = (list) => (Array.isArray(list) ? list : [list]).map((item) => String(item ?? '')); + const searches = items(queries); + const addresses = items(urls); + // Check before trimming: even a leading/trailing newline changes the caller's item + // shape. collect.yml splits this input into lines, and URL parsing discards newlines. + if ([...searches, ...addresses].some((item) => /[\r\n]/.test(item))) { + return { value: undefined, error: 'a query or URL must be one line' }; + } + const clean = (list) => list.map((item) => item.trim()).filter(Boolean); + const pages = clean(addresses); const bad = pages.filter((page) => { if (!/^https?:\/\/./i.test(page)) return true; try { new URL(page); return false; } catch { return true; } }); if (bad.length) return { value: undefined, error: `not an http(s) URL: ${bad.join(', ')}` }; - const lines = [...clean(queries), ...pages]; + const lines = [...clean(searches), ...pages]; return { value: lines.length ? lines.join('\n') : undefined, error: '' }; } diff --git a/research-kit/test/cli.test.mjs b/research-kit/test/cli.test.mjs index 0212e2d7..70642dcd 100644 --- a/research-kit/test/cli.test.mjs +++ b/research-kit/test/cli.test.mjs @@ -1581,3 +1581,15 @@ test('install-hooks --dry-run answers what the real run answers, deployed or not const gitconfig = fs.existsSync(path.join(home, 'gitconfig')) ? fs.readFileSync(path.join(home, 'gitconfig'), 'utf8') : ''; assert.doesNotMatch(gitconfig, /hooksPath/, 'a dry run set core.hooksPath'); }); + +test('collect-remote refuses line breaks in queries and URLs before asking for a token', () => { + const root = project(); + for (const flag of ['--query', '--url']) { + for (const value of ['https://a.example/x\nsecond', 'https://a.example/x\rsecond']) { + const r = run('collect-remote.mjs', ['--repository', 'o/r', '--topic', 't', flag, value], { root }); + assert.equal(r.status, 3); + assert.match(r.err.split('\n')[0], /must be one line/); + assert.ok(!r.err.includes('no GitHub token'), 'the input refusal must come first'); + } + } +}); diff --git a/research-kit/test/dispatch.test.mjs b/research-kit/test/dispatch.test.mjs index 1c4aa50d..0485b8dd 100644 --- a/research-kit/test/dispatch.test.mjs +++ b/research-kit/test/dispatch.test.mjs @@ -521,3 +521,14 @@ function require_read(bytes) { // openZip is what unwrapArtifact uses; reading the one entry is where the throw came from. return importedOpenZip(bytes).read('package.zip'); } + +test('each query and URL must be one line before workflow input serialization', () => { + for (const key of ['queries', 'urls']) { + for (const value of ['https://a.example/x\ninjected', 'https://a.example/x\rinjected', + 'https://a.example/x\r\ninjected', '\nhttps://a.example/x', 'https://a.example/x\n', '\r\n']) { + const r = queriesInput({ [key]: [value] }); + assert.equal(r.value, undefined, `${key}: ${JSON.stringify(value)} was serialized`); + assert.match(r.error, /must be one line/); + } + } +}); diff --git a/research-kit/test/mcp.test.mjs b/research-kit/test/mcp.test.mjs index 5dd3f641..34961b7f 100644 --- a/research-kit/test/mcp.test.mjs +++ b/research-kit/test/mcp.test.mjs @@ -725,3 +725,15 @@ test('the server installs the closed-stdout guard before it writes its first byt assert.ok(call !== -1, 'mcp-server.mjs no longer tolerates a closed stdout (2026-09-30)'); assert.ok(call < firstWrite, `the guard is installed after the first write (write at ${firstWrite}, guard at ${call})`); }); + +test('collect refuses line breaks inside a query or URL before dispatch', async () => { + for (const key of ['queries', 'urls']) { + for (const value of ['https://a.example/x\nsecond', 'https://a.example/x\rsecond']) { + const d = deps(); + const r = await handle(call('collect', { repository: 'o/r', topic: 'x', [key]: [value] }), d); + assert.equal(r.result.isError, true); + assert.match(r.result.content[0].text, /must be one line/); + assert.equal(d.seen.dispatch, undefined, 'a line break must not start a run'); + } + } +}); From d374f613ecdf5d92bb5f86632fb6dd64e80afc7e Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 01:54:28 +0300 Subject: [PATCH 03/27] fix(collect): redact rejected URL diagnostics what changed: Fix #378; mcp.mjs, collect-remote.mjs, MCP/CLI regression coverage, architecture map, changelog and test inventory. why: Restore the existing collection input and credential-handling contract. This is a bug fix with one remedy; no alternative design was set aside. what it touched: Shared remote collection seams; existing ADR-0033/0034 behavior. No new domain term or public interface. what you verified: verified - dispatch/MCP red-first reproduction and passing combined regression run (83 tests); CLI integration regressions pass in the broader command run. The commit gate runs the required complete offline suite before accepting this commit. what you got wrong and fixed: mistake - running six full suites concurrently exceeded the default 3900-second budget; where - local commit validation on this Windows host; impact - affected hooks refused commits before publication; cause - excess concurrent subprocess and filesystem work; fix - retry one commit at a time with the ADR-0142 timeout extension (RESEARCH_KIT_GATE_TIMEOUT=7920); verified - the required full-suite commit gate accepted the commit. --- CHANGELOG.md | 4 ++++ docs/ARCHITECTURE.md | 4 ++-- research-kit/README.md | 2 +- research-kit/bin/collect-remote.mjs | 2 +- research-kit/lib/mcp.mjs | 2 +- research-kit/test/cli.test.mjs | 10 ++++++++++ research-kit/test/mcp.test.mjs | 13 +++++++++++++ 7 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..ce3994bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 +- Rejected collection URLs pass through the existing credential redactor before + MCP tool results or `collect-remote` stderr can echo them (#378). Regression + coverage includes caller credentials in URL userinfo and query parameters. + - 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..c401dd34 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -180,7 +180,7 @@ is an open decision (2026-09-30): | `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 | -| `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 | +| `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. Rejected query/URL input passes through the existing credential redactor before it becomes tool-result text (#378, 2026-10-12), including caller credentials absent from the server environment. | 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 | @@ -203,7 +203,7 @@ is an open decision (2026-09-30): | `bin/measure.mjs` | The judge-free citation report for the current project (ADR-0089); `--json` for machines. A report, never a gate; exit 2 outside a research project. | ADR-0089 | | `bin/export-warc.mjs` | The corpus as one WARC 1.1 `.warc.gz` for archive tools (ADR-0090); `--out` (default `research-corpus.warc.gz`), written whole with `writeBytes` after `readWarc` re-reads it; a refused write is named by `writeFailure`, exit 2 (2026-09-29). A copy, never a gate; exit 2 outside a research project. | ADR-0090 | | `bin/disclosure.mjs` | Probes a run and reports what a stranger can read. Calls `honourEnvProxy()` before its first request (ADR-0047). Exit codes ARE the finding, so it can gate something later: `0` shape only, `1` content readable, `2` subject leaked, `3` could not measure - which includes any probe that never reached GitHub, unless an exposure was measured anyway (`exitCode` in the lib). An unknown flag matching token/auth/key/secret is refused with the reason - authenticating this probe would answer a different question. | ADR-0035 | -| `bin/collect-remote.mjs` | The one command an agent or a desktop app needs: dispatch the collector on GitHub, learn which run it started, wait, download, unwrap, and validate with the same code a human would run. Calls `honourEnvProxy()` before its first request, so a proxy-only network can dispatch at all (ADR-0047). Prints the run id **before** it waits, so a caller that dies mid-wait can still find its run - and a caller that never sees that line knows the dispatch itself did not land. **No `--token` flag**, and an unknown flag matching token/auth/key/secret/pat is refused with the reason rather than the spelling. Exit codes separate the four things a caller does differently about: `0` valid (still not authorized to build), `1` invalid package, `2` run failed or incomplete, `3` could not start, `4` dispatched and still running with the run id on stdout. An `--out` it cannot write is refused (exit 3, `OUT_DIR`) before the token is read or a run dispatched (2026-09-27); `--no-wait` downloads nothing and skips the check. Each failed poll the watch survives is printed with its number (2026-10-01). `--run-id ` picks up a run already dispatched - after `--no-wait`, a timeout (exit 4) or a lost connection - waits, downloads and judges it, and refuses `--topic` beside it (2026-10-01). The remedies and exit 4 had pointed at that flag while it did not exist, so the only way back to a run was to pay for another. A failure that names no way out now says how to pick the run up. A bare `--out`, `--workflow` or `--ref` is refused as `-- needs a value` (exit 3) beside the five checked values (2026-10-11, #270): the parser reads a bare flag as `true`, so `--out` had become the folder `true`, created before the token was asked for, and `--workflow` or `--ref` would have dispatched against the literal name "true". | ADR-0033 | +| `bin/collect-remote.mjs` | The one command an agent or a desktop app needs: dispatch the collector on GitHub, learn which run it started, wait, download, unwrap, and validate with the same code a human would run. Calls `honourEnvProxy()` before its first request, so a proxy-only network can dispatch at all (ADR-0047). Prints the run id **before** it waits, so a caller that dies mid-wait can still find its run - and a caller that never sees that line knows the dispatch itself did not land. **No `--token` flag**, and an unknown flag matching token/auth/key/secret/pat is refused with the reason rather than the spelling. Exit codes separate the four things a caller does differently about: `0` valid (still not authorized to build), `1` invalid package, `2` run failed or incomplete, `3` could not start, `4` dispatched and still running with the run id on stdout. An `--out` it cannot write is refused (exit 3, `OUT_DIR`) before the token is read or a run dispatched (2026-09-27); `--no-wait` downloads nothing and skips the check. Each failed poll the watch survives is printed with its number (2026-10-01). `--run-id ` picks up a run already dispatched - after `--no-wait`, a timeout (exit 4) or a lost connection - waits, downloads and judges it, and refuses `--topic` beside it (2026-10-01). The remedies and exit 4 had pointed at that flag while it did not exist, so the only way back to a run was to pay for another. A failure that names no way out now says how to pick the run up. A bare `--out`, `--workflow` or `--ref` is refused as `-- needs a value` (exit 3) beside the five checked values (2026-10-11, #270): the parser reads a bare flag as `true`, so `--out` had become the folder `true`, created before the token was asked for, and `--workflow` or `--ref` would have dispatched against the literal name "true". Rejected URL diagnostics pass through `redact` before stderr (#378, 2026-10-12), including credentials supplied by the caller rather than this process. | ADR-0033 | | `bin/researcher-release.mjs` | The release CLI: `validate`, `conform`, `fi-validate`. **A usage error returns 2 from every subcommand**, and a status maps to its own code — `PASS` 0, `FAIL`/`REOPEN` 1, `INCOMPLETE` 2, `BLOCKED` 3. `4` is the unrecognised-status fallback and never a usage error, so a caller can distinguish "did not run" from "ran and produced something unknown". `--help` prints the usage to stdout and exits 0 (2026-09-27), as do `path-authority`, `ledger-conformance`, `fi-sidecar-conformance` and `artifact`. All five had exited 2. `--report` is written through `writeText` (2026-09-28, ADR-0079), whole or not at all. `fi-validate --report` names a report path the environment refuses through `writeFailure` and exits 2 (2026-09-30); it had crashed with a stack and exit 1, its code for FAIL. `test/hardening.test.mjs` now requires every `bin/` command that imports a writer from `lib/` to call `writeFailure`, or to name the handler that makes it unnecessary. | — | | `bin/panel.mjs` | Starts the desktop panel (`lib/panel.mjs`) and opens the browser at its address (`explorer.exe` on Windows, `open`, `xdg-open`; `--no-open` prints it only). Flags: `--project `, `--port `, `--no-open`. Stops on Ctrl+C. A Windows desktop shortcut is a target the operator writes (README), not an installed launcher (ADR-0050). | ADR-0147 | | `release-validator.mjs` | Read-only R28–R32 release evidence validation: envelope/predecessor/role/visibility/promotion checks, ledger anchoring, and deterministic `PASS`/`INCOMPLETE`/`FAIL`/`REOPEN` reduction. It never rewrites records, pointers, registries, or role rosters. **Also the facade** over the four primitives below: all 21 of its original exports are re-exported unchanged, so the eighteen files importing from it were not touched. 1324 → ~1010 lines. Record ids and file lists sort by `compareText` (2026-10-01), the order the Python runner uses. `validateRelease` hands an explicit `genesisPath` and `checkpointsDir` to the ledger check, holds them to the ledger's below-the-root rule, and refuses either without a ledger (2026-10-11): until then both were dropped unread. | R28–R32 validator contract | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/bin/collect-remote.mjs b/research-kit/bin/collect-remote.mjs index 877b4bcb..a7b537de 100644 --- a/research-kit/bin/collect-remote.mjs +++ b/research-kit/bin/collect-remote.mjs @@ -155,7 +155,7 @@ checkFlagValues(flags, { // token would otherwise hide it, and a dispatched bad URL would be searched as text. const queriesValue = queriesInput({ queries: flagList(flags.query), urls: flagList(flags.url) }); if (queriesValue.error) { - process.stderr.write(`${queriesValue.error}\n fix: --url takes a page address starting with https://\n`); + process.stderr.write(`${redact(queriesValue.error)}\n fix: --url takes a page address starting with https://\n`); process.exit(EXIT.CANNOT_START); } diff --git a/research-kit/lib/mcp.mjs b/research-kit/lib/mcp.mjs index 1e5da319..6ab1cd8b 100644 --- a/research-kit/lib/mcp.mjs +++ b/research-kit/lib/mcp.mjs @@ -311,7 +311,7 @@ async function callTool(message, deps) { try { if (name === 'collect') { const queries = queriesInput({ queries: args.queries ?? [], urls: args.urls ?? [] }); - if (queries.error) return ok(message.id, { isError: true, content: [text(`${queries.error}. urls takes page addresses starting with https://`)] }); + if (queries.error) return ok(message.id, { isError: true, content: [text(redact(`${queries.error}. urls takes page addresses starting with https://`))] }); const started = await deps.dispatch({ repository: args.repository, inputs: { diff --git a/research-kit/test/cli.test.mjs b/research-kit/test/cli.test.mjs index 0212e2d7..0c8b0916 100644 --- a/research-kit/test/cli.test.mjs +++ b/research-kit/test/cli.test.mjs @@ -1581,3 +1581,13 @@ test('install-hooks --dry-run answers what the real run answers, deployed or not const gitconfig = fs.existsSync(path.join(home, 'gitconfig')) ? fs.readFileSync(path.join(home, 'gitconfig'), 'utf8') : ''; assert.doesNotMatch(gitconfig, /hooksPath/, 'a dry run set core.hooksPath'); }); + +test('collect-remote redacts credentials in refused URLs', () => { + const credentials = [`ghp_${'z'.repeat(36)}`, `github_pat_${'B2'.repeat(20)}`]; + const r = run('collect-remote.mjs', ['--repository', 'o/r', '--topic', 't', + '--url', `ftp://user:${credentials[0]}@a.example/`, '--url', `ftp://a.example/?token=${credentials[1]}`], { root: project() }); + assert.equal(r.status, 3); + for (const credential of credentials) assert.ok(!r.all.includes(credential), 'a caller credential leaked'); + assert.match(r.err, /redacted/); + assert.match(r.err, /not an http\(s\) URL/); +}); diff --git a/research-kit/test/mcp.test.mjs b/research-kit/test/mcp.test.mjs index 5dd3f641..44af0917 100644 --- a/research-kit/test/mcp.test.mjs +++ b/research-kit/test/mcp.test.mjs @@ -725,3 +725,16 @@ test('the server installs the closed-stdout guard before it writes its first byt assert.ok(call !== -1, 'mcp-server.mjs no longer tolerates a closed stdout (2026-09-30)'); assert.ok(call < firstWrite, `the guard is installed after the first write (write at ${firstWrite}, guard at ${call})`); }); + +test('refused URLs never echo caller credentials in a tool result', async () => { + const credentials = [`ghp_${'z'.repeat(36)}`, `github_pat_${'B2'.repeat(20)}`]; + const d = deps(); + const r = await handle(call('collect', { repository: 'o/r', topic: 'x', urls: [ + `ftp://user:${credentials[0]}@a.example/`, `ftp://a.example/?token=${credentials[1]}`, + ] }), d); + assert.equal(r.result.isError, true); + for (const credential of credentials) assert.ok(!JSON.stringify(r).includes(credential), 'a caller credential leaked'); + assert.match(r.result.content[0].text, /redacted/); + assert.match(r.result.content[0].text, /not an http\(s\) URL/); + assert.equal(d.seen.dispatch, undefined, 'a refused URL must not start a run'); +}); From 822fe1ad18190b1a24f0086a6c0688ab9ac06ddc Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 02:26:01 +0300 Subject: [PATCH 04/27] fix(dispatch): diagnose non-JSON dispatch responses what changed: dispatchCollection parses successful responses through readJsonBody; the existing malformed-body regression now covers dispatch as well as run/artifact lookup. why: A maintenance page was diagnosed as missing workflow_run_id and suggested pinning an API version already pinned. This restores the existing BAD_BODY contract; no alternative design was set aside. what it touched: dispatch.mjs, dispatch.test.mjs, its architecture map row and CHANGELOG.md; existing ADR-0033 semantics, no new domain terms. what you verified: verified - red-first dispatch reproduction (31 passed, one expected failure), then passing targeted dispatch tests. The required complete offline suite runs in the commit gate. what you got wrong and fixed: mistake - running six full suites concurrently exceeded the default 3900-second budget; where - local commit validation on this Windows host; impact - affected hooks refused commits before publication; cause - excess concurrent subprocess and filesystem work; fix - retry one commit at a time with the ADR-0142 timeout extension (RESEARCH_KIT_GATE_TIMEOUT=7920); verified - the required full-suite commit gate accepted the commit. --- CHANGELOG.md | 4 ++++ docs/ARCHITECTURE.md | 2 +- research-kit/lib/dispatch.mjs | 5 ++--- research-kit/test/dispatch.test.mjs | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..9978f085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 +- Dispatch responses with a successful HTTP status and a non-JSON body now report + `BAD_BODY`, with the existing proxy/maintenance-page remedy (#377). `NO_RUN_ID` + remains reserved for JSON responses without an integer workflow run ID. + - 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..23d24097 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -182,7 +182,7 @@ is an open decision (2026-09-30): | `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 | | `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 | +| `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). `dispatchCollection` also uses `readJsonBody`: a successful non-JSON response is `BAD_BODY` with the proxy/maintenance-page remedy; valid JSON missing the run ID retains `NO_RUN_ID` (#377, 2026-10-12). | 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. | `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 | diff --git a/research-kit/lib/dispatch.mjs b/research-kit/lib/dispatch.mjs index 9d4dd8b3..f069c767 100644 --- a/research-kit/lib/dispatch.mjs +++ b/research-kit/lib/dispatch.mjs @@ -201,8 +201,7 @@ export async function dispatchCollection({ throw new DispatchError('HTTP', `dispatch failed: HTTP ${response.status}. ${await readError(response)}`, { status: response.status }); } - let body; - try { body = await response.json(); } catch { body = null; } + const body = await readJsonBody(response, 'dispatch'); const workflowRunId = body?.workflow_run_id; if (!Number.isInteger(workflowRunId)) { throw new DispatchError('NO_RUN_ID', `the dispatch returned HTTP ${response.status} with no workflow_run_id`, { @@ -225,7 +224,7 @@ function stringifyInputs(inputs) { /** * A 200 whose body is not JSON, as a DispatchError rather than a raw SyntaxError. * - * dispatchCollection already guarded its body; getRun and listArtifacts did not. A 200 with + * dispatchCollection, getRun and listArtifacts share this parser. A 200 with * an HTML body - a maintenance page, a TLS-inspecting proxy, an edge cache under load - * threw `SyntaxError: Unexpected token <` straight out, and collect-remote reported it only * as UNKNOWN, with no status and no remedy (found 2026-09-30, break-test). The parser's diff --git a/research-kit/test/dispatch.test.mjs b/research-kit/test/dispatch.test.mjs index 1c4aa50d..14c72a49 100644 --- a/research-kit/test/dispatch.test.mjs +++ b/research-kit/test/dispatch.test.mjs @@ -206,6 +206,7 @@ test('a 200 whose body is not JSON is a dispatch error, not a SyntaxError', asyn json: async () => { throw new SyntaxError('Unexpected token < in JSON at position 0'); }, }); for (const [name, call] of [ + ['dispatchCollection', () => dispatchCollection({ repository: REPO, token: TOKEN, fetch: html })], ['getRun', () => getRun({ repository: REPO, runId: 1, token: TOKEN, fetch: html })], ['listArtifacts', () => listArtifacts({ repository: REPO, runId: 1, token: TOKEN, fetch: html })], ]) { From dd8024218d1c7153f9a6b9577b28aaebe580ff0d Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 02:54:30 +0300 Subject: [PATCH 05/27] fix(runtime): reject non-origin proxy URLs what changed: Require a root pathname, no search and no fragment in usableProxyUrl; extend proxy and doctor regressions, including a loopback-only fetch that must still go direct. why: Undici parseOrigin refuses those URL shapes, so auto-enabling them crashes Node fetch and doctor incorrectly reports success. Restore the existing invalid-proxy path (ADR-0047); no alternative design was set aside. what it touched: runtime.mjs, proxy.test.mjs, doctor.test.mjs, runtime/doctor architecture rows and CHANGELOG.md. No new interface or domain term. what you verified: verified - red-first runtime assertion and doctor regression. Targeted proxy/runtime/doctor checks and the required full suite run before commit acceptance. what you got wrong and fixed: mistake - running six full suites concurrently exceeded the default 3900-second budget; where - local commit validation on this Windows host; impact - affected hooks refused commits before publication; cause - excess concurrent subprocess and filesystem work; fix - retry one commit at a time with the ADR-0142 timeout extension (RESEARCH_KIT_GATE_TIMEOUT=7920); verified - the required full-suite commit gate accepted the commit. --- CHANGELOG.md | 6 ++++++ docs/ARCHITECTURE.md | 4 ++-- research-kit/lib/runtime.mjs | 5 +++-- research-kit/test/doctor.test.mjs | 6 ++++++ research-kit/test/proxy.test.mjs | 21 +++++++++++++-------- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..6c063ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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 +- Proxy URLs containing a non-root path, query or fragment take the existing + invalid-proxy path rather than enabling Node proxy support and crashing in + Undici (#380). Requests remain direct, the existing diagnostic names the + variable and accepted spelling, and `doctor` warns. A bare trailing slash stays + valid. + - 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..1b268af6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -224,7 +224,7 @@ is an open decision (2026-09-30): | `similarity.mjs` | **Near-duplicate detection between two captured pages**, and nothing else. Word shingles (5-word, case- and punctuation-folded, URLs stripped) reduced to a 128-hash MinHash `sketch`; `similarity` estimates Jaccard, `isMirror` compares it to `MIRROR_THRESHOLD`, and `documentGroups` single-link-clusters rows into distinct DOCUMENTS. Exists because `corroboration` counted HOSTS and therefore **inverted** on a mirror: a republished copy on an unrelated host scored `independent` - the registry's best grade - while carrying one witness plus a staleness `freshness` cannot see, since that check grades when a page was FETCHED. `MIRROR_THRESHOLD = 0.25` is **chosen from measurement, not taste**: all 1081 capture pairs in this repository were compared on 2026-09-21, the known stale mirror (`nodejs.org` x `lira.epac.to`, six majors apart) scored 0.3879 and the highest-scoring genuinely different cross-host pair scored 0.0275 - a fourteen-fold gap, so the line is not delicate. `sketch` returns **null** below `MIN_SHINGLES`, and every caller must read null as "cannot tell" rather than "different", which is why two unjudgeable rows stay two documents instead of merging into an invented mirror. Hashing is seeded FNV-1a so a sketch is identical across runs and platforms - a fingerprint that decides a gate finding must grade the same corpus the same way everywhere. Judges no meaning and cannot tell a correct page from a wrong one (ADR-0013). | ADR-0036 | | `preflight.mjs` | **The verdict** and the **verdict context**. Assembles findings from CHECKS into pass/fail; one function, three callers (commit gate, edit gate, CLI) so they cannot disagree. Owns the verdict's input unit: `verdictContext(root)` reads the corpus, the gate state (`readGateState`), and the operator's `evidencePolicy` together — same snapshot, same verdict, by construction. | ADR-0004 | | `gate.mjs` | Gate policy: `isGated` (the four markers), `evaluate` (commit/edit), the GATE_OFF override recording, **what phase 1 may commit** (ADR-0048: `research/`, the root `SCAFFOLDING` - `.gitattributes`, `.gitignore`, `AGENTS.md`, `START_HERE.md` - and `docs/ARCHITECTURE.md` while `isEmptyArchitectureMap` says its staged bytes, read by `readStagedText`, are still the template's; until 2026-09-27 only `research/`, so a new project's first `git add -A` was refused and its `.gitattributes` could not travel), and the architecture-map rule (`architectureMapBreach` over `loadGateConfig` — guarded paths are project configuration, `research/kit.json`, defaults `src/lib/bin/scripts/app`). Contains no repo-specific path. **Staged paths remain data, with checked acquisition** (ADR-0154): the default commit CLI lazily calls `readStagedPaths` after the ungated and `GATE_OFF` returns. One actual `git diff --cached --name-only --no-renames -z` supplies paths only on successful complete output; the existing 64 MiB capture rejects nonzero exit and buffer failure, and the reader rejects malformed NUL data. Successful zero bytes means `[]`; unreadable scope means null and raises the existing architecture-map breach even after green preflight, ordinary exit 1 under either posture. Explicit arrays/null and the existing stdin/repeated `--staged` options retain their meaning; edits never acquire a list. **One deliberate exception to "no CLI parsing"** remains for explicitly supplied stdin data (`stagedPathsFromStdin` / `splitPathList`), because argv was O(n²) to build and bounded besides. It returns **null for "stdin could not be read" and `[]` for "read, and empty"**, preserving unknown scope rather than assuming an empty set. No list read claims an atomic snapshot with later index materialization. `stdinIsReadable()` asks `isatty(0)` rather than inferring from the file type, which gets two cases wrong in opposite directions (a socketpair is not a FIFO; `/dev/null` is a character device but reads as immediate EOF). The architecture-map block's fix says to **update** the map, then `git add` it (2026-09-27): a bare `git add docs/ARCHITECTURE.md` stages nothing while the map is unchanged, so the printed fix left the commit blocked. `bin/gate.mjs` no longer closes that block with "Phase 1 is not done until preflight prints PASS" - a map block happens only when the gate passes. `isPhaseOneEdit(root, file)` answers the same question for the edit hook, about one file. The empty-index verdict — a gated project whose `research/` is untracked, every fresh project's first commit — removes the scratch dir `materializeIndex` made before it learned the index was empty (2026-09-28): it returned before `scratch` was assigned, so the `finally` that removes it never ran, and every such verdict leaked one temp directory. **Integrity before scope** (ADR-0093): a failing verdict whose findings include an `INTEGRITY_RULES` provenance rule blocks the commit even when it is confined to `research/` - the ADR-0048 allowance covers completeness findings only. `stdinIsReadable(stdin = process.stdin)` takes the stream to ask about, so the hook's terminal case is testable (2026-10-01). **The suite rule** (ADR-0120, 2026-10-01): `suiteBreach(root, stagedPaths, { run })` runs after the map rule, in the commit gate only, and only where `isKitCheckout` finds the kit's four markers and a staged path is under `research-kit/` (or the list is unknown); `runSuiteHere` spawns the checkout's `bin/selftest.mjs` with `RESEARCH_KIT_RESULT_FILE` pointed at a scratch file and `RESEARCH_KIT_ALLOW_UNSUP=1` (unsupported is not red, ADR-0108), git's hook environment stripped (`GIT_INDEX_FILE`, `GIT_DIR`, `GIT_PREFIX` and kin - the same `REPOSITORY_LOCATION` list as the edit hook, else every scratch repository's git in the suite would act on the index being committed), and reads the file, never stdout: failures block naming the counts, no result blocks as a crashed runner, a run past its budget is stopped and blocks as unfinished, the fix naming `RESEARCH_KIT_GATE_TIMEOUT` before the override, and a green run whose runner exited non-zero (the README count check) blocks too. `suiteOwed(root, stagedPaths)` is the predicate alone; `suiteBreach`'s `announce` (`evaluate`'s `announceSuite`) is called right before the run, so `bin/gate.mjs` says the suite is running only when it is - not when the map rule or preflight refused the commit first. `evaluate` takes `runSuite` so the verdict is testable without a suite. **The suite's budget follows the gate's watchdog** (ADR-0142, 2026-10-05): `suiteBudgetMs(env)` is `SUITE_TIMEOUT_MS` (65 min, twice the slowest honest run measured, 1825 s on the operator's Windows PC) unless `RESEARCH_KIT_GATE_TIMEOUT` is set to a positive duration `gateTimeoutSeconds` reads (seconds, an optional fraction, an optional `s`/`m`/`h`/`d`, at most nine digits before the point - the grammar the hook's `gate_timeout_readable` checks too; `0` and anything outside it leave the default), and then that watchdog less `GATE_REST_S` (120 s, the hook's default watchdog for a gate that runs no suite), never more than half a short one, at least 1 ms and at most 2^31 - 1 - so the gate stops the suite and blocks before the hook's watchdog fires, whenever its own work fits what is left. `runSuiteHere` stops the runner with SIGKILL: the harness's SIGTERM listener kept a synchronously blocked runner alive past its budget on POSIX; a stopped run's scratch is left in the temp folder. The budget had been 20 minutes, which that PC's green suite met on every kit commit, and the variable had reached only the watchdog: set below the budget, the watchdog killed the gate first and a fail-open machine allowed a commit whose suite never reported. **The fix lines no longer say `--no-verify` is recorded** (2026-10-03, outside audit): git skips this hook, so nothing can record it; `checks.mjs` had said so since ADR-0035 while the gate's own fix text, the README, AGENTS.md and CONTEXT.md said the opposite. **Red suite diagnostics** (2026-10-08): `suiteBreach` forwards up to five `failed` identifiers, each limited to 160 characters with controls normalized, in its existing detail string; omitted or clipped data is named, and legacy missing labels are explicit. Counts and verdict keys are unchanged. `runSuiteHere` still removes its scratch result; no result retention system is added. | ADR-0002, ADR-0007, ADR-0008, ADR-0020, ADR-0093, ADR-0120, ADR-0142, ADR-0154 | -| `doctor.mjs` | Read-only diagnostics: machine + project + gate + chain health in one report. Consumes the verdict context (`verdictContext` / `readGateState`) for the chain-verified corpus and the gate-state triple; adds one machine read of its own (the git-resolved `hooksPathEffective`) for report. **Role-aware** (ADR-0010): the Firecrawl findings are informational on a `builder`, and the handoff findings (ADR-0011) are blockers there — the collection entrypoints' refusal is what makes that split honest. The handoff findings come from the owner, not from a second composition: doctor passes the corpus it already read to `verifyHandoff(root, { corpus })`, so the arrival question has one implementation and one set of pins. One deliberate write: the repository-local hooksPath override log (the third override is silent by nature — the bypassed hook cannot report it). `gateHealth` and `runDoctor` accept explicit Git/config/settings paths and a child environment so diagnostics can run against disposable machine fixtures without consulting host state. **`commitGateState`** asks WHOSE pre-commit is at `core.hooksPath` - `current` / `foreign` / `unusable` - because executable is not the same as ours. It used to ask only whether some executable hook was there, the same existence-for-correspondence mistake the `deploy` check made; `settingsState` has asked the question of the EDIT gate since a registration here named our hook inside a different checkout, and `core.hooksPath` is a single machine-wide setting that husky, lefthook and pre-commit all rewrite. Pure and separate from `gateHealth`, which reads real git config - a rule testable only by fishing a finding out of an array on a host-dependent path is a rule nobody tests (ADR-0004), which is why the older test for it asserts the severity is one of three. **Ownership is decided before executability**, because the first version asked about the mode first and so reported a non-executable FOREIGN hook as `unusable` with `chmod +x` beside it - advice to repair somebody else's gate while leaving this one just as absent. That ordering bug was invisible on Windows, where `hookExecutability` returns ok for any file that exists, and surfaced on Linux in CI. On a **collector**, handoff findings are `info` in the collector's words ("does not exist yet - nothing has been collected here; a builder handed this corpus would be refused"), since 2026-09-26: they were graded `pass` with the builder's wording, so a fresh project printed "pass ... the corpus arrived without its ledger". On a builder they remain blockers, worded as before. It reports the **Node line** and, when a proxy variable is set, whether this Node sends the kit's requests through it A collector without the Firecrawl CLI is told the exact install command, `npm install -g firecrawl-cli@` from `cliInstallSpec()` (2026-09-27; it said "install the Firecrawl CLI" and nothing anywhere said how). (`proxy`: keyless pages, search requests and remote collection; a warning naming the version to move to - or, for a value Node cannot parse, the spelling it needs - since the Firecrawl CLI has its own proxy handling) - measured on 22.20.0, where the fetch went around the proxy (ADR-0046, ADR-0047). A collector that explicitly chose a keyless or browser transport passes the Firecrawl checks; nothing chosen still fails (ADR-0095). The Firecrawl fixes (`firecrawl-cli`, `firecrawl-auth`) name the no-account route beside the install or login - `"transport": "http-keyless"` in the machine config, the setting that makes them pass (ADR-0095) - where the CLI fix had offered only a per-run flag and the login fix nothing (2026-09-29). `editGateState` reads an entry whose `hooks` is not a list, or a hook that is `null`, as naming nothing (2026-09-30); `[null]` had thrown a `TypeError`. `scanForSecrets` catches a folder it cannot read where it reads it, and skips a folder it has already walked by real path (2026-09-30): one try/catch around the whole walk had ended the scan at the first unreadable folder, leaving every file after it unscanned and reporting nothing found. `scanForSecrets` records a cap stop separately from the scanned count (2026-10-08, bounded-scan audit repair): it keeps the 2000-text-file limit and reports incomplete coverage only when that cap actually prevents further traversal; ending at the final file's boundary is not itself incomplete. `runDoctor` reports a capped scan as a `secret-scan` warning, beside any unchanged critical `secret` hits, instead of reporting a capped no-hit scan as passing. It names no unobserved remaining-file count and makes no whole-repository clearance claim. `bin/doctor.mjs` reports `KIT_VERSION`, as `kitVersion` in `--json` and on its last line (ADR-0116). Reports each ill-typed posture key as a `config-ill-typed` warning naming the key, the values the kit reads, and the state it resolved to (ADR-0121). The `firecrawl-cli` finding judges the installed version with the run's own rule (`cliCompatibility`): an unsupported major is a **fail** with the remedy on a collector that needs the CLI and information elsewhere, a newer minor passes and says what it was tested against - doctor had printed `pass 2.0.0` for a CLI the run then refused (2026-10-02, break-test pass 3). `scanForSecrets` skips a binary file and counts it (`skippedBinary`, named in the coverage line) instead of matching credential patterns against an executable's string table (ADR-0128, 2026-10-02): run on a Windows home folder it reported fourteen committed keys, every one the PEM marker OpenSSH and libssh2 parse, or random bytes spelling `sk-`. **`handOnState`** (2026-10-03, ADR-0134; since 2026-10-04 defined in `installer.mjs` and re-exported here, ADR-0141, so the install judges what it records by the same rule): what the hooks hand on to - none, missing, kit, other - and `gateHealth` warns on kit and missing; the maintainer's machine handed every commit on to the previous implementation's gate, which crashed and failed open while doctor said READY. `handOnState` resolves a relative folder from the repository's top level (`base`), where git runs its hooks from; doctor had resolved `.custom-hooks` against its own working directory and from a subdirectory called a working hand-on missing (fourth audit round, 2026-10-03). `scanForSecrets` skips `research/raw` at ANY depth (2026-10-04): the skip matched the relative path `research/raw`, so only the root's captures were outside the scan and a nested decision project's were read as the project's own files - CI tripped `google-api-key` on a Google page's source sibling (ADR-0140), Google's own public keys; a capture is page content, and the folder exists wherever a nested project does (ADR-0030). | ADR-0010, ADR-0011, ADR-0095, ADR-0134 | +| `doctor.mjs` | Read-only diagnostics: machine + project + gate + chain health in one report. Consumes the verdict context (`verdictContext` / `readGateState`) for the chain-verified corpus and the gate-state triple; adds one machine read of its own (the git-resolved `hooksPathEffective`) for report. **Role-aware** (ADR-0010): the Firecrawl findings are informational on a `builder`, and the handoff findings (ADR-0011) are blockers there — the collection entrypoints' refusal is what makes that split honest. The handoff findings come from the owner, not from a second composition: doctor passes the corpus it already read to `verifyHandoff(root, { corpus })`, so the arrival question has one implementation and one set of pins. One deliberate write: the repository-local hooksPath override log (the third override is silent by nature — the bypassed hook cannot report it). `gateHealth` and `runDoctor` accept explicit Git/config/settings paths and a child environment so diagnostics can run against disposable machine fixtures without consulting host state. **`commitGateState`** asks WHOSE pre-commit is at `core.hooksPath` - `current` / `foreign` / `unusable` - because executable is not the same as ours. It used to ask only whether some executable hook was there, the same existence-for-correspondence mistake the `deploy` check made; `settingsState` has asked the question of the EDIT gate since a registration here named our hook inside a different checkout, and `core.hooksPath` is a single machine-wide setting that husky, lefthook and pre-commit all rewrite. Pure and separate from `gateHealth`, which reads real git config - a rule testable only by fishing a finding out of an array on a host-dependent path is a rule nobody tests (ADR-0004), which is why the older test for it asserts the severity is one of three. **Ownership is decided before executability**, because the first version asked about the mode first and so reported a non-executable FOREIGN hook as `unusable` with `chmod +x` beside it - advice to repair somebody else's gate while leaving this one just as absent. That ordering bug was invisible on Windows, where `hookExecutability` returns ok for any file that exists, and surfaced on Linux in CI. On a **collector**, handoff findings are `info` in the collector's words ("does not exist yet - nothing has been collected here; a builder handed this corpus would be refused"), since 2026-09-26: they were graded `pass` with the builder's wording, so a fresh project printed "pass ... the corpus arrived without its ledger". On a builder they remain blockers, worded as before. It reports the **Node line** and, when a proxy variable is set, whether this Node sends the kit's requests through it A collector without the Firecrawl CLI is told the exact install command, `npm install -g firecrawl-cli@` from `cliInstallSpec()` (2026-09-27; it said "install the Firecrawl CLI" and nothing anywhere said how). (`proxy`: keyless pages, search requests and remote collection; a warning naming the version to move to - or, for a value Node cannot parse, the spelling it needs - since the Firecrawl CLI has its own proxy handling) - measured on 22.20.0, where the fetch went around the proxy (ADR-0046, ADR-0047). A collector that explicitly chose a keyless or browser transport passes the Firecrawl checks; nothing chosen still fails (ADR-0095). The Firecrawl fixes (`firecrawl-cli`, `firecrawl-auth`) name the no-account route beside the install or login - `"transport": "http-keyless"` in the machine config, the setting that makes them pass (ADR-0095) - where the CLI fix had offered only a per-run flag and the login fix nothing (2026-09-29). `editGateState` reads an entry whose `hooks` is not a list, or a hook that is `null`, as naming nothing (2026-09-30); `[null]` had thrown a `TypeError`. `scanForSecrets` catches a folder it cannot read where it reads it, and skips a folder it has already walked by real path (2026-09-30): one try/catch around the whole walk had ended the scan at the first unreadable folder, leaving every file after it unscanned and reporting nothing found. `scanForSecrets` records a cap stop separately from the scanned count (2026-10-08, bounded-scan audit repair): it keeps the 2000-text-file limit and reports incomplete coverage only when that cap actually prevents further traversal; ending at the final file's boundary is not itself incomplete. `runDoctor` reports a capped scan as a `secret-scan` warning, beside any unchanged critical `secret` hits, instead of reporting a capped no-hit scan as passing. It names no unobserved remaining-file count and makes no whole-repository clearance claim. `bin/doctor.mjs` reports `KIT_VERSION`, as `kitVersion` in `--json` and on its last line (ADR-0116). Reports each ill-typed posture key as a `config-ill-typed` warning naming the key, the values the kit reads, and the state it resolved to (ADR-0121). The `firecrawl-cli` finding judges the installed version with the run's own rule (`cliCompatibility`): an unsupported major is a **fail** with the remedy on a collector that needs the CLI and information elsewhere, a newer minor passes and says what it was tested against - doctor had printed `pass 2.0.0` for a CLI the run then refused (2026-10-02, break-test pass 3). `scanForSecrets` skips a binary file and counts it (`skippedBinary`, named in the coverage line) instead of matching credential patterns against an executable's string table (ADR-0128, 2026-10-02): run on a Windows home folder it reported fourteen committed keys, every one the PEM marker OpenSSH and libssh2 parse, or random bytes spelling `sk-`. **`handOnState`** (2026-10-03, ADR-0134; since 2026-10-04 defined in `installer.mjs` and re-exported here, ADR-0141, so the install judges what it records by the same rule): what the hooks hand on to - none, missing, kit, other - and `gateHealth` warns on kit and missing; the maintainer's machine handed every commit on to the previous implementation's gate, which crashed and failed open while doctor said READY. `handOnState` resolves a relative folder from the repository's top level (`base`), where git runs its hooks from; doctor had resolved `.custom-hooks` against its own working directory and from a subdirectory called a working hand-on missing (fourth audit round, 2026-10-03). `scanForSecrets` skips `research/raw` at ANY depth (2026-10-04): the skip matched the relative path `research/raw`, so only the root's captures were outside the scan and a nested decision project's were read as the project's own files - CI tripped `google-api-key` on a Google page's source sibling (ADR-0140), Google's own public keys; a capture is page content, and the folder exists wherever a nested project does (ADR-0030). The shared unusable-proxy check also rejects proxy URLs carrying a non-root path, query or fragment, so those configurations warn instead of promising proxy use (#380, 2026-10-12). | ADR-0010, ADR-0011, ADR-0095, ADR-0134 | | `panel.mjs` | **The desktop panel** (ADR-0147, 2026-10-07): `createPanel({ project, env, kitRoot, kitHome })` serves `research-kit/panel/` (one page, a script, a stylesheet) and a small JSON API on **127.0.0.1 only**, a thin shell over commands that already exist. It owns no verdict: doctor, handoff, preflight and install run as children with the fixed argument lists in `PANEL_COMMANDS`, in the chosen project folder, and their output is shown with the search key redacted. **No command from the page collects** - `research.mjs` and `decompose.mjs` are absent from `PANEL_COMMANDS`; the one way pages are fetched through the panel is auto-collect (`lib/auto-collect.mjs`, ADR-0148), for a builder's validated request, on a collector, within the operator's caps. Its routes are `GET /api/requests` (the queue and the meter), `POST /api/autocollect` (the settings), `/api/requests/check` and `/api/requests/resume`. It writes two things: the search provider's key into the machine config through `transport.mjs`'s `SEARCH_KEY` (so the vendor's name stays in the adapter and the registry, NFR-3), refused when the config resolves inside a git repository (Rule 6); and which project it shows, a full path to an existing folder, never searched for. The topic and `BRIEF.md` are read, never written (ADR-0056). `builderInstructions` is the hand-off text: the collector's push, ledger included, then the builder's own steps in `$HOME` spelling (ADR-0050) - declare the role, handoff, preflight, inspect an authored complete brief and its current inputs, never collect. Its readiness checklist requires classified map rows and rewritten Findings; a file-presence observation grants no build authorization. Commit-report placeholders require actual observed results. A collected Git-request result waits for the collector to record review/closure before dependent building. Every request passes four checks: the bound address, a Host header naming it (DNS rebinding), an Origin, when sent, that is its own, and for `/api/` the per-launch token, initialized through the launch URL fragment and required in x-panel-token. The launcher prints that capability URL; terminal-log and browser-history privacy are not guaranteed by this module. POSTs must be JSON, so a plain cross-site form is refused. Update is refused when the panel runs from the installed copy, which has nothing newer to install. Command runs, the project switch and auto-collect cycles share one lock (`running`, handed to `createAutoCollect` as `lock`): none runs beside another, so no preflight or update reads the corpus or the kit tree while a cycle writes it. | ADR-0147, ADR-0148 | | `requests.mjs` | **A builder's fact request as a file** (ADR-0148, 2026-10-07): `research/requests/.json`, the `fact-request` skill made machine-readable. `requestProblems(request, { perRequestPages })` validates it as untrusted input: the fixed fields (`fact`, `blocks`, `urls`, `queries`, `prefer`, `unknown`, `maxPages`, `topic`), bounded sizes, and `urlProblem` refuses a non-http(s) URL, one with a user name or password, and one naming this machine or an internal address (`isInternal` from `http-transport.mjs`, ADR-0110). A request over the per-request cap is refused, not trimmed. `listRequests` reads the folder, skipping the collector's own `.result.json` and `.plan.json` and ignoring a name that is not an id, and a request whose file, `.plan.json` or `.result.json` is a symbolic link (a builder's link would make the collector read or overwrite a file of its own machine; `writeText` follows links). `applyRequest` and `writeResult` refuse to write through a link as well. `applyRequest` writes the contract row (a new OPEN `U-n`, or an existing non-CLOSED row; it refuses requests naming a closed unknown) and the `plan.json` entries, and the plan for that run alone, with `maxScrapes` set to the request's pages. `contractUnknownStatus` reads the status of a contract row for continuation checks. `writeResult` records the outcome beside the request. Terminal and partial results are trusted only when `recordTrustedResult` has recorded hashes of both request and result in the machine-local `research-kit.autocollect.json`; `isTrustedResult` checks those hashes so a builder cannot submit a result as if the collector wrote it. The auto-collect settings (`off`/`auto`, pages a request, pages a day, interval, the folder for new topics) and the daily meter live in `research-kit.autocollect.json` beside the machine config (`readAutoCollect`, `saveAutoCollect`, `recordSpend`, `setPaused`); no key is in them. | ADR-0148 | | `auto-collect.mjs` | **The collector listening** (ADR-0148, 2026-10-07): `createAutoCollect({ project, env, kitRoot, exec, lock })` returns `cycle`, `status`, `schedule`, `resume`, `stop`. A cycle resolves the project folder to its final path (`fs.realpathSync.native`) before comparing it with git's top level, so a Windows 8.3 short name or a symlinked folder cannot turn a path git stages into one outside the repository. `status()` uses that same canonical project path so its trust lookup matches collector records across alternate Windows path spellings. A cycle refuses unless `collectionPolicy` says the machine may collect, and skips while another run holds the shared `lock` (the panel's; its own when none is given). Otherwise it runs `git pull --ff-only`; a failed pull is surfaced and blocks the cycle before requests are read or processed. On success, it goes through each request. Results received over git are ignored unless their request and result hashes match collector-local trust records, including in `status()`; trusted terminal results retry pending commit/push delivery without collecting again, and trusted `partial` results carry continuation state and resume only while their contract unknown is not CLOSED. An invalid one gets a refused result, committed and pushed. A valid one, within the per-request and daily caps and with `research/` clean, is applied (`requests.mjs`), then its target-specific `research/requests/.plan.json` runs, followed by `preflight.mjs`, whose verdict is recorded, not acted on. It then stages `research/` and the ledger by name (`git add -f`), commits, and pushes when there is an upstream. Accounting travels on the fixed Node research child's private IPC descriptor (ADR-0149), never its human output: exactly one bounded, typed report with measured pages within the admitted allowance and the exhaustion provider. Missing, invalid, duplicate or over-allowance reporting reserves the remaining admitted allowance against the daily meter, labels actual spend unknown, records a terminal failed result and pauses. Earlier measured continuation attempts stay counted once. A valid received report survives failed exit or later interruption; interruption also pauses. Diagnostic output keeps a bounded redacted tail with a truncation notice, and failure detail is bounded separately. The legacy `spentPages`/`creditsStopped` exports remain diagnostic compatibility helpers. It never passes `--fallback`: received exhaustion accounting makes the loop commit the partial corpus and continuation state, pause until the operator resumes, then reuse the saved target, unknown and remaining page allowance (ADR-0129). A `topic` request gets `new-project.mjs` and `decompose.mjs --max-scrapes 0` in a dated folder under the folder the operator approved, never over an existing project (ADR-0056). It is refused when no folder is approved. Diagnostic details, refusal reasons and preflight verdicts are scrubbed with the existing redactor before result files or commit reports are written (2026-10-08). Status also scrubs inherited trusted result details, delivery diagnostics and pause text without rewriting those files or changing their trust hashes; the configured redactor defines which secret is recognized. Every child gets a fixed argument list; `exec` is injectable for tests. The panel labels its unchanged numeric meter as pages used or reserved. **Allowance regression coverage** (2026-10-09): the four-cycle credit exhaustion and resume fixture runs real synthetic Node scripts against persisted request, plan, result and meter files, with a finite injected Git executor. Native round-trip, partial-delivery and topic-resume fixtures retain real Git and corpus-transfer assertions. The test watchdog remains 60 seconds. **Complete Git answers** (ADR-0151): the bounded child byte buffer reports a private `truncated` flag. Repository and Git metadata paths, upstream, ahead count, commit subject and changed paths, dirty state, and pending delivery status/diff all refuse truncated output before it can authorize work; a malformed ahead count or missing quiet-diff exit also blocks delivery. A newly written partial result is trusted before fingerprinting, so refused inspection stages nothing and cannot make a restart collect it again. Exit-only operations retain bounded diagnostic logs. **Admission survives collector termination** (ADR-0152): `reserveCollection` saves the newly admitted allowance and an owned in-flight pause atomically before the fixed research child starts; a failed meter write prevents spawn. `settleCollection` refunds unused allowance once on a valid receipt, preserves earlier spend and distinct pauses, and retains the full charge for unknown/spawn-failed attempts. Whole collector termination leaves its charge/pause across restart or project switch. Continuation reserves only its remaining newly admitted pages. Day-aware settlement never refunds yesterday against today; cross-day completion charging is conservative, and an over-cap completion remains paused. The existing file writer is atomic, while the mutex remains per collector instance; racing independent instances and power-loss durability are not certified by this correction. Successful result.next says captures were delivered: the builder reads/judges, the collector records Git Findings, closures, MAP and the current authored BRIEF; another fetch is only for a missing external fact. ADR-0052 local received-package review remains available, and derived approval stays role-agnostic (2026-10-08, ADR-0150). | ADR-0148, ADR-0149, ADR-0150, ADR-0151, ADR-0152 | @@ -232,7 +232,7 @@ is an open decision (2026-09-30): | `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, an `EVIDENCE.md` that is absent or holds no evidence table (`handoff-evidence-missing`, `handoff-evidence-unparsed`; the report carries `rows`, and the CLI and doctor print the count - 2026-10-02: with no rows there were no citations, so "every cited capture on disk" held vacuously and handoff said OK over a corpus whose table a sync tool had dropped), every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). The report carries `nothingCollected` (no ledger entry, no evidence row, no capture) since 2026-09-27, so a caller can tell a project never collected from a corpus that failed to travel. The finding names are unchanged, so the doctor and the artifact validator read them as before. The report also carries `brief: { state, todo }` (`briefReview`, 2026-09-27, ADR-0061): the CLI's "handoff OK" is followed by a note when the brief is a draft (naming the judged sections still TODO) or has not been drafted. It is never a finding, and the exit stays 0. **The line-ending remedy is judged by running it** (2026-09-27, ADR-0062): per affected capture it prints `git rm --cached` (the index entry only) then `git checkout HEAD --`, which rewrites the file as LF through the pin, and it prints the pin (`PIN_LINES`, to add in an editor) only when `.gitattributes` lacks it. Every printed command is a plain git command with no comment, so cmd and PowerShell run it as written (ADR-0070). `--renormalize` had fixed only the index, so the capture handoff reads stayed CRLF. A **third cause** (2026-09-28): a ledger present and empty while captures are on disk lost its entries where the captures are - a one-line ledger whose torn tail `doctor --fix-arity` dropped - so `ledgerLost` gets `ledgerLostRemedy()`: restore it from git, or re-collect with `--force`. It was given the push remedy, which on the collector pushes the same empty ledger. A ledger that is merely **short** is the same cause: a cited capture on disk that no entry records is `handoff-capture-unledgered` (2026-09-28) and gets the same remedy - handoff had said OK and exited 0 while preflight failed it as `fetch-entry-exists`. **`CORPUS_ADD`** (2026-09-28) is how the kit prints putting a corpus into git - `git add research/`, then `git add -f research/raw/.fetches.jsonl` - shared by the push remedy and the commit gate's fix: both said `git add -f` on the whole folder, which also committed the machine-local `.usage.jsonl`, `.failures.jsonl`, `.diagnostics.jsonl` and `.fetches.lock`. A **fourth cause** (2026-10-04, break-test pass 4, F5b): a capture whose body hash fails and NOT by line endings (`body-unmodified/modified`) was changed after its fetch, and the report carries it as `altered`; it no longer counts as "did not travel", and **`alteredRemedy(files, { isRepo })`** prints, per file, `git status --porcelain --` to tell a local change from a committed one, then `git checkout HEAD --` followed by handoff again (no folder-wide checkout, which would discard uncommitted work beside it), and for the committed case `git log --oneline --` to find the commit that held the fetched bytes and restore from it, here or on the collector; without git metadata it prints no command. Judged by running it (2026-10-04, external review): a dirty status says only that this checkout changed the file after the commit - a capture committed altered and edited again is dirty too - so the remedy no longer claims the committed copy is the fetched one; and `--force` is no longer offered, because a new fetch writes a new capture beside this one, or refuses to overwrite a differing source sibling, while `verifyLedger` still checks the entry that names this file. The push remedy it used to get sends the same bytes again when the change was committed, and sends an operator to another machine when the committed copy here was one checkout away. **`safePathspec(file)`** (2026-10-04, two external reviews) decides whether a capture path is printed inside a git command at all: letters, digits, `.`, `_`, `-` and `/`, the alphabet the collector's own names come from. Anything else is named but never printed as a command, because no one spelling is read literally by cmd, PowerShell and sh alike and by git: a space unquoted is two pathspecs, `$copy` inside double quotes is expanded to nothing by PowerShell, and `[12]` is a git glob whatever the shell did, so `git checkout HEAD --` restored a neighbour and discarded its uncommitted work. For those the remedy prints commands that type no name at all: the operator lists them, one per line, in `handoff-names.txt` (`NAMES_FILE`) and runs `git --literal-pathspecs checkout HEAD --pathspec-from-file=handoff-names.txt` (the line-ending remedy adds the `git rm --cached` step the same way) - "quote it for your shell and prefix `:(literal)`" was not enough, because cmd expands `%NAME%` inside double quotes and PowerShell `$name` (third external review, 2026-10-04, verified by running in cmd); `--pathspec-from-file` hands git the bytes of the file with no shell in between, and `--literal-pathspecs` switches pattern syntax off (both git 2.25+). Such a name reaches the ledger rarely but not only by hand: `captureName` embeds the URL's host as it is, so an IPv6 literal or an adapter-supplied host outside DNS's alphabet produces one (third review). The classification runs over EVERY affected file (`classify`), not the five the text names: an unsafe sixth file got no note and "handle the remaining files the same way, each by name" - an instruction to type the glob that restores a neighbour (third external review, 2026-10-04). **`insideRepository(root)`** (2026-10-04, two external reviews) decides whether the runnable remedies are printed, and it asks git: `git -C rev-parse --is-inside-work-tree`. It was `root/.git` alone, so a nested decision project (ADR-0030), which has none of its own, was told it had "no git metadata"; then a walk up for a `.git` entry, which walked past a bare repository the project sat inside and printed a checkout git refused with "this operation must be run in a work tree". The commands are git's, so git decides whether they run; without git on PATH the walk is the fallback: it walks the PHYSICAL path (realpath first - a project reached through a junction into a repository had read as outside it, and one linked out of a repository as inside; third review) and reads `GIT_CEILING_DIRECTORIES` as git does: absolute entries only, each through realpath until an empty entry, after which the rest are taken as written. Git's pathspecs are relative to the cwd, so the printed commands run unchanged from the project folder. The tests that want "no git metadata" from a scratch fixture set `GIT_CEILING_DIRECTORIES` above it and go through `handoffRemedy`, so a detection that always says "repository" fails them. **`repositoryState(root)`** is the full answer, `{ inside, refused }`: when git answered with something other than yes, "not a git repository" or "must be run in a work tree" - `fatal: detected dubious ownership` - `refused` carries its first line and both remedies say git refused to read the repository and to fix that first, where they said "no git metadata, re-copy the corpus" (third review, 2026-10-04); no command is printed either way. | ADR-0011, ADR-0020 | | `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, and (from 2026-09-21) `AGENTS.md`, which is the file the agent protocol is *written in* — caught drifting by this check when ADR-0030 gave decision research a committed home and the rule that sends an agent there had to be where agents read their rules — each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. `bin/bundle.mjs --json` prints `verifyBundle`'s result as JSON (2026-09-28): the flag was accepted and never read, so a caller parsing it got the markdown report. `START_HERE.md` joined `EXPECTED_TO_DRIFT` on 2026-09-29: the archived copy held the author's machine paths, and the portable one is the template rendered with `$HOME`. | ADR-0028 | | `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 | -| `runtime.mjs` | The host prerequisites a command actually needs: `checkNode` (22+), `checkGit`, `checkPython` (3.11+ since 2026-09-28, ADR-0078 - one number with the READMEs and CI's pin, reading either stream because Python 2 printed its version to stderr, trying `python3` **first** and continuing past an old interpreter rather than stopping at it — `python` is an alias for 2.7 on the most common Linux and older-macOS layout, and stopping there reported "no usable Python" with a good 3.12 one name away; an interpreter whose `--version` prints no version is passed over like an old one, and a refusal quotes what it printed - it was accepted as "proceeding" until 2026-09-28, and failed later in the conformance tests; it names the interpreter it chose as `exe` (2026-09-28), and the test harness's `findPython` runs that one - the harness had its own probe, `python` first with no version check, so an old alias failed every cross-language test), **`MAX_PAGE_BYTES`, `boundedText`, `CHILD_OUTPUT_LIMIT` and `outputOverflow`** (2026-09-28, ADR-0082) bound every transport child - `boundedText` what a child downloads, the other two what it hands back: they ran under spawnSync's 1 MiB default, so a page just over it failed with ENOBUFS - and a Firecrawl scrape that large was paid for and lost - and an overflow is now one sentence, its partial output discarded; and `requireRuntime({ node, git, python })` which exits **3** — distinct from a product failure — naming the missing piece and how to install it. Deliberately per-command rather than one gate that demands everything: Python is used by the cross-language conformance runners and nothing else, and a command that only reads the corpus has no business refusing to run because a language it never calls is absent. **The Node line, judged** (2026-09-27, ADR-0046): `nodeLine` passes a supported line, warns on an odd line below 27 (never LTS, end-of-life after six months) and fails below the floor; `nodeHonoursEnvProxy` says whether fetch can use a configured proxy - 22.21+ on the 22 line, 24.0+ after, never 23 (docs/decisions/2026-09-27-node-support). **A configured proxy, for every request the kit makes** (ADR-0047): `PROXY_VARIABLES` / `proxyVariable`; `fetchEnv` for a child that fetches; and for a command that fetches in its own process, `envProxyPlan` / `honourEnvProxy` - `set` through `http.setGlobalProxyFromEnv()` on 24.14+/25.4+, `reexec` with the flag on 22.21+/24.0-24.13 (the same standard streams, stop signals forwarded, the child's exit code, and `--disable-warning=UNDICI-EHPA` - Node 22's "EnvHttpProxyAgent is experimental" on every run, and only that warning), `unsupported` with one stderr line naming the version to move to, `none` when no proxy is set or the operator chose at startup (the variable even at `0`, `--use-env-proxy` in argv or `NODE_OPTIONS`), `invalid` when a value Node reads (lowercase wins) is not an http(s) URL - `unusableProxy`; with the flag on such a value crashes every fetch inside Node, so it is left off, and the message shows `proxySpelling` without ever echoing the value. **`fallbackCost(name)`** (2026-09-30) words what falling back onto the fetch provider's search costs: free on `http-keyless` and `browser`, which have no meter, and "this spends fetch credits" for any other, because a provider the kit does not know may be metered. The line had said "spends fetch credits" on the keyless transport. **`fetchFailure(err)`** (2026-09-30) is the keyless and SerpAPI children's error text: Node's `fetch failed` plus the reason on `err.cause` (`getaddrinfo ENOTFOUND host`, `ECONNREFUSED`, `redirect count exceeded`), with any URL in the cause cut to its host so a key in a query string never rides along. Every network failure had read `fetch failed`. `fetchFailure` also reads the first inner error of an `AggregateError` cause that carries no message or code of its own (2026-09-30), as a dual-stack connection failure can. `fetchFailure` cuts every URL to its host in the failure's own message as well as in its cause (`hostOnly`, both return paths): Node throws a credential-bearing or unparseable URL with no cause and the whole URL in the message, and one signed URL in a plan was written nine times into the ledger as that entry's error (break-test 2026-10-01). **A body is read in the charset the server declared** (2026-10-03, output-reliability audit G7): `charsetOf(contentType)` reads the label, `decoderFor(contentType, bytes, who)` picks the decoder - a byte-order mark first, then the declared label through `TextDecoder`, a label it does not know falling back to UTF-8 with a sentence in `fallback` - and `boundedBody` returns `{ text, charset, fallback }`; `boundedText` is its text alone, for the JSON answers of SerpAPI, SearXNG and the Wayback Machine, which RFC 8259 makes UTF-8. Every body had been decoded as UTF-8 whatever the header said, so a `windows-1252` page's euro sign captured as U+FFFD and the hash proved a page the server never sent. A legacy label over bytes that are valid multi-byte UTF-8 - the common server misconfiguration - is read as UTF-8, because such bytes are almost never intended as Latin-1 and the always-UTF-8 reading had got those pages right by accident. The review of that fix (2026-10-03) added three readings and took one back: bytes that are not valid UTF-8 under a UTF-8 label, or none - the page that names its charset only in a `` tag - are a named fallback instead of silent U+FFFD; a UTF-16 label over a body with no NUL byte (`hasNul`), which UTF-16 text of a page cannot avoid, is not believed and the body is read as UTF-8, where it had decoded to CJK garbage end to end; an unknown label over pure ASCII is an exact reading, not a fallback; and `boundedText` reads as UTF-8 whatever the label says, because the JSON callers had always had that reading and a parse decides what it means - `boundedBytes` is the shared bounded read beneath both. `charsetOf` drops a stray quote (`charset="utf-8`). `nodeDecodesWindows1252` is the version rule behind `nodeLine`'s second `fail` (2026-10-11, ADR-0159): Node 22.13.0 through 22.22.0 read `windows-1252` as Latin-1 (nodejs/node#60888), so the declared-charset reading above silently lost 0x80-0x9F on those builds; `doctor` names the defect and the first good build, 22.22.1. | ADR-0046, ADR-0047, ADR-0159 | +| `runtime.mjs` | The host prerequisites a command actually needs: `checkNode` (22+), `checkGit`, `checkPython` (3.11+ since 2026-09-28, ADR-0078 - one number with the READMEs and CI's pin, reading either stream because Python 2 printed its version to stderr, trying `python3` **first** and continuing past an old interpreter rather than stopping at it — `python` is an alias for 2.7 on the most common Linux and older-macOS layout, and stopping there reported "no usable Python" with a good 3.12 one name away; an interpreter whose `--version` prints no version is passed over like an old one, and a refusal quotes what it printed - it was accepted as "proceeding" until 2026-09-28, and failed later in the conformance tests; it names the interpreter it chose as `exe` (2026-09-28), and the test harness's `findPython` runs that one - the harness had its own probe, `python` first with no version check, so an old alias failed every cross-language test), **`MAX_PAGE_BYTES`, `boundedText`, `CHILD_OUTPUT_LIMIT` and `outputOverflow`** (2026-09-28, ADR-0082) bound every transport child - `boundedText` what a child downloads, the other two what it hands back: they ran under spawnSync's 1 MiB default, so a page just over it failed with ENOBUFS - and a Firecrawl scrape that large was paid for and lost - and an overflow is now one sentence, its partial output discarded; and `requireRuntime({ node, git, python })` which exits **3** — distinct from a product failure — naming the missing piece and how to install it. Deliberately per-command rather than one gate that demands everything: Python is used by the cross-language conformance runners and nothing else, and a command that only reads the corpus has no business refusing to run because a language it never calls is absent. **The Node line, judged** (2026-09-27, ADR-0046): `nodeLine` passes a supported line, warns on an odd line below 27 (never LTS, end-of-life after six months) and fails below the floor; `nodeHonoursEnvProxy` says whether fetch can use a configured proxy - 22.21+ on the 22 line, 24.0+ after, never 23 (docs/decisions/2026-09-27-node-support). **A configured proxy, for every request the kit makes** (ADR-0047): `PROXY_VARIABLES` / `proxyVariable`; `fetchEnv` for a child that fetches; and for a command that fetches in its own process, `envProxyPlan` / `honourEnvProxy` - `set` through `http.setGlobalProxyFromEnv()` on 24.14+/25.4+, `reexec` with the flag on 22.21+/24.0-24.13 (the same standard streams, stop signals forwarded, the child's exit code, and `--disable-warning=UNDICI-EHPA` - Node 22's "EnvHttpProxyAgent is experimental" on every run, and only that warning), `unsupported` with one stderr line naming the version to move to, `none` when no proxy is set or the operator chose at startup (the variable even at `0`, `--use-env-proxy` in argv or `NODE_OPTIONS`), `invalid` when a value Node reads (lowercase wins) is not an http(s) URL - `unusableProxy`; with the flag on such a value crashes every fetch inside Node, so it is left off, and the message shows `proxySpelling` without ever echoing the value. **`fallbackCost(name)`** (2026-09-30) words what falling back onto the fetch provider's search costs: free on `http-keyless` and `browser`, which have no meter, and "this spends fetch credits" for any other, because a provider the kit does not know may be metered. The line had said "spends fetch credits" on the keyless transport. **`fetchFailure(err)`** (2026-09-30) is the keyless and SerpAPI children's error text: Node's `fetch failed` plus the reason on `err.cause` (`getaddrinfo ENOTFOUND host`, `ECONNREFUSED`, `redirect count exceeded`), with any URL in the cause cut to its host so a key in a query string never rides along. Every network failure had read `fetch failed`. `fetchFailure` also reads the first inner error of an `AggregateError` cause that carries no message or code of its own (2026-09-30), as a dual-stack connection failure can. `fetchFailure` cuts every URL to its host in the failure's own message as well as in its cause (`hostOnly`, both return paths): Node throws a credential-bearing or unparseable URL with no cause and the whole URL in the message, and one signed URL in a plan was written nine times into the ledger as that entry's error (break-test 2026-10-01). **A body is read in the charset the server declared** (2026-10-03, output-reliability audit G7): `charsetOf(contentType)` reads the label, `decoderFor(contentType, bytes, who)` picks the decoder - a byte-order mark first, then the declared label through `TextDecoder`, a label it does not know falling back to UTF-8 with a sentence in `fallback` - and `boundedBody` returns `{ text, charset, fallback }`; `boundedText` is its text alone, for the JSON answers of SerpAPI, SearXNG and the Wayback Machine, which RFC 8259 makes UTF-8. Every body had been decoded as UTF-8 whatever the header said, so a `windows-1252` page's euro sign captured as U+FFFD and the hash proved a page the server never sent. A legacy label over bytes that are valid multi-byte UTF-8 - the common server misconfiguration - is read as UTF-8, because such bytes are almost never intended as Latin-1 and the always-UTF-8 reading had got those pages right by accident. The review of that fix (2026-10-03) added three readings and took one back: bytes that are not valid UTF-8 under a UTF-8 label, or none - the page that names its charset only in a `` tag - are a named fallback instead of silent U+FFFD; a UTF-16 label over a body with no NUL byte (`hasNul`), which UTF-16 text of a page cannot avoid, is not believed and the body is read as UTF-8, where it had decoded to CJK garbage end to end; an unknown label over pure ASCII is an exact reading, not a fallback; and `boundedText` reads as UTF-8 whatever the label says, because the JSON callers had always had that reading and a parse decides what it means - `boundedBytes` is the shared bounded read beneath both. `charsetOf` drops a stray quote (`charset="utf-8`). `nodeDecodesWindows1252` is the version rule behind `nodeLine`'s second `fail` (2026-10-11, ADR-0159): Node 22.13.0 through 22.22.0 read `windows-1252` as Latin-1 (nodejs/node#60888), so the declared-charset reading above silently lost 0x80-0x9F on those builds; `doctor` names the defect and the first good build, 22.22.1. `usableProxyUrl` mirrors Undici origin parsing: pathname must be `/` and search/hash must be empty (#380, 2026-10-12). A non-origin proxy URL follows the existing invalid plan, preserving direct requests and the named diagnostic rather than crashing during proxy initialization. | ADR-0046, ADR-0047, ADR-0159 | | `scaffold.mjs` | Project shape: `LAYOUT` (12 entries), the **four** `GATE_MARKERS` (pinned by ADR-0001 and its test), template rendering, placeholder validation, hook executability. `.gitattributes` is a LAYOUT entry but **not** a gate marker: a project without it still gates, it just breaks on a Windows builder (ADR-0020). `UNTITLED_TOPIC` names the placeholder topic, so `decompose` can tell a project without a topic from one that has one (ADR-0056). A folder created while scaffolding a brand-new project is not counted as `repaired` (2026-09-27): only a folder missing from a project that already existed is a repair. **`MERGED_FILES` (`.gitignore`, `.gitattributes`) are merged into an operator's existing copy** (2026-09-27, ADR-0064): `missingKitLines` finds the kit's rule lines it lacks (whitespace collapsed) and they are appended once under a marked comment, so `.env` stays out and the ledger stays in. `validateProject` warns `kit-rules-missing` and `agents-md-foreign` (an AGENTS.md without the kit's heading, kept but named). A created folder counts as `repaired` only when `research/` already existed (2026-09-27): the operator's own `.gitignore` had made a fresh folder read as a project. | ADR-0001, ADR-0020 | | `installer.mjs` | Installs the git hook (records/restores prior `hooksPath`) and the edit-time gate (backs up, repairs known corruption, refuses unfamiliar states). The hook was renamed (A10.4): the installer recognises a registration left at a retired name, replaces it rather than adding a second, and reports the repair — `retiredRepairNote`. **And whether the deployment is still the kit you have** (`deployedDrift`, `driftNote`). `deploy` is a full mirror, so drift is exact rather than heuristic: every source file should be present at the destination with the same bytes, and nothing else should be there. Doctor measures the drift against the home the install state recorded (the one the hooks point at; it had measured `KIT_HOME` and named the recorded one), and run from the deployed kit itself - `node ~/.agents/research-kit/bin/doctor.mjs`, the copy an agent runs - it says so as `info`, with the command that does measure, instead of `pass ... matches this tree`: a tree compared with itself always matches, and a line appended to the deployed `lib/core.mjs` had passed that way (break-test pass 3, 2026-10-02). This exists because doctor's `deploy` check read the install STATE and stopped - "kit deployed ", pass, READY - and recorded is not current. Measured on this machine 2026-09-22, that passed over a deployment **missing 119 files and carrying 38 stale ones**, including a skill describing a protocol without the step shipped that morning. An agent invoking the skill runs the DEPLOYED copy, so every fix since the last install existed only in the repository while the machine reported ready. The skill is reported separately from the kit because it drifts independently and is the file that tells an agent what the protocol IS. **Reported, never repaired**: `install.mjs` acts, and a health check that quietly rewrote what it measured would erase the evidence of the problem it found - pinned by a test that asserts the missing file is still missing afterwards. `removeEditGate` drops the `PreToolUse` list and `hooks` object its entry leaves empty, and removes a settings file with nothing left (2026-09-27): `--uninstall` on a machine with no settings file left `{"hooks": {"PreToolUse": []}}` behind. `removeCommitGate` restores the recorded `hooksPath` only while the current value is still the kit's own folder (2026-09-27, ADR-0063), and otherwise leaves it and names it: an uninstall had unset a machine's `/opt/myhooks`, which the kit never set. `installEditGate` refuses, as the commit gate already did, when `hooks/edit-gate.mjs` is not deployed (2026-09-27): it had registered a command naming a missing file, so every Edit would run a hook that crashed. `deployedDrift` asks `isDirectory`, not `exists`, of the kit home and each skill location, and `listTree` guards its `readdirSync`/`statSync` (break-test, 2026-09-29): `RESEARCH_KIT_HOME` naming a regular file sent `readdirSync` at it, and `doctor` died with a raw ENOTDIR stack; a file there now reads as not deployed. `listTree` walks each real folder once (2026-09-29): a folder link back to an ancestor re-listed the tree on every lap until ELOOP, about 40 phantom "extra" files per real one. `deploy({ dryRun })` lists what the deploy would actually prune - retired files that exist, plus the mirror's extras (2026-09-29, Arena break test): it listed every retired name whether or not it was there, so a machine never installed read "would prune" three files, and it never named a stale file the mirror would remove. `readSettings` also refuses a `hooks.PreToolUse` node of a type the walkers do not handle (`knownHookShape`, 2026-09-30): `[null]`, a number, a string, or an entry whose `hooks` is not a list of objects reads as `unfamiliar`, and the file is not rewritten. It had reached the walkers and thrown a raw `TypeError` out of `install-hooks.mjs`. An entry with no `hooks` key is still the known shape. `knownHookShape` also requires `hooks` itself, each entry and each hook to be plain objects (2026-09-30): `"hooks": "none"`, `null`, a list or a number had passed as the known shape, and the install spread the string into the file. **Project binding is checked before deployment writes** (2026-10-08, ADR-0006): `deploy({ into })` refuses a missing path, a regular file, or a directory with none of `.git`, `AGENTS.md`, or `research/`; `.git` may be a file or directory so worktrees remain eligible, `AGENTS.md` must be a file, and `research/` must be a directory. The same refusal precedes the mirror, personal/project skill copies and install-state update on the real run and the preview; `install.mjs --into` reports it with exit 2. The guard had been absent, so a typo could deploy the home copy, prune its old files and bind skills into an unrelated folder. Config readers retain their separate last-good snapshot policy. `deploy` refuses, touching nothing, a `RESEARCH_KIT_HOME` that holds files and is neither a kit (`lib/core.mjs`, `bin/gate.mjs` and `bin/preflight.mjs`, all three: one alone took a project with a `lib/core.mjs` for a kit) nor the recorded deployment, `--dry-run` included; `install` exits 2 (ADR-0111, 2026-10-01). The mirror had deleted a folder's own files and reported them as retired. The refusal reads only the first few files it names (2026-10-01): counting all of them took 1.6 s for /usr. `installCommitGate` records the hooks folder it replaced as `research-kit.previousHooksPath` in git's global config, never the kit's own, and `removeCommitGate` clears it (ADR-0113). **A dry run answers what the real run answers** (2026-10-01, arena #196): `installCommitGate` checks that `githooks/pre-commit` is deployed before it previews, and `installEditGate`'s check no longer reads `!dryRun && !exists(hook)` - on a machine with nothing deployed `--dry-run` promised both gates and exited 0 while the real run refused both and exited 1. Both previews carry `ok: true`, one result shape for every caller, and `bin/install-hooks.mjs` tests `ok` before `dryRun`: the first cut reordered the CLI without that key and a healthy deployed machine printed `commit gate: undefined`, which a CLI test over a deployed kit now pins. **A repository-local `core.hooksPath` is reported at install time** (2026-10-03, ADR-0131): `installCommitGate` takes `cwd`, probes it with `localHooksPathOverride`, and returns `displaced: { cwd, local }` on the real run and the dry run alike; `bin/install-hooks.mjs` says it on stderr with the remedy and exits 0, and the install goes through - the gate is machine state. The installer had read and written only the global path and said "installed" from inside a husky repository where the gate would never run (outside review, 2026-10-02, three rounds). **A dry run writes nothing on any path** (2026-10-03, outside audit): `removeCommitGate`, `removeEditGate` and `uninstall` take `dryRun` and answer before any write, and `bin/install-hooks.mjs` reads `--dry-run` FIRST - it was read after the role, the posture and the uninstall had run, so `--dry-run --role builder --fail-closed` saved both and `--dry-run --uninstall` unset the installed commit gate. Both tree walks skip `__pycache__` (2026-10-04, break-test pass 4 F1): CPython's cache from the conformance runners was deployed with the kit and then counted as drift on every machine that had run the suite. **The skill set** (2026-10-06, ADR-0146): `SKILL_SET_DIR` (`skills/`) and `shippedSkills(from)` - every directory there holding a `SKILL.md`, sorted; `deploy` mirrors each to `/` in every `skillRoots()` root and, with `into`, beside the bound research-first skill (`boundSet`); `deployedDrift` measures each against every root where research-first is deployed, so a root installed before the set existed is drift, reported, never repaired. **A same-named skill that is not the kit's is left as it is** (2026-10-06, review): a set skill is written only where its folder is absent or its SKILL.md is, byte for byte, the one the kit last deployed there (`skillSetHashes` in the install state, kept across deploys) or the one it would deploy now - the content decides, never a recorded path, which outlives the kit's copy; any other is returned as `skillConflicts` (the dry run included), printed by `install.mjs`, and skipped by drift. The first cut overwrote a user's own `brainstorming/SKILL.md` and left its other files mixed with the kit's. A set skill the kit owns is **mirrored** (`copyTree` with `mirror`) and its extras count as drift (`orphaned`), unlike research-first's (PR review, 2026-10-06): the deploy had copied over the folder, so a reference a later kit dropped stayed and could still steer the skill, and doctor called it current. **`handOnState`** lives here since 2026-10-04 (ADR-0141; doctor re-exports it): what the hooks hand on to - none, missing, kit, other - and `installCommitGate` records no folder it classifies as `kit` (`handsOnTo`), on a first install or a re-install from the state file. doctor's one-line remedy for that hand-on (ADR-0134) had lasted one session on the operator's machine: the installer kept the old kit's `githooks/` in the install state and the next `install-hooks` run wrote it back into git config, and the old gate blocked commits again on its outdated checks. The state file and git config agree once an install has run; a `missing` folder stays recorded, for the uninstall restore. **Read-only deployed files remain replaceable** (2026-10-08, break-test pass 7 correction): `copyTree` restores write permission on an existing regular-file destination before copying, independently of the incoming source's mode, and explicitly applies the source's ordinary permission bits with owner-write after copying. This covers repeated read-only-source deployment and replacement of earlier read-only output from a normal writable source. Windows chmod changes write permission only; the copied file's read-only attribute is cleared, while POSIX ordinary execute bits are preserved and `githooks` keeps its final `HOOK_MODE` (E-04/E-05 in `docs/decisions/2026-10-07-break-test-external-facts`). Source-mode propagation was observed in the original Linux probe rather than promised by the copy API; directory, ownership and ACL failures remain separate limits. | ADR-0012, ADR-0131, ADR-0141 | | `render.mjs` | Terminal tables and Markdown findings, with no policy or I/O. `renderTable` sizes each column with a **fold, not a spread** (2026-09-28, Arena break test 12): `Math.max(header, ...rows.map(...))` passes one ARGUMENT per row, and V8 exhausts the stack somewhere past 100,000 of them - measured, 50,000 rows rendered and 200,000 threw `RangeError: Maximum call stack size exceeded`, which is a raw stack trace where a report belongs. Nothing in the kit produces a report that large today; a renderer that dies on a large input is still a renderer with an undocumented ceiling. **`renderGateWarnings(verdict)`** presents an existing evaluation in Markdown for the brief, audits and package reading guides (ADR-0143). It preserves every warning reason, remedy and supplied row/unknown target, labels whole-corpus evaluation time, and distinguishes unavailable observations from an evaluated zero. Presentation only: it performs no I/O or policy judgment. | ADR-0143 | diff --git a/research-kit/lib/runtime.mjs b/research-kit/lib/runtime.mjs index a37b18b9..44eb2ef6 100644 --- a/research-kit/lib/runtime.mjs +++ b/research-kit/lib/runtime.mjs @@ -83,11 +83,12 @@ export function proxyVariable(env = process.env) { return PROXY_VARIABLES.find((key) => env[key]) ?? null; } -// Node takes a proxy as an http: or https: URL with a host (E-03). +// Node takes an http(s) origin: Undici's parseOrigin refuses paths, queries and fragments. function usableProxyUrl(value) { try { const url = new URL(String(value)); - return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname); + return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname) + && url.pathname === '/' && !url.search && !url.hash; } catch { return false; } diff --git a/research-kit/test/doctor.test.mjs b/research-kit/test/doctor.test.mjs index e05704bc..0db511eb 100644 --- a/research-kit/test/doctor.test.mjs +++ b/research-kit/test/doctor.test.mjs @@ -1013,6 +1013,12 @@ test('behind a proxy, a Node that cannot use it is named, with the version to mo 'the lowercase variable wins when both are set (E-03), so the malformed one is never read'); const secret = find(nodeFindings('24.21.0', { HTTPS_PROXY: 'user:hunter2@proxy.example:8080' }), 'proxy'); assert.equal(`${secret.detail} ${secret.fix}`.includes('hunter2'), false, 'doctor printed a proxy password'); + for (const value of ['http://proxy.example:8080/path', 'http://proxy.example:8080?x=1', 'http://proxy.example:8080#fragment']) { + const finding = find(nodeFindings('24.21.0', { HTTPS_PROXY: value }), 'proxy'); + assert.equal(finding.severity, 'warn', 'a non-origin proxy URL was reported as working'); + assert.match(finding.detail, /cannot use.*direct/); + assert.match(finding.fix, /http:\/\/host:port/); + } }); test('doctor reports the Node line: supported, odd and short-lived, or below the floor', () => { diff --git a/research-kit/test/proxy.test.mjs b/research-kit/test/proxy.test.mjs index 8afd8c19..fd580d6f 100644 --- a/research-kit/test/proxy.test.mjs +++ b/research-kit/test/proxy.test.mjs @@ -351,7 +351,8 @@ test('the SerpAPI child is started with the flag, from the environment its calle // ignored and requests went direct. So turning the proxy on for such a value turned a working // machine into a crashing one. The value is left alone, and the operator is told. -const MALFORMED = ['proxy.example:8080', 'not a url', 'http://', 'socks5://127.0.0.1:1080']; +const MALFORMED = ['proxy.example:8080', 'not a url', 'http://', 'socks5://127.0.0.1:1080', + 'http://proxy.example:8080/path', 'http://proxy.example:8080?x=1', 'http://proxy.example:8080#fragment']; test('a proxy value Node cannot use is not turned on: the flag stays off, and the plan says why', () => { for (const value of MALFORMED) { @@ -363,6 +364,7 @@ test('a proxy value Node cannot use is not turned on: the flag stays off, and th assert.equal(planFor({ HTTPS_PROXY: 'http://ok.example:8080', HTTP_PROXY: 'proxy.example:8080' }), 'invalid', 'one unusable variable is enough: Node builds every configured proxy when the flag is on'); assert.equal(fetchEnv({ HTTPS_PROXY: 'https://ok.example:8443' }).NODE_USE_ENV_PROXY, '1', 'an https proxy URL is usable'); + assert.equal(fetchEnv({ HTTPS_PROXY: 'http://ok.example:8080/' }).NODE_USE_ENV_PROXY, '1', 'a root slash is a usable origin'); }); test('invalid: the command names the variable, says requests go direct, and shows the form Node needs', async () => { @@ -379,18 +381,21 @@ test('invalid: the command names the variable, says requests go direct, and show assert.match(text, /http:\/\/proxy\.example:8080/, 'the operator is not shown the spelling that works'); }); -test('LIVE: a scheme-less proxy value does not break a search that would have gone direct', async () => { +test('LIVE: an unusable proxy value does not break a search that would have gone direct', async () => { // The stand-in answers plain requests itself, so it can play the vendor: reached directly, // it returns the payload. The malformed proxy value must leave that path working. const vendor = await standInProxy({ body: JSON.stringify(PAYLOAD) }); try { const root = tempDir('rk-proxy-bad-'); - const r = serpapi.search('direct despite a bad proxy value', { - key: KEY, endpoint: `${vendor.url}/search`, timeout: 15_000, - env: { ...hostEnv(root), HTTP_PROXY: 'proxy.example:8080', HTTPS_PROXY: 'proxy.example:8080' }, - }); - assert.equal(r.ok, true, `the search broke on a proxy value it should have left alone: ${String(r.error).slice(0, 300)}`); - assert.equal(r.results[0].url, 'https://a.example/1'); + for (const value of ['proxy.example:8080', 'http://proxy.example:8080/path', + 'http://proxy.example:8080?x=1', 'http://proxy.example:8080#fragment']) { + const r = serpapi.search('direct despite a bad proxy value', { + key: KEY, endpoint: `${vendor.url}/search`, timeout: 15_000, + env: { ...hostEnv(root), HTTP_PROXY: value, HTTPS_PROXY: value }, + }); + assert.equal(r.ok, true, `the search broke on a proxy value it should have left alone: ${String(r.error).slice(0, 300)}`); + assert.equal(r.results[0].url, 'https://a.example/1'); + } } finally { vendor.close(); } From cb23853220e9c1fae283209380f31b0374add255 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 03:21:30 +0300 Subject: [PATCH 06/27] fix(doctor): respect disabled proxy startup choices what changed: Added a shared startup-choice reader used by runtime selection and doctor; warn when NODE_USE_ENV_PROXY is not 1 or startup flags disable proxies. Regression coverage includes environment values, flag precedence and unchanged no-override behavior. why: doctor promised proxy routing when the operator had explicitly left it off. Restore ADR-0047's diagnostic and startup-choice contract; no alternative design was set aside. what it touched: runtime.mjs, doctor.mjs, proxy/doctor tests, architecture rows, changelog and test inventory. No new public flag, config key or domain term. what you verified: verified - red-first direct doctor assertion. The targeted doctor/proxy tests and required complete offline suite run before commit acceptance. what you got wrong and fixed: mistake - running six full suites concurrently exceeded the default 3900-second budget; where - local commit validation on this Windows host; impact - affected hooks refused commits before publication; cause - excess concurrent subprocess and filesystem work; fix - retry one commit at a time with the ADR-0142 timeout extension (RESEARCH_KIT_GATE_TIMEOUT=7920); verified - the required full-suite commit gate accepted the commit. --- CHANGELOG.md | 6 ++++++ docs/ARCHITECTURE.md | 4 ++-- research-kit/README.md | 2 +- research-kit/lib/doctor.mjs | 8 ++++++-- research-kit/lib/runtime.mjs | 14 ++++++++++---- research-kit/test/doctor.test.mjs | 20 ++++++++++++++++++-- research-kit/test/proxy.test.mjs | 20 +++++++++++++++++++- 7 files changed, 62 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..88212698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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 +- `doctor` warns when an explicit Node startup choice leaves a configured proxy + off (#381): `NODE_USE_ENV_PROXY` accepts only `1`, and `--no-use-env-proxy` + disables it. The diagnostic shares the runtime choice reader and respects + command-line flag precedence over `NODE_OPTIONS` and the environment variable; + it never overrules the operator or echoes the variable value. + - 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..2ff7b1c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -224,7 +224,7 @@ is an open decision (2026-09-30): | `similarity.mjs` | **Near-duplicate detection between two captured pages**, and nothing else. Word shingles (5-word, case- and punctuation-folded, URLs stripped) reduced to a 128-hash MinHash `sketch`; `similarity` estimates Jaccard, `isMirror` compares it to `MIRROR_THRESHOLD`, and `documentGroups` single-link-clusters rows into distinct DOCUMENTS. Exists because `corroboration` counted HOSTS and therefore **inverted** on a mirror: a republished copy on an unrelated host scored `independent` - the registry's best grade - while carrying one witness plus a staleness `freshness` cannot see, since that check grades when a page was FETCHED. `MIRROR_THRESHOLD = 0.25` is **chosen from measurement, not taste**: all 1081 capture pairs in this repository were compared on 2026-09-21, the known stale mirror (`nodejs.org` x `lira.epac.to`, six majors apart) scored 0.3879 and the highest-scoring genuinely different cross-host pair scored 0.0275 - a fourteen-fold gap, so the line is not delicate. `sketch` returns **null** below `MIN_SHINGLES`, and every caller must read null as "cannot tell" rather than "different", which is why two unjudgeable rows stay two documents instead of merging into an invented mirror. Hashing is seeded FNV-1a so a sketch is identical across runs and platforms - a fingerprint that decides a gate finding must grade the same corpus the same way everywhere. Judges no meaning and cannot tell a correct page from a wrong one (ADR-0013). | ADR-0036 | | `preflight.mjs` | **The verdict** and the **verdict context**. Assembles findings from CHECKS into pass/fail; one function, three callers (commit gate, edit gate, CLI) so they cannot disagree. Owns the verdict's input unit: `verdictContext(root)` reads the corpus, the gate state (`readGateState`), and the operator's `evidencePolicy` together — same snapshot, same verdict, by construction. | ADR-0004 | | `gate.mjs` | Gate policy: `isGated` (the four markers), `evaluate` (commit/edit), the GATE_OFF override recording, **what phase 1 may commit** (ADR-0048: `research/`, the root `SCAFFOLDING` - `.gitattributes`, `.gitignore`, `AGENTS.md`, `START_HERE.md` - and `docs/ARCHITECTURE.md` while `isEmptyArchitectureMap` says its staged bytes, read by `readStagedText`, are still the template's; until 2026-09-27 only `research/`, so a new project's first `git add -A` was refused and its `.gitattributes` could not travel), and the architecture-map rule (`architectureMapBreach` over `loadGateConfig` — guarded paths are project configuration, `research/kit.json`, defaults `src/lib/bin/scripts/app`). Contains no repo-specific path. **Staged paths remain data, with checked acquisition** (ADR-0154): the default commit CLI lazily calls `readStagedPaths` after the ungated and `GATE_OFF` returns. One actual `git diff --cached --name-only --no-renames -z` supplies paths only on successful complete output; the existing 64 MiB capture rejects nonzero exit and buffer failure, and the reader rejects malformed NUL data. Successful zero bytes means `[]`; unreadable scope means null and raises the existing architecture-map breach even after green preflight, ordinary exit 1 under either posture. Explicit arrays/null and the existing stdin/repeated `--staged` options retain their meaning; edits never acquire a list. **One deliberate exception to "no CLI parsing"** remains for explicitly supplied stdin data (`stagedPathsFromStdin` / `splitPathList`), because argv was O(n²) to build and bounded besides. It returns **null for "stdin could not be read" and `[]` for "read, and empty"**, preserving unknown scope rather than assuming an empty set. No list read claims an atomic snapshot with later index materialization. `stdinIsReadable()` asks `isatty(0)` rather than inferring from the file type, which gets two cases wrong in opposite directions (a socketpair is not a FIFO; `/dev/null` is a character device but reads as immediate EOF). The architecture-map block's fix says to **update** the map, then `git add` it (2026-09-27): a bare `git add docs/ARCHITECTURE.md` stages nothing while the map is unchanged, so the printed fix left the commit blocked. `bin/gate.mjs` no longer closes that block with "Phase 1 is not done until preflight prints PASS" - a map block happens only when the gate passes. `isPhaseOneEdit(root, file)` answers the same question for the edit hook, about one file. The empty-index verdict — a gated project whose `research/` is untracked, every fresh project's first commit — removes the scratch dir `materializeIndex` made before it learned the index was empty (2026-09-28): it returned before `scratch` was assigned, so the `finally` that removes it never ran, and every such verdict leaked one temp directory. **Integrity before scope** (ADR-0093): a failing verdict whose findings include an `INTEGRITY_RULES` provenance rule blocks the commit even when it is confined to `research/` - the ADR-0048 allowance covers completeness findings only. `stdinIsReadable(stdin = process.stdin)` takes the stream to ask about, so the hook's terminal case is testable (2026-10-01). **The suite rule** (ADR-0120, 2026-10-01): `suiteBreach(root, stagedPaths, { run })` runs after the map rule, in the commit gate only, and only where `isKitCheckout` finds the kit's four markers and a staged path is under `research-kit/` (or the list is unknown); `runSuiteHere` spawns the checkout's `bin/selftest.mjs` with `RESEARCH_KIT_RESULT_FILE` pointed at a scratch file and `RESEARCH_KIT_ALLOW_UNSUP=1` (unsupported is not red, ADR-0108), git's hook environment stripped (`GIT_INDEX_FILE`, `GIT_DIR`, `GIT_PREFIX` and kin - the same `REPOSITORY_LOCATION` list as the edit hook, else every scratch repository's git in the suite would act on the index being committed), and reads the file, never stdout: failures block naming the counts, no result blocks as a crashed runner, a run past its budget is stopped and blocks as unfinished, the fix naming `RESEARCH_KIT_GATE_TIMEOUT` before the override, and a green run whose runner exited non-zero (the README count check) blocks too. `suiteOwed(root, stagedPaths)` is the predicate alone; `suiteBreach`'s `announce` (`evaluate`'s `announceSuite`) is called right before the run, so `bin/gate.mjs` says the suite is running only when it is - not when the map rule or preflight refused the commit first. `evaluate` takes `runSuite` so the verdict is testable without a suite. **The suite's budget follows the gate's watchdog** (ADR-0142, 2026-10-05): `suiteBudgetMs(env)` is `SUITE_TIMEOUT_MS` (65 min, twice the slowest honest run measured, 1825 s on the operator's Windows PC) unless `RESEARCH_KIT_GATE_TIMEOUT` is set to a positive duration `gateTimeoutSeconds` reads (seconds, an optional fraction, an optional `s`/`m`/`h`/`d`, at most nine digits before the point - the grammar the hook's `gate_timeout_readable` checks too; `0` and anything outside it leave the default), and then that watchdog less `GATE_REST_S` (120 s, the hook's default watchdog for a gate that runs no suite), never more than half a short one, at least 1 ms and at most 2^31 - 1 - so the gate stops the suite and blocks before the hook's watchdog fires, whenever its own work fits what is left. `runSuiteHere` stops the runner with SIGKILL: the harness's SIGTERM listener kept a synchronously blocked runner alive past its budget on POSIX; a stopped run's scratch is left in the temp folder. The budget had been 20 minutes, which that PC's green suite met on every kit commit, and the variable had reached only the watchdog: set below the budget, the watchdog killed the gate first and a fail-open machine allowed a commit whose suite never reported. **The fix lines no longer say `--no-verify` is recorded** (2026-10-03, outside audit): git skips this hook, so nothing can record it; `checks.mjs` had said so since ADR-0035 while the gate's own fix text, the README, AGENTS.md and CONTEXT.md said the opposite. **Red suite diagnostics** (2026-10-08): `suiteBreach` forwards up to five `failed` identifiers, each limited to 160 characters with controls normalized, in its existing detail string; omitted or clipped data is named, and legacy missing labels are explicit. Counts and verdict keys are unchanged. `runSuiteHere` still removes its scratch result; no result retention system is added. | ADR-0002, ADR-0007, ADR-0008, ADR-0020, ADR-0093, ADR-0120, ADR-0142, ADR-0154 | -| `doctor.mjs` | Read-only diagnostics: machine + project + gate + chain health in one report. Consumes the verdict context (`verdictContext` / `readGateState`) for the chain-verified corpus and the gate-state triple; adds one machine read of its own (the git-resolved `hooksPathEffective`) for report. **Role-aware** (ADR-0010): the Firecrawl findings are informational on a `builder`, and the handoff findings (ADR-0011) are blockers there — the collection entrypoints' refusal is what makes that split honest. The handoff findings come from the owner, not from a second composition: doctor passes the corpus it already read to `verifyHandoff(root, { corpus })`, so the arrival question has one implementation and one set of pins. One deliberate write: the repository-local hooksPath override log (the third override is silent by nature — the bypassed hook cannot report it). `gateHealth` and `runDoctor` accept explicit Git/config/settings paths and a child environment so diagnostics can run against disposable machine fixtures without consulting host state. **`commitGateState`** asks WHOSE pre-commit is at `core.hooksPath` - `current` / `foreign` / `unusable` - because executable is not the same as ours. It used to ask only whether some executable hook was there, the same existence-for-correspondence mistake the `deploy` check made; `settingsState` has asked the question of the EDIT gate since a registration here named our hook inside a different checkout, and `core.hooksPath` is a single machine-wide setting that husky, lefthook and pre-commit all rewrite. Pure and separate from `gateHealth`, which reads real git config - a rule testable only by fishing a finding out of an array on a host-dependent path is a rule nobody tests (ADR-0004), which is why the older test for it asserts the severity is one of three. **Ownership is decided before executability**, because the first version asked about the mode first and so reported a non-executable FOREIGN hook as `unusable` with `chmod +x` beside it - advice to repair somebody else's gate while leaving this one just as absent. That ordering bug was invisible on Windows, where `hookExecutability` returns ok for any file that exists, and surfaced on Linux in CI. On a **collector**, handoff findings are `info` in the collector's words ("does not exist yet - nothing has been collected here; a builder handed this corpus would be refused"), since 2026-09-26: they were graded `pass` with the builder's wording, so a fresh project printed "pass ... the corpus arrived without its ledger". On a builder they remain blockers, worded as before. It reports the **Node line** and, when a proxy variable is set, whether this Node sends the kit's requests through it A collector without the Firecrawl CLI is told the exact install command, `npm install -g firecrawl-cli@` from `cliInstallSpec()` (2026-09-27; it said "install the Firecrawl CLI" and nothing anywhere said how). (`proxy`: keyless pages, search requests and remote collection; a warning naming the version to move to - or, for a value Node cannot parse, the spelling it needs - since the Firecrawl CLI has its own proxy handling) - measured on 22.20.0, where the fetch went around the proxy (ADR-0046, ADR-0047). A collector that explicitly chose a keyless or browser transport passes the Firecrawl checks; nothing chosen still fails (ADR-0095). The Firecrawl fixes (`firecrawl-cli`, `firecrawl-auth`) name the no-account route beside the install or login - `"transport": "http-keyless"` in the machine config, the setting that makes them pass (ADR-0095) - where the CLI fix had offered only a per-run flag and the login fix nothing (2026-09-29). `editGateState` reads an entry whose `hooks` is not a list, or a hook that is `null`, as naming nothing (2026-09-30); `[null]` had thrown a `TypeError`. `scanForSecrets` catches a folder it cannot read where it reads it, and skips a folder it has already walked by real path (2026-09-30): one try/catch around the whole walk had ended the scan at the first unreadable folder, leaving every file after it unscanned and reporting nothing found. `scanForSecrets` records a cap stop separately from the scanned count (2026-10-08, bounded-scan audit repair): it keeps the 2000-text-file limit and reports incomplete coverage only when that cap actually prevents further traversal; ending at the final file's boundary is not itself incomplete. `runDoctor` reports a capped scan as a `secret-scan` warning, beside any unchanged critical `secret` hits, instead of reporting a capped no-hit scan as passing. It names no unobserved remaining-file count and makes no whole-repository clearance claim. `bin/doctor.mjs` reports `KIT_VERSION`, as `kitVersion` in `--json` and on its last line (ADR-0116). Reports each ill-typed posture key as a `config-ill-typed` warning naming the key, the values the kit reads, and the state it resolved to (ADR-0121). The `firecrawl-cli` finding judges the installed version with the run's own rule (`cliCompatibility`): an unsupported major is a **fail** with the remedy on a collector that needs the CLI and information elsewhere, a newer minor passes and says what it was tested against - doctor had printed `pass 2.0.0` for a CLI the run then refused (2026-10-02, break-test pass 3). `scanForSecrets` skips a binary file and counts it (`skippedBinary`, named in the coverage line) instead of matching credential patterns against an executable's string table (ADR-0128, 2026-10-02): run on a Windows home folder it reported fourteen committed keys, every one the PEM marker OpenSSH and libssh2 parse, or random bytes spelling `sk-`. **`handOnState`** (2026-10-03, ADR-0134; since 2026-10-04 defined in `installer.mjs` and re-exported here, ADR-0141, so the install judges what it records by the same rule): what the hooks hand on to - none, missing, kit, other - and `gateHealth` warns on kit and missing; the maintainer's machine handed every commit on to the previous implementation's gate, which crashed and failed open while doctor said READY. `handOnState` resolves a relative folder from the repository's top level (`base`), where git runs its hooks from; doctor had resolved `.custom-hooks` against its own working directory and from a subdirectory called a working hand-on missing (fourth audit round, 2026-10-03). `scanForSecrets` skips `research/raw` at ANY depth (2026-10-04): the skip matched the relative path `research/raw`, so only the root's captures were outside the scan and a nested decision project's were read as the project's own files - CI tripped `google-api-key` on a Google page's source sibling (ADR-0140), Google's own public keys; a capture is page content, and the folder exists wherever a nested project does (ADR-0030). | ADR-0010, ADR-0011, ADR-0095, ADR-0134 | +| `doctor.mjs` | Read-only diagnostics: machine + project + gate + chain health in one report. Consumes the verdict context (`verdictContext` / `readGateState`) for the chain-verified corpus and the gate-state triple; adds one machine read of its own (the git-resolved `hooksPathEffective`) for report. **Role-aware** (ADR-0010): the Firecrawl findings are informational on a `builder`, and the handoff findings (ADR-0011) are blockers there — the collection entrypoints' refusal is what makes that split honest. The handoff findings come from the owner, not from a second composition: doctor passes the corpus it already read to `verifyHandoff(root, { corpus })`, so the arrival question has one implementation and one set of pins. One deliberate write: the repository-local hooksPath override log (the third override is silent by nature — the bypassed hook cannot report it). `gateHealth` and `runDoctor` accept explicit Git/config/settings paths and a child environment so diagnostics can run against disposable machine fixtures without consulting host state. **`commitGateState`** asks WHOSE pre-commit is at `core.hooksPath` - `current` / `foreign` / `unusable` - because executable is not the same as ours. It used to ask only whether some executable hook was there, the same existence-for-correspondence mistake the `deploy` check made; `settingsState` has asked the question of the EDIT gate since a registration here named our hook inside a different checkout, and `core.hooksPath` is a single machine-wide setting that husky, lefthook and pre-commit all rewrite. Pure and separate from `gateHealth`, which reads real git config - a rule testable only by fishing a finding out of an array on a host-dependent path is a rule nobody tests (ADR-0004), which is why the older test for it asserts the severity is one of three. **Ownership is decided before executability**, because the first version asked about the mode first and so reported a non-executable FOREIGN hook as `unusable` with `chmod +x` beside it - advice to repair somebody else's gate while leaving this one just as absent. That ordering bug was invisible on Windows, where `hookExecutability` returns ok for any file that exists, and surfaced on Linux in CI. On a **collector**, handoff findings are `info` in the collector's words ("does not exist yet - nothing has been collected here; a builder handed this corpus would be refused"), since 2026-09-26: they were graded `pass` with the builder's wording, so a fresh project printed "pass ... the corpus arrived without its ledger". On a builder they remain blockers, worded as before. It reports the **Node line** and, when a proxy variable is set, whether this Node sends the kit's requests through it A collector without the Firecrawl CLI is told the exact install command, `npm install -g firecrawl-cli@` from `cliInstallSpec()` (2026-09-27; it said "install the Firecrawl CLI" and nothing anywhere said how). (`proxy`: keyless pages, search requests and remote collection; a warning naming the version to move to - or, for a value Node cannot parse, the spelling it needs - since the Firecrawl CLI has its own proxy handling) - measured on 22.20.0, where the fetch went around the proxy (ADR-0046, ADR-0047). A collector that explicitly chose a keyless or browser transport passes the Firecrawl checks; nothing chosen still fails (ADR-0095). The Firecrawl fixes (`firecrawl-cli`, `firecrawl-auth`) name the no-account route beside the install or login - `"transport": "http-keyless"` in the machine config, the setting that makes them pass (ADR-0095) - where the CLI fix had offered only a per-run flag and the login fix nothing (2026-09-29). `editGateState` reads an entry whose `hooks` is not a list, or a hook that is `null`, as naming nothing (2026-09-30); `[null]` had thrown a `TypeError`. `scanForSecrets` catches a folder it cannot read where it reads it, and skips a folder it has already walked by real path (2026-09-30): one try/catch around the whole walk had ended the scan at the first unreadable folder, leaving every file after it unscanned and reporting nothing found. `scanForSecrets` records a cap stop separately from the scanned count (2026-10-08, bounded-scan audit repair): it keeps the 2000-text-file limit and reports incomplete coverage only when that cap actually prevents further traversal; ending at the final file's boundary is not itself incomplete. `runDoctor` reports a capped scan as a `secret-scan` warning, beside any unchanged critical `secret` hits, instead of reporting a capped no-hit scan as passing. It names no unobserved remaining-file count and makes no whole-repository clearance claim. `bin/doctor.mjs` reports `KIT_VERSION`, as `kitVersion` in `--json` and on its last line (ADR-0116). Reports each ill-typed posture key as a `config-ill-typed` warning naming the key, the values the kit reads, and the state it resolved to (ADR-0121). The `firecrawl-cli` finding judges the installed version with the run's own rule (`cliCompatibility`): an unsupported major is a **fail** with the remedy on a collector that needs the CLI and information elsewhere, a newer minor passes and says what it was tested against - doctor had printed `pass 2.0.0` for a CLI the run then refused (2026-10-02, break-test pass 3). `scanForSecrets` skips a binary file and counts it (`skippedBinary`, named in the coverage line) instead of matching credential patterns against an executable's string table (ADR-0128, 2026-10-02): run on a Windows home folder it reported fourteen committed keys, every one the PEM marker OpenSSH and libssh2 parse, or random bytes spelling `sk-`. **`handOnState`** (2026-10-03, ADR-0134; since 2026-10-04 defined in `installer.mjs` and re-exported here, ADR-0141, so the install judges what it records by the same rule): what the hooks hand on to - none, missing, kit, other - and `gateHealth` warns on kit and missing; the maintainer's machine handed every commit on to the previous implementation's gate, which crashed and failed open while doctor said READY. `handOnState` resolves a relative folder from the repository's top level (`base`), where git runs its hooks from; doctor had resolved `.custom-hooks` against its own working directory and from a subdirectory called a working hand-on missing (fourth audit round, 2026-10-03). `scanForSecrets` skips `research/raw` at ANY depth (2026-10-04): the skip matched the relative path `research/raw`, so only the root's captures were outside the scan and a nested decision project's were read as the project's own files - CI tripped `google-api-key` on a Google page's source sibling (ADR-0140), Google's own public keys; a capture is page content, and the folder exists wherever a nested project does (ADR-0030). `machineHealth` uses the shared `envProxyChoice` with injectable `execArgv` (#381, 2026-10-12). An explicit disabled choice warns that requests go direct and names how to enable the proxy, rather than promising proxy use from Node version support alone; the raw variable value is never echoed. | ADR-0010, ADR-0011, ADR-0095, ADR-0134 | | `panel.mjs` | **The desktop panel** (ADR-0147, 2026-10-07): `createPanel({ project, env, kitRoot, kitHome })` serves `research-kit/panel/` (one page, a script, a stylesheet) and a small JSON API on **127.0.0.1 only**, a thin shell over commands that already exist. It owns no verdict: doctor, handoff, preflight and install run as children with the fixed argument lists in `PANEL_COMMANDS`, in the chosen project folder, and their output is shown with the search key redacted. **No command from the page collects** - `research.mjs` and `decompose.mjs` are absent from `PANEL_COMMANDS`; the one way pages are fetched through the panel is auto-collect (`lib/auto-collect.mjs`, ADR-0148), for a builder's validated request, on a collector, within the operator's caps. Its routes are `GET /api/requests` (the queue and the meter), `POST /api/autocollect` (the settings), `/api/requests/check` and `/api/requests/resume`. It writes two things: the search provider's key into the machine config through `transport.mjs`'s `SEARCH_KEY` (so the vendor's name stays in the adapter and the registry, NFR-3), refused when the config resolves inside a git repository (Rule 6); and which project it shows, a full path to an existing folder, never searched for. The topic and `BRIEF.md` are read, never written (ADR-0056). `builderInstructions` is the hand-off text: the collector's push, ledger included, then the builder's own steps in `$HOME` spelling (ADR-0050) - declare the role, handoff, preflight, inspect an authored complete brief and its current inputs, never collect. Its readiness checklist requires classified map rows and rewritten Findings; a file-presence observation grants no build authorization. Commit-report placeholders require actual observed results. A collected Git-request result waits for the collector to record review/closure before dependent building. Every request passes four checks: the bound address, a Host header naming it (DNS rebinding), an Origin, when sent, that is its own, and for `/api/` the per-launch token, initialized through the launch URL fragment and required in x-panel-token. The launcher prints that capability URL; terminal-log and browser-history privacy are not guaranteed by this module. POSTs must be JSON, so a plain cross-site form is refused. Update is refused when the panel runs from the installed copy, which has nothing newer to install. Command runs, the project switch and auto-collect cycles share one lock (`running`, handed to `createAutoCollect` as `lock`): none runs beside another, so no preflight or update reads the corpus or the kit tree while a cycle writes it. | ADR-0147, ADR-0148 | | `requests.mjs` | **A builder's fact request as a file** (ADR-0148, 2026-10-07): `research/requests/.json`, the `fact-request` skill made machine-readable. `requestProblems(request, { perRequestPages })` validates it as untrusted input: the fixed fields (`fact`, `blocks`, `urls`, `queries`, `prefer`, `unknown`, `maxPages`, `topic`), bounded sizes, and `urlProblem` refuses a non-http(s) URL, one with a user name or password, and one naming this machine or an internal address (`isInternal` from `http-transport.mjs`, ADR-0110). A request over the per-request cap is refused, not trimmed. `listRequests` reads the folder, skipping the collector's own `.result.json` and `.plan.json` and ignoring a name that is not an id, and a request whose file, `.plan.json` or `.result.json` is a symbolic link (a builder's link would make the collector read or overwrite a file of its own machine; `writeText` follows links). `applyRequest` and `writeResult` refuse to write through a link as well. `applyRequest` writes the contract row (a new OPEN `U-n`, or an existing non-CLOSED row; it refuses requests naming a closed unknown) and the `plan.json` entries, and the plan for that run alone, with `maxScrapes` set to the request's pages. `contractUnknownStatus` reads the status of a contract row for continuation checks. `writeResult` records the outcome beside the request. Terminal and partial results are trusted only when `recordTrustedResult` has recorded hashes of both request and result in the machine-local `research-kit.autocollect.json`; `isTrustedResult` checks those hashes so a builder cannot submit a result as if the collector wrote it. The auto-collect settings (`off`/`auto`, pages a request, pages a day, interval, the folder for new topics) and the daily meter live in `research-kit.autocollect.json` beside the machine config (`readAutoCollect`, `saveAutoCollect`, `recordSpend`, `setPaused`); no key is in them. | ADR-0148 | | `auto-collect.mjs` | **The collector listening** (ADR-0148, 2026-10-07): `createAutoCollect({ project, env, kitRoot, exec, lock })` returns `cycle`, `status`, `schedule`, `resume`, `stop`. A cycle resolves the project folder to its final path (`fs.realpathSync.native`) before comparing it with git's top level, so a Windows 8.3 short name or a symlinked folder cannot turn a path git stages into one outside the repository. `status()` uses that same canonical project path so its trust lookup matches collector records across alternate Windows path spellings. A cycle refuses unless `collectionPolicy` says the machine may collect, and skips while another run holds the shared `lock` (the panel's; its own when none is given). Otherwise it runs `git pull --ff-only`; a failed pull is surfaced and blocks the cycle before requests are read or processed. On success, it goes through each request. Results received over git are ignored unless their request and result hashes match collector-local trust records, including in `status()`; trusted terminal results retry pending commit/push delivery without collecting again, and trusted `partial` results carry continuation state and resume only while their contract unknown is not CLOSED. An invalid one gets a refused result, committed and pushed. A valid one, within the per-request and daily caps and with `research/` clean, is applied (`requests.mjs`), then its target-specific `research/requests/.plan.json` runs, followed by `preflight.mjs`, whose verdict is recorded, not acted on. It then stages `research/` and the ledger by name (`git add -f`), commits, and pushes when there is an upstream. Accounting travels on the fixed Node research child's private IPC descriptor (ADR-0149), never its human output: exactly one bounded, typed report with measured pages within the admitted allowance and the exhaustion provider. Missing, invalid, duplicate or over-allowance reporting reserves the remaining admitted allowance against the daily meter, labels actual spend unknown, records a terminal failed result and pauses. Earlier measured continuation attempts stay counted once. A valid received report survives failed exit or later interruption; interruption also pauses. Diagnostic output keeps a bounded redacted tail with a truncation notice, and failure detail is bounded separately. The legacy `spentPages`/`creditsStopped` exports remain diagnostic compatibility helpers. It never passes `--fallback`: received exhaustion accounting makes the loop commit the partial corpus and continuation state, pause until the operator resumes, then reuse the saved target, unknown and remaining page allowance (ADR-0129). A `topic` request gets `new-project.mjs` and `decompose.mjs --max-scrapes 0` in a dated folder under the folder the operator approved, never over an existing project (ADR-0056). It is refused when no folder is approved. Diagnostic details, refusal reasons and preflight verdicts are scrubbed with the existing redactor before result files or commit reports are written (2026-10-08). Status also scrubs inherited trusted result details, delivery diagnostics and pause text without rewriting those files or changing their trust hashes; the configured redactor defines which secret is recognized. Every child gets a fixed argument list; `exec` is injectable for tests. The panel labels its unchanged numeric meter as pages used or reserved. **Allowance regression coverage** (2026-10-09): the four-cycle credit exhaustion and resume fixture runs real synthetic Node scripts against persisted request, plan, result and meter files, with a finite injected Git executor. Native round-trip, partial-delivery and topic-resume fixtures retain real Git and corpus-transfer assertions. The test watchdog remains 60 seconds. **Complete Git answers** (ADR-0151): the bounded child byte buffer reports a private `truncated` flag. Repository and Git metadata paths, upstream, ahead count, commit subject and changed paths, dirty state, and pending delivery status/diff all refuse truncated output before it can authorize work; a malformed ahead count or missing quiet-diff exit also blocks delivery. A newly written partial result is trusted before fingerprinting, so refused inspection stages nothing and cannot make a restart collect it again. Exit-only operations retain bounded diagnostic logs. **Admission survives collector termination** (ADR-0152): `reserveCollection` saves the newly admitted allowance and an owned in-flight pause atomically before the fixed research child starts; a failed meter write prevents spawn. `settleCollection` refunds unused allowance once on a valid receipt, preserves earlier spend and distinct pauses, and retains the full charge for unknown/spawn-failed attempts. Whole collector termination leaves its charge/pause across restart or project switch. Continuation reserves only its remaining newly admitted pages. Day-aware settlement never refunds yesterday against today; cross-day completion charging is conservative, and an over-cap completion remains paused. The existing file writer is atomic, while the mutex remains per collector instance; racing independent instances and power-loss durability are not certified by this correction. Successful result.next says captures were delivered: the builder reads/judges, the collector records Git Findings, closures, MAP and the current authored BRIEF; another fetch is only for a missing external fact. ADR-0052 local received-package review remains available, and derived approval stays role-agnostic (2026-10-08, ADR-0150). | ADR-0148, ADR-0149, ADR-0150, ADR-0151, ADR-0152 | @@ -232,7 +232,7 @@ is an open decision (2026-09-30): | `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, an `EVIDENCE.md` that is absent or holds no evidence table (`handoff-evidence-missing`, `handoff-evidence-unparsed`; the report carries `rows`, and the CLI and doctor print the count - 2026-10-02: with no rows there were no citations, so "every cited capture on disk" held vacuously and handoff said OK over a corpus whose table a sync tool had dropped), every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). The report carries `nothingCollected` (no ledger entry, no evidence row, no capture) since 2026-09-27, so a caller can tell a project never collected from a corpus that failed to travel. The finding names are unchanged, so the doctor and the artifact validator read them as before. The report also carries `brief: { state, todo }` (`briefReview`, 2026-09-27, ADR-0061): the CLI's "handoff OK" is followed by a note when the brief is a draft (naming the judged sections still TODO) or has not been drafted. It is never a finding, and the exit stays 0. **The line-ending remedy is judged by running it** (2026-09-27, ADR-0062): per affected capture it prints `git rm --cached` (the index entry only) then `git checkout HEAD --`, which rewrites the file as LF through the pin, and it prints the pin (`PIN_LINES`, to add in an editor) only when `.gitattributes` lacks it. Every printed command is a plain git command with no comment, so cmd and PowerShell run it as written (ADR-0070). `--renormalize` had fixed only the index, so the capture handoff reads stayed CRLF. A **third cause** (2026-09-28): a ledger present and empty while captures are on disk lost its entries where the captures are - a one-line ledger whose torn tail `doctor --fix-arity` dropped - so `ledgerLost` gets `ledgerLostRemedy()`: restore it from git, or re-collect with `--force`. It was given the push remedy, which on the collector pushes the same empty ledger. A ledger that is merely **short** is the same cause: a cited capture on disk that no entry records is `handoff-capture-unledgered` (2026-09-28) and gets the same remedy - handoff had said OK and exited 0 while preflight failed it as `fetch-entry-exists`. **`CORPUS_ADD`** (2026-09-28) is how the kit prints putting a corpus into git - `git add research/`, then `git add -f research/raw/.fetches.jsonl` - shared by the push remedy and the commit gate's fix: both said `git add -f` on the whole folder, which also committed the machine-local `.usage.jsonl`, `.failures.jsonl`, `.diagnostics.jsonl` and `.fetches.lock`. A **fourth cause** (2026-10-04, break-test pass 4, F5b): a capture whose body hash fails and NOT by line endings (`body-unmodified/modified`) was changed after its fetch, and the report carries it as `altered`; it no longer counts as "did not travel", and **`alteredRemedy(files, { isRepo })`** prints, per file, `git status --porcelain --` to tell a local change from a committed one, then `git checkout HEAD --` followed by handoff again (no folder-wide checkout, which would discard uncommitted work beside it), and for the committed case `git log --oneline --` to find the commit that held the fetched bytes and restore from it, here or on the collector; without git metadata it prints no command. Judged by running it (2026-10-04, external review): a dirty status says only that this checkout changed the file after the commit - a capture committed altered and edited again is dirty too - so the remedy no longer claims the committed copy is the fetched one; and `--force` is no longer offered, because a new fetch writes a new capture beside this one, or refuses to overwrite a differing source sibling, while `verifyLedger` still checks the entry that names this file. The push remedy it used to get sends the same bytes again when the change was committed, and sends an operator to another machine when the committed copy here was one checkout away. **`safePathspec(file)`** (2026-10-04, two external reviews) decides whether a capture path is printed inside a git command at all: letters, digits, `.`, `_`, `-` and `/`, the alphabet the collector's own names come from. Anything else is named but never printed as a command, because no one spelling is read literally by cmd, PowerShell and sh alike and by git: a space unquoted is two pathspecs, `$copy` inside double quotes is expanded to nothing by PowerShell, and `[12]` is a git glob whatever the shell did, so `git checkout HEAD --` restored a neighbour and discarded its uncommitted work. For those the remedy prints commands that type no name at all: the operator lists them, one per line, in `handoff-names.txt` (`NAMES_FILE`) and runs `git --literal-pathspecs checkout HEAD --pathspec-from-file=handoff-names.txt` (the line-ending remedy adds the `git rm --cached` step the same way) - "quote it for your shell and prefix `:(literal)`" was not enough, because cmd expands `%NAME%` inside double quotes and PowerShell `$name` (third external review, 2026-10-04, verified by running in cmd); `--pathspec-from-file` hands git the bytes of the file with no shell in between, and `--literal-pathspecs` switches pattern syntax off (both git 2.25+). Such a name reaches the ledger rarely but not only by hand: `captureName` embeds the URL's host as it is, so an IPv6 literal or an adapter-supplied host outside DNS's alphabet produces one (third review). The classification runs over EVERY affected file (`classify`), not the five the text names: an unsafe sixth file got no note and "handle the remaining files the same way, each by name" - an instruction to type the glob that restores a neighbour (third external review, 2026-10-04). **`insideRepository(root)`** (2026-10-04, two external reviews) decides whether the runnable remedies are printed, and it asks git: `git -C rev-parse --is-inside-work-tree`. It was `root/.git` alone, so a nested decision project (ADR-0030), which has none of its own, was told it had "no git metadata"; then a walk up for a `.git` entry, which walked past a bare repository the project sat inside and printed a checkout git refused with "this operation must be run in a work tree". The commands are git's, so git decides whether they run; without git on PATH the walk is the fallback: it walks the PHYSICAL path (realpath first - a project reached through a junction into a repository had read as outside it, and one linked out of a repository as inside; third review) and reads `GIT_CEILING_DIRECTORIES` as git does: absolute entries only, each through realpath until an empty entry, after which the rest are taken as written. Git's pathspecs are relative to the cwd, so the printed commands run unchanged from the project folder. The tests that want "no git metadata" from a scratch fixture set `GIT_CEILING_DIRECTORIES` above it and go through `handoffRemedy`, so a detection that always says "repository" fails them. **`repositoryState(root)`** is the full answer, `{ inside, refused }`: when git answered with something other than yes, "not a git repository" or "must be run in a work tree" - `fatal: detected dubious ownership` - `refused` carries its first line and both remedies say git refused to read the repository and to fix that first, where they said "no git metadata, re-copy the corpus" (third review, 2026-10-04); no command is printed either way. | ADR-0011, ADR-0020 | | `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, and (from 2026-09-21) `AGENTS.md`, which is the file the agent protocol is *written in* — caught drifting by this check when ADR-0030 gave decision research a committed home and the rule that sends an agent there had to be where agents read their rules — each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. `bin/bundle.mjs --json` prints `verifyBundle`'s result as JSON (2026-09-28): the flag was accepted and never read, so a caller parsing it got the markdown report. `START_HERE.md` joined `EXPECTED_TO_DRIFT` on 2026-09-29: the archived copy held the author's machine paths, and the portable one is the template rendered with `$HOME`. | ADR-0028 | | `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 | -| `runtime.mjs` | The host prerequisites a command actually needs: `checkNode` (22+), `checkGit`, `checkPython` (3.11+ since 2026-09-28, ADR-0078 - one number with the READMEs and CI's pin, reading either stream because Python 2 printed its version to stderr, trying `python3` **first** and continuing past an old interpreter rather than stopping at it — `python` is an alias for 2.7 on the most common Linux and older-macOS layout, and stopping there reported "no usable Python" with a good 3.12 one name away; an interpreter whose `--version` prints no version is passed over like an old one, and a refusal quotes what it printed - it was accepted as "proceeding" until 2026-09-28, and failed later in the conformance tests; it names the interpreter it chose as `exe` (2026-09-28), and the test harness's `findPython` runs that one - the harness had its own probe, `python` first with no version check, so an old alias failed every cross-language test), **`MAX_PAGE_BYTES`, `boundedText`, `CHILD_OUTPUT_LIMIT` and `outputOverflow`** (2026-09-28, ADR-0082) bound every transport child - `boundedText` what a child downloads, the other two what it hands back: they ran under spawnSync's 1 MiB default, so a page just over it failed with ENOBUFS - and a Firecrawl scrape that large was paid for and lost - and an overflow is now one sentence, its partial output discarded; and `requireRuntime({ node, git, python })` which exits **3** — distinct from a product failure — naming the missing piece and how to install it. Deliberately per-command rather than one gate that demands everything: Python is used by the cross-language conformance runners and nothing else, and a command that only reads the corpus has no business refusing to run because a language it never calls is absent. **The Node line, judged** (2026-09-27, ADR-0046): `nodeLine` passes a supported line, warns on an odd line below 27 (never LTS, end-of-life after six months) and fails below the floor; `nodeHonoursEnvProxy` says whether fetch can use a configured proxy - 22.21+ on the 22 line, 24.0+ after, never 23 (docs/decisions/2026-09-27-node-support). **A configured proxy, for every request the kit makes** (ADR-0047): `PROXY_VARIABLES` / `proxyVariable`; `fetchEnv` for a child that fetches; and for a command that fetches in its own process, `envProxyPlan` / `honourEnvProxy` - `set` through `http.setGlobalProxyFromEnv()` on 24.14+/25.4+, `reexec` with the flag on 22.21+/24.0-24.13 (the same standard streams, stop signals forwarded, the child's exit code, and `--disable-warning=UNDICI-EHPA` - Node 22's "EnvHttpProxyAgent is experimental" on every run, and only that warning), `unsupported` with one stderr line naming the version to move to, `none` when no proxy is set or the operator chose at startup (the variable even at `0`, `--use-env-proxy` in argv or `NODE_OPTIONS`), `invalid` when a value Node reads (lowercase wins) is not an http(s) URL - `unusableProxy`; with the flag on such a value crashes every fetch inside Node, so it is left off, and the message shows `proxySpelling` without ever echoing the value. **`fallbackCost(name)`** (2026-09-30) words what falling back onto the fetch provider's search costs: free on `http-keyless` and `browser`, which have no meter, and "this spends fetch credits" for any other, because a provider the kit does not know may be metered. The line had said "spends fetch credits" on the keyless transport. **`fetchFailure(err)`** (2026-09-30) is the keyless and SerpAPI children's error text: Node's `fetch failed` plus the reason on `err.cause` (`getaddrinfo ENOTFOUND host`, `ECONNREFUSED`, `redirect count exceeded`), with any URL in the cause cut to its host so a key in a query string never rides along. Every network failure had read `fetch failed`. `fetchFailure` also reads the first inner error of an `AggregateError` cause that carries no message or code of its own (2026-09-30), as a dual-stack connection failure can. `fetchFailure` cuts every URL to its host in the failure's own message as well as in its cause (`hostOnly`, both return paths): Node throws a credential-bearing or unparseable URL with no cause and the whole URL in the message, and one signed URL in a plan was written nine times into the ledger as that entry's error (break-test 2026-10-01). **A body is read in the charset the server declared** (2026-10-03, output-reliability audit G7): `charsetOf(contentType)` reads the label, `decoderFor(contentType, bytes, who)` picks the decoder - a byte-order mark first, then the declared label through `TextDecoder`, a label it does not know falling back to UTF-8 with a sentence in `fallback` - and `boundedBody` returns `{ text, charset, fallback }`; `boundedText` is its text alone, for the JSON answers of SerpAPI, SearXNG and the Wayback Machine, which RFC 8259 makes UTF-8. Every body had been decoded as UTF-8 whatever the header said, so a `windows-1252` page's euro sign captured as U+FFFD and the hash proved a page the server never sent. A legacy label over bytes that are valid multi-byte UTF-8 - the common server misconfiguration - is read as UTF-8, because such bytes are almost never intended as Latin-1 and the always-UTF-8 reading had got those pages right by accident. The review of that fix (2026-10-03) added three readings and took one back: bytes that are not valid UTF-8 under a UTF-8 label, or none - the page that names its charset only in a `` tag - are a named fallback instead of silent U+FFFD; a UTF-16 label over a body with no NUL byte (`hasNul`), which UTF-16 text of a page cannot avoid, is not believed and the body is read as UTF-8, where it had decoded to CJK garbage end to end; an unknown label over pure ASCII is an exact reading, not a fallback; and `boundedText` reads as UTF-8 whatever the label says, because the JSON callers had always had that reading and a parse decides what it means - `boundedBytes` is the shared bounded read beneath both. `charsetOf` drops a stray quote (`charset="utf-8`). `nodeDecodesWindows1252` is the version rule behind `nodeLine`'s second `fail` (2026-10-11, ADR-0159): Node 22.13.0 through 22.22.0 read `windows-1252` as Latin-1 (nodejs/node#60888), so the declared-charset reading above silently lost 0x80-0x9F on those builds; `doctor` names the defect and the first good build, 22.22.1. | ADR-0046, ADR-0047, ADR-0159 | +| `runtime.mjs` | The host prerequisites a command actually needs: `checkNode` (22+), `checkGit`, `checkPython` (3.11+ since 2026-09-28, ADR-0078 - one number with the READMEs and CI's pin, reading either stream because Python 2 printed its version to stderr, trying `python3` **first** and continuing past an old interpreter rather than stopping at it — `python` is an alias for 2.7 on the most common Linux and older-macOS layout, and stopping there reported "no usable Python" with a good 3.12 one name away; an interpreter whose `--version` prints no version is passed over like an old one, and a refusal quotes what it printed - it was accepted as "proceeding" until 2026-09-28, and failed later in the conformance tests; it names the interpreter it chose as `exe` (2026-09-28), and the test harness's `findPython` runs that one - the harness had its own probe, `python` first with no version check, so an old alias failed every cross-language test), **`MAX_PAGE_BYTES`, `boundedText`, `CHILD_OUTPUT_LIMIT` and `outputOverflow`** (2026-09-28, ADR-0082) bound every transport child - `boundedText` what a child downloads, the other two what it hands back: they ran under spawnSync's 1 MiB default, so a page just over it failed with ENOBUFS - and a Firecrawl scrape that large was paid for and lost - and an overflow is now one sentence, its partial output discarded; and `requireRuntime({ node, git, python })` which exits **3** — distinct from a product failure — naming the missing piece and how to install it. Deliberately per-command rather than one gate that demands everything: Python is used by the cross-language conformance runners and nothing else, and a command that only reads the corpus has no business refusing to run because a language it never calls is absent. **The Node line, judged** (2026-09-27, ADR-0046): `nodeLine` passes a supported line, warns on an odd line below 27 (never LTS, end-of-life after six months) and fails below the floor; `nodeHonoursEnvProxy` says whether fetch can use a configured proxy - 22.21+ on the 22 line, 24.0+ after, never 23 (docs/decisions/2026-09-27-node-support). **A configured proxy, for every request the kit makes** (ADR-0047): `PROXY_VARIABLES` / `proxyVariable`; `fetchEnv` for a child that fetches; and for a command that fetches in its own process, `envProxyPlan` / `honourEnvProxy` - `set` through `http.setGlobalProxyFromEnv()` on 24.14+/25.4+, `reexec` with the flag on 22.21+/24.0-24.13 (the same standard streams, stop signals forwarded, the child's exit code, and `--disable-warning=UNDICI-EHPA` - Node 22's "EnvHttpProxyAgent is experimental" on every run, and only that warning), `unsupported` with one stderr line naming the version to move to, `none` when no proxy is set or the operator chose at startup (the variable even at `0`, `--use-env-proxy` in argv or `NODE_OPTIONS`), `invalid` when a value Node reads (lowercase wins) is not an http(s) URL - `unusableProxy`; with the flag on such a value crashes every fetch inside Node, so it is left off, and the message shows `proxySpelling` without ever echoing the value. **`fallbackCost(name)`** (2026-09-30) words what falling back onto the fetch provider's search costs: free on `http-keyless` and `browser`, which have no meter, and "this spends fetch credits" for any other, because a provider the kit does not know may be metered. The line had said "spends fetch credits" on the keyless transport. **`fetchFailure(err)`** (2026-09-30) is the keyless and SerpAPI children's error text: Node's `fetch failed` plus the reason on `err.cause` (`getaddrinfo ENOTFOUND host`, `ECONNREFUSED`, `redirect count exceeded`), with any URL in the cause cut to its host so a key in a query string never rides along. Every network failure had read `fetch failed`. `fetchFailure` also reads the first inner error of an `AggregateError` cause that carries no message or code of its own (2026-09-30), as a dual-stack connection failure can. `fetchFailure` cuts every URL to its host in the failure's own message as well as in its cause (`hostOnly`, both return paths): Node throws a credential-bearing or unparseable URL with no cause and the whole URL in the message, and one signed URL in a plan was written nine times into the ledger as that entry's error (break-test 2026-10-01). **A body is read in the charset the server declared** (2026-10-03, output-reliability audit G7): `charsetOf(contentType)` reads the label, `decoderFor(contentType, bytes, who)` picks the decoder - a byte-order mark first, then the declared label through `TextDecoder`, a label it does not know falling back to UTF-8 with a sentence in `fallback` - and `boundedBody` returns `{ text, charset, fallback }`; `boundedText` is its text alone, for the JSON answers of SerpAPI, SearXNG and the Wayback Machine, which RFC 8259 makes UTF-8. Every body had been decoded as UTF-8 whatever the header said, so a `windows-1252` page's euro sign captured as U+FFFD and the hash proved a page the server never sent. A legacy label over bytes that are valid multi-byte UTF-8 - the common server misconfiguration - is read as UTF-8, because such bytes are almost never intended as Latin-1 and the always-UTF-8 reading had got those pages right by accident. The review of that fix (2026-10-03) added three readings and took one back: bytes that are not valid UTF-8 under a UTF-8 label, or none - the page that names its charset only in a `` tag - are a named fallback instead of silent U+FFFD; a UTF-16 label over a body with no NUL byte (`hasNul`), which UTF-16 text of a page cannot avoid, is not believed and the body is read as UTF-8, where it had decoded to CJK garbage end to end; an unknown label over pure ASCII is an exact reading, not a fallback; and `boundedText` reads as UTF-8 whatever the label says, because the JSON callers had always had that reading and a parse decides what it means - `boundedBytes` is the shared bounded read beneath both. `charsetOf` drops a stray quote (`charset="utf-8`). `nodeDecodesWindows1252` is the version rule behind `nodeLine`'s second `fail` (2026-10-11, ADR-0159): Node 22.13.0 through 22.22.0 read `windows-1252` as Latin-1 (nodejs/node#60888), so the declared-charset reading above silently lost 0x80-0x9F on those builds; `doctor` names the defect and the first good build, 22.22.1. `envProxyChoice` distinguishes an unspecified startup choice from enabled/disabled, with the final explicit flag taking precedence over the variable and command-line flags over `NODE_OPTIONS` (#381, 2026-10-12). `proxyChosen` uses that reader to retain the existing no-override rule; only an exact variable value of `1` enables the proxy. | ADR-0046, ADR-0047, ADR-0159 | | `scaffold.mjs` | Project shape: `LAYOUT` (12 entries), the **four** `GATE_MARKERS` (pinned by ADR-0001 and its test), template rendering, placeholder validation, hook executability. `.gitattributes` is a LAYOUT entry but **not** a gate marker: a project without it still gates, it just breaks on a Windows builder (ADR-0020). `UNTITLED_TOPIC` names the placeholder topic, so `decompose` can tell a project without a topic from one that has one (ADR-0056). A folder created while scaffolding a brand-new project is not counted as `repaired` (2026-09-27): only a folder missing from a project that already existed is a repair. **`MERGED_FILES` (`.gitignore`, `.gitattributes`) are merged into an operator's existing copy** (2026-09-27, ADR-0064): `missingKitLines` finds the kit's rule lines it lacks (whitespace collapsed) and they are appended once under a marked comment, so `.env` stays out and the ledger stays in. `validateProject` warns `kit-rules-missing` and `agents-md-foreign` (an AGENTS.md without the kit's heading, kept but named). A created folder counts as `repaired` only when `research/` already existed (2026-09-27): the operator's own `.gitignore` had made a fresh folder read as a project. | ADR-0001, ADR-0020 | | `installer.mjs` | Installs the git hook (records/restores prior `hooksPath`) and the edit-time gate (backs up, repairs known corruption, refuses unfamiliar states). The hook was renamed (A10.4): the installer recognises a registration left at a retired name, replaces it rather than adding a second, and reports the repair — `retiredRepairNote`. **And whether the deployment is still the kit you have** (`deployedDrift`, `driftNote`). `deploy` is a full mirror, so drift is exact rather than heuristic: every source file should be present at the destination with the same bytes, and nothing else should be there. Doctor measures the drift against the home the install state recorded (the one the hooks point at; it had measured `KIT_HOME` and named the recorded one), and run from the deployed kit itself - `node ~/.agents/research-kit/bin/doctor.mjs`, the copy an agent runs - it says so as `info`, with the command that does measure, instead of `pass ... matches this tree`: a tree compared with itself always matches, and a line appended to the deployed `lib/core.mjs` had passed that way (break-test pass 3, 2026-10-02). This exists because doctor's `deploy` check read the install STATE and stopped - "kit deployed ", pass, READY - and recorded is not current. Measured on this machine 2026-09-22, that passed over a deployment **missing 119 files and carrying 38 stale ones**, including a skill describing a protocol without the step shipped that morning. An agent invoking the skill runs the DEPLOYED copy, so every fix since the last install existed only in the repository while the machine reported ready. The skill is reported separately from the kit because it drifts independently and is the file that tells an agent what the protocol IS. **Reported, never repaired**: `install.mjs` acts, and a health check that quietly rewrote what it measured would erase the evidence of the problem it found - pinned by a test that asserts the missing file is still missing afterwards. `removeEditGate` drops the `PreToolUse` list and `hooks` object its entry leaves empty, and removes a settings file with nothing left (2026-09-27): `--uninstall` on a machine with no settings file left `{"hooks": {"PreToolUse": []}}` behind. `removeCommitGate` restores the recorded `hooksPath` only while the current value is still the kit's own folder (2026-09-27, ADR-0063), and otherwise leaves it and names it: an uninstall had unset a machine's `/opt/myhooks`, which the kit never set. `installEditGate` refuses, as the commit gate already did, when `hooks/edit-gate.mjs` is not deployed (2026-09-27): it had registered a command naming a missing file, so every Edit would run a hook that crashed. `deployedDrift` asks `isDirectory`, not `exists`, of the kit home and each skill location, and `listTree` guards its `readdirSync`/`statSync` (break-test, 2026-09-29): `RESEARCH_KIT_HOME` naming a regular file sent `readdirSync` at it, and `doctor` died with a raw ENOTDIR stack; a file there now reads as not deployed. `listTree` walks each real folder once (2026-09-29): a folder link back to an ancestor re-listed the tree on every lap until ELOOP, about 40 phantom "extra" files per real one. `deploy({ dryRun })` lists what the deploy would actually prune - retired files that exist, plus the mirror's extras (2026-09-29, Arena break test): it listed every retired name whether or not it was there, so a machine never installed read "would prune" three files, and it never named a stale file the mirror would remove. `readSettings` also refuses a `hooks.PreToolUse` node of a type the walkers do not handle (`knownHookShape`, 2026-09-30): `[null]`, a number, a string, or an entry whose `hooks` is not a list of objects reads as `unfamiliar`, and the file is not rewritten. It had reached the walkers and thrown a raw `TypeError` out of `install-hooks.mjs`. An entry with no `hooks` key is still the known shape. `knownHookShape` also requires `hooks` itself, each entry and each hook to be plain objects (2026-09-30): `"hooks": "none"`, `null`, a list or a number had passed as the known shape, and the install spread the string into the file. **Project binding is checked before deployment writes** (2026-10-08, ADR-0006): `deploy({ into })` refuses a missing path, a regular file, or a directory with none of `.git`, `AGENTS.md`, or `research/`; `.git` may be a file or directory so worktrees remain eligible, `AGENTS.md` must be a file, and `research/` must be a directory. The same refusal precedes the mirror, personal/project skill copies and install-state update on the real run and the preview; `install.mjs --into` reports it with exit 2. The guard had been absent, so a typo could deploy the home copy, prune its old files and bind skills into an unrelated folder. Config readers retain their separate last-good snapshot policy. `deploy` refuses, touching nothing, a `RESEARCH_KIT_HOME` that holds files and is neither a kit (`lib/core.mjs`, `bin/gate.mjs` and `bin/preflight.mjs`, all three: one alone took a project with a `lib/core.mjs` for a kit) nor the recorded deployment, `--dry-run` included; `install` exits 2 (ADR-0111, 2026-10-01). The mirror had deleted a folder's own files and reported them as retired. The refusal reads only the first few files it names (2026-10-01): counting all of them took 1.6 s for /usr. `installCommitGate` records the hooks folder it replaced as `research-kit.previousHooksPath` in git's global config, never the kit's own, and `removeCommitGate` clears it (ADR-0113). **A dry run answers what the real run answers** (2026-10-01, arena #196): `installCommitGate` checks that `githooks/pre-commit` is deployed before it previews, and `installEditGate`'s check no longer reads `!dryRun && !exists(hook)` - on a machine with nothing deployed `--dry-run` promised both gates and exited 0 while the real run refused both and exited 1. Both previews carry `ok: true`, one result shape for every caller, and `bin/install-hooks.mjs` tests `ok` before `dryRun`: the first cut reordered the CLI without that key and a healthy deployed machine printed `commit gate: undefined`, which a CLI test over a deployed kit now pins. **A repository-local `core.hooksPath` is reported at install time** (2026-10-03, ADR-0131): `installCommitGate` takes `cwd`, probes it with `localHooksPathOverride`, and returns `displaced: { cwd, local }` on the real run and the dry run alike; `bin/install-hooks.mjs` says it on stderr with the remedy and exits 0, and the install goes through - the gate is machine state. The installer had read and written only the global path and said "installed" from inside a husky repository where the gate would never run (outside review, 2026-10-02, three rounds). **A dry run writes nothing on any path** (2026-10-03, outside audit): `removeCommitGate`, `removeEditGate` and `uninstall` take `dryRun` and answer before any write, and `bin/install-hooks.mjs` reads `--dry-run` FIRST - it was read after the role, the posture and the uninstall had run, so `--dry-run --role builder --fail-closed` saved both and `--dry-run --uninstall` unset the installed commit gate. Both tree walks skip `__pycache__` (2026-10-04, break-test pass 4 F1): CPython's cache from the conformance runners was deployed with the kit and then counted as drift on every machine that had run the suite. **The skill set** (2026-10-06, ADR-0146): `SKILL_SET_DIR` (`skills/`) and `shippedSkills(from)` - every directory there holding a `SKILL.md`, sorted; `deploy` mirrors each to `/` in every `skillRoots()` root and, with `into`, beside the bound research-first skill (`boundSet`); `deployedDrift` measures each against every root where research-first is deployed, so a root installed before the set existed is drift, reported, never repaired. **A same-named skill that is not the kit's is left as it is** (2026-10-06, review): a set skill is written only where its folder is absent or its SKILL.md is, byte for byte, the one the kit last deployed there (`skillSetHashes` in the install state, kept across deploys) or the one it would deploy now - the content decides, never a recorded path, which outlives the kit's copy; any other is returned as `skillConflicts` (the dry run included), printed by `install.mjs`, and skipped by drift. The first cut overwrote a user's own `brainstorming/SKILL.md` and left its other files mixed with the kit's. A set skill the kit owns is **mirrored** (`copyTree` with `mirror`) and its extras count as drift (`orphaned`), unlike research-first's (PR review, 2026-10-06): the deploy had copied over the folder, so a reference a later kit dropped stayed and could still steer the skill, and doctor called it current. **`handOnState`** lives here since 2026-10-04 (ADR-0141; doctor re-exports it): what the hooks hand on to - none, missing, kit, other - and `installCommitGate` records no folder it classifies as `kit` (`handsOnTo`), on a first install or a re-install from the state file. doctor's one-line remedy for that hand-on (ADR-0134) had lasted one session on the operator's machine: the installer kept the old kit's `githooks/` in the install state and the next `install-hooks` run wrote it back into git config, and the old gate blocked commits again on its outdated checks. The state file and git config agree once an install has run; a `missing` folder stays recorded, for the uninstall restore. **Read-only deployed files remain replaceable** (2026-10-08, break-test pass 7 correction): `copyTree` restores write permission on an existing regular-file destination before copying, independently of the incoming source's mode, and explicitly applies the source's ordinary permission bits with owner-write after copying. This covers repeated read-only-source deployment and replacement of earlier read-only output from a normal writable source. Windows chmod changes write permission only; the copied file's read-only attribute is cleared, while POSIX ordinary execute bits are preserved and `githooks` keeps its final `HOOK_MODE` (E-04/E-05 in `docs/decisions/2026-10-07-break-test-external-facts`). Source-mode propagation was observed in the original Linux probe rather than promised by the copy API; directory, ownership and ACL failures remain separate limits. | ADR-0012, ADR-0131, ADR-0141 | | `render.mjs` | Terminal tables and Markdown findings, with no policy or I/O. `renderTable` sizes each column with a **fold, not a spread** (2026-09-28, Arena break test 12): `Math.max(header, ...rows.map(...))` passes one ARGUMENT per row, and V8 exhausts the stack somewhere past 100,000 of them - measured, 50,000 rows rendered and 200,000 threw `RangeError: Maximum call stack size exceeded`, which is a raw stack trace where a report belongs. Nothing in the kit produces a report that large today; a renderer that dies on a large input is still a renderer with an undocumented ceiling. **`renderGateWarnings(verdict)`** presents an existing evaluation in Markdown for the brief, audits and package reading guides (ADR-0143). It preserves every warning reason, remedy and supplied row/unknown target, labels whole-corpus evaluation time, and distinguishes unavailable observations from an evaluated zero. Presentation only: it performs no I/O or policy judgment. | ADR-0143 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/doctor.mjs b/research-kit/lib/doctor.mjs index 80353be0..fc56ffde 100644 --- a/research-kit/lib/doctor.mjs +++ b/research-kit/lib/doctor.mjs @@ -19,7 +19,7 @@ import { recordOverride } from './provenance.mjs'; import { probeFirecrawl, selectTransport } from './transport.mjs'; import { loadConfig, configPath } from './machine.mjs'; import { cliInstallSpec, cliCompatibility } from './firecrawl.mjs'; -import { nodeLine, nodeHonoursEnvProxy, proxyVariable, unusableProxy, proxySpelling } from './runtime.mjs'; +import { nodeLine, nodeHonoursEnvProxy, proxyVariable, unusableProxy, proxySpelling, envProxyChoice } from './runtime.mjs'; import { verifyBundle, bundleSummary } from './bundle.mjs'; import { posture, machineRole, collectionPolicy, readMachineConfig, retiredEnvNotes, @@ -41,7 +41,7 @@ function samePath(a, b) { // ---------------------------------------------------------------- machine -export function machineHealth({ env = process.env, gitPaths = {}, probe = probeFirecrawl, nodeVersion = process.versions.node } = {}) { +export function machineHealth({ env = process.env, gitPaths = {}, probe = probeFirecrawl, nodeVersion = process.versions.node, execArgv = process.execArgv } = {}) { const out = []; const read = readMachineConfig(env); const post = posture(env); @@ -93,6 +93,10 @@ export function machineHealth({ env = process.env, gitPaths = {}, probe = probeF out.push(f('warn', 'proxy', `${unusable} is set to a value Node cannot use as a proxy (it takes an http:// or https:// URL) - the kit's requests go direct`, `write it as ${proxySpelling(env[unusable])}`)); + } else if (proxy && envProxyChoice({ env, execArgv }) === false) { + out.push(f('warn', 'proxy', + 'NODE_USE_ENV_PROXY is set to a value other than 1, or --no-use-env-proxy was selected; the kit leaves the proxy off and requests go direct', + 'unset NODE_USE_ENV_PROXY or set it to 1, and remove --no-use-env-proxy from Node startup options')); } else if (proxy) { out.push(nodeHonoursEnvProxy(nodeVersion) ? f('pass', 'proxy', `${proxy} is set, and node ${nodeVersion} sends the kit's requests through it`) diff --git a/research-kit/lib/runtime.mjs b/research-kit/lib/runtime.mjs index a37b18b9..85d6639e 100644 --- a/research-kit/lib/runtime.mjs +++ b/research-kit/lib/runtime.mjs @@ -290,10 +290,16 @@ export function fetchEnv(env = process.env) { // on the command line or in NODE_OPTIONS. The flag wins over the variable (E-02). Either // way the choice is theirs. Our own restart sets the variable, so it cannot loop. function proxyChosen(env, execArgv) { - const flag = /(^|\s)--(no-)?use-env-proxy(\s|=|$)/; - return env.NODE_USE_ENV_PROXY !== undefined - || execArgv.some((arg) => flag.test(arg)) - || flag.test(env.NODE_OPTIONS ?? ''); + return envProxyChoice({ env, execArgv }) !== null; +} + +/** The operator's startup choice: true/false when set, null when unspecified. */ +export function envProxyChoice({ env = process.env, execArgv = process.execArgv } = {}) { + const flag = /(^|\s)--(no-)?use-env-proxy(?=\s|=|$)/g; + // NODE_OPTIONS is read first; command-line flags override it. The final flag wins. + const selected = [...`${env.NODE_OPTIONS ?? ''} ${execArgv.join(' ')}`.matchAll(flag)].at(-1); + if (selected) return !selected[2]; + return env.NODE_USE_ENV_PROXY === undefined ? null : env.NODE_USE_ENV_PROXY === '1'; } /** diff --git a/research-kit/test/doctor.test.mjs b/research-kit/test/doctor.test.mjs index e05704bc..2e0c7c27 100644 --- a/research-kit/test/doctor.test.mjs +++ b/research-kit/test/doctor.test.mjs @@ -983,13 +983,29 @@ test('registeredPath reads the path out of a command, quoted or bare', async () // 22.20.0 the fetch went around the proxy and got HTTP 403. Nothing told the operator; the // error named no proxy. -const nodeFindings = (nodeVersion, extraEnv = {}) => { +const nodeFindings = (nodeVersion, extraEnv = {}, execArgv = []) => { const { env } = machine({ config: { role: 'collector' } }); const scrubbed = { ...env }; for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) delete scrubbed[key]; - return machineHealth({ env: { ...scrubbed, ...extraEnv }, probe: READY, nodeVersion }); + return machineHealth({ env: { ...scrubbed, ...extraEnv }, probe: READY, nodeVersion, execArgv }); }; +test('doctor warns when an explicit startup choice leaves the configured proxy off', () => { + const configured = { HTTPS_PROXY: 'http://proxy.example:8080' }; + const proxyFinding = (env, execArgv = []) => find(nodeFindings('24.21.0', { ...configured, ...env }, execArgv), 'proxy'); + for (const value of ['0', 'true', 'yes', '', 'garbage']) { + const finding = proxyFinding({ NODE_USE_ENV_PROXY: value }); + assert.equal(finding.severity, 'warn'); + assert.match(finding.detail, /NODE_USE_ENV_PROXY.*requests go direct/); + assert.match(finding.fix, /unset NODE_USE_ENV_PROXY or set it to 1/); + } + assert.equal(proxyFinding({ NODE_USE_ENV_PROXY: '1' }).severity, 'pass'); + assert.equal(proxyFinding({ NODE_OPTIONS: '--use-env-proxy' }).severity, 'pass'); + assert.equal(proxyFinding({ NODE_USE_ENV_PROXY: '1', NODE_OPTIONS: '--no-use-env-proxy' }).severity, 'warn'); + assert.equal(proxyFinding({ NODE_OPTIONS: '--no-use-env-proxy' }, ['--use-env-proxy']).severity, 'pass'); + assert.equal(proxyFinding({ NODE_OPTIONS: '--use-env-proxy' }, ['--no-use-env-proxy']).severity, 'warn'); +}); + test('behind a proxy, a Node that cannot use it is named, with the version to move to', () => { const old = find(nodeFindings('22.20.0', { HTTPS_PROXY: 'http://proxy.example:8080' }), 'proxy'); assert.ok(old, 'a proxy is configured and doctor says nothing about whether Node can use it'); diff --git a/research-kit/test/proxy.test.mjs b/research-kit/test/proxy.test.mjs index 8afd8c19..bed0a67c 100644 --- a/research-kit/test/proxy.test.mjs +++ b/research-kit/test/proxy.test.mjs @@ -17,7 +17,7 @@ import net from 'node:net'; import { EventEmitter } from 'node:events'; import { test, describe, assert, tempDir, fs, path, KIT_ROOT } from './harness.mjs'; import * as serpapi from '../lib/serpapi.mjs'; -import { nodeHonoursEnvProxy, envProxyPlan, honourEnvProxy, fetchEnv, PROXY_VARIABLES } from '../lib/runtime.mjs'; +import { nodeHonoursEnvProxy, envProxyPlan, envProxyChoice, honourEnvProxy, fetchEnv, PROXY_VARIABLES } from '../lib/runtime.mjs'; describe('proxy'); @@ -271,6 +271,24 @@ test('a choice made at startup is never overruled, even a choice of 0', () => { assert.equal(planFor({ ...PROXIED, NODE_OPTIONS: '--max-old-space-size=4096 --no-use-env-proxy' }, { setGlobal: SWITCH }), 'none'); }); +test('the startup proxy choice distinguishes disabled values and respects flag precedence', () => { + const choice = (env, execArgv = []) => envProxyChoice({ env, execArgv }); + assert.equal(choice({}), null); + assert.equal(choice({ NODE_USE_ENV_PROXY: '1' }), true); + for (const value of ['0', 'true', 'yes', '', 'garbage']) { + const env = { ...PROXIED, NODE_USE_ENV_PROXY: value }; + assert.equal(choice(env), false); + assert.equal(planFor(env, { setGlobal: SWITCH }), 'none', 'an explicit value must never be overruled'); + assert.equal(fetchEnv(env), env, 'the child environment keeps the operator choice'); + assert.equal(choice(env, ['--use-env-proxy']), true, 'the flag outranks the variable'); + } + assert.equal(choice({ NODE_USE_ENV_PROXY: '1', NODE_OPTIONS: '--no-use-env-proxy' }), false); + assert.equal(choice({ NODE_OPTIONS: '--no-use-env-proxy' }, ['--use-env-proxy']), true); + assert.equal(choice({ NODE_OPTIONS: '--use-env-proxy' }, ['--no-use-env-proxy']), false); + assert.equal(choice({}, ['--use-env-proxy', '--no-use-env-proxy']), false); + assert.equal(choice({}, ['--no-use-env-proxy', '--use-env-proxy']), true); +}); + test('set: the proxy is turned on once, with the environment, and nothing restarts', async () => { const calls = []; const got = await honourEnvProxy({ From e41fcaddb1eeeb787e2b153f66c23c7d6396f469 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 10:29:01 +0300 Subject: [PATCH 07/27] fix(machine): reject non-object config snapshots what changed: Only non-array JSON objects may create or restore a last-good machine config snapshot. Added regressions for arrays, strings, numbers, booleans and null, plus preservation of an existing good backup. why: A JSON scalar or array holds no machine settings; restoring it after a parse failure silently enabled collector and fail-open defaults. Apply the existing ADR-0020/0023 fallback instead. No alternative design was set aside. what it touched: machine.mjs, machine tests, architecture map, changelog and README test inventory. No new config key, role or public interface. what you verified: verified - red-first reproduction (23 passed, two expected failures), then machine tests (25 passed, zero failures); handoff OK; root preflight PASS with its existing unstamped-brief warning; doctor READY on the collector with firecrawl-cli. The required full suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 5 +++++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/machine.mjs | 6 ++++-- research-kit/test/machine.test.mjs | 27 +++++++++++++++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..153d040f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 + +- Machine config snapshots accept only non-array JSON objects (#368). Scalar or + array configs cannot create or replace a good snapshot; unreadable configs with + unusable backups fall back to fail-closed and role unknown. + - 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..f3d995b5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -228,7 +228,7 @@ is an open decision (2026-09-30): | `panel.mjs` | **The desktop panel** (ADR-0147, 2026-10-07): `createPanel({ project, env, kitRoot, kitHome })` serves `research-kit/panel/` (one page, a script, a stylesheet) and a small JSON API on **127.0.0.1 only**, a thin shell over commands that already exist. It owns no verdict: doctor, handoff, preflight and install run as children with the fixed argument lists in `PANEL_COMMANDS`, in the chosen project folder, and their output is shown with the search key redacted. **No command from the page collects** - `research.mjs` and `decompose.mjs` are absent from `PANEL_COMMANDS`; the one way pages are fetched through the panel is auto-collect (`lib/auto-collect.mjs`, ADR-0148), for a builder's validated request, on a collector, within the operator's caps. Its routes are `GET /api/requests` (the queue and the meter), `POST /api/autocollect` (the settings), `/api/requests/check` and `/api/requests/resume`. It writes two things: the search provider's key into the machine config through `transport.mjs`'s `SEARCH_KEY` (so the vendor's name stays in the adapter and the registry, NFR-3), refused when the config resolves inside a git repository (Rule 6); and which project it shows, a full path to an existing folder, never searched for. The topic and `BRIEF.md` are read, never written (ADR-0056). `builderInstructions` is the hand-off text: the collector's push, ledger included, then the builder's own steps in `$HOME` spelling (ADR-0050) - declare the role, handoff, preflight, inspect an authored complete brief and its current inputs, never collect. Its readiness checklist requires classified map rows and rewritten Findings; a file-presence observation grants no build authorization. Commit-report placeholders require actual observed results. A collected Git-request result waits for the collector to record review/closure before dependent building. Every request passes four checks: the bound address, a Host header naming it (DNS rebinding), an Origin, when sent, that is its own, and for `/api/` the per-launch token, initialized through the launch URL fragment and required in x-panel-token. The launcher prints that capability URL; terminal-log and browser-history privacy are not guaranteed by this module. POSTs must be JSON, so a plain cross-site form is refused. Update is refused when the panel runs from the installed copy, which has nothing newer to install. Command runs, the project switch and auto-collect cycles share one lock (`running`, handed to `createAutoCollect` as `lock`): none runs beside another, so no preflight or update reads the corpus or the kit tree while a cycle writes it. | ADR-0147, ADR-0148 | | `requests.mjs` | **A builder's fact request as a file** (ADR-0148, 2026-10-07): `research/requests/.json`, the `fact-request` skill made machine-readable. `requestProblems(request, { perRequestPages })` validates it as untrusted input: the fixed fields (`fact`, `blocks`, `urls`, `queries`, `prefer`, `unknown`, `maxPages`, `topic`), bounded sizes, and `urlProblem` refuses a non-http(s) URL, one with a user name or password, and one naming this machine or an internal address (`isInternal` from `http-transport.mjs`, ADR-0110). A request over the per-request cap is refused, not trimmed. `listRequests` reads the folder, skipping the collector's own `.result.json` and `.plan.json` and ignoring a name that is not an id, and a request whose file, `.plan.json` or `.result.json` is a symbolic link (a builder's link would make the collector read or overwrite a file of its own machine; `writeText` follows links). `applyRequest` and `writeResult` refuse to write through a link as well. `applyRequest` writes the contract row (a new OPEN `U-n`, or an existing non-CLOSED row; it refuses requests naming a closed unknown) and the `plan.json` entries, and the plan for that run alone, with `maxScrapes` set to the request's pages. `contractUnknownStatus` reads the status of a contract row for continuation checks. `writeResult` records the outcome beside the request. Terminal and partial results are trusted only when `recordTrustedResult` has recorded hashes of both request and result in the machine-local `research-kit.autocollect.json`; `isTrustedResult` checks those hashes so a builder cannot submit a result as if the collector wrote it. The auto-collect settings (`off`/`auto`, pages a request, pages a day, interval, the folder for new topics) and the daily meter live in `research-kit.autocollect.json` beside the machine config (`readAutoCollect`, `saveAutoCollect`, `recordSpend`, `setPaused`); no key is in them. | ADR-0148 | | `auto-collect.mjs` | **The collector listening** (ADR-0148, 2026-10-07): `createAutoCollect({ project, env, kitRoot, exec, lock })` returns `cycle`, `status`, `schedule`, `resume`, `stop`. A cycle resolves the project folder to its final path (`fs.realpathSync.native`) before comparing it with git's top level, so a Windows 8.3 short name or a symlinked folder cannot turn a path git stages into one outside the repository. `status()` uses that same canonical project path so its trust lookup matches collector records across alternate Windows path spellings. A cycle refuses unless `collectionPolicy` says the machine may collect, and skips while another run holds the shared `lock` (the panel's; its own when none is given). Otherwise it runs `git pull --ff-only`; a failed pull is surfaced and blocks the cycle before requests are read or processed. On success, it goes through each request. Results received over git are ignored unless their request and result hashes match collector-local trust records, including in `status()`; trusted terminal results retry pending commit/push delivery without collecting again, and trusted `partial` results carry continuation state and resume only while their contract unknown is not CLOSED. An invalid one gets a refused result, committed and pushed. A valid one, within the per-request and daily caps and with `research/` clean, is applied (`requests.mjs`), then its target-specific `research/requests/.plan.json` runs, followed by `preflight.mjs`, whose verdict is recorded, not acted on. It then stages `research/` and the ledger by name (`git add -f`), commits, and pushes when there is an upstream. Accounting travels on the fixed Node research child's private IPC descriptor (ADR-0149), never its human output: exactly one bounded, typed report with measured pages within the admitted allowance and the exhaustion provider. Missing, invalid, duplicate or over-allowance reporting reserves the remaining admitted allowance against the daily meter, labels actual spend unknown, records a terminal failed result and pauses. Earlier measured continuation attempts stay counted once. A valid received report survives failed exit or later interruption; interruption also pauses. Diagnostic output keeps a bounded redacted tail with a truncation notice, and failure detail is bounded separately. The legacy `spentPages`/`creditsStopped` exports remain diagnostic compatibility helpers. It never passes `--fallback`: received exhaustion accounting makes the loop commit the partial corpus and continuation state, pause until the operator resumes, then reuse the saved target, unknown and remaining page allowance (ADR-0129). A `topic` request gets `new-project.mjs` and `decompose.mjs --max-scrapes 0` in a dated folder under the folder the operator approved, never over an existing project (ADR-0056). It is refused when no folder is approved. Diagnostic details, refusal reasons and preflight verdicts are scrubbed with the existing redactor before result files or commit reports are written (2026-10-08). Status also scrubs inherited trusted result details, delivery diagnostics and pause text without rewriting those files or changing their trust hashes; the configured redactor defines which secret is recognized. Every child gets a fixed argument list; `exec` is injectable for tests. The panel labels its unchanged numeric meter as pages used or reserved. **Allowance regression coverage** (2026-10-09): the four-cycle credit exhaustion and resume fixture runs real synthetic Node scripts against persisted request, plan, result and meter files, with a finite injected Git executor. Native round-trip, partial-delivery and topic-resume fixtures retain real Git and corpus-transfer assertions. The test watchdog remains 60 seconds. **Complete Git answers** (ADR-0151): the bounded child byte buffer reports a private `truncated` flag. Repository and Git metadata paths, upstream, ahead count, commit subject and changed paths, dirty state, and pending delivery status/diff all refuse truncated output before it can authorize work; a malformed ahead count or missing quiet-diff exit also blocks delivery. A newly written partial result is trusted before fingerprinting, so refused inspection stages nothing and cannot make a restart collect it again. Exit-only operations retain bounded diagnostic logs. **Admission survives collector termination** (ADR-0152): `reserveCollection` saves the newly admitted allowance and an owned in-flight pause atomically before the fixed research child starts; a failed meter write prevents spawn. `settleCollection` refunds unused allowance once on a valid receipt, preserves earlier spend and distinct pauses, and retains the full charge for unknown/spawn-failed attempts. Whole collector termination leaves its charge/pause across restart or project switch. Continuation reserves only its remaining newly admitted pages. Day-aware settlement never refunds yesterday against today; cross-day completion charging is conservative, and an over-cap completion remains paused. The existing file writer is atomic, while the mutex remains per collector instance; racing independent instances and power-loss durability are not certified by this correction. Successful result.next says captures were delivered: the builder reads/judges, the collector records Git Findings, closures, MAP and the current authored BRIEF; another fetch is only for a missing external fact. ADR-0052 local received-package review remains available, and derived approval stays role-agnostic (2026-10-08, ADR-0150). | ADR-0148, ADR-0149, ADR-0150, ADR-0151, ADR-0152 | -| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | +| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. Only non-array JSON objects may create or restore a last-good snapshot (#368, 2026-10-13). A parsed scalar/array leaves an existing good snapshot intact; an unusable backup falls back to fail-closed with role unknown. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | | `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, an `EVIDENCE.md` that is absent or holds no evidence table (`handoff-evidence-missing`, `handoff-evidence-unparsed`; the report carries `rows`, and the CLI and doctor print the count - 2026-10-02: with no rows there were no citations, so "every cited capture on disk" held vacuously and handoff said OK over a corpus whose table a sync tool had dropped), every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). The report carries `nothingCollected` (no ledger entry, no evidence row, no capture) since 2026-09-27, so a caller can tell a project never collected from a corpus that failed to travel. The finding names are unchanged, so the doctor and the artifact validator read them as before. The report also carries `brief: { state, todo }` (`briefReview`, 2026-09-27, ADR-0061): the CLI's "handoff OK" is followed by a note when the brief is a draft (naming the judged sections still TODO) or has not been drafted. It is never a finding, and the exit stays 0. **The line-ending remedy is judged by running it** (2026-09-27, ADR-0062): per affected capture it prints `git rm --cached` (the index entry only) then `git checkout HEAD --`, which rewrites the file as LF through the pin, and it prints the pin (`PIN_LINES`, to add in an editor) only when `.gitattributes` lacks it. Every printed command is a plain git command with no comment, so cmd and PowerShell run it as written (ADR-0070). `--renormalize` had fixed only the index, so the capture handoff reads stayed CRLF. A **third cause** (2026-09-28): a ledger present and empty while captures are on disk lost its entries where the captures are - a one-line ledger whose torn tail `doctor --fix-arity` dropped - so `ledgerLost` gets `ledgerLostRemedy()`: restore it from git, or re-collect with `--force`. It was given the push remedy, which on the collector pushes the same empty ledger. A ledger that is merely **short** is the same cause: a cited capture on disk that no entry records is `handoff-capture-unledgered` (2026-09-28) and gets the same remedy - handoff had said OK and exited 0 while preflight failed it as `fetch-entry-exists`. **`CORPUS_ADD`** (2026-09-28) is how the kit prints putting a corpus into git - `git add research/`, then `git add -f research/raw/.fetches.jsonl` - shared by the push remedy and the commit gate's fix: both said `git add -f` on the whole folder, which also committed the machine-local `.usage.jsonl`, `.failures.jsonl`, `.diagnostics.jsonl` and `.fetches.lock`. A **fourth cause** (2026-10-04, break-test pass 4, F5b): a capture whose body hash fails and NOT by line endings (`body-unmodified/modified`) was changed after its fetch, and the report carries it as `altered`; it no longer counts as "did not travel", and **`alteredRemedy(files, { isRepo })`** prints, per file, `git status --porcelain --` to tell a local change from a committed one, then `git checkout HEAD --` followed by handoff again (no folder-wide checkout, which would discard uncommitted work beside it), and for the committed case `git log --oneline --` to find the commit that held the fetched bytes and restore from it, here or on the collector; without git metadata it prints no command. Judged by running it (2026-10-04, external review): a dirty status says only that this checkout changed the file after the commit - a capture committed altered and edited again is dirty too - so the remedy no longer claims the committed copy is the fetched one; and `--force` is no longer offered, because a new fetch writes a new capture beside this one, or refuses to overwrite a differing source sibling, while `verifyLedger` still checks the entry that names this file. The push remedy it used to get sends the same bytes again when the change was committed, and sends an operator to another machine when the committed copy here was one checkout away. **`safePathspec(file)`** (2026-10-04, two external reviews) decides whether a capture path is printed inside a git command at all: letters, digits, `.`, `_`, `-` and `/`, the alphabet the collector's own names come from. Anything else is named but never printed as a command, because no one spelling is read literally by cmd, PowerShell and sh alike and by git: a space unquoted is two pathspecs, `$copy` inside double quotes is expanded to nothing by PowerShell, and `[12]` is a git glob whatever the shell did, so `git checkout HEAD --` restored a neighbour and discarded its uncommitted work. For those the remedy prints commands that type no name at all: the operator lists them, one per line, in `handoff-names.txt` (`NAMES_FILE`) and runs `git --literal-pathspecs checkout HEAD --pathspec-from-file=handoff-names.txt` (the line-ending remedy adds the `git rm --cached` step the same way) - "quote it for your shell and prefix `:(literal)`" was not enough, because cmd expands `%NAME%` inside double quotes and PowerShell `$name` (third external review, 2026-10-04, verified by running in cmd); `--pathspec-from-file` hands git the bytes of the file with no shell in between, and `--literal-pathspecs` switches pattern syntax off (both git 2.25+). Such a name reaches the ledger rarely but not only by hand: `captureName` embeds the URL's host as it is, so an IPv6 literal or an adapter-supplied host outside DNS's alphabet produces one (third review). The classification runs over EVERY affected file (`classify`), not the five the text names: an unsafe sixth file got no note and "handle the remaining files the same way, each by name" - an instruction to type the glob that restores a neighbour (third external review, 2026-10-04). **`insideRepository(root)`** (2026-10-04, two external reviews) decides whether the runnable remedies are printed, and it asks git: `git -C rev-parse --is-inside-work-tree`. It was `root/.git` alone, so a nested decision project (ADR-0030), which has none of its own, was told it had "no git metadata"; then a walk up for a `.git` entry, which walked past a bare repository the project sat inside and printed a checkout git refused with "this operation must be run in a work tree". The commands are git's, so git decides whether they run; without git on PATH the walk is the fallback: it walks the PHYSICAL path (realpath first - a project reached through a junction into a repository had read as outside it, and one linked out of a repository as inside; third review) and reads `GIT_CEILING_DIRECTORIES` as git does: absolute entries only, each through realpath until an empty entry, after which the rest are taken as written. Git's pathspecs are relative to the cwd, so the printed commands run unchanged from the project folder. The tests that want "no git metadata" from a scratch fixture set `GIT_CEILING_DIRECTORIES` above it and go through `handoffRemedy`, so a detection that always says "repository" fails them. **`repositoryState(root)`** is the full answer, `{ inside, refused }`: when git answered with something other than yes, "not a git repository" or "must be run in a work tree" - `fatal: detected dubious ownership` - `refused` carries its first line and both remedies say git refused to read the repository and to fix that first, where they said "no git metadata, re-copy the corpus" (third review, 2026-10-04); no command is printed either way. | ADR-0011, ADR-0020 | | `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, and (from 2026-09-21) `AGENTS.md`, which is the file the agent protocol is *written in* — caught drifting by this check when ADR-0030 gave decision research a committed home and the rule that sends an agent there had to be where agents read their rules — each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. `bin/bundle.mjs --json` prints `verifyBundle`'s result as JSON (2026-09-28): the flag was accepted and never read, so a caller parsing it got the markdown report. `START_HERE.md` joined `EXPECTED_TO_DRIFT` on 2026-09-29: the archived copy held the author's machine paths, and the portable one is the template rendered with `$HOME`. | ADR-0028 | | `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/machine.mjs b/research-kit/lib/machine.mjs index 59d715ac..7ebc8aef 100644 --- a/research-kit/lib/machine.mjs +++ b/research-kit/lib/machine.mjs @@ -199,11 +199,13 @@ export function readMachineConfig(env = process.env) { try { const raw = parseJson(text ?? ''); const { settings, retired, illTyped } = shape(raw && typeof raw === 'object' ? raw : {}); - try { writeText(lastGoodPath(file), text); } catch { /* snapshot is best-effort */ } + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + try { writeText(lastGoodPath(file), text); } catch { /* snapshot is best-effort */ } + } return { settings, state: 'readable', source: file, error: null, retiredKeys: retired, illTypedKeys: illTyped }; } catch (err) { const snapshot = readJson(lastGoodPath(file), null); - if (snapshot) { + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { const { settings, retired, illTyped } = shape(snapshot); return { settings, state: 'unreadable', resolvedFrom: 'snapshot', source: lastGoodPath(file), error: err.message, retiredKeys: retired, illTypedKeys: illTyped }; } diff --git a/research-kit/test/machine.test.mjs b/research-kit/test/machine.test.mjs index 251d7851..d5144f95 100644 --- a/research-kit/test/machine.test.mjs +++ b/research-kit/test/machine.test.mjs @@ -86,6 +86,33 @@ test('unreadable with nothing to hold fails CLOSED, and tightens exactly two kno assert.equal(posture(env).exitCode, 2, 'unreadable and resolved to blocking is its own code'); }); +test('a non-object snapshot cannot restore a role or a permissive posture', () => { + for (const value of [[], 'x', [{ role: 'builder' }], null, 0, false, true]) { + const { env } = envWith('{"role": "buil', { snapshot: JSON.stringify(value) }); + const read = readMachineConfig(env); + assert.equal(read.resolvedFrom, 'fallback-closed', JSON.stringify(value)); + assert.equal(read.settings.role, 'unknown'); + assert.equal(read.settings.failOpen, false); + assert.equal(collectionPolicy(env).mayCollect, false); + assert.equal(posture(env).exitCode, 2); + } +}); + +test('a parsed non-object config never creates or replaces the last-good snapshot', () => { + for (const value of [[], 'x', null, 0, false, true]) { + const contents = JSON.stringify(value); + const { env, file } = envWith(contents); + readMachineConfig(env); + assert.equal(fs.existsSync(`${file}.last-good`), false, `${contents} became a snapshot`); + const held = JSON.stringify({ role: 'builder', failOpen: false }); + fs.writeFileSync(`${file}.last-good`, held); + readMachineConfig(env); + assert.equal(fs.readFileSync(`${file}.last-good`, 'utf8'), held); + fs.writeFileSync(file, '{"role": "buil'); + assert.equal(readMachineConfig(env).settings.role, 'builder'); + } +}); + // ADR-0121 (2026-10-02, arena break-test #198): a posture key that is PRESENT and not of its // type resolved to the laxer default - `{"failOpen":"false"}` to fail-open, `"STRICT"` to // pluralist, `"HARD-BLOCK"` to ask - while `role` beside them resolves to the restrictive From 4882744b8951fe8ebe48240f2d041f84576cb2db Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 10:48:42 +0300 Subject: [PATCH 08/27] fix(collect): validate client references before dispatch what changed: MCP argument validation and collect-remote use the existing checkClientRef identifier rule before token lookup or dispatch. A bare CLI flag is refused, empty/omitted references remain accepted, and the shared checker refuses CR/LF. Added MCP and CLI integration regressions and expanded the producer's existing identifier tests. why: Malformed references could start workflow runs that were certain to fail. Reuse the existing rule without rewriting a caller's correlation token. No alternative design was set aside. what it touched: mcp.mjs, collect-remote.mjs, artifact.mjs, MCP/CLI/producer tests, architecture rows, changelog and README inventory. No new flag, config field or domain term. what you verified: verified - red-first MCP reproduction (47 passed, one expected failure), then MCP/producer tests (80 passed, zero failed, two unsupported symlink capabilities on this host); thirteen offline CLI boundary cases passed, including valid 64-character and empty references, refusal before output creation, and credential redaction. Handoff OK; root preflight PASS; doctor READY collector/firecrawl-cli. The full suite runs in the required commit gate before acceptance. what you got wrong and fixed: mistake - adding client-ref to the generic flag checker rejected an explicit empty value; where - collect-remote argument validation; impact - caught before commit or delivery, it would have changed the existing optional-reference behavior; cause - that checker treats an empty string as a missing value; fix - refuse only the bare boolean flag there and let checkClientRef judge strings; verified - thirteen passing CLI boundary cases including an explicit empty reference. --- CHANGELOG.md | 5 +++++ docs/ARCHITECTURE.md | 6 ++--- research-kit/README.md | 2 +- research-kit/bin/collect-remote.mjs | 11 ++++++++++ research-kit/lib/artifact.mjs | 2 +- research-kit/lib/mcp.mjs | 5 +++++ research-kit/test/artifact-producer.test.mjs | 2 +- research-kit/test/cli.test.mjs | 23 ++++++++++++++++++++ research-kit/test/mcp.test.mjs | 23 ++++++++++++++++++++ 9 files changed, 73 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..b544174f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 + +- MCP collect and collect-remote refuse unsafe client references before dispatch + (#375), using the existing shared identifier rule. The CLI also refuses a bare + --client-ref, and the checker rejects trailing line breaks. + - 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..8d6c52b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -179,8 +179,8 @@ 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 | -| `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 | +| `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`. Caller references use checkClientRef before token lookup, output-folder creation or dispatch (#375, 2026-10-13); CR/LF are refused by the shared checker, and diagnostics are redacted. | 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. Caller references use checkClientRef before token lookup, output-folder creation or dispatch (#375, 2026-10-13); CR/LF are refused by the shared checker, and diagnostics are redacted. | 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 | @@ -203,7 +203,7 @@ is an open decision (2026-09-30): | `bin/measure.mjs` | The judge-free citation report for the current project (ADR-0089); `--json` for machines. A report, never a gate; exit 2 outside a research project. | ADR-0089 | | `bin/export-warc.mjs` | The corpus as one WARC 1.1 `.warc.gz` for archive tools (ADR-0090); `--out` (default `research-corpus.warc.gz`), written whole with `writeBytes` after `readWarc` re-reads it; a refused write is named by `writeFailure`, exit 2 (2026-09-29). A copy, never a gate; exit 2 outside a research project. | ADR-0090 | | `bin/disclosure.mjs` | Probes a run and reports what a stranger can read. Calls `honourEnvProxy()` before its first request (ADR-0047). Exit codes ARE the finding, so it can gate something later: `0` shape only, `1` content readable, `2` subject leaked, `3` could not measure - which includes any probe that never reached GitHub, unless an exposure was measured anyway (`exitCode` in the lib). An unknown flag matching token/auth/key/secret is refused with the reason - authenticating this probe would answer a different question. | ADR-0035 | -| `bin/collect-remote.mjs` | The one command an agent or a desktop app needs: dispatch the collector on GitHub, learn which run it started, wait, download, unwrap, and validate with the same code a human would run. Calls `honourEnvProxy()` before its first request, so a proxy-only network can dispatch at all (ADR-0047). Prints the run id **before** it waits, so a caller that dies mid-wait can still find its run - and a caller that never sees that line knows the dispatch itself did not land. **No `--token` flag**, and an unknown flag matching token/auth/key/secret/pat is refused with the reason rather than the spelling. Exit codes separate the four things a caller does differently about: `0` valid (still not authorized to build), `1` invalid package, `2` run failed or incomplete, `3` could not start, `4` dispatched and still running with the run id on stdout. An `--out` it cannot write is refused (exit 3, `OUT_DIR`) before the token is read or a run dispatched (2026-09-27); `--no-wait` downloads nothing and skips the check. Each failed poll the watch survives is printed with its number (2026-10-01). `--run-id ` picks up a run already dispatched - after `--no-wait`, a timeout (exit 4) or a lost connection - waits, downloads and judges it, and refuses `--topic` beside it (2026-10-01). The remedies and exit 4 had pointed at that flag while it did not exist, so the only way back to a run was to pay for another. A failure that names no way out now says how to pick the run up. A bare `--out`, `--workflow` or `--ref` is refused as `-- needs a value` (exit 3) beside the five checked values (2026-10-11, #270): the parser reads a bare flag as `true`, so `--out` had become the folder `true`, created before the token was asked for, and `--workflow` or `--ref` would have dispatched against the literal name "true". | ADR-0033 | +| `bin/collect-remote.mjs` | The one command an agent or a desktop app needs: dispatch the collector on GitHub, learn which run it started, wait, download, unwrap, and validate with the same code a human would run. Calls `honourEnvProxy()` before its first request, so a proxy-only network can dispatch at all (ADR-0047). Prints the run id **before** it waits, so a caller that dies mid-wait can still find its run - and a caller that never sees that line knows the dispatch itself did not land. **No `--token` flag**, and an unknown flag matching token/auth/key/secret/pat is refused with the reason rather than the spelling. Exit codes separate the four things a caller does differently about: `0` valid (still not authorized to build), `1` invalid package, `2` run failed or incomplete, `3` could not start, `4` dispatched and still running with the run id on stdout. An `--out` it cannot write is refused (exit 3, `OUT_DIR`) before the token is read or a run dispatched (2026-09-27); `--no-wait` downloads nothing and skips the check. Each failed poll the watch survives is printed with its number (2026-10-01). `--run-id ` picks up a run already dispatched - after `--no-wait`, a timeout (exit 4) or a lost connection - waits, downloads and judges it, and refuses `--topic` beside it (2026-10-01). The remedies and exit 4 had pointed at that flag while it did not exist, so the only way back to a run was to pay for another. A failure that names no way out now says how to pick the run up. A bare `--out`, `--workflow` or `--ref` is refused as `-- needs a value` (exit 3) beside the five checked values (2026-10-11, #270): the parser reads a bare flag as `true`, so `--out` had become the folder `true`, created before the token was asked for, and `--workflow` or `--ref` would have dispatched against the literal name "true". Caller references use checkClientRef before token lookup, output-folder creation or dispatch (#375, 2026-10-13); CR/LF are refused by the shared checker, and diagnostics are redacted. | ADR-0033 | | `bin/researcher-release.mjs` | The release CLI: `validate`, `conform`, `fi-validate`. **A usage error returns 2 from every subcommand**, and a status maps to its own code — `PASS` 0, `FAIL`/`REOPEN` 1, `INCOMPLETE` 2, `BLOCKED` 3. `4` is the unrecognised-status fallback and never a usage error, so a caller can distinguish "did not run" from "ran and produced something unknown". `--help` prints the usage to stdout and exits 0 (2026-09-27), as do `path-authority`, `ledger-conformance`, `fi-sidecar-conformance` and `artifact`. All five had exited 2. `--report` is written through `writeText` (2026-09-28, ADR-0079), whole or not at all. `fi-validate --report` names a report path the environment refuses through `writeFailure` and exits 2 (2026-09-30); it had crashed with a stack and exit 1, its code for FAIL. `test/hardening.test.mjs` now requires every `bin/` command that imports a writer from `lib/` to call `writeFailure`, or to name the handler that makes it unnecessary. | — | | `bin/panel.mjs` | Starts the desktop panel (`lib/panel.mjs`) and opens the browser at its address (`explorer.exe` on Windows, `open`, `xdg-open`; `--no-open` prints it only). Flags: `--project `, `--port `, `--no-open`. Stops on Ctrl+C. A Windows desktop shortcut is a target the operator writes (README), not an installed launcher (ADR-0050). | ADR-0147 | | `release-validator.mjs` | Read-only R28–R32 release evidence validation: envelope/predecessor/role/visibility/promotion checks, ledger anchoring, and deterministic `PASS`/`INCOMPLETE`/`FAIL`/`REOPEN` reduction. It never rewrites records, pointers, registries, or role rosters. **Also the facade** over the four primitives below: all 21 of its original exports are re-exported unchanged, so the eighteen files importing from it were not touched. 1324 → ~1010 lines. Record ids and file lists sort by `compareText` (2026-10-01), the order the Python runner uses. `validateRelease` hands an explicit `genesisPath` and `checkpointsDir` to the ledger check, holds them to the ledger's below-the-root rule, and refuses either without a ledger (2026-10-11): until then both were dropped unread. | R28–R32 validator contract | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/bin/collect-remote.mjs b/research-kit/bin/collect-remote.mjs index 877b4bcb..a84edf15 100644 --- a/research-kit/bin/collect-remote.mjs +++ b/research-kit/bin/collect-remote.mjs @@ -26,6 +26,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { parseFlags, flagList, canonicalJson, checkFlagValues } from '../lib/core.mjs'; import { requireRuntime, honourEnvProxy } from '../lib/runtime.mjs'; +import { checkClientRef } from '../lib/artifact.mjs'; import { dispatchCollection, waitForRun, MAX_POLL_RETRIES, fetchCorpus, queriesInput, usableOutDir, tokenFromEnv, redact, DispatchError, API_VERSION, TOKEN_VARS, @@ -151,6 +152,16 @@ checkFlagValues(flags, { out: {}, workflow: {}, ref: {}, }, { exit: EXIT.CANNOT_START }); +if (flags['client-ref'] === true) { + process.stderr.write('--client-ref needs a value\n'); + process.exit(EXIT.CANNOT_START); +} +const clientReference = checkClientRef(flags['client-ref']); +if (!clientReference.ok) { + process.stderr.write(`${redact(clientReference.detail)}\n`); + process.exit(EXIT.CANNOT_START); +} + // The caller's own typo is refused before anything else is asked of them - a missing // token would otherwise hide it, and a dispatched bad URL would be searched as text. const queriesValue = queriesInput({ queries: flagList(flags.query), urls: flagList(flags.url) }); diff --git a/research-kit/lib/artifact.mjs b/research-kit/lib/artifact.mjs index b03796f8..7d329d81 100644 --- a/research-kit/lib/artifact.mjs +++ b/research-kit/lib/artifact.mjs @@ -54,7 +54,7 @@ const CLIENT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export function checkClientRef(value) { if (value === null || value === undefined || value === '') return { ok: true, value: null }; - if (typeof value !== 'string' || !CLIENT_REF_RE.test(value)) { + if (typeof value !== 'string' || /[\r\n]/.test(value) || !CLIENT_REF_RE.test(value)) { return { ok: false, // Rewriting a caller's correlation token into something "safe" is the worst of both diff --git a/research-kit/lib/mcp.mjs b/research-kit/lib/mcp.mjs index 1e5da319..56845865 100644 --- a/research-kit/lib/mcp.mjs +++ b/research-kit/lib/mcp.mjs @@ -45,6 +45,7 @@ import { dispatchCollection, getRunSummary, fetchCorpus, tokenFromEnv, redact, DispatchError, queriesInput, usableOutDir } from './dispatch.mjs'; import { KIT_VERSION } from './core.mjs'; +import { checkClientRef } from './artifact.mjs'; /** * DUAL-ERA, and the reason is that the specification is ahead of every client. @@ -418,6 +419,10 @@ export function validateArgs(tool, args) { if (prop.minimum !== undefined && value < prop.minimum) return `${key} must be at least ${prop.minimum}`; if (prop.maximum !== undefined && value > prop.maximum) return `${key} must be at most ${prop.maximum}`; } + if (tool.name === 'collect') { + const reference = checkClientRef(args.client_ref); + if (!reference.ok) return redact(reference.detail); + } return null; } diff --git a/research-kit/test/artifact-producer.test.mjs b/research-kit/test/artifact-producer.test.mjs index b576eb9e..22d2ac49 100644 --- a/research-kit/test/artifact-producer.test.mjs +++ b/research-kit/test/artifact-producer.test.mjs @@ -324,7 +324,7 @@ test('roles and media types are assigned, not guessed at read time', () => { // ---------------------------------------------------------------- client_ref and naming test('an invalid client_ref is refused, never rewritten', () => { - for (const bad of ['../escape', 'has space', '-leading-dash', 'a'.repeat(65), 'sl/ash']) { + for (const bad of ['../escape', 'has space', '-leading-dash', 'a'.repeat(65), 'sl/ash', 'job\n', 'job\r', 'job\r\n']) { const check = checkClientRef(bad); assert.equal(check.ok, false, `${JSON.stringify(bad)} should be refused`); assert.ok(/refusing rather than rewriting/.test(check.detail)); diff --git a/research-kit/test/cli.test.mjs b/research-kit/test/cli.test.mjs index 0212e2d7..00b1602c 100644 --- a/research-kit/test/cli.test.mjs +++ b/research-kit/test/cli.test.mjs @@ -21,6 +21,29 @@ import { renderTable } from '../lib/render.mjs'; describe('cli'); +test('collect-remote refuses unsafe client references before token lookup or creating out', () => { + const root = project(); + const out = path.join(root, 'not-created'); + for (const clientRef of ['job 1', 'x'.repeat(65), 'a/b', '..', 'ünï', '-lead', 'job\n', 'job\r']) { + const r = run('collect-remote.mjs', ['--repository', 'o/r', '--topic', 't', '--client-ref', clientRef, '--out', out], { root }); + assert.equal(r.status, 3); + assert.match(r.err, /client_ref.*refusing rather than rewriting/); + assert.equal(fs.existsSync(out), false); + } + const bare = run('collect-remote.mjs', ['--repository', 'o/r', '--topic', 't', '--client-ref'], { root }); + assert.equal(bare.status, 3); + assert.match(bare.err, /--client-ref needs a value/); + for (const clientRef of ['', 'job-1.a_b', 'a'.repeat(64)]) { + const r = run('collect-remote.mjs', ['--repository', 'o/r', '--topic', 't', '--client-ref', clientRef, '--no-wait'], { root }); + assert.equal(r.status, 3); + assert.match(r.err, /no GitHub token/, 'valid input must reach the existing missing-token refusal'); + } + const secret = `github_pat_${'A1b2C3d4E5'.repeat(4)}`; + const redacted = run('collect-remote.mjs', ['--repository', 'o/r', '--topic', 't', '--client-ref', `${secret}/bad`], { root }); + assert.ok(!redacted.all.includes(secret)); + assert.match(redacted.err, /redacted/); +}); + function preflightScopeEnv(evidencePolicy = 'pluralist') { const home = tempDir('rk-preflight-scope-home-'); const config = path.join(home, 'config.json'); diff --git a/research-kit/test/mcp.test.mjs b/research-kit/test/mcp.test.mjs index 5dd3f641..991f5edf 100644 --- a/research-kit/test/mcp.test.mjs +++ b/research-kit/test/mcp.test.mjs @@ -258,6 +258,29 @@ test('an unknown or mistyped argument is refused, not ignored', () => { // ---------------------------------------------------------------- collecting +test('collect refuses unsafe client references before token lookup or dispatch', async () => { + const bad = ['job 1', 'x'.repeat(65), 'a/b', '..', 'ünï', '-lead', 'job\n', 'job\r']; + for (const client_ref of bad) { + for (const env of [TOKEN_ENV, {}]) { + const d = deps({ env }); + const r = await handle(call('collect', { repository: 'o/r', topic: 'x', client_ref }), d); + assert.equal(r.error, undefined); + assert.equal(r.result.isError, true, JSON.stringify(client_ref)); + assert.match(r.result.content[0].text, /client_ref.*refusing rather than rewriting/); + assert.equal(d.seen.dispatch, undefined); + } + } + for (const client_ref of ['', 'job-1.a_b', 'a'.repeat(64)]) { + const d = deps(); + const r = await handle(call('collect', { repository: 'o/r', topic: 'x', client_ref }), d); + assert.equal(r.result.isError, undefined); + assert.equal(d.seen.dispatch.inputs.client_ref, client_ref); + } + const secret = `github_pat_${'A1b2C3d4E5'.repeat(4)}`; + const r = await handle(call('collect', { repository: 'o/r', topic: 'x', client_ref: `${secret}/bad` }), deps()); + assert.ok(!JSON.stringify(r).includes(secret), 'the refusal leaked a caller credential'); +}); + test('collect refuses non-array query and URL arguments before dispatch', async () => { const cases = [ ['queries', 42], ['queries', 'q'], ['queries', {}], ['queries', null], From 369c36111e579f8648fdf586cbbb92d5178f4231 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 11:22:15 +0300 Subject: [PATCH 09/27] fix(download): resolve output folders before returning links what changed: usableOutDir resolves its argument and returns the absolute path it checked. MCP fetch_corpus passes that path to the shared downloader. Added download and MCP regressions for relative nested folders and links to the actual saved file. why: A relative download folder previously produced a root-anchored file URI that named a different location. Use the same resolved folder for access checks, writing and linking. No alternative design was set aside. what it touched: dispatch.mjs, mcp.mjs, dispatch/MCP tests, architecture rows, changelog and test inventory. No new option or path semantics for absolute folders. what you verified: verified - red-first reproduction (79 passed, two expected failures), then dispatch/MCP regressions (81 passed, zero failed); handoff OK and preflight PASS in this checkout. The required complete suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 4 ++++ docs/ARCHITECTURE.md | 4 ++-- research-kit/README.md | 2 +- research-kit/lib/dispatch.mjs | 10 ++++++---- research-kit/lib/mcp.mjs | 4 ++-- research-kit/test/dispatch.test.mjs | 20 +++++++++++++++++++- research-kit/test/mcp.test.mjs | 17 +++++++++++++++++ 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..8e80c5e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 + +- Corpus download folders resolve to absolute paths before writes and links (#372), + so relative MCP out_dir values return a link to the file actually saved. + - 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..4ab571f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -180,9 +180,9 @@ is an open decision (2026-09-30): | `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 | -| `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 | +| `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. Output folders resolve to absolute paths before access checks; the MCP caller passes that same path to fetchCorpus so the write and resource link identify the same file (#372, 2026-10-13). | 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 | +| `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). Output folders resolve to absolute paths before access checks; the MCP caller passes that same path to fetchCorpus so the write and resource link identify the same file (#372, 2026-10-13). | 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. | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/dispatch.mjs b/research-kit/lib/dispatch.mjs index 9d4dd8b3..186d2795 100644 --- a/research-kit/lib/dispatch.mjs +++ b/research-kit/lib/dispatch.mjs @@ -382,22 +382,24 @@ export async function getRunSummary({ repository, runId, token, fetch: doFetch = } /** - * `usableOutDir(dir)` -> `dir`, created if absent, or a named `OUT_DIR` failure. + * `usableOutDir(dir)` -> its absolute path, created if absent, or a named `OUT_DIR` failure. * * Exported because every caller that downloads should check it before it spends * anything: `collect-remote` before it dispatches a paid run, the MCP server before it * reads the run, and `fetchCorpus` before it downloads. */ export function usableOutDir(dir) { + let absolute; try { - fs.mkdirSync(dir, { recursive: true }); - fs.accessSync(dir, fs.constants.W_OK); + absolute = path.resolve(dir); + fs.mkdirSync(absolute, { recursive: true }); + fs.accessSync(absolute, fs.constants.W_OK); } catch (err) { throw new DispatchError('OUT_DIR', `${dir} is not a folder this process can write (${err.code ?? err.message})`, { remedy: 'pass an existing writable folder, or omit it for the default', }); } - return dir; + return absolute; } /** diff --git a/research-kit/lib/mcp.mjs b/research-kit/lib/mcp.mjs index 1e5da319..77de81a7 100644 --- a/research-kit/lib/mcp.mjs +++ b/research-kit/lib/mcp.mjs @@ -337,7 +337,7 @@ async function callTool(message, deps) { } // fetch_corpus - the folder first, so an unusable one costs no GitHub call - if (args.out_dir !== undefined) usableOutDir(args.out_dir); + const outDir = args.out_dir === undefined ? undefined : usableOutDir(args.out_dir); const state = await deps.summary({ repository: args.repository, runId: args.workflow_run_id, token }); if (state.status !== 'completed') { return ok(message.id, { @@ -353,7 +353,7 @@ async function callTool(message, deps) { }); } - const got = await deps.corpus({ repository: args.repository, runId: args.workflow_run_id, token, outDir: args.out_dir }); + const got = await deps.corpus({ repository: args.repository, runId: args.workflow_run_id, token, outDir }); const { validation, file } = got; return ok(message.id, { // The whole point of the format, restated where an agent cannot miss it. diff --git a/research-kit/test/dispatch.test.mjs b/research-kit/test/dispatch.test.mjs index 1c4aa50d..3869e7a0 100644 --- a/research-kit/test/dispatch.test.mjs +++ b/research-kit/test/dispatch.test.mjs @@ -8,13 +8,31 @@ import { test, describe, assert, tempDir, cleanup, fs, path } from './harness.mjs'; import { dispatchCollection, waitForRun, getRun, listArtifacts, downloadArtifact, unwrapArtifact, queriesInput, - tokenFromEnv, redact, DispatchError, API_VERSION, TOKEN_VARS, fetchCorpus, + tokenFromEnv, redact, DispatchError, API_VERSION, TOKEN_VARS, fetchCorpus, usableOutDir, } from '../lib/dispatch.mjs'; import { rawZip } from './artifact-fixtures.mjs'; import { openZip as importedOpenZip } from '../lib/artifact-zip.mjs'; describe('dispatch'); +test('relative output folders resolve once and downloads report the actual absolute file', async () => { + const root = tempDir('rk-relative-out-'); + try { + const relative = path.relative(process.cwd(), path.join(root, 'nested', 'out')); + const absolute = path.resolve(relative); + assert.equal(usableOutDir(relative), absolute); + const bytes = Buffer.from('not a zip'); + const doFetch = stubFetch([ + jsonResponse(200, { artifacts: [{ id: 1, name: 'research-kit-corpus-v1-job-1', expired: false }] }), + { ok: true, status: 200, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length) }, + ]); + const r = await fetchCorpus({ repository: REPO, runId: 1, token: TOKEN, outDir: relative, fetch: doFetch }); + assert.equal(r.file, path.join(absolute, 'research-kit-corpus-v1-job-1.zip')); + assert.equal(path.isAbsolute(r.file), true); + assert.deepEqual(fs.readFileSync(r.file), bytes); + } finally { cleanup(root); } +}); + const TOKEN = `github_pat_${'A1b2C3d4E5'.repeat(4)}`; const REPO = 'example-org/example-project'; diff --git a/research-kit/test/mcp.test.mjs b/research-kit/test/mcp.test.mjs index 5dd3f641..a2709f62 100644 --- a/research-kit/test/mcp.test.mjs +++ b/research-kit/test/mcp.test.mjs @@ -383,6 +383,23 @@ test('a Windows path becomes a usable file URI', () => { 'backslashes are not path separators in a URI, and a client given one cannot open the file'); }); +test('a relative output folder gives a link to the actual absolute download path', async () => { + const root = tempDir('rk-mcp-relative-out-'); + try { + const out_dir = path.relative(process.cwd(), path.join(root, 'nested', 'out')); + const expected = path.join(path.resolve(out_dir), 'corpus.zip'); + const d = deps({ corpus: async ({ outDir }) => { + fs.writeFileSync(path.join(outDir, 'corpus.zip'), 'download'); + return { file: path.join(outDir, 'corpus.zip'), validation: { status: 'PASS', buildAuthorized: false, state: 'REVIEW_REQUIRED', errors: [] } }; + } }); + const r = await handle(call('fetch_corpus', { repository: 'o/r', workflow_run_id: 42, out_dir }), d); + assert.equal(r.result.isError, false); + assert.equal(r.result.content.find((c) => c.type === 'resource_link').uri, + resourceLink(expected, { name: 'corpus.zip', description: 'd' }).uri); + assert.equal(fs.readFileSync(expected, 'utf8'), 'download'); + } finally { cleanup(root); } +}); + test('buildAuthorized reaches the caller in structured form and in words', async () => { const r = await handle(call('fetch_corpus', { repository: 'o/r', workflow_run_id: 42 }), deps()); assert.equal(r.result.structuredContent.buildAuthorized, false); From 319c3ad8f34cadd2cd6253e39265d0d53bda64ef Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 11:48:33 +0300 Subject: [PATCH 10/27] fix(mcp): report invalid downloads as validation failures what changed: A non-PASS corpus download returns redacted validator codes, messages and remedies in its text result, with no resource link or review instructions. Build authorization remains false. Expanded existing invalid-package coverage over FAIL, BLOCKED and INCOMPLETE, including unreadable and contradictory inputs. why: Corrupt downloads were described as collected corpora awaiting review, while their actual remedies were absent from the text. Keep the existing approved/collected texts for PASS only. No alternative design was set aside. what it touched: mcp.mjs, existing MCP tests, architecture row and changelog. No new status, tool or field; inventory unchanged. what you verified: verified - red-first MCP reproduction (46 passed, one expected failure), then 47 passing MCP tests; handoff OK and preflight PASS in this checkout. The required full suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 5 +++++ docs/ARCHITECTURE.md | 2 +- research-kit/lib/mcp.mjs | 31 ++++++++++++++++++++++--------- research-kit/test/mcp.test.mjs | 30 +++++++++++++++++++++++------- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..ccc61510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 + +- MCP fetch_corpus renders non-PASS validation findings and remedies in its text + result and omits the resource link instead of describing corrupt or unsupported + downloads as a collected corpus awaiting review (#371). + - 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..14d40750 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -180,7 +180,7 @@ is an open decision (2026-09-30): | `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 | -| `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 | +| `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. Non-PASS corpus downloads return only a redacted diagnostic with validator codes and remedies, never a corpus link or review instructions; buildAuthorized stays false (#371, 2026-10-13). | ADR-0034, ADR-0049, ADR-0143 | | `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 | diff --git a/research-kit/lib/mcp.mjs b/research-kit/lib/mcp.mjs index 1e5da319..28d262ea 100644 --- a/research-kit/lib/mcp.mjs +++ b/research-kit/lib/mcp.mjs @@ -355,17 +355,30 @@ async function callTool(message, deps) { const got = await deps.corpus({ repository: args.repository, runId: args.workflow_run_id, token, outDir: args.out_dir }); const { validation, file } = got; + const structuredContent = { + status: validation.status, + buildAuthorized: validation.status === 'PASS' && validation.buildAuthorized, + state: validation.state, + reviewedBy: validation.reviewedBy, + packageId: validation.packageId, + workflowRunId: args.workflow_run_id, + errors: validation.errors, + }; + if (validation.status !== 'PASS') { + const diagnostics = (validation.errors ?? []).map((problem) => + `${problem.code}: ${problem.message}${problem.remedy ? ` - fix: ${problem.remedy}` : ''}`); + return ok(message.id, { + structuredContent, + content: [text(redact([ + `This download did not validate (${validation.status}). Do not review or build from it.`, + ...diagnostics, + ].join('\n')))], + isError: true, + }); + } return ok(message.id, { // The whole point of the format, restated where an agent cannot miss it. - structuredContent: { - status: validation.status, - buildAuthorized: validation.buildAuthorized, - state: validation.state, - reviewedBy: validation.reviewedBy, - packageId: validation.packageId, - workflowRunId: args.workflow_run_id, - errors: validation.errors, - }, + structuredContent, content: [ text(validation.buildAuthorized ? `This package is an APPROVED brief: the gate passed, and the brief declares it was reviewed by: ${validation.reviewedBy}. ` diff --git a/research-kit/test/mcp.test.mjs b/research-kit/test/mcp.test.mjs index 5dd3f641..7f3b5b3e 100644 --- a/research-kit/test/mcp.test.mjs +++ b/research-kit/test/mcp.test.mjs @@ -404,13 +404,29 @@ test('an approved corpus says so instead, and the two texts cannot both appear', }); test('an invalid package is flagged isError even though the call succeeded', async () => { - const d = deps({ corpus: async () => ({ - file: '/tmp/x.zip', - validation: { status: 'FAIL', buildAuthorized: false, state: 'REVIEW_REQUIRED', packageId: null, errors: [{ code: 'FILE-HASH-MISMATCH', message: 'x' }] }, - }) }); - const r = await handle(call('fetch_corpus', { repository: 'o/r', workflow_run_id: 42 }), d); - assert.equal(r.result.isError, true); - assert.equal(r.result.structuredContent.errors.length, 1); + for (const [status, code, state, buildAuthorized] of [ + ['FAIL', 'FILE-HASH-MISMATCH', 'REVIEW_REQUIRED', false], + ['FAIL', 'ZIP-READ', null, false], + ['BLOCKED', 'ZIP-READ', null, false], + ['INCOMPLETE', 'FORMAT-VERSION', 'APPROVED_BRIEF', true], + ]) { + const diagnostic = { code, message: 'the download could not be validated', remedy: 'download the artifact again' }; + const d = deps({ corpus: async () => ({ + file: '/tmp/x.zip', validation: { status, buildAuthorized, state, packageId: null, errors: [diagnostic] }, + }) }); + const r = await handle(call('fetch_corpus', { repository: 'o/r', workflow_run_id: 42 }), d); + assert.equal(r.result.isError, true); + assert.equal(r.result.structuredContent.buildAuthorized, false); + assert.deepEqual(r.result.structuredContent.errors, [diagnostic]); + const content = r.result.content; + assert.ok(content.every((item) => item.type === 'text'), 'an invalid download was linked as a corpus ZIP'); + const message = content.map((item) => item.text).join('\n'); + assert.ok(message.includes(status)); + assert.ok(message.includes(code)); + assert.match(message, /the download could not be validated/); + assert.match(message, /download the artifact again/); + assert.doesNotMatch(message, /COLLECTED CORPUS|APPROVED brief|review steps remain|README-FIRST/); + } }); test('a thrown transport failure never leaks the token', async () => { From 38ca79b9ed7e407127da79e8a58e24a983c912c7 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 13:34:30 +0300 Subject: [PATCH 11/27] fix(transport): refuse inherited registry names what changed: Fetch and search selection now require an own member of the existing registry. Added coverage for constructor, toString, __proto__, valueOf and hasOwnProperty through explicit arguments, environment and machine config. why: Plain object lookups accepted inherited members as adapters, allowing a run to start with no real transport. Use the existing UNKNOWN_TRANSPORT and UNKNOWN_SEARCH_PROVIDER refusals. No alternative design was set aside. what it touched: transport.mjs, transport tests, architecture row, changelog and README inventory. No new provider, setting or interface. what you verified: verified - red-first transport reproduction (120 passed, one expected failure), then 121 passing transport tests with zero failures; handoff OK and preflight PASS in this checkout. The required full suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 4 ++++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/transport.mjs | 4 ++-- research-kit/test/transport.test.mjs | 22 ++++++++++++++++++++++ 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..7ecf4df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 + +- Fetch and search registries refuse inherited object members as provider names + at every precedence level (#349), before a run or doctor can treat them as adapters. + - 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..0e44080f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,7 +185,7 @@ is an open decision (2026-09-30): | `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. -| `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 | +| `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. Registry lookups accept only own members, so inherited names such as constructor, toString and __proto__ receive the existing unknown-name refusal at every precedence level (#349, 2026-10-13). | 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 | | `witness.mjs` | The **opt-in Wayback witness** (2026-09-30): `lookup(url, { timestamp })` asks `archive.org/wayback/available` for the snapshot closest to the capture time, as a child job through the proxy-aware fetch, and returns `{ witnessed, snapshot, timestamp, status }` or `{ witnessed: false, reason }`; it never throws. `parseAvailability` treats every field as optional, because the API page is from 2013 and says it changes. Lookup only: it never requests a save. Its child reports a failed lookup through `fetchFailure` like the other children (2026-09-30), and `waybackTimestamp` takes a date string and returns empty for an invalid date instead of throwing. | ADR-0106 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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/transport.mjs b/research-kit/lib/transport.mjs index 0e7efdb8..281be8fb 100644 --- a/research-kit/lib/transport.mjs +++ b/research-kit/lib/transport.mjs @@ -194,7 +194,7 @@ export function selectSearch({ explicit = '', env = process.env, config = null, : side.name === firecrawl.ANONYMOUS_NAME ? `${side.name} cannot search without a key` : ''; if (asked) { - const adapter = SEARCH_PROVIDERS[asked]; + const adapter = Object.hasOwn(SEARCH_PROVIDERS, asked) ? SEARCH_PROVIDERS[asked] : undefined; if (!adapter) { const err = new Error(`unknown search provider "${asked}". Known search providers: ${SEARCH_PROVIDER_NAMES.join(', ')}`); err.code = 'UNKNOWN_SEARCH_PROVIDER'; @@ -279,7 +279,7 @@ export function selectSearch({ explicit = '', env = process.env, config = null, function selectFetch({ explicit = '', env = process.env, probe = probeFirecrawl, config = null } = {}) { const asked = explicit || env.RESEARCH_KIT_TRANSPORT || (config ?? loadConfig(env)).transport || ''; if (asked) { - const adapter = TRANSPORTS[asked]; + const adapter = Object.hasOwn(TRANSPORTS, asked) ? TRANSPORTS[asked] : undefined; if (!adapter) { const err = new Error(`unknown transport "${asked}". Known transports: ${TRANSPORT_NAMES.join(', ')}`); err.code = 'UNKNOWN_TRANSPORT'; diff --git a/research-kit/test/transport.test.mjs b/research-kit/test/transport.test.mjs index 697d18fb..9d7bc1ba 100644 --- a/research-kit/test/transport.test.mjs +++ b/research-kit/test/transport.test.mjs @@ -46,6 +46,28 @@ test('an unknown transport is an error naming the two that exist', () => { assert.throws(() => selectTransport({ explicit: 'curl', env: {}, config: {} }), /Known transports/); }); +test('inherited registry members are refused at every transport and search precedence level', () => { + const absent = () => ({ installed: false, authenticated: false, version: null, credits: null }); + for (const name of ['constructor', 'toString', '__proto__', 'valueOf', 'hasOwnProperty']) { + for (const choice of [ + { explicit: name, env: {}, config: {} }, + { env: { RESEARCH_KIT_TRANSPORT: name }, config: {} }, + { env: {}, config: { transport: name } }, + ]) { + assert.throws(() => selectTransport({ ...choice, probe: absent }), (err) => + err.code === 'UNKNOWN_TRANSPORT' && /Known transports/.test(err.message)); + } + for (const choice of [ + { explicit: name, env: {}, config: {} }, + { env: { RESEARCH_KIT_SEARCH_TRANSPORT: name }, config: {} }, + { env: {}, config: { searchTransport: name } }, + ]) { + assert.throws(() => selectSearch({ ...choice, probe: absent }), (err) => + err.code === 'UNKNOWN_SEARCH_PROVIDER' && /Known search providers/.test(err.message)); + } + } +}); + test('probeFirecrawl takes injected probes, so doctor\'s tests need no CLI', () => { // cache:false, because the probe is now memoised per process - two different injected // CLIs in one test are two different questions, and both have to be asked. From bda03196758f1ba3339b0565c94f111c85c24bc8 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 13:59:05 +0300 Subject: [PATCH 12/27] fix(timeline): keep multiline errors in one table row what changed: Folded whitespace in timeline details before escaping table pipes. Added CR/LF, trailing-newline and pipe regressions, and regenerated the root TIMELINE.md from its existing corpus. why: Firecrawl's trailing stderr newline split failure rows across lines, including four rows in the committed root timeline. Normalize the derived table cell, preserving the underlying evidence. No alternative design was set aside. what it touched: timeline.mjs, hardening tests, architecture row, changelog, README inventory and derived research/TIMELINE.md. The provenance ledger and raw captures were not edited; their content remains evidence. what you verified: verified - red-first reproduction (65 passed, one expected failure, two unsupported capabilities), then 66 passing hardening tests with zero failures and two unsupported symlink capabilities; timeline regeneration produced 60 events and its diff joins exactly the four broken failure rows; ledger SHA256 stayed unchanged; handoff OK and preflight PASS. The required full suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 4 ++++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/timeline.mjs | 2 +- research-kit/test/hardening.test.mjs | 17 +++++++++++++++++ research/TIMELINE.md | 12 ++++-------- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..dd45aed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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 + +- Timeline details fold multiline errors into one table row before pipe escaping + (#354). Regenerated the root timeline without changing its provenance ledger. + - 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..9000128c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -236,7 +236,7 @@ is an open decision (2026-09-30): | `scaffold.mjs` | Project shape: `LAYOUT` (12 entries), the **four** `GATE_MARKERS` (pinned by ADR-0001 and its test), template rendering, placeholder validation, hook executability. `.gitattributes` is a LAYOUT entry but **not** a gate marker: a project without it still gates, it just breaks on a Windows builder (ADR-0020). `UNTITLED_TOPIC` names the placeholder topic, so `decompose` can tell a project without a topic from one that has one (ADR-0056). A folder created while scaffolding a brand-new project is not counted as `repaired` (2026-09-27): only a folder missing from a project that already existed is a repair. **`MERGED_FILES` (`.gitignore`, `.gitattributes`) are merged into an operator's existing copy** (2026-09-27, ADR-0064): `missingKitLines` finds the kit's rule lines it lacks (whitespace collapsed) and they are appended once under a marked comment, so `.env` stays out and the ledger stays in. `validateProject` warns `kit-rules-missing` and `agents-md-foreign` (an AGENTS.md without the kit's heading, kept but named). A created folder counts as `repaired` only when `research/` already existed (2026-09-27): the operator's own `.gitignore` had made a fresh folder read as a project. | ADR-0001, ADR-0020 | | `installer.mjs` | Installs the git hook (records/restores prior `hooksPath`) and the edit-time gate (backs up, repairs known corruption, refuses unfamiliar states). The hook was renamed (A10.4): the installer recognises a registration left at a retired name, replaces it rather than adding a second, and reports the repair — `retiredRepairNote`. **And whether the deployment is still the kit you have** (`deployedDrift`, `driftNote`). `deploy` is a full mirror, so drift is exact rather than heuristic: every source file should be present at the destination with the same bytes, and nothing else should be there. Doctor measures the drift against the home the install state recorded (the one the hooks point at; it had measured `KIT_HOME` and named the recorded one), and run from the deployed kit itself - `node ~/.agents/research-kit/bin/doctor.mjs`, the copy an agent runs - it says so as `info`, with the command that does measure, instead of `pass ... matches this tree`: a tree compared with itself always matches, and a line appended to the deployed `lib/core.mjs` had passed that way (break-test pass 3, 2026-10-02). This exists because doctor's `deploy` check read the install STATE and stopped - "kit deployed ", pass, READY - and recorded is not current. Measured on this machine 2026-09-22, that passed over a deployment **missing 119 files and carrying 38 stale ones**, including a skill describing a protocol without the step shipped that morning. An agent invoking the skill runs the DEPLOYED copy, so every fix since the last install existed only in the repository while the machine reported ready. The skill is reported separately from the kit because it drifts independently and is the file that tells an agent what the protocol IS. **Reported, never repaired**: `install.mjs` acts, and a health check that quietly rewrote what it measured would erase the evidence of the problem it found - pinned by a test that asserts the missing file is still missing afterwards. `removeEditGate` drops the `PreToolUse` list and `hooks` object its entry leaves empty, and removes a settings file with nothing left (2026-09-27): `--uninstall` on a machine with no settings file left `{"hooks": {"PreToolUse": []}}` behind. `removeCommitGate` restores the recorded `hooksPath` only while the current value is still the kit's own folder (2026-09-27, ADR-0063), and otherwise leaves it and names it: an uninstall had unset a machine's `/opt/myhooks`, which the kit never set. `installEditGate` refuses, as the commit gate already did, when `hooks/edit-gate.mjs` is not deployed (2026-09-27): it had registered a command naming a missing file, so every Edit would run a hook that crashed. `deployedDrift` asks `isDirectory`, not `exists`, of the kit home and each skill location, and `listTree` guards its `readdirSync`/`statSync` (break-test, 2026-09-29): `RESEARCH_KIT_HOME` naming a regular file sent `readdirSync` at it, and `doctor` died with a raw ENOTDIR stack; a file there now reads as not deployed. `listTree` walks each real folder once (2026-09-29): a folder link back to an ancestor re-listed the tree on every lap until ELOOP, about 40 phantom "extra" files per real one. `deploy({ dryRun })` lists what the deploy would actually prune - retired files that exist, plus the mirror's extras (2026-09-29, Arena break test): it listed every retired name whether or not it was there, so a machine never installed read "would prune" three files, and it never named a stale file the mirror would remove. `readSettings` also refuses a `hooks.PreToolUse` node of a type the walkers do not handle (`knownHookShape`, 2026-09-30): `[null]`, a number, a string, or an entry whose `hooks` is not a list of objects reads as `unfamiliar`, and the file is not rewritten. It had reached the walkers and thrown a raw `TypeError` out of `install-hooks.mjs`. An entry with no `hooks` key is still the known shape. `knownHookShape` also requires `hooks` itself, each entry and each hook to be plain objects (2026-09-30): `"hooks": "none"`, `null`, a list or a number had passed as the known shape, and the install spread the string into the file. **Project binding is checked before deployment writes** (2026-10-08, ADR-0006): `deploy({ into })` refuses a missing path, a regular file, or a directory with none of `.git`, `AGENTS.md`, or `research/`; `.git` may be a file or directory so worktrees remain eligible, `AGENTS.md` must be a file, and `research/` must be a directory. The same refusal precedes the mirror, personal/project skill copies and install-state update on the real run and the preview; `install.mjs --into` reports it with exit 2. The guard had been absent, so a typo could deploy the home copy, prune its old files and bind skills into an unrelated folder. Config readers retain their separate last-good snapshot policy. `deploy` refuses, touching nothing, a `RESEARCH_KIT_HOME` that holds files and is neither a kit (`lib/core.mjs`, `bin/gate.mjs` and `bin/preflight.mjs`, all three: one alone took a project with a `lib/core.mjs` for a kit) nor the recorded deployment, `--dry-run` included; `install` exits 2 (ADR-0111, 2026-10-01). The mirror had deleted a folder's own files and reported them as retired. The refusal reads only the first few files it names (2026-10-01): counting all of them took 1.6 s for /usr. `installCommitGate` records the hooks folder it replaced as `research-kit.previousHooksPath` in git's global config, never the kit's own, and `removeCommitGate` clears it (ADR-0113). **A dry run answers what the real run answers** (2026-10-01, arena #196): `installCommitGate` checks that `githooks/pre-commit` is deployed before it previews, and `installEditGate`'s check no longer reads `!dryRun && !exists(hook)` - on a machine with nothing deployed `--dry-run` promised both gates and exited 0 while the real run refused both and exited 1. Both previews carry `ok: true`, one result shape for every caller, and `bin/install-hooks.mjs` tests `ok` before `dryRun`: the first cut reordered the CLI without that key and a healthy deployed machine printed `commit gate: undefined`, which a CLI test over a deployed kit now pins. **A repository-local `core.hooksPath` is reported at install time** (2026-10-03, ADR-0131): `installCommitGate` takes `cwd`, probes it with `localHooksPathOverride`, and returns `displaced: { cwd, local }` on the real run and the dry run alike; `bin/install-hooks.mjs` says it on stderr with the remedy and exits 0, and the install goes through - the gate is machine state. The installer had read and written only the global path and said "installed" from inside a husky repository where the gate would never run (outside review, 2026-10-02, three rounds). **A dry run writes nothing on any path** (2026-10-03, outside audit): `removeCommitGate`, `removeEditGate` and `uninstall` take `dryRun` and answer before any write, and `bin/install-hooks.mjs` reads `--dry-run` FIRST - it was read after the role, the posture and the uninstall had run, so `--dry-run --role builder --fail-closed` saved both and `--dry-run --uninstall` unset the installed commit gate. Both tree walks skip `__pycache__` (2026-10-04, break-test pass 4 F1): CPython's cache from the conformance runners was deployed with the kit and then counted as drift on every machine that had run the suite. **The skill set** (2026-10-06, ADR-0146): `SKILL_SET_DIR` (`skills/`) and `shippedSkills(from)` - every directory there holding a `SKILL.md`, sorted; `deploy` mirrors each to `/` in every `skillRoots()` root and, with `into`, beside the bound research-first skill (`boundSet`); `deployedDrift` measures each against every root where research-first is deployed, so a root installed before the set existed is drift, reported, never repaired. **A same-named skill that is not the kit's is left as it is** (2026-10-06, review): a set skill is written only where its folder is absent or its SKILL.md is, byte for byte, the one the kit last deployed there (`skillSetHashes` in the install state, kept across deploys) or the one it would deploy now - the content decides, never a recorded path, which outlives the kit's copy; any other is returned as `skillConflicts` (the dry run included), printed by `install.mjs`, and skipped by drift. The first cut overwrote a user's own `brainstorming/SKILL.md` and left its other files mixed with the kit's. A set skill the kit owns is **mirrored** (`copyTree` with `mirror`) and its extras count as drift (`orphaned`), unlike research-first's (PR review, 2026-10-06): the deploy had copied over the folder, so a reference a later kit dropped stayed and could still steer the skill, and doctor called it current. **`handOnState`** lives here since 2026-10-04 (ADR-0141; doctor re-exports it): what the hooks hand on to - none, missing, kit, other - and `installCommitGate` records no folder it classifies as `kit` (`handsOnTo`), on a first install or a re-install from the state file. doctor's one-line remedy for that hand-on (ADR-0134) had lasted one session on the operator's machine: the installer kept the old kit's `githooks/` in the install state and the next `install-hooks` run wrote it back into git config, and the old gate blocked commits again on its outdated checks. The state file and git config agree once an install has run; a `missing` folder stays recorded, for the uninstall restore. **Read-only deployed files remain replaceable** (2026-10-08, break-test pass 7 correction): `copyTree` restores write permission on an existing regular-file destination before copying, independently of the incoming source's mode, and explicitly applies the source's ordinary permission bits with owner-write after copying. This covers repeated read-only-source deployment and replacement of earlier read-only output from a normal writable source. Windows chmod changes write permission only; the copied file's read-only attribute is cleared, while POSIX ordinary execute bits are preserved and `githooks` keeps its final `HOOK_MODE` (E-04/E-05 in `docs/decisions/2026-10-07-break-test-external-facts`). Source-mode propagation was observed in the original Linux probe rather than promised by the copy API; directory, ownership and ACL failures remain separate limits. | ADR-0012, ADR-0131, ADR-0141 | | `render.mjs` | Terminal tables and Markdown findings, with no policy or I/O. `renderTable` sizes each column with a **fold, not a spread** (2026-09-28, Arena break test 12): `Math.max(header, ...rows.map(...))` passes one ARGUMENT per row, and V8 exhausts the stack somewhere past 100,000 of them - measured, 50,000 rows rendered and 200,000 threw `RangeError: Maximum call stack size exceeded`, which is a raw stack trace where a report belongs. Nothing in the kit produces a report that large today; a renderer that dies on a large input is still a renderer with an undocumented ceiling. **`renderGateWarnings(verdict)`** presents an existing evaluation in Markdown for the brief, audits and package reading guides (ADR-0143). It preserves every warning reason, remedy and supplied row/unknown target, labels whole-corpus evaluation time, and distinguishes unavailable observations from an evaluated zero. Presentation only: it performs no I/O or policy judgment. | ADR-0143 | -| `timeline.mjs` | TIMELINE.md generation and the diagnostics-log appender the gate CLI records through. Its regenerate command is spelled by `documentCommand`, with the project's recorded `kitPath` first (ADR-0084). The When column writes a day as a date and a real time as `YYYY-MM-DD HH:MM UTC` (2026-09-27): it had mixed evidence dates with the ledger's day-precision `T00:00:00.000Z` stamps. A migration row gives its recorded reason or note and a prior row its file, not `url -> raw` ("undefined -> undefined", fixed 2026-09-29). Events sort by `compareText` on their timestamp (2026-10-01). | ADR-0051 | +| `timeline.mjs` | TIMELINE.md generation and the diagnostics-log appender the gate CLI records through. Its regenerate command is spelled by `documentCommand`, with the project's recorded `kitPath` first (ADR-0084). The When column writes a day as a date and a real time as `YYYY-MM-DD HH:MM UTC` (2026-09-27): it had mixed evidence dates with the ledger's day-precision `T00:00:00.000Z` stamps. A migration row gives its recorded reason or note and a prior row its file, not `url -> raw` ("undefined -> undefined", fixed 2026-09-29). Events sort by `compareText` on their timestamp (2026-10-01). Timeline details fold whitespace before escaping table pipes, keeping multiline fetch errors in a single Markdown row; the root timeline is regenerated from its intact corpus (#354, 2026-10-13). | ADR-0051 | **Not a module, but part of the map:** `examples/release-evidence/` (added 2026-09-20) is six synthetic release packages plus `build.mjs` (regenerates them, keeping their hashes diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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/timeline.mjs b/research-kit/lib/timeline.mjs index 47ca733b..f39e682e 100644 --- a/research-kit/lib/timeline.mjs +++ b/research-kit/lib/timeline.mjs @@ -71,7 +71,7 @@ export function renderTimeline(root, { corpus = null } = {}) { '', '| When | What | Detail |', '|---|---|---|', - ...events.map((e) => `| ${when(e.at)} | ${e.kind} | ${String(e.detail).replace(/\|/g, '\\|')} |`), + ...events.map((e) => `| ${when(e.at)} | ${e.kind} | ${String(e.detail).replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|')} |`), '', ]; writeText(resolve(root, PATHS.timeline), `${lines.join('\n')}\n`); diff --git a/research-kit/test/hardening.test.mjs b/research-kit/test/hardening.test.mjs index 42506bed..9190fbef 100644 --- a/research-kit/test/hardening.test.mjs +++ b/research-kit/test/hardening.test.mjs @@ -599,6 +599,23 @@ test('the timeline writes each moment in one readable form', () => { assert.ok(whens.includes('2026-09-27 14:05 UTC'), `a real time was lost: ${whens}`); }); +test('timeline errors with CR/LF or pipes stay inside one readable table row', () => { + const dir = makePassingProject(); + const corpus = readCorpus(dir); + for (const error of ['Error: failed\n', 'Error:\r\nretry | later\r\n', 'Error:\nsecond\nthird']) { + corpus.ledger.entries.push({ at: '2026-10-13T01:02:03.000Z', op: 'fail', url: 'https://x.invalid/page', error }); + } + renderTimeline(dir, { corpus }); + const lines = readText(resolve(dir, PATHS.timeline)).split('\n'); + const rows = lines.filter((line) => line.includes('fetch failed')); + assert.equal(rows.length, 3); + assert.deepEqual(rows, [ + '| 2026-10-13 01:02 UTC | fetch failed | https://x.invalid/page - Error: failed |', + '| 2026-10-13 01:02 UTC | fetch failed | https://x.invalid/page - Error: retry \\| later |', + '| 2026-10-13 01:02 UTC | fetch failed | https://x.invalid/page - Error: second third |', + ]); +}); + // Found 2026-09-28 (an outside break-test, F-02; confirmed): writeText opened its target // with 'w', which EMPTIES it before a byte is written, so a write cut short - a full disk, a // quota, EFBIG, a killed process - left a partial new file where the old one had been. diff --git a/research/TIMELINE.md b/research/TIMELINE.md index 9a070485..01b1929e 100644 --- a/research/TIMELINE.md +++ b/research/TIMELINE.md @@ -61,12 +61,8 @@ of a gate decision. Regenerate with `node research-kit/bin/timeline.mjs`. | 2026-09-26 | scrape | https://serpapi.com/pricing -> research/raw/2026-09-26-serpapi-plans-and-pricing-serpapi-d0c89b50.md (firecrawl-cli) | | 2026-09-26 | scrape | https://serpapi.com/account-api -> research/raw/2026-09-26-account-api-serpapi-serpapi-8f9c166b.md (firecrawl-cli) | | 2026-09-26 | scrape | https://serpapi.com/faq -> research/raw/2026-09-26-serpapi-faq-serpapi-7dec5bc6.md (firecrawl-cli) | -| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/pricing - Error: Request failed with status code 405 - | -| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/account-api - Error: Request failed with status code 405 - | -| 2026-09-26 21:20 UTC | fetch failed | https://www.merriam-webster.com/dictionary/search - Error: Request failed with status code 405 - | -| 2026-09-26 21:20 UTC | fetch failed | https://plan-international.org/ - Error: Request failed with status code 405 - | +| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/pricing - Error: Request failed with status code 405 | +| 2026-09-26 21:20 UTC | fetch failed | https://serpapi.com/account-api - Error: Request failed with status code 405 | +| 2026-09-26 21:20 UTC | fetch failed | https://www.merriam-webster.com/dictionary/search - Error: Request failed with status code 405 | +| 2026-09-26 21:20 UTC | fetch failed | https://plan-international.org/ - Error: Request failed with status code 405 | From fe7f338f5643dab890ee3f49e8a7af91914c902a Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 14:41:01 +0300 Subject: [PATCH 13/27] fix(selftest): refuse every unmatched test group what changed: Validate every requested group against available test filenames before importing any test. An unmatched group is named and refused with exit 2, including beside a valid group. Added CLI regressions for both orders and for valid multi-group runs. why: The union of group matches silently dropped misspelled groups while printing all tests passed. Apply the existing no-match refusal to each requested group. No alternative design was set aside. what it touched: selftest CLI, new selftest-cli tests, architecture description, changelog and research-kit README inventory. No new flag or result format. what you verified: verified - red-first one pass and one expected refusal failure; then three CLI regressions passed with zero failures, including parent-report isolation. Handoff OK and preflight PASS in this checkout. The required full offline suite runs in the commit gate before acceptance. what you got wrong and fixed: mistake - CLI regression children initially inherited RESEARCH_KIT_RESULT_FILE from the parent suite. where - selftest-cli.test.mjs runGroups. impact - a child wrote a partial group report to the full-suite report path; the observer captured that partial sample. The initial commit remained unpublished, and no delivered PR was affected. cause - copied the parent environment without excluding its output artifact path. fix - remove that variable from child environments and add a parent-report preservation regression; amend the unpublished commit through the full gate. verified - the new regression first failed with the parent file overwritten, then all three CLI tests passed in 13.3 seconds with the parent report unchanged. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/bin/selftest.mjs | 10 +++++-- research-kit/test/selftest-cli.test.mjs | 39 +++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 research-kit/test/selftest-cli.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..8af461a6 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 + +- selftest refuses each unmatched group before starting tests, including when another requested group matches (#332). - 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..52cab6b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -286,7 +286,7 @@ maps to exit code — `PASS` 0, `FAIL`/`REOPEN` 1, `INCOMPLETE` 2, `BLOCKED` 3, and a **usage error exits 2 from every subcommand**, printing the usage block to stderr; `test/support-policy.test.mjs` pins that by running the CLI. It writes exactly one thing, only when asked: `fi-validate --report `), -`selftest.mjs` (the whole suite, queued by the harness — which **awaits** every test, +`selftest.mjs` (every requested group must match a test file before any test starts; an unmatched group is named and refused with exit 2, including beside a valid group; #332. CLI regressions isolate child report paths from the parent suite; the whole suite, queued by the harness — which **awaits** every test, so `ok` means the assertions settled; ADR-0021. It runs from the repository root whatever the caller's cwd, resolving a relative `RESEARCH_KIT_RESULT_FILE` against the caller's folder first; started anywhere else, it crashed at import before any test ran - ADR-0071); `RESEARCH_KIT_ALLOW_UNSUP=1` lets a LOCAL run whose only blockers are UNSUP tests exit 0, ending "NOT a full pass" and writing `unsupportedWaived` into the result file, and is ignored when `CI` is set (2026-09-30, ADR-0108); a block whose only blockers are unsupported tests ends "No test failed ... this is not a red suite", naming the opt-in, where it said "A red suite stops work" - the protocol's stop-the-line sentence, which stopped a break-test at its baseline on a host with no Chromium (2026-10-02); below the Node floor it refuses before any test runs, exit 2, naming the floor and the fix - on Node 20 it ran and reported 28 red tests each saying "this kit needs 22 or newer" (2026-10-02; `RESEARCH_KIT_TEST_NODE_VERSION` is the harness's seam); the result file names the failing tests (`failed: [labels]`) beside their count, so two runs compare as sets - a count cannot show one test starting to fail while another starts to pass (break-test pass 5, 2026-10-04); a `RESEARCH_KIT_RESULT_FILE` that cannot be written is named on stderr and a green run exits 2 for it, where it exited 0 and left CI's summary reading "crashed before reporting" beside a green step (2026-10-02); it gives the run a scratch home and removes every `RESEARCH_KIT_*` variable but the ones that describe the host or the run (the result file, the unsupported opt-in, the browser, the gate timeout, the live opt-ins, the harness's `RESEARCH_KIT_TEST_*` seams), and the vendor keys, before the first test (2026-10-02, ADR-0123) - the tests that spawn kit commands read the operator's machine config, deployed kit and CLI login, so a `strict` posture config turned 18 tests red and this container's `http-keyless` config hid two cli tests red on any host whose Firecrawl CLI is installed and not logged in; a test file that throws while it is imported is reported as a named FAIL by `importTestFiles` in the harness, and the rest of the suite still runs and counts - one such file used to abort the whole run with no count); it tries the temp folder once before the run and, if it cannot be used, names it and the TMPDIR fix at the top and under a red summary - an unwritable one gave 541 EACCES failures and no cause; a closed stdout no longer kills the run - `| head -1` printed an unhandled EPIPE and exited 1 while the suite was still going, so a green run read as broken (2026-09-28); outside a repository checkout - a deployed kit - it refuses with exit 2 and points at `doctor`, where 80 tests had failed on a healthy install, ADR-0080; a temp folder that does not exist yet is created by its probe and by the harness's `tempDir` through `createTempFolder`, which creates each missing ancestor with its own non-recursive mkdir and records only a mkdir that succeeded, each at the front of the list so it stays deepest first at every moment (a sort after the loop was skipped by a mkdir that threw part-way, and an ancestor already recorded was left behind: 2026-10-04, second review) - recording from the existence check before it left a window in which another process created the same chain and this run removed that process's empty folder (2026-10-04, external review) - and removes them with the scratch at exit by a non-recursive rmdir that refuses anything non-empty - the run had left the created chain behind, empty, `/nonexistent/dir/xyz` for that TMPDIR and an empty folder inside the checkout for a relative one: break-test pass 4, F3, 2026-10-04). diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..27f8d04f 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 +1792 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/bin/selftest.mjs b/research-kit/bin/selftest.mjs index f5f2e85e..cd78474e 100644 --- a/research-kit/bin/selftest.mjs +++ b/research-kit/bin/selftest.mjs @@ -45,8 +45,14 @@ if (!nodeFloor.ok) { process.exit(2); } -const files = listFiles(dir) - .filter((name) => name.endsWith('.test.mjs')) +const testFiles = listFiles(dir).filter((name) => name.endsWith('.test.mjs')); +const unmatched = positional.filter((needle) => !testFiles.some((name) => name.includes(needle))); +if (unmatched.length) { + process.stderr.write(`selftest: ${unmatched.map((name) => JSON.stringify(name)).join(', ')} matches no test file in ${dir}; groups are named by file, e.g. warc for warc.test.mjs\n`); + process.exit(2); +} + +const files = testFiles .filter((name) => !positional.length || positional.some((needle) => name.includes(needle))) .sort(); diff --git a/research-kit/test/selftest-cli.test.mjs b/research-kit/test/selftest-cli.test.mjs new file mode 100644 index 00000000..a5ddd0d6 --- /dev/null +++ b/research-kit/test/selftest-cli.test.mjs @@ -0,0 +1,39 @@ +// The suite must run every requested group, or refuse before any tests start. +import { spawnSync } from 'node:child_process'; +import { test, describe, assert, tempDir, path, KIT_ROOT, fs } from './harness.mjs'; + +describe('selftest-cli'); + +function runGroups(groups, env = process.env) { + const childEnv = { ...env }; + delete childEnv.RESEARCH_KIT_RESULT_FILE; + return spawnSync(process.execPath, [path.join(KIT_ROOT, 'bin', 'selftest.mjs'), ...groups], { + cwd: tempDir('rk-selftest-groups-'), env: childEnv, encoding: 'utf8', windowsHide: true, timeout: 60000, + }); +} + +test('unknown groups are named and refused, alone or mixed with valid groups', () => { + for (const groups of [['no-such-group'], ['warc', 'no-such-group'], ['no-such-group', 'warc']]) { + const result = runGroups(groups); + assert.equal(result.status, 2, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /no-such-group/); + assert.match(result.stderr, /matches no test file/); + assert.doesNotMatch(result.stdout, /^ok\s|all tests passed|passed, \d+ failed/m); + } +}); + +test('multiple valid groups still run every matching test file', () => { + const result = runGroups(['warc', 'similarity']); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /^ok\s+warc >/m); + assert.match(result.stdout, /^ok\s+similarity >/m); + assert.match(result.stdout, /all tests passed/); +}); + +test('regression subprocesses cannot overwrite the parent suite report', () => { + const file = path.join(tempDir('rk-parent-report-'), 'parent-report.json'); + fs.writeFileSync(file, 'PARENT REPORT\n'); + const result = runGroups(['warc', 'similarity'], { ...process.env, RESEARCH_KIT_RESULT_FILE: file }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.equal(fs.readFileSync(file, 'utf8'), 'PARENT REPORT\n'); +}); From dee72a6a17123fb95f0bdfa62f362f951f3817bd Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 16:15:42 +0300 Subject: [PATCH 14/27] fix(download): isolate default package paths and refuse links what changed: Default downloads now create a fresh private mkdtemp folder per call. Before writing a package, lstat refuses an existing or dangling symbolic link as OUT_DIR. MCP's tool description names the private default. Added repeated-name isolation and real symlink regressions. why: A public artifact name in the shared temp root let downloads replace each other and follow a planted link. Allocate the existing default output in its own directory and refuse redirection at the package name. No alternative design was set aside. what it touched: dispatch and MCP libraries, dispatch tests, architecture row, changelog and research-kit README inventory. Explicit output-folder selection and atomic package writes retain their existing interfaces. what you verified: verified - red-first 79 passes, one expected isolation failure and one unsupported file-symlink capability; then 80 passes, zero failures and that same unsupported test across dispatch/MCP. Handoff OK, preflight PASS, doctor READY collector/firecrawl-cli in this checkout. Untested locally - real file symlinks and POSIX directory permissions; CI exercises those supported capabilities. The required full offline suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/dispatch.mjs | 22 ++++++++++++- research-kit/lib/mcp.mjs | 2 +- research-kit/test/dispatch.test.mjs | 51 ++++++++++++++++++++++++++++- 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..a6e55ba8 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 + +- Default corpus downloads use a fresh private temp folder and refuse symlinks at the package filename, preventing shared-name overwrites and redirected writes (#373). - 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..2fb41981 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -182,7 +182,7 @@ is an open decision (2026-09-30): | `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 | | `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 | +| `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). Default corpus downloads use a fresh private mkdtemp folder per call; existing or dangling symbolic links at the package name are refused as OUT_DIR before the write, and MCP describes the private default (#373). | 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. | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/dispatch.mjs b/research-kit/lib/dispatch.mjs index 9d4dd8b3..14cb0d72 100644 --- a/research-kit/lib/dispatch.mjs +++ b/research-kit/lib/dispatch.mjs @@ -415,7 +415,17 @@ export async function fetchCorpus({ } = {}) { // The folder is checked first: the download is the slow, rate-limited part, and an // unusable folder found after it would waste it and surface as a raw ENOTDIR. - const dir = usableOutDir(outDir ?? tempBase()); + let dir; + try { + dir = outDir == null + ? fs.mkdtempSync(path.join(usableOutDir(tempBase()), 'research-kit-corpus-')) + : usableOutDir(outDir); + } catch (err) { + if (err instanceof DispatchError) throw err; + throw new DispatchError('OUT_DIR', `could not create a private download folder (${err.code ?? err.message})`, { + remedy: 'pass an existing writable folder, or repair the system temp directory', + }); + } const artifacts = await listArtifacts({ repository, runId, token, fetch: doFetch, api }); const wanted = artifacts.filter((a) => !a.expired && a.name.startsWith('research-kit-corpus-v1-')); @@ -442,6 +452,16 @@ export async function fetchCorpus({ }); } const file = path.join(dir, fileName); + let target; + try { target = fs.lstatSync(file); } + catch (err) { + if (err.code !== 'ENOENT') throw new DispatchError('OUT_DIR', `could not inspect ${file} (${err.code ?? err.message})`, { + remedy: 'choose a writable output folder without a symbolic link at the package name', + }); + } + if (target?.isSymbolicLink()) throw new DispatchError('OUT_DIR', `${file} is a symbolic link; the package write must not be redirected`, { + remedy: 'remove the link or choose another writable output folder', + }); // Whole or not at all (ADR-0079), as writeArtifact is: a failed write must not empty a // package already at this name (Arena break test 10, 2026-09-28). writeBytes(file, pkg); diff --git a/research-kit/lib/mcp.mjs b/research-kit/lib/mcp.mjs index 1e5da319..e18461d4 100644 --- a/research-kit/lib/mcp.mjs +++ b/research-kit/lib/mcp.mjs @@ -147,7 +147,7 @@ export const TOOLS = Object.freeze([ properties: { repository: { type: 'string', description: 'owner/name' }, workflow_run_id: { type: 'integer', minimum: 1, description: 'The id `collect` returned' }, - out_dir: { type: 'string', description: 'Where to save the package. Default: the system temp directory.' }, + out_dir: { type: 'string', description: 'Where to save the package. Default: a new private folder in the system temp directory.' }, }, }, }, diff --git a/research-kit/test/dispatch.test.mjs b/research-kit/test/dispatch.test.mjs index 1c4aa50d..a2430673 100644 --- a/research-kit/test/dispatch.test.mjs +++ b/research-kit/test/dispatch.test.mjs @@ -5,7 +5,8 @@ // sent, that a token never appears in an error, that a 204 is diagnosed rather than // swallowed. The live path is verified separately, by running it. -import { test, describe, assert, tempDir, cleanup, fs, path } from './harness.mjs'; +import { test, describe, assert, tempDir, cleanup, fs, path, requireSymlink } from './harness.mjs'; +import { tempBase } from '../lib/core.mjs'; import { dispatchCollection, waitForRun, getRun, listArtifacts, downloadArtifact, unwrapArtifact, queriesInput, tokenFromEnv, redact, DispatchError, API_VERSION, TOKEN_VARS, fetchCorpus, @@ -15,6 +16,54 @@ import { openZip as importedOpenZip } from '../lib/artifact-zip.mjs'; describe('dispatch'); +test('default corpus downloads use different private directories even for the same artifact name', async () => { + const name = `research-kit-corpus-v1-private-${process.pid}`; + const downloads = []; + const fetch = () => stubFetch([ + jsonResponse(200, { artifacts: [{ id: 7, name, expired: false }] }), + { ok: true, status: 200, arrayBuffer: async () => Buffer.from('package bytes') }, + ]); + try { + for (let runId = 1; runId <= 2; runId++) downloads.push(await fetchCorpus({ repository: REPO, runId, token: TOKEN, fetch: fetch() })); + const folders = downloads.map((download) => path.dirname(download.file)); + assert.notEqual(folders[0], folders[1]); + for (const download of downloads) { + const folder = path.dirname(download.file); + assert.equal(path.dirname(folder), tempBase()); + assert.match(path.basename(folder), /^research-kit-corpus-/); + assert.equal(fs.readFileSync(download.file, 'utf8'), 'package bytes'); + if (process.platform !== 'win32') assert.equal(fs.statSync(folder).mode & 0o077, 0); + } + } finally { + for (const download of downloads) { + const folder = path.dirname(download.file); + if (folder === tempBase()) fs.rmSync(download.file, { force: true }); + else cleanup(folder); + } + } +}); + +test('an existing or dangling package symlink is refused without writing its target', async () => { + const dir = tempDir('rk-download-link-'); + const name = 'research-kit-corpus-v1-link'; + const target = path.join(dir, 'victim.txt'); + const output = path.join(dir, `${name}.zip`); + for (const dangling of [false, true]) { + if (!dangling) fs.writeFileSync(target, 'precious'); + requireSymlink(target, output, 'a package symlink must not redirect a download'); + const fetch = stubFetch([ + jsonResponse(200, { artifacts: [{ id: 7, name, expired: false }] }), + { ok: true, status: 200, arrayBuffer: async () => Buffer.from('replacement') }, + ]); + await assert.rejects(() => fetchCorpus({ repository: REPO, runId: 1, token: TOKEN, outDir: dir, fetch }), + (error) => error.code === 'OUT_DIR' && /symbolic link/.test(error.message)); + if (dangling) assert.equal(fs.existsSync(target), false); + else assert.equal(fs.readFileSync(target, 'utf8'), 'precious'); + fs.unlinkSync(output); + if (!dangling) fs.unlinkSync(target); + } +}); + const TOKEN = `github_pat_${'A1b2C3d4E5'.repeat(4)}`; const REPO = 'example-org/example-project'; From cbc6e3c5890788cb434245b3f13134887bdda474 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 14:21:38 +0300 Subject: [PATCH 15/27] fix(hooks): include configured hooks in scoped reads what changed: Scoped core.hooksPath reads now request Git include expansion. A disposable global-config fixture covers ordinary includes and gitdir conditional includes through preview, install, recorded hand-on and uninstall restoration. why: Git defaults scoped config reads to excludes, so a configured hooks folder was reported as unset and lost from the hand-on record. Request the effective included value with the existing scoped reader. No alternative design was set aside. what it touched: machine.mjs, doctor tests, architecture map, changelog and research-kit README inventory. Writes still use the existing Git config writer. what you verified: verified - red-first 66 passes and one expected include-read failure; then 90 doctor/machine tests passed with zero failures. Handoff OK and preflight PASS in this checkout. The required full offline suite runs in the commit gate before acceptance. Git's config documentation confirms scoped reads need --includes. what you got wrong and fixed: mistake - the first Windows fixture spelled the conditional Git path with Node realpath, which did not match Git's RUNNER~1 temp-path spelling. A follow-up using --absolute-git-dir targeted the wrong matching path. fix - derive the worktree root from `git rev-parse --show-toplevel`, so Git supplies the path spelling it uses for includeIf. verified - 90 doctor/machine tests passed, zero failures, in 108.5 seconds; the final fixture correction is queued for the full commit gate and supported CI. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/machine.mjs | 2 +- research-kit/test/doctor.test.mjs | 39 ++++++++++++++++++++++++++++++- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..baaf6d7f 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 + +- Scoped hooksPath reads expand Git include and includeIf directives, preserving the previous hooks folder through installation and removal (#369). - 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..a0335c7c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -228,7 +228,7 @@ is an open decision (2026-09-30): | `panel.mjs` | **The desktop panel** (ADR-0147, 2026-10-07): `createPanel({ project, env, kitRoot, kitHome })` serves `research-kit/panel/` (one page, a script, a stylesheet) and a small JSON API on **127.0.0.1 only**, a thin shell over commands that already exist. It owns no verdict: doctor, handoff, preflight and install run as children with the fixed argument lists in `PANEL_COMMANDS`, in the chosen project folder, and their output is shown with the search key redacted. **No command from the page collects** - `research.mjs` and `decompose.mjs` are absent from `PANEL_COMMANDS`; the one way pages are fetched through the panel is auto-collect (`lib/auto-collect.mjs`, ADR-0148), for a builder's validated request, on a collector, within the operator's caps. Its routes are `GET /api/requests` (the queue and the meter), `POST /api/autocollect` (the settings), `/api/requests/check` and `/api/requests/resume`. It writes two things: the search provider's key into the machine config through `transport.mjs`'s `SEARCH_KEY` (so the vendor's name stays in the adapter and the registry, NFR-3), refused when the config resolves inside a git repository (Rule 6); and which project it shows, a full path to an existing folder, never searched for. The topic and `BRIEF.md` are read, never written (ADR-0056). `builderInstructions` is the hand-off text: the collector's push, ledger included, then the builder's own steps in `$HOME` spelling (ADR-0050) - declare the role, handoff, preflight, inspect an authored complete brief and its current inputs, never collect. Its readiness checklist requires classified map rows and rewritten Findings; a file-presence observation grants no build authorization. Commit-report placeholders require actual observed results. A collected Git-request result waits for the collector to record review/closure before dependent building. Every request passes four checks: the bound address, a Host header naming it (DNS rebinding), an Origin, when sent, that is its own, and for `/api/` the per-launch token, initialized through the launch URL fragment and required in x-panel-token. The launcher prints that capability URL; terminal-log and browser-history privacy are not guaranteed by this module. POSTs must be JSON, so a plain cross-site form is refused. Update is refused when the panel runs from the installed copy, which has nothing newer to install. Command runs, the project switch and auto-collect cycles share one lock (`running`, handed to `createAutoCollect` as `lock`): none runs beside another, so no preflight or update reads the corpus or the kit tree while a cycle writes it. | ADR-0147, ADR-0148 | | `requests.mjs` | **A builder's fact request as a file** (ADR-0148, 2026-10-07): `research/requests/.json`, the `fact-request` skill made machine-readable. `requestProblems(request, { perRequestPages })` validates it as untrusted input: the fixed fields (`fact`, `blocks`, `urls`, `queries`, `prefer`, `unknown`, `maxPages`, `topic`), bounded sizes, and `urlProblem` refuses a non-http(s) URL, one with a user name or password, and one naming this machine or an internal address (`isInternal` from `http-transport.mjs`, ADR-0110). A request over the per-request cap is refused, not trimmed. `listRequests` reads the folder, skipping the collector's own `.result.json` and `.plan.json` and ignoring a name that is not an id, and a request whose file, `.plan.json` or `.result.json` is a symbolic link (a builder's link would make the collector read or overwrite a file of its own machine; `writeText` follows links). `applyRequest` and `writeResult` refuse to write through a link as well. `applyRequest` writes the contract row (a new OPEN `U-n`, or an existing non-CLOSED row; it refuses requests naming a closed unknown) and the `plan.json` entries, and the plan for that run alone, with `maxScrapes` set to the request's pages. `contractUnknownStatus` reads the status of a contract row for continuation checks. `writeResult` records the outcome beside the request. Terminal and partial results are trusted only when `recordTrustedResult` has recorded hashes of both request and result in the machine-local `research-kit.autocollect.json`; `isTrustedResult` checks those hashes so a builder cannot submit a result as if the collector wrote it. The auto-collect settings (`off`/`auto`, pages a request, pages a day, interval, the folder for new topics) and the daily meter live in `research-kit.autocollect.json` beside the machine config (`readAutoCollect`, `saveAutoCollect`, `recordSpend`, `setPaused`); no key is in them. | ADR-0148 | | `auto-collect.mjs` | **The collector listening** (ADR-0148, 2026-10-07): `createAutoCollect({ project, env, kitRoot, exec, lock })` returns `cycle`, `status`, `schedule`, `resume`, `stop`. A cycle resolves the project folder to its final path (`fs.realpathSync.native`) before comparing it with git's top level, so a Windows 8.3 short name or a symlinked folder cannot turn a path git stages into one outside the repository. `status()` uses that same canonical project path so its trust lookup matches collector records across alternate Windows path spellings. A cycle refuses unless `collectionPolicy` says the machine may collect, and skips while another run holds the shared `lock` (the panel's; its own when none is given). Otherwise it runs `git pull --ff-only`; a failed pull is surfaced and blocks the cycle before requests are read or processed. On success, it goes through each request. Results received over git are ignored unless their request and result hashes match collector-local trust records, including in `status()`; trusted terminal results retry pending commit/push delivery without collecting again, and trusted `partial` results carry continuation state and resume only while their contract unknown is not CLOSED. An invalid one gets a refused result, committed and pushed. A valid one, within the per-request and daily caps and with `research/` clean, is applied (`requests.mjs`), then its target-specific `research/requests/.plan.json` runs, followed by `preflight.mjs`, whose verdict is recorded, not acted on. It then stages `research/` and the ledger by name (`git add -f`), commits, and pushes when there is an upstream. Accounting travels on the fixed Node research child's private IPC descriptor (ADR-0149), never its human output: exactly one bounded, typed report with measured pages within the admitted allowance and the exhaustion provider. Missing, invalid, duplicate or over-allowance reporting reserves the remaining admitted allowance against the daily meter, labels actual spend unknown, records a terminal failed result and pauses. Earlier measured continuation attempts stay counted once. A valid received report survives failed exit or later interruption; interruption also pauses. Diagnostic output keeps a bounded redacted tail with a truncation notice, and failure detail is bounded separately. The legacy `spentPages`/`creditsStopped` exports remain diagnostic compatibility helpers. It never passes `--fallback`: received exhaustion accounting makes the loop commit the partial corpus and continuation state, pause until the operator resumes, then reuse the saved target, unknown and remaining page allowance (ADR-0129). A `topic` request gets `new-project.mjs` and `decompose.mjs --max-scrapes 0` in a dated folder under the folder the operator approved, never over an existing project (ADR-0056). It is refused when no folder is approved. Diagnostic details, refusal reasons and preflight verdicts are scrubbed with the existing redactor before result files or commit reports are written (2026-10-08). Status also scrubs inherited trusted result details, delivery diagnostics and pause text without rewriting those files or changing their trust hashes; the configured redactor defines which secret is recognized. Every child gets a fixed argument list; `exec` is injectable for tests. The panel labels its unchanged numeric meter as pages used or reserved. **Allowance regression coverage** (2026-10-09): the four-cycle credit exhaustion and resume fixture runs real synthetic Node scripts against persisted request, plan, result and meter files, with a finite injected Git executor. Native round-trip, partial-delivery and topic-resume fixtures retain real Git and corpus-transfer assertions. The test watchdog remains 60 seconds. **Complete Git answers** (ADR-0151): the bounded child byte buffer reports a private `truncated` flag. Repository and Git metadata paths, upstream, ahead count, commit subject and changed paths, dirty state, and pending delivery status/diff all refuse truncated output before it can authorize work; a malformed ahead count or missing quiet-diff exit also blocks delivery. A newly written partial result is trusted before fingerprinting, so refused inspection stages nothing and cannot make a restart collect it again. Exit-only operations retain bounded diagnostic logs. **Admission survives collector termination** (ADR-0152): `reserveCollection` saves the newly admitted allowance and an owned in-flight pause atomically before the fixed research child starts; a failed meter write prevents spawn. `settleCollection` refunds unused allowance once on a valid receipt, preserves earlier spend and distinct pauses, and retains the full charge for unknown/spawn-failed attempts. Whole collector termination leaves its charge/pause across restart or project switch. Continuation reserves only its remaining newly admitted pages. Day-aware settlement never refunds yesterday against today; cross-day completion charging is conservative, and an over-cap completion remains paused. The existing file writer is atomic, while the mutex remains per collector instance; racing independent instances and power-loss durability are not certified by this correction. Successful result.next says captures were delivered: the builder reads/judges, the collector records Git Findings, closures, MAP and the current authored BRIEF; another fetch is only for a missing external fact. ADR-0052 local received-package review remains available, and derived approval stays role-agnostic (2026-10-08, ADR-0150). | ADR-0148, ADR-0149, ADR-0150, ADR-0151, ADR-0152 | -| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | +| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers that expand Git include and includeIf directives, including scoped reads, so installation preserves and restores the included hooks folder; #369), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | | `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, an `EVIDENCE.md` that is absent or holds no evidence table (`handoff-evidence-missing`, `handoff-evidence-unparsed`; the report carries `rows`, and the CLI and doctor print the count - 2026-10-02: with no rows there were no citations, so "every cited capture on disk" held vacuously and handoff said OK over a corpus whose table a sync tool had dropped), every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). The report carries `nothingCollected` (no ledger entry, no evidence row, no capture) since 2026-09-27, so a caller can tell a project never collected from a corpus that failed to travel. The finding names are unchanged, so the doctor and the artifact validator read them as before. The report also carries `brief: { state, todo }` (`briefReview`, 2026-09-27, ADR-0061): the CLI's "handoff OK" is followed by a note when the brief is a draft (naming the judged sections still TODO) or has not been drafted. It is never a finding, and the exit stays 0. **The line-ending remedy is judged by running it** (2026-09-27, ADR-0062): per affected capture it prints `git rm --cached` (the index entry only) then `git checkout HEAD --`, which rewrites the file as LF through the pin, and it prints the pin (`PIN_LINES`, to add in an editor) only when `.gitattributes` lacks it. Every printed command is a plain git command with no comment, so cmd and PowerShell run it as written (ADR-0070). `--renormalize` had fixed only the index, so the capture handoff reads stayed CRLF. A **third cause** (2026-09-28): a ledger present and empty while captures are on disk lost its entries where the captures are - a one-line ledger whose torn tail `doctor --fix-arity` dropped - so `ledgerLost` gets `ledgerLostRemedy()`: restore it from git, or re-collect with `--force`. It was given the push remedy, which on the collector pushes the same empty ledger. A ledger that is merely **short** is the same cause: a cited capture on disk that no entry records is `handoff-capture-unledgered` (2026-09-28) and gets the same remedy - handoff had said OK and exited 0 while preflight failed it as `fetch-entry-exists`. **`CORPUS_ADD`** (2026-09-28) is how the kit prints putting a corpus into git - `git add research/`, then `git add -f research/raw/.fetches.jsonl` - shared by the push remedy and the commit gate's fix: both said `git add -f` on the whole folder, which also committed the machine-local `.usage.jsonl`, `.failures.jsonl`, `.diagnostics.jsonl` and `.fetches.lock`. A **fourth cause** (2026-10-04, break-test pass 4, F5b): a capture whose body hash fails and NOT by line endings (`body-unmodified/modified`) was changed after its fetch, and the report carries it as `altered`; it no longer counts as "did not travel", and **`alteredRemedy(files, { isRepo })`** prints, per file, `git status --porcelain --` to tell a local change from a committed one, then `git checkout HEAD --` followed by handoff again (no folder-wide checkout, which would discard uncommitted work beside it), and for the committed case `git log --oneline --` to find the commit that held the fetched bytes and restore from it, here or on the collector; without git metadata it prints no command. Judged by running it (2026-10-04, external review): a dirty status says only that this checkout changed the file after the commit - a capture committed altered and edited again is dirty too - so the remedy no longer claims the committed copy is the fetched one; and `--force` is no longer offered, because a new fetch writes a new capture beside this one, or refuses to overwrite a differing source sibling, while `verifyLedger` still checks the entry that names this file. The push remedy it used to get sends the same bytes again when the change was committed, and sends an operator to another machine when the committed copy here was one checkout away. **`safePathspec(file)`** (2026-10-04, two external reviews) decides whether a capture path is printed inside a git command at all: letters, digits, `.`, `_`, `-` and `/`, the alphabet the collector's own names come from. Anything else is named but never printed as a command, because no one spelling is read literally by cmd, PowerShell and sh alike and by git: a space unquoted is two pathspecs, `$copy` inside double quotes is expanded to nothing by PowerShell, and `[12]` is a git glob whatever the shell did, so `git checkout HEAD --` restored a neighbour and discarded its uncommitted work. For those the remedy prints commands that type no name at all: the operator lists them, one per line, in `handoff-names.txt` (`NAMES_FILE`) and runs `git --literal-pathspecs checkout HEAD --pathspec-from-file=handoff-names.txt` (the line-ending remedy adds the `git rm --cached` step the same way) - "quote it for your shell and prefix `:(literal)`" was not enough, because cmd expands `%NAME%` inside double quotes and PowerShell `$name` (third external review, 2026-10-04, verified by running in cmd); `--pathspec-from-file` hands git the bytes of the file with no shell in between, and `--literal-pathspecs` switches pattern syntax off (both git 2.25+). Such a name reaches the ledger rarely but not only by hand: `captureName` embeds the URL's host as it is, so an IPv6 literal or an adapter-supplied host outside DNS's alphabet produces one (third review). The classification runs over EVERY affected file (`classify`), not the five the text names: an unsafe sixth file got no note and "handle the remaining files the same way, each by name" - an instruction to type the glob that restores a neighbour (third external review, 2026-10-04). **`insideRepository(root)`** (2026-10-04, two external reviews) decides whether the runnable remedies are printed, and it asks git: `git -C rev-parse --is-inside-work-tree`. It was `root/.git` alone, so a nested decision project (ADR-0030), which has none of its own, was told it had "no git metadata"; then a walk up for a `.git` entry, which walked past a bare repository the project sat inside and printed a checkout git refused with "this operation must be run in a work tree". The commands are git's, so git decides whether they run; without git on PATH the walk is the fallback: it walks the PHYSICAL path (realpath first - a project reached through a junction into a repository had read as outside it, and one linked out of a repository as inside; third review) and reads `GIT_CEILING_DIRECTORIES` as git does: absolute entries only, each through realpath until an empty entry, after which the rest are taken as written. Git's pathspecs are relative to the cwd, so the printed commands run unchanged from the project folder. The tests that want "no git metadata" from a scratch fixture set `GIT_CEILING_DIRECTORIES` above it and go through `handoffRemedy`, so a detection that always says "repository" fails them. **`repositoryState(root)`** is the full answer, `{ inside, refused }`: when git answered with something other than yes, "not a git repository" or "must be run in a work tree" - `fatal: detected dubious ownership` - `refused` carries its first line and both remedies say git refused to read the repository and to fix that first, where they said "no git metadata, re-copy the corpus" (third review, 2026-10-04); no command is printed either way. | ADR-0011, ADR-0020 | | `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, and (from 2026-09-21) `AGENTS.md`, which is the file the agent protocol is *written in* — caught drifting by this check when ADR-0030 gave decision research a committed home and the rule that sends an agent there had to be where agents read their rules — each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. `bin/bundle.mjs --json` prints `verifyBundle`'s result as JSON (2026-09-28): the flag was accepted and never read, so a caller parsing it got the markdown report. `START_HERE.md` joined `EXPECTED_TO_DRIFT` on 2026-09-29: the archived copy held the author's machine paths, and the portable one is the template rendered with `$HOME`. | ADR-0028 | | `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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/machine.mjs b/research-kit/lib/machine.mjs index 59d715ac..180fb38c 100644 --- a/research-kit/lib/machine.mjs +++ b/research-kit/lib/machine.mjs @@ -357,7 +357,7 @@ export function gitVersion(opts = {}) { /** Scope-aware readers: which hooksPath is set where. */ export function hooksPath(scope = 'global', opts = {}) { const flag = { global: '--global', local: '--local', system: '--system' }[scope] ?? '--global'; - return git(['config', flag, '--get', 'core.hooksPath'], opts); + return git(['config', flag, '--includes', '--get', 'core.hooksPath'], opts); } export function hooksPathEffective(opts = {}) { diff --git a/research-kit/test/doctor.test.mjs b/research-kit/test/doctor.test.mjs index e05704bc..d7a57d9a 100644 --- a/research-kit/test/doctor.test.mjs +++ b/research-kit/test/doctor.test.mjs @@ -7,7 +7,7 @@ import { TESTED_CLI_VERSION } from '../lib/firecrawl.mjs'; import { PATHS, resolve, readText, writeText, readJson } from '../lib/core.mjs'; import { runDoctor, machineHealth, gateHealth, editGateState, scanForSecrets } from '../lib/doctor.mjs'; import { installEditGate, removeEditGate, installCommitGate, removeCommitGate, settingsState, retiredRepairNote, MATCHER } from '../lib/installer.mjs'; -import { EDIT_GATE_HOOK, RETIRED_EDIT_GATE_HOOKS } from '../lib/machine.mjs'; +import { EDIT_GATE_HOOK, RETIRED_EDIT_GATE_HOOKS, hooksPath, previousHooksPath } from '../lib/machine.mjs'; describe('doctor'); @@ -26,6 +26,43 @@ function machine({ config = {}, settings = undefined } = {}) { const find = (findings, name) => findings.find((f) => f.name === name); +test('included global hooks are recorded, preserved and restored by the commit-gate install', () => { + requireGit('global include-based hooks'); + for (const conditional of [false, true]) { + const { dir, env } = machine(); + const repo = path.join(dir, 'repo'); + fs.mkdirSync(repo); + const globalFile = path.join(dir, 'gitconfig'); + const gitPaths = { cwd: repo, env: { ...env, GIT_CONFIG_GLOBAL: globalFile, GIT_CONFIG_NOSYSTEM: '1' } }; + const initialized = spawnSync('git', fixtureInitArgs(repo), { env: gitPaths.env, encoding: 'utf8', windowsHide: true }); + assert.equal(initialized.status, 0, initialized.stderr); + const old = path.join(dir, 'included-hooks'); + writeText(path.join(old, 'pre-commit'), '#!/bin/sh\n# somebody else\n'); + const included = path.join(dir, 'included.gitconfig'); + writeText(included, `[core]\n hooksPath = ${JSON.stringify(old.replace(/\\/g, '/'))}\n`); + const gitTopLevel = spawnSync('git', ['rev-parse', '--show-toplevel'], { + cwd: repo, env: gitPaths.env, encoding: 'utf8', windowsHide: true, + }); + assert.equal(gitTopLevel.status, 0, gitTopLevel.stderr); + const heading = conditional + ? `[includeIf "gitdir:${gitTopLevel.stdout.trim().replace(/\\/g, '/')}/"]` + : '[include]'; + writeText(globalFile, `${heading}\n path = ${JSON.stringify(included.replace(/\\/g, '/'))}\n`); + assert.equal(hooksPath('global', gitPaths), old.replace(/\\/g, '/'), conditional ? 'conditional include at Git’s resolved worktree path' : 'ordinary include'); + const preview = installCommitGate({ kitHome: KIT_ROOT, env, gitPaths, cwd: repo, dryRun: true }); + assert.equal(preview.previous, old.replace(/\\/g, '/')); + const installed = installCommitGate({ kitHome: KIT_ROOT, env, gitPaths, cwd: repo }); + assert.equal(installed.ok, true); + assert.equal(installed.previous, old.replace(/\\/g, '/')); + assert.equal(previousHooksPath(gitPaths), old.replace(/\\/g, '/')); + const state = readJson(env.RESEARCH_KIT_INSTALL_STATE); + assert.equal(state.previousHooksPath, old.replace(/\\/g, '/')); + const removed = removeCommitGate({ env, gitPaths }); + assert.equal(removed.restored, old.replace(/\\/g, '/')); + assert.equal(hooksPath('global', gitPaths), old.replace(/\\/g, '/')); + } +}); + // A no-hit result describes inspected files. Reaching the count at the final file is // different from returning at that count while another path remains uninspected. test('secret cap: coverage distinguishes a complete boundary from an uninspected path', () => { From 1dae0ab39506986f1e4788c8e7009320529b4e56 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 16:59:51 +0300 Subject: [PATCH 16/27] fix(doctor): shape install records before reading paths what changed: Install-state reads accept only objects, keep non-empty string paths and path arrays, and filter skill hashes with the installer's existing string-value rule. Doctor warns by filename when an existing record lacks a readable deployment path instead of passing ill-typed data to path functions. why: Parsed JSON was treated as a valid install record, allowing numbers, arrays and other values to crash doctor or be spread into future writes. Apply the same boundary typing already used for machine config. No alternative design was set aside. what it touched: machine/doctor libraries and tests, architecture, changelog and research-kit README inventory. Existing install-state field names and other metadata are preserved. what you verified: verified - red-first 89 passes and three expected failures; then 92 doctor/machine tests passed in 134.6 seconds. Covered non-object records, ill-typed and blank paths, mixed arrays/hash values, read-only preservation, named deploy warnings and the CLI's JSON output. Handoff OK, preflight PASS, doctor READY collector/firecrawl-cli in this checkout. The required full offline suite runs in the commit gate before acceptance. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/doctor.mjs | 8 +++++--- research-kit/lib/machine.mjs | 17 ++++++++++++++- research-kit/test/doctor.test.mjs | 22 ++++++++++++++++++++ research-kit/test/machine.test.mjs | 33 +++++++++++++++++++++++++++++- 7 files changed, 79 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..4c17f2de 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 + +- Install-state reads reject non-object records and ill-typed paths; doctor reports a named deploy warning instead of a raw path stack (#365). - 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..3d0e05c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -228,7 +228,7 @@ is an open decision (2026-09-30): | `panel.mjs` | **The desktop panel** (ADR-0147, 2026-10-07): `createPanel({ project, env, kitRoot, kitHome })` serves `research-kit/panel/` (one page, a script, a stylesheet) and a small JSON API on **127.0.0.1 only**, a thin shell over commands that already exist. It owns no verdict: doctor, handoff, preflight and install run as children with the fixed argument lists in `PANEL_COMMANDS`, in the chosen project folder, and their output is shown with the search key redacted. **No command from the page collects** - `research.mjs` and `decompose.mjs` are absent from `PANEL_COMMANDS`; the one way pages are fetched through the panel is auto-collect (`lib/auto-collect.mjs`, ADR-0148), for a builder's validated request, on a collector, within the operator's caps. Its routes are `GET /api/requests` (the queue and the meter), `POST /api/autocollect` (the settings), `/api/requests/check` and `/api/requests/resume`. It writes two things: the search provider's key into the machine config through `transport.mjs`'s `SEARCH_KEY` (so the vendor's name stays in the adapter and the registry, NFR-3), refused when the config resolves inside a git repository (Rule 6); and which project it shows, a full path to an existing folder, never searched for. The topic and `BRIEF.md` are read, never written (ADR-0056). `builderInstructions` is the hand-off text: the collector's push, ledger included, then the builder's own steps in `$HOME` spelling (ADR-0050) - declare the role, handoff, preflight, inspect an authored complete brief and its current inputs, never collect. Its readiness checklist requires classified map rows and rewritten Findings; a file-presence observation grants no build authorization. Commit-report placeholders require actual observed results. A collected Git-request result waits for the collector to record review/closure before dependent building. Every request passes four checks: the bound address, a Host header naming it (DNS rebinding), an Origin, when sent, that is its own, and for `/api/` the per-launch token, initialized through the launch URL fragment and required in x-panel-token. The launcher prints that capability URL; terminal-log and browser-history privacy are not guaranteed by this module. POSTs must be JSON, so a plain cross-site form is refused. Update is refused when the panel runs from the installed copy, which has nothing newer to install. Command runs, the project switch and auto-collect cycles share one lock (`running`, handed to `createAutoCollect` as `lock`): none runs beside another, so no preflight or update reads the corpus or the kit tree while a cycle writes it. | ADR-0147, ADR-0148 | | `requests.mjs` | **A builder's fact request as a file** (ADR-0148, 2026-10-07): `research/requests/.json`, the `fact-request` skill made machine-readable. `requestProblems(request, { perRequestPages })` validates it as untrusted input: the fixed fields (`fact`, `blocks`, `urls`, `queries`, `prefer`, `unknown`, `maxPages`, `topic`), bounded sizes, and `urlProblem` refuses a non-http(s) URL, one with a user name or password, and one naming this machine or an internal address (`isInternal` from `http-transport.mjs`, ADR-0110). A request over the per-request cap is refused, not trimmed. `listRequests` reads the folder, skipping the collector's own `.result.json` and `.plan.json` and ignoring a name that is not an id, and a request whose file, `.plan.json` or `.result.json` is a symbolic link (a builder's link would make the collector read or overwrite a file of its own machine; `writeText` follows links). `applyRequest` and `writeResult` refuse to write through a link as well. `applyRequest` writes the contract row (a new OPEN `U-n`, or an existing non-CLOSED row; it refuses requests naming a closed unknown) and the `plan.json` entries, and the plan for that run alone, with `maxScrapes` set to the request's pages. `contractUnknownStatus` reads the status of a contract row for continuation checks. `writeResult` records the outcome beside the request. Terminal and partial results are trusted only when `recordTrustedResult` has recorded hashes of both request and result in the machine-local `research-kit.autocollect.json`; `isTrustedResult` checks those hashes so a builder cannot submit a result as if the collector wrote it. The auto-collect settings (`off`/`auto`, pages a request, pages a day, interval, the folder for new topics) and the daily meter live in `research-kit.autocollect.json` beside the machine config (`readAutoCollect`, `saveAutoCollect`, `recordSpend`, `setPaused`); no key is in them. | ADR-0148 | | `auto-collect.mjs` | **The collector listening** (ADR-0148, 2026-10-07): `createAutoCollect({ project, env, kitRoot, exec, lock })` returns `cycle`, `status`, `schedule`, `resume`, `stop`. A cycle resolves the project folder to its final path (`fs.realpathSync.native`) before comparing it with git's top level, so a Windows 8.3 short name or a symlinked folder cannot turn a path git stages into one outside the repository. `status()` uses that same canonical project path so its trust lookup matches collector records across alternate Windows path spellings. A cycle refuses unless `collectionPolicy` says the machine may collect, and skips while another run holds the shared `lock` (the panel's; its own when none is given). Otherwise it runs `git pull --ff-only`; a failed pull is surfaced and blocks the cycle before requests are read or processed. On success, it goes through each request. Results received over git are ignored unless their request and result hashes match collector-local trust records, including in `status()`; trusted terminal results retry pending commit/push delivery without collecting again, and trusted `partial` results carry continuation state and resume only while their contract unknown is not CLOSED. An invalid one gets a refused result, committed and pushed. A valid one, within the per-request and daily caps and with `research/` clean, is applied (`requests.mjs`), then its target-specific `research/requests/.plan.json` runs, followed by `preflight.mjs`, whose verdict is recorded, not acted on. It then stages `research/` and the ledger by name (`git add -f`), commits, and pushes when there is an upstream. Accounting travels on the fixed Node research child's private IPC descriptor (ADR-0149), never its human output: exactly one bounded, typed report with measured pages within the admitted allowance and the exhaustion provider. Missing, invalid, duplicate or over-allowance reporting reserves the remaining admitted allowance against the daily meter, labels actual spend unknown, records a terminal failed result and pauses. Earlier measured continuation attempts stay counted once. A valid received report survives failed exit or later interruption; interruption also pauses. Diagnostic output keeps a bounded redacted tail with a truncation notice, and failure detail is bounded separately. The legacy `spentPages`/`creditsStopped` exports remain diagnostic compatibility helpers. It never passes `--fallback`: received exhaustion accounting makes the loop commit the partial corpus and continuation state, pause until the operator resumes, then reuse the saved target, unknown and remaining page allowance (ADR-0129). A `topic` request gets `new-project.mjs` and `decompose.mjs --max-scrapes 0` in a dated folder under the folder the operator approved, never over an existing project (ADR-0056). It is refused when no folder is approved. Diagnostic details, refusal reasons and preflight verdicts are scrubbed with the existing redactor before result files or commit reports are written (2026-10-08). Status also scrubs inherited trusted result details, delivery diagnostics and pause text without rewriting those files or changing their trust hashes; the configured redactor defines which secret is recognized. Every child gets a fixed argument list; `exec` is injectable for tests. The panel labels its unchanged numeric meter as pages used or reserved. **Allowance regression coverage** (2026-10-09): the four-cycle credit exhaustion and resume fixture runs real synthetic Node scripts against persisted request, plan, result and meter files, with a finite injected Git executor. Native round-trip, partial-delivery and topic-resume fixtures retain real Git and corpus-transfer assertions. The test watchdog remains 60 seconds. **Complete Git answers** (ADR-0151): the bounded child byte buffer reports a private `truncated` flag. Repository and Git metadata paths, upstream, ahead count, commit subject and changed paths, dirty state, and pending delivery status/diff all refuse truncated output before it can authorize work; a malformed ahead count or missing quiet-diff exit also blocks delivery. A newly written partial result is trusted before fingerprinting, so refused inspection stages nothing and cannot make a restart collect it again. Exit-only operations retain bounded diagnostic logs. **Admission survives collector termination** (ADR-0152): `reserveCollection` saves the newly admitted allowance and an owned in-flight pause atomically before the fixed research child starts; a failed meter write prevents spawn. `settleCollection` refunds unused allowance once on a valid receipt, preserves earlier spend and distinct pauses, and retains the full charge for unknown/spawn-failed attempts. Whole collector termination leaves its charge/pause across restart or project switch. Continuation reserves only its remaining newly admitted pages. Day-aware settlement never refunds yesterday against today; cross-day completion charging is conservative, and an over-cap completion remains paused. The existing file writer is atomic, while the mutex remains per collector instance; racing independent instances and power-loss durability are not certified by this correction. Successful result.next says captures were delivered: the builder reads/judges, the collector records Git Findings, closures, MAP and the current authored BRIEF; another fetch is only for a missing external fact. ADR-0052 local received-package review remains available, and derived approval stays role-agnostic (2026-10-08, ADR-0150). | ADR-0148, ADR-0149, ADR-0150, ADR-0151, ADR-0152 | -| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | +| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). Install-state reads require an object and shape existing path, path-array and skill-hash fields before consumers use them; doctor names an existing unreadable or pathless record in its deploy warning (#365). | ADR-0002, ADR-0010, ADR-0012 | | `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, an `EVIDENCE.md` that is absent or holds no evidence table (`handoff-evidence-missing`, `handoff-evidence-unparsed`; the report carries `rows`, and the CLI and doctor print the count - 2026-10-02: with no rows there were no citations, so "every cited capture on disk" held vacuously and handoff said OK over a corpus whose table a sync tool had dropped), every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). The report carries `nothingCollected` (no ledger entry, no evidence row, no capture) since 2026-09-27, so a caller can tell a project never collected from a corpus that failed to travel. The finding names are unchanged, so the doctor and the artifact validator read them as before. The report also carries `brief: { state, todo }` (`briefReview`, 2026-09-27, ADR-0061): the CLI's "handoff OK" is followed by a note when the brief is a draft (naming the judged sections still TODO) or has not been drafted. It is never a finding, and the exit stays 0. **The line-ending remedy is judged by running it** (2026-09-27, ADR-0062): per affected capture it prints `git rm --cached` (the index entry only) then `git checkout HEAD --`, which rewrites the file as LF through the pin, and it prints the pin (`PIN_LINES`, to add in an editor) only when `.gitattributes` lacks it. Every printed command is a plain git command with no comment, so cmd and PowerShell run it as written (ADR-0070). `--renormalize` had fixed only the index, so the capture handoff reads stayed CRLF. A **third cause** (2026-09-28): a ledger present and empty while captures are on disk lost its entries where the captures are - a one-line ledger whose torn tail `doctor --fix-arity` dropped - so `ledgerLost` gets `ledgerLostRemedy()`: restore it from git, or re-collect with `--force`. It was given the push remedy, which on the collector pushes the same empty ledger. A ledger that is merely **short** is the same cause: a cited capture on disk that no entry records is `handoff-capture-unledgered` (2026-09-28) and gets the same remedy - handoff had said OK and exited 0 while preflight failed it as `fetch-entry-exists`. **`CORPUS_ADD`** (2026-09-28) is how the kit prints putting a corpus into git - `git add research/`, then `git add -f research/raw/.fetches.jsonl` - shared by the push remedy and the commit gate's fix: both said `git add -f` on the whole folder, which also committed the machine-local `.usage.jsonl`, `.failures.jsonl`, `.diagnostics.jsonl` and `.fetches.lock`. A **fourth cause** (2026-10-04, break-test pass 4, F5b): a capture whose body hash fails and NOT by line endings (`body-unmodified/modified`) was changed after its fetch, and the report carries it as `altered`; it no longer counts as "did not travel", and **`alteredRemedy(files, { isRepo })`** prints, per file, `git status --porcelain --` to tell a local change from a committed one, then `git checkout HEAD --` followed by handoff again (no folder-wide checkout, which would discard uncommitted work beside it), and for the committed case `git log --oneline --` to find the commit that held the fetched bytes and restore from it, here or on the collector; without git metadata it prints no command. Judged by running it (2026-10-04, external review): a dirty status says only that this checkout changed the file after the commit - a capture committed altered and edited again is dirty too - so the remedy no longer claims the committed copy is the fetched one; and `--force` is no longer offered, because a new fetch writes a new capture beside this one, or refuses to overwrite a differing source sibling, while `verifyLedger` still checks the entry that names this file. The push remedy it used to get sends the same bytes again when the change was committed, and sends an operator to another machine when the committed copy here was one checkout away. **`safePathspec(file)`** (2026-10-04, two external reviews) decides whether a capture path is printed inside a git command at all: letters, digits, `.`, `_`, `-` and `/`, the alphabet the collector's own names come from. Anything else is named but never printed as a command, because no one spelling is read literally by cmd, PowerShell and sh alike and by git: a space unquoted is two pathspecs, `$copy` inside double quotes is expanded to nothing by PowerShell, and `[12]` is a git glob whatever the shell did, so `git checkout HEAD --` restored a neighbour and discarded its uncommitted work. For those the remedy prints commands that type no name at all: the operator lists them, one per line, in `handoff-names.txt` (`NAMES_FILE`) and runs `git --literal-pathspecs checkout HEAD --pathspec-from-file=handoff-names.txt` (the line-ending remedy adds the `git rm --cached` step the same way) - "quote it for your shell and prefix `:(literal)`" was not enough, because cmd expands `%NAME%` inside double quotes and PowerShell `$name` (third external review, 2026-10-04, verified by running in cmd); `--pathspec-from-file` hands git the bytes of the file with no shell in between, and `--literal-pathspecs` switches pattern syntax off (both git 2.25+). Such a name reaches the ledger rarely but not only by hand: `captureName` embeds the URL's host as it is, so an IPv6 literal or an adapter-supplied host outside DNS's alphabet produces one (third review). The classification runs over EVERY affected file (`classify`), not the five the text names: an unsafe sixth file got no note and "handle the remaining files the same way, each by name" - an instruction to type the glob that restores a neighbour (third external review, 2026-10-04). **`insideRepository(root)`** (2026-10-04, two external reviews) decides whether the runnable remedies are printed, and it asks git: `git -C rev-parse --is-inside-work-tree`. It was `root/.git` alone, so a nested decision project (ADR-0030), which has none of its own, was told it had "no git metadata"; then a walk up for a `.git` entry, which walked past a bare repository the project sat inside and printed a checkout git refused with "this operation must be run in a work tree". The commands are git's, so git decides whether they run; without git on PATH the walk is the fallback: it walks the PHYSICAL path (realpath first - a project reached through a junction into a repository had read as outside it, and one linked out of a repository as inside; third review) and reads `GIT_CEILING_DIRECTORIES` as git does: absolute entries only, each through realpath until an empty entry, after which the rest are taken as written. Git's pathspecs are relative to the cwd, so the printed commands run unchanged from the project folder. The tests that want "no git metadata" from a scratch fixture set `GIT_CEILING_DIRECTORIES` above it and go through `handoffRemedy`, so a detection that always says "repository" fails them. **`repositoryState(root)`** is the full answer, `{ inside, refused }`: when git answered with something other than yes, "not a git repository" or "must be run in a work tree" - `fatal: detected dubious ownership` - `refused` carries its first line and both remedies say git refused to read the repository and to fix that first, where they said "no git metadata, re-copy the corpus" (third review, 2026-10-04); no command is printed either way. | ADR-0011, ADR-0020 | | `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, and (from 2026-09-21) `AGENTS.md`, which is the file the agent protocol is *written in* — caught drifting by this check when ADR-0030 gave decision research a committed home and the rule that sends an agent there had to be where agents read their rules — each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. `bin/bundle.mjs --json` prints `verifyBundle`'s result as JSON (2026-09-28): the flag was accepted and never read, so a caller parsing it got the markdown report. `START_HERE.md` joined `EXPECTED_TO_DRIFT` on 2026-09-29: the archived copy held the author's machine paths, and the portable one is the template rendered with `$HOME`. | ADR-0028 | | `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..27f8d04f 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 +1792 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/doctor.mjs b/research-kit/lib/doctor.mjs index 80353be0..6289c127 100644 --- a/research-kit/lib/doctor.mjs +++ b/research-kit/lib/doctor.mjs @@ -25,7 +25,7 @@ import { posture, machineRole, collectionPolicy, readMachineConfig, retiredEnvNotes, hooksPath, hooksPathEffective, runtimePaths, skillLocations, readInstallState, gitVersion, isGitRepo, localHooksPathOverride, previousHooksPath, repoTopLevel, RETIRED_CONFIG_KEYS, RETIRED_EDIT_GATE_HOOKS, - EDIT_GATE_HOOK, KIT_HOME, namesHook, + EDIT_GATE_HOOK, KIT_HOME, INSTALL_STATE_PATH, namesHook, } from './machine.mjs'; import { kitCommand } from './core.mjs'; @@ -542,8 +542,10 @@ export function runDoctor(root, { env = process.env, gitPaths = {}, probe = prob // `commitGateState` reads the same way - so the measurement and the message agree. const deployHome = installed?.kitHome ?? KIT_HOME; const drift = deployedDrift({ env, kitHome: deployHome }); - if (!installed) { - findings.push(f('warn', 'deploy', `no install state recorded - run: node ${path.join(KIT_ROOT, 'bin', 'install.mjs')}`)); + if (!installed?.kitHome) { + const stateFile = env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH; + const detail = exists(stateFile) ? `the install state at ${stateFile} is not readable` : 'no install state recorded'; + findings.push(f('warn', 'deploy', `${detail} - run: node ${path.join(KIT_ROOT, 'bin', 'install.mjs')}`)); } else if (samePath(KIT_ROOT, deployHome)) { // Found 2026-10-02 (break-test pass 3): run from the deployed copy - the copy an agent // runs - after a line was appended to its lib/core.mjs, doctor printed "matches this diff --git a/research-kit/lib/machine.mjs b/research-kit/lib/machine.mjs index 59d715ac..3524bdc4 100644 --- a/research-kit/lib/machine.mjs +++ b/research-kit/lib/machine.mjs @@ -418,7 +418,22 @@ export function localHooksPathOverride(cwd = process.cwd(), opts = {}) { // ---------------------------------------------------------------- install state export function readInstallState(env = process.env) { - return readJson(env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH, null); + const raw = readJson(env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH, null); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const state = { ...raw }; + const isPath = (value) => typeof value === 'string' && value.trim().length > 0; + for (const key of ['kitHome', 'hooksPath', 'previousHooksPath', 'bound', 'deployedFrom']) { + if (!isPath(state[key])) delete state[key]; + } + for (const key of ['skills', 'boundSet']) { + if (key in state) state[key] = Array.isArray(state[key]) ? state[key].filter(isPath) : []; + } + if ('skillSetHashes' in state) { + const hashes = state.skillSetHashes; + state.skillSetHashes = hashes && typeof hashes === 'object' && !Array.isArray(hashes) + ? Object.fromEntries(Object.entries(hashes).filter(([, hash]) => typeof hash === 'string')) : {}; + } + return state; } export function writeInstallState(state, env = process.env) { diff --git a/research-kit/test/doctor.test.mjs b/research-kit/test/doctor.test.mjs index e05704bc..ecd66d11 100644 --- a/research-kit/test/doctor.test.mjs +++ b/research-kit/test/doctor.test.mjs @@ -26,6 +26,28 @@ function machine({ config = {}, settings = undefined } = {}) { const find = (findings, name) => findings.find((f) => f.name === name); +test('malformed install records produce a named deploy warning and valid CLI JSON', () => { + const root = makePassingProject(); + const { dir, env } = machine({}); + const gitEnv = { ...env, GIT_CONFIG_GLOBAL: path.join(dir, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; + writeText(gitEnv.GIT_CONFIG_GLOBAL, `[core]\n hooksPath = ${JSON.stringify(path.join(KIT_ROOT, 'githooks').replace(/\\/g, '/'))}\n`); + for (const record of [{ kitHome: 42 }, { kitHome: {} }, { kitHome: true }, { kitHome: ['/x'] }, { kitHome: '' }, [], 'str']) { + writeText(env.RESEARCH_KIT_INSTALL_STATE, JSON.stringify(record)); + const report = runDoctor(root, { env: gitEnv, gitPaths: { env: gitEnv }, probe: READY, record: false }); + const warning = find(report.findings, 'deploy'); + assert.equal(warning.severity, 'warn'); + assert.ok(warning.detail.includes(env.RESEARCH_KIT_INSTALL_STATE)); + assert.match(warning.detail, /not readable/); + } + writeText(env.RESEARCH_KIT_INSTALL_STATE, '{"kitHome":42}'); + const result = spawnSync(process.execPath, [path.join(KIT_ROOT, 'bin', 'doctor.mjs'), '--json'], { + cwd: root, env: gitEnv, encoding: 'utf8', windowsHide: true, + }); + assert.doesNotMatch(result.stderr, /ERR_INVALID_ARG_TYPE|TypeError/); + const report = JSON.parse(result.stdout); + assert.ok(report.findings.some((finding) => finding.name === 'deploy' && finding.severity === 'warn')); +}); + // A no-hit result describes inspected files. Reaching the count at the final file is // different from returning at that count while another path remains uninspected. test('secret cap: coverage distinguishes a complete boundary from an uninspected path', () => { diff --git a/research-kit/test/machine.test.mjs b/research-kit/test/machine.test.mjs index 251d7851..96b5135d 100644 --- a/research-kit/test/machine.test.mjs +++ b/research-kit/test/machine.test.mjs @@ -6,13 +6,44 @@ import { pathToFileURL } from 'node:url'; import { test, describe, assert, tempDir, fs, path, os, KIT_ROOT } from './harness.mjs'; import { writeText, readText, homeDir } from '../lib/core.mjs'; import { - readMachineConfig, loadConfig, saveConfig, posture, machineRole, collectionPolicy, + readMachineConfig, loadConfig, saveConfig, posture, machineRole, collectionPolicy, readInstallState, collectionRefusal, evidencePolicy, runtimePaths, skillLocations, retiredEnvNotes, RUNTIME_ANCHORS, RETIRED_ENV_VARS, RETIRED_CONFIG_KEYS, DEFAULTS, ROLES, } from '../lib/machine.mjs'; describe('machine'); +test('install state refuses JSON that is not an object', () => { + const file = path.join(tempDir('rk-state-shape-'), 'install.json'); + const env = { RESEARCH_KIT_INSTALL_STATE: file }; + for (const value of [null, [], 'str', true, 42]) { + writeText(file, JSON.stringify(value)); + assert.equal(readInstallState(env), null, JSON.stringify(value)); + } +}); + +test('install state keeps typed paths, arrays and skill hashes without changing the file', () => { + const file = path.join(tempDir('rk-state-fields-'), 'install.json'); + const env = { RESEARCH_KIT_INSTALL_STATE: file }; + const paths = ['kitHome', 'hooksPath', 'previousHooksPath', 'bound', 'deployedFrom']; + for (const value of [42, true, {}, ['/x'], '', ' ']) { + writeText(file, JSON.stringify(Object.fromEntries(paths.map((key) => [key, value])))); + const state = readInstallState(env); + for (const key of paths) assert.equal(state[key], undefined, `${key}: ${JSON.stringify(value)}`); + } + const raw = { ...Object.fromEntries(paths.map((key) => [key, '/valid/path'])), skills: ['/a', 42, '', '/b'], + boundSet: ['/c', false, null], skillSetHashes: { '/a': 'hash', '/b': 42 }, at: 'saved-time' }; + writeText(file, JSON.stringify(raw)); + const before = readText(file); + const state = readInstallState(env); + for (const key of paths) assert.equal(state[key], raw[key]); + assert.deepEqual(state.skills, ['/a', '/b']); + assert.deepEqual(state.boundSet, ['/c']); + assert.deepEqual(state.skillSetHashes, { '/a': 'hash' }); + assert.equal(state.at, 'saved-time'); + assert.equal(readText(file), before); +}); + function envWith(contents, { snapshot = null, extra = {} } = {}) { const dir = tempDir('research-kit-config-'); const file = path.join(dir, 'research-kit.config.json'); From 211caabce5a6c85b4d48e75180e18c5112a85393 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 17:19:28 +0300 Subject: [PATCH 17/27] fix(artifact): require provenance from packaged evidence what changed: Ledger verification is triggered by actual raw archive entries and parsed evidence rows as well as manifest claims. Added relabelled-capture/missing-ledger, relabelled-capture/invalid-ledger and evidence-rows-only regressions. Corrected the existing failed-collection fixture to have an actually empty evidence table. why: Untrusted manifest roles and capture counts could turn off provenance checks while evidence still travelled. Judge what the archive carries, preserving the accepted empty failed-collection case. No alternative design was set aside. what it touched: artifact validator/tests, architecture row, changelog and research-kit README inventory. No new format, option or provenance implementation. what you verified: verified - red-first 45 passes and one expected failure; after the implementation, the old failed-collection fixture exposed retained evidence rows despite its empty-scaffold claim. Corrected that fixture, then all 46 artifact-validator tests passed in 6.5 seconds. Missing/garbage chains fail even with relabelled roles and zero counts; the genuinely empty failed-collection case passes. Handoff OK, preflight PASS, doctor READY collector/firecrawl-cli in this checkout. The required full offline suite runs in the commit gate before acceptance. what you got wrong and fixed: mistake - initially accepted the existing failed-collection fixture's empty-scaffold description. where - fixture 03-failed-collection-valid. impact - the first targeted run rejected it because it actually retained collected evidence; no delivered PR was affected. cause - the old fixture removed captures and ledger but kept the populated EVIDENCE table. fix - empty that synthetic table to represent the promised uncollected scaffold, preserving production checks. verified - 46 artifact-validator tests passed, including both the empty scaffold and the evidence-rows-only ledger requirement. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/artifact-validator.mjs | 14 ++++---- research-kit/test/artifact-validator.test.mjs | 32 +++++++++++++++++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..e94e92d5 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 + +- Artifact ledger checks follow actual capture entries and evidence rows, even when the manifest relabels captures and declares zero; genuinely empty failed-collection scaffolds remain valid (#358). - 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..a436a2c5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,7 +178,7 @@ is an open decision (2026-09-30): | `config.mjs` | A **compatibility shim**, nine lines, re-exporting `machine.mjs`'s config surface so an older import path keeps working. It adds nothing and decides nothing. Listed because it was the one module in `lib/` this map did not mention — found by counting files against rows during the 2026-09-20 reconciliation, which is the only way that kind of omission surfaces. | ADR-0002 | | `archive.mjs` | **One container format**: `buildZip(entries)` → bytes, `writeZip(file, entries)` → `{ file, bytes, entries }` (written whole or not at all, through `writeBytes`, since 2026-09-28), `crc32`, `entryName`. ZIP with deflate (store when deflating would only grow the payload), 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-validator.mjs` | Ledger verification follows actual archive capture entries and parsed evidence rows, so relabelling roles or declaring zero captures cannot disable provenance checking; an empty failed-collection scaffold still passes (#358). **Is this package valid, and does it authorize a build** — two questions, kept apart. `validateArtifact({ file, expectedClientRef })` → `{ status, buildAuthorized, packageId, clientRef, workflowRunId, state, errors, warnings }`, where `status: 'PASS'` means internally consistent and says *nothing* about permission. Offline and read-only by construction: no transport imported, `process.env` never read, nothing from the package executed, no shell, and the only write is a `fs.mkdtemp` copy of `project/` removed in a `finally` — needed because the chain and handoff checks are the kit's existing code (`readCorpus`, `verifyHandoff`) and reuse beats a second implementation of provenance verification nobody tested. Stable error codes (`ZIP-*`, `MANIFEST-*`, `FILE-*`, `LEDGER-*`, `CAPTURE-*`, `AUTHORIZATION-INCONSISTENT`, `SECRET-DETECTED`, `FORMAT-UNSUPPORTED`, `CLIENT-REF-MISMATCH`) and **never a secret value in an error** — the report names the pattern and the line. `authorizationProblems()` is the one implementation of the build rule; the schema encodes it a second time and when the schema catches it first the rule is still reported in its own words. An unsupported format **major** is `INCOMPLETE`, not invalid. A crash is `BLOCKED`, never `PASS`. Its report carries `reviewedBy` (`review.by`, or `undeclared` for a 1.0.0 package) and judges nothing by it (ADR-0074). 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 | | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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-validator.mjs b/research-kit/lib/artifact-validator.mjs index 656646f9..da467a5d 100644 --- a/research-kit/lib/artifact-validator.mjs +++ b/research-kit/lib/artifact-validator.mjs @@ -28,11 +28,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { sha256, tempBase, looksBinary } from './core.mjs'; +import { sha256, tempBase, looksBinary, HEADERS } from './core.mjs'; import { openZip, ZipError, ZIP_LIMITS } from './artifact-zip.mjs'; import { parseJsonNoDuplicates } from './release/json.mjs'; import { validateJsonSchema } from './release/schema.mjs'; -import { readCorpus } from './corpus.mjs'; +import { readCorpus, parseTable } from './corpus.mjs'; import { verifyHandoff } from './handoff.mjs'; import { SECRET_PATTERNS } from './doctor.mjs'; @@ -345,11 +345,13 @@ export function validateArtifact({ // package shape whose entire purpose is to carry a diagnosis home, and would do it // with a message implying evidence went missing when none was ever fetched. // - // So the trigger is a claim: a declared RAW_CAPTURE, or a manifest asserting captures. - // Either of those says "there is evidence in here", and evidence without its chain is - // exactly what this format refuses to let travel. + // The contents are also evidence: a producer cannot disable the chain check by + // relabelling captures or declaring their count as zero. + const packagedEvidence = bodies.get('project/research/EVIDENCE.md'); const claimsEvidence = declaredFiles.some((f) => f.role === 'RAW_CAPTURE') - || (manifest.collection?.captures ?? 0) > 0; + || (manifest.collection?.captures ?? 0) > 0 + || zip.names.some((name) => name.startsWith('project/research/raw/') && name !== LEDGER_PATH && !name.endsWith('/')) + || (packagedEvidence && parseTable(packagedEvidence.toString('utf8'), HEADERS.evidence).rows.length > 0); if (claimsEvidence) { if (!zip.has(LEDGER_PATH)) { reject('FAIL', 'LEDGER-MISSING', `${LEDGER_PATH} is not in the package, but the package claims collected evidence - a corpus without its chain proves nothing`, diff --git a/research-kit/test/artifact-validator.test.mjs b/research-kit/test/artifact-validator.test.mjs index 077fdc67..252a460c 100644 --- a/research-kit/test/artifact-validator.test.mjs +++ b/research-kit/test/artifact-validator.test.mjs @@ -26,6 +26,33 @@ const copyPayload = (payload) => payload.map((e) => ({ name: e.name, data: Buffe const text = (s) => Buffer.from(s, 'utf8'); +test('relabelled evidence still requires and verifies its ledger from package contents', () => { + for (const variant of ['missing-ledger', 'invalid-ledger', 'rows-without-captures']) { + const manifest = clone(collected.manifest); + let payload = copyPayload(collected.payload); + manifest.collection.captures = 0; + manifest.files = manifest.files.map((entry) => entry.role === 'RAW_CAPTURE' ? { ...entry, role: 'OTHER' } : entry); + if (variant === 'invalid-ledger') { + const ledger = payload.find((entry) => entry.name.endsWith('.fetches.jsonl')); + ledger.data = text('{"garbage":true}\n'); + const declared = manifest.files.find((entry) => entry.path === ledger.name); + declared.byteLength = ledger.data.length; + declared.sha256 = sha256(ledger.data); + } else { + const removed = new Set(payload.filter((entry) => entry.name.endsWith('.fetches.jsonl') + || (variant === 'rows-without-captures' && entry.name.startsWith('project/research/raw/'))).map((entry) => entry.name)); + payload = payload.filter((entry) => !removed.has(entry.name)); + manifest.files = manifest.files.filter((entry) => !removed.has(entry.path)); + } + const file = writeFixture(scratch, variant, seal(payload, manifest, { recomputeFiles: false })); + const report = validateArtifact({ file, tempRoot: scratch }); + assert.equal(report.status, 'FAIL', variant); + assert.equal(report.buildAuthorized, false); + const code = variant === 'invalid-ledger' ? 'LEDGER-INVALID' : 'LEDGER-MISSING'; + assert.ok(report.errors.some((error) => error.code === code), JSON.stringify(report.errors)); + } +}); + /** * name -> { build, expect }. `expect.code` means "this code must be among the errors", * not "this is the only error": a package can be wrong in more than one way and pinning @@ -46,6 +73,11 @@ const FIXTURES = { build: () => { const payload = copyPayload(collected.payload) .filter((e) => !e.name.startsWith('project/research/raw/')); + // A genuinely uncollected scaffold has no evidence rows either. Leaving the + // collected fixture's table here falsely claimed evidence with no chain. + payload.find((entry) => entry.name === 'project/research/EVIDENCE.md').data = text( + '# Evidence\n\n| ID | Retrieved | Type | URL | Finding | Raw |\n|---|---|---|---|---|---|\n', + ); const manifest = clone(collected.manifest); manifest.state = 'COLLECTION_FAILED'; manifest.kind = 'COLLECTED_CORPUS'; From aa1d673f66f364eab0230b1bfdc9c0a9ca02d3c0 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 17:46:34 +0300 Subject: [PATCH 18/27] 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'); From dfaf34c9a6d4c39110d0b313d4dd881728611989 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 18:45:22 +0300 Subject: [PATCH 19/27] fix(artifact): rederive package approval from contents what changed: Share authorization derivation between producer and validator. Re-derive claimed approvals from package contents under a portable baseline, preserve false claims, and verify pre-0069 format-1 stamps against their historical input hash. Add regressions, ADR-0160, source probes and indexes. why: A manifest's approval booleans are claims, not proof. Portable validation must evaluate package contents without consumer policy and preserve verified legacy behavior. what it touched: artifact state/producer/validator modules and tests; architecture map and 64-module inventory, ADR-0160, decision research corpus and trace, changelog and README inventory/count. what you verified: verified - targeted artifact group 105 passed, 0 failed, 2 unsupported Windows symlink checks; architecture-map 21 passed; root preflight PASS (28 pass, 1 warn); handoff OK; doctor ok with no blockers. Full commit gate is running again. what you got wrong and fixed: mistake - initial module inventory stayed at 63 after adding artifact-state.mjs; where - docs/ARCHITECTURE.md current inventory; impact - first gate blocked commit, no delivery affected; cause - new module row was added without updating the filesystem count; fix - corrected count to 64; verified - architecture-map passed 21/21. Also mistake - research probe README trace link had the wrong relative depth; where - nested research/probes/README.md; impact - link would not resolve, no product impact; cause - parent directory depth was miscounted; fix - corrected to ../../../collection-357.log; verified - rg shows corrected link. --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 7 +- ...l-is-rederived-with-a-portable-baseline.md | 66 + docs/adr/README.md | 2 + .../.gitattributes | 10 + .../.gitignore | 32 + .../AGENTS.md | 164 +++ .../START_HERE.md | 81 ++ .../docs/ARCHITECTURE.md | 36 + .../research/BRIEF.md | 52 + .../research/DISCOVERY.md | 48 + .../research/EVIDENCE.md | 19 + .../research/MAP.md | 35 + .../research/SOURCES.md | 16 + .../research/TIMELINE.md | 7 + .../research/kit.json | 7 + .../research/plan.json | 24 + .../research/probes/README.md | 5 + .../probes/probe-historical-profiles.mjs | 48 + .../research/probes/profiles.json | 58 + .../research/raw/.fetches.jsonl | 9 + .../research/raw/.gitkeep | 0 ...nd-the-verdi-githubusercontent-56f0ed5d.md | 47 + ...artifact-mjs-githubusercontent-f0a2d470.md | 592 +++++++++ ...alidator-mjs-githubusercontent-34c19498.md | 524 ++++++++ ...13-brief-mjs-githubusercontent-37cece34.md | 365 ++++++ ...13-brief-mjs-githubusercontent-fea9e4f1.md | 289 +++++ ...ief-test-mjs-githubusercontent-237028e4.md | 205 +++ ...3-checks-mjs-githubusercontent-c6fb39d5.md | 1114 +++++++++++++++++ ...-machine-mjs-githubusercontent-2ccae69e.md | 440 +++++++ ...reflight-mjs-githubusercontent-4b093d0d.md | 103 ++ docs/decisions/README.md | 1 + docs/decisions/collection-357.log | 24 + research-kit/README.md | 5 +- research-kit/lib/artifact-state.mjs | 150 +++ research-kit/lib/artifact-validator.mjs | 18 + research-kit/lib/artifact.mjs | 112 +- research-kit/test/artifact-producer.test.mjs | 15 +- research-kit/test/artifact-validator.test.mjs | 111 +- 39 files changed, 4726 insertions(+), 116 deletions(-) create mode 100644 docs/adr/0160-package-approval-is-rederived-with-a-portable-baseline.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/.gitattributes create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/.gitignore create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/AGENTS.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/START_HERE.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/docs/ARCHITECTURE.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/BRIEF.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/DISCOVERY.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/EVIDENCE.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/MAP.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/SOURCES.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/TIMELINE.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/kit.json create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/plan.json create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/README.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/probe-historical-profiles.mjs create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/profiles.json create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/.fetches.jsonl create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/.gitkeep create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdi-githubusercontent-56f0ed5d.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-mjs-githubusercontent-f0a2d470.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-validator-mjs-githubusercontent-34c19498.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-37cece34.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-fea9e4f1.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-test-mjs-githubusercontent-237028e4.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-checks-mjs-githubusercontent-c6fb39d5.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-machine-mjs-githubusercontent-2ccae69e.md create mode 100644 docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-preflight-mjs-githubusercontent-4b093d0d.md create mode 100644 docs/decisions/collection-357.log create mode 100644 research-kit/lib/artifact-state.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..b081bde3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 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 +- Artifact validation no longer trusts an `APPROVED_BRIEF` manifest by itself (#357, ADR-0160). It re-derives approval from checked package contents with a portable baseline, preserves false claims, and accepts only verified format-1 pre-0069 stamp hashes. - 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..39c89717 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,8 +178,9 @@ is an open decision (2026-09-30): | `config.mjs` | A **compatibility shim**, nine lines, re-exporting `machine.mjs`'s config surface so an older import path keeps working. It adds nothing and decides nothing. Listed because it was the one module in `lib/` this map did not mention — found by counting files against rows during the 2026-09-20 reconciliation, which is the only way that kind of omission surfaces. | ADR-0002 | | `archive.mjs` | **One container format**: `buildZip(entries)` → bytes, `writeZip(file, entries)` → `{ file, bytes, entries }` (written whole or not at all, through `writeBytes`, since 2026-09-28), `crc32`, `entryName`. ZIP with deflate (store when deflating would only grow the payload), 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-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. Claimed approvals are re-derived from checked bytes with the portable unpromoted pluralist, 180-day baseline; warnings name that baseline and format-1 legacy compatibility. | ADR-0032, ADR-0160 | +| `artifact.mjs` | **Packaging a project without changing it; shared authorization derivation lives in `artifact-state.mjs`.** `createArtifact` → `{ bytes, name, manifest, entries, derived }`; `writeArtifact` writes then **re-validates with the consumer's own validator** before reporting success; its bytes go through `writeBytes`, so re-creating a package at a name that already holds one cannot trade a good package for a truncated one (2026-09-28). There is no `--build-authorized` and no option that reaches an authorization field: `deriveState(root)` runs the real gate with `env` defaulting to `{}` so a packaging shell's override cannot become a property of a package that outlives it. `mapClassified` and `briefReviewed` have honest machine answers; `findingsReviewState` answers the third **negatively** — a `Finding` byte-identical to what `firstFinding` re-extracts from the capture was demonstrably never rewritten, which detects the unreviewed case without grading the reviewed one (ADR-0013's line, uncrossed). It repeats the collector's own call exactly - the page body after the front-matter, falling back to the page's `title` or `url` from that front-matter - since 2026-09-27: run over the whole file, the extractor read the front-matter as the page, and a URL-only corpus nobody had touched (one JSON capture, run 36287211468) was packaged `findingsReviewed: true`. `packageName` never carries the topic (an artifact listing is world-readable on a public repository) and falls back to `run-`; `checkClientRef` refuses rather than sanitises. Inventory sorted by ZIP path, manifest and digest appended **last**, digest over the exact manifest bytes. README-FIRST in a package that is not yet approved prints the preflight and brief commands and **one `create` line carrying the package's own identity** (`repackageArgs`, 2026-09-27): the manifest is written once, and the package gave its reviewer no way to get one that says `APPROVED_BRIEF`. Commands are spelled by `homeCommand` (ADR-0050). `createArtifact` checks the identity against the manifest schema's own `source` rules before deriving or writing anything (`sourceProblem`, 2026-09-27): `create --commit abc` wrote a package, then reported it did not validate, and left it on disk. **`review.by`** (format 1.1.0, ADR-0074) is the reviewer's declaration read from `BRIEF.md` by `reviewedBy`: `agent` or `undeclared` (`human` was retired in format 2.0.0, ADR-0107). Not an approval condition. README-FIRST no longer says "a human reviewed it"; it names the declared reviewer, or says none was declared. `collectProjectFiles` and the explicit ledger add refuse (`OUTSIDE_PROJECT`) any path whose real location is outside the project: it followed links and packed outside files, a private key included (ADR-0076). The same walk asks the consumer's own `checkEntryName` about every name it is about to store and refuses the run before a byte is written (`UNPACKAGEABLE_NAME`, 2026-09-28): `entryName` translates a backslash into a path separator, so a file named `a\b.md` travelled as `a/b.md` - a directory invented out of a name - while the manifest kept the original, and only the read-back caught the contradiction, after the package had been written. The same refusal covers two files differing only in case (2026-09-28), 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-state.mjs` | The one approval derivation shared by producer and validator: real gate and handoff, classified map, reviewed findings, authored/current brief; consumers rederive claimed approval under the explicit portable baseline and exact format-1 pre-0069 input hash (ADR-0160). | ADR-0032, ADR-0160 | | `mcp.mjs` | **The collector as a Model Context Protocol server**, and the first module in this repository built from an APPROVED BRIEF rather than from a plan - every design choice cites an E-row in `docs/decisions/2026-09-21-agent-interface/`. `handle(message, deps)` is a pure function of a JSON-RPC message plus injected transport, so the whole surface is tested offline. **stdio, not Streamable HTTP**: semantics are identical on both, so the difference is operational, and remote costs the entire OAuth 2.1 chain. **Two tools, not one** - `collect` returns a run id immediately, `fetch_corpus` returns a link - because a server cannot initiate a request, so the alternative is one call held open for minutes and an agent that loses a paid run when its process dies. **A `resource_link`, never embedded bytes**: the spec defines embedding but states no maximum size, so a link is the defensible option. **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 | @@ -571,7 +572,7 @@ Counted against the filesystem, not estimated: | | | |---|---| -| `lib/` modules | **63** (58, plus the 5 in `lib/release/`) — every one has a row above | +| `lib/` modules | **64** (59, plus the 5 in `lib/release/`) — every one has a row above | | `bin/` entrypoints | **33**, of which **4 are Python**: 3 conformance runners and the module they share | | `schemas/` | **12** | | `conformance/` vector packets | **3** | diff --git a/docs/adr/0160-package-approval-is-rederived-with-a-portable-baseline.md b/docs/adr/0160-package-approval-is-rederived-with-a-portable-baseline.md new file mode 100644 index 00000000..10042a16 --- /dev/null +++ b/docs/adr/0160-package-approval-is-rederived-with-a-portable-baseline.md @@ -0,0 +1,66 @@ +# ADR-0160 - Package approval is re-derived with a portable baseline + +- **Date:** 2026-10-13 +- **Status:** accepted +- **Area:** artifact approval derivation and validation +- **Refines:** ADR-0032; retains ADR-0069 and ADR-0138 compatibility +- **Defect:** #357, qualified gap-audit rank 2 + +## Evidence + +The [decision research](../decisions/2026-10-13-portable-artifact-authorization/research/BRIEF.md) +has nine pinned first-party source captures and a verified ledger. Its three unknowns +are closed and its own preflight passes. The retained profile probe uses explicitly +synthetic passing corpora with the actual historical writer at +`3bac1f26ddbf8cb6a794626e53dc2af313860940`, plus the current writer at `3fa03fa`. +These are historical-format fixtures generated by those real writers, not archived user +corpora. Both the unstamped and early-stamped writer outputs are format 1.0.0 and claim +approval. The early stamp's historical input hash matches its covered inputs but differs +from the current hash; applying only current producer currency rules rejects that valid +historical profile. The current writer's stamp explicitly contains `gate=unknown` while +the early one has no gate field, even though `draftStamp` reports unknown for both. + +## Decision + +- Move the existing approval derivation to one shared module so the producer and + validator use the same gate, map, findings and authored-brief rules without an import + cycle. The producer keeps its current API and machine-specific preflight behavior. +- For a package claiming approval, the validator materializes the already checked + project bytes and re-derives approval with an explicit portable context. It uses the + shared unpromoted pluralist findings and default 180-day freshness horizon, never + consumer policy, hooks or overrides. Report that baseline and its warning findings. +- A package declaring authorization false is never upgraded. Invalid bytes, schema or + provenance still prevent approval before any derivation can authorize a build. +- Current matching stamps retain their normal currency check. Authored unstamped + briefs retain ADR-0138's approval path with unknown currency disclosed. +- Only format major 1 may use pre-0069 stamp compatibility: the last valid literal + marker must have no gate field, and its inputs must match the exact historical hash + over topic, intent, unknowns and evidence rows. Report this compatibility explicitly. + Current gate, map, findings and authored-section checks still run on that corpus. + A mismatch in the historical covered inputs is not accepted, and an explicit + `gate=unknown` marker does not qualify as a missing-field legacy marker. +- Resolve a blank Raw cell through `captureOf`, as the shared corpus resolver does. + An unresolved or unreadable row is not reviewed; extractor-identical findings remain + negative evidence of an unreviewed row. This does not grade semantic correctness. + +## Rejected alternatives + +- **Use the consumer's normal preflight context.** The manifest carries neither the + producer's strict policy nor its custom freshness horizon; a consumer policy or hook + cannot establish the producer's exact verdict and makes authorization nonportable. +- **Reject every old input hash.** The actual historical writer probe produces a valid + early approval that fails that test solely because ADR-0069 expanded the hash. +- **Ignore all historical stamps.** Comparing the historical covered inputs preserves + a check that would otherwise disappear; current and legacy mismatches remain refusals. +- **Compare false claims to an approving derived state and upgrade them.** A conservative + producer declaration remains conservative; the validator cannot supply approval the + incoming package explicitly withheld. + +## Consequences and trigger + +No package format or public CLI option changes. The result's existing warning channel +discloses the portable baseline and legacy compatibility. This checks consistency with +the packaged corpus rather than establishing who reviewed it; ADR-0032's self-attested +provenance and ADR-0013's semantic-review boundary remain in force. A future format with +an explicit policy or a new stamp profile must define its own derivation/compatibility +rules before this legacy exception is extended beyond major 1. diff --git a/docs/adr/README.md b/docs/adr/README.md index ea690bbe..f8af6d78 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -175,4 +175,6 @@ directory and are treated as ADR-equivalent. | [0158](0158-a-small-core-extraction-is-a-proposal-not-a-plan.md) | **A small-core extraction is a proposal, not a plan.** The 2026-10-10 audit's core boundary sketch (93 files, 25,565 physical lines at that head; release-evidence validation, the panel and optional surfaces as candidates for separate distribution) stays a proposal until the ADR-0145 results exist and a measured dependency review of the seam is recorded under `docs/decisions/` (ADR-0030). No boundary, command, distribution or freeze exception adopted. Rejected: extracting the release layer now, rejecting the proposal, keeping it only in working notes. | | [0159](0159-the-22-line-starts-at-22-22-1-because-earlier-builds-lose-windows-1252.md) | **The 22 line starts at 22.22.1, because 22.13.0 to 22.22.0 lose windows-1252.** Those builds' `TextDecoder` reads `windows-1252` as Latin-1 (nodejs/node#60888), so a page's euro signs and curly quotes are captured as control characters with no warning; `nodeLine` reports them as `fail` naming the defect and the first good build, the README says 22.22.1+ or 24+, the hard floor every command refuses below stays at 22. Rejected: a table decoder for 0x80-0x9F behind a runtime probe, refusing those builds in `checkNode`, documenting only. | +| [0160](0160-package-approval-is-rederived-with-a-portable-baseline.md) | Claimed package approval is re-derived from its checked corpus with an unpromoted portable gate; false claims remain false, and major-1 early stamps require their actual historical input hash and a missing gate field. | + Domain vocabulary lives in [`CONTEXT.md`](../../CONTEXT.md). diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/.gitattributes b/docs/decisions/2026-10-13-portable-artifact-authorization/.gitattributes new file mode 100644 index 00000000..b5aaf646 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/.gitattributes @@ -0,0 +1,10 @@ +# The corpus's line endings are integrity, not style. +# +# A capture's body hash is taken over the LF bytes the collector fetched. git's smudge +# filter rewrites every text file on a default Windows checkout (core.autocrlf=true), so +# without this file the whole corpus fails `body-unmodified` on arrival with nothing +# actually wrong with it. This file outranks a reader's core.autocrlf. See ADR-0020. + +research/raw/* text eol=lf +*.jsonl text eol=lf +research/*.md text eol=lf diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/.gitignore b/docs/decisions/2026-10-13-portable-artifact-authorization/.gitignore new file mode 100644 index 00000000..bc49b943 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/.gitignore @@ -0,0 +1,32 @@ +node_modules/ +.DS_Store + +# Credentials never belong in a research project (AGENTS.md Rule 6). Note that +# `firecrawl env` writes the key into .env, which is why the rule says never to run it +# inside a repository. +.env +.env.* +*.pem +*.key + +# The Firecrawl CLI saves scrape output into .firecrawl/ in whatever directory it runs +# from. That is a vendor cache, not evidence - the kit's own captures live in +# research/raw/ with a ledger entry behind each one. +.firecrawl/ + +# The kit's local review logs are not evidence. +research/raw/.usage.jsonl +research/raw/.failures.jsonl +research/raw/.diagnostics.jsonl +research/raw/.fetches.lock +# The gate's override log is this machine's record of a bypass (ADR-0081); doctor reads it here. +research/overrides.log + +# export-warc's default output (ADR-0090): a copy of the corpus for archive tools, not +# evidence - the captures and the ledger are. A WARC written elsewhere with --out is yours. +research-corpus.warc.gz + +# The chain IS evidence and must travel. The explicit negation is here so a broader +# dotfile rule added later cannot silently exclude it - this repository lost its ledger +# exactly that way once. +!research/raw/.fetches.jsonl diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/AGENTS.md b/docs/decisions/2026-10-13-portable-artifact-authorization/AGENTS.md new file mode 100644 index 00000000..21cb67c1 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/AGENTS.md @@ -0,0 +1,164 @@ +# Research-first project bootstrap + +Agent instructions for this project. Read this before planning, designing, or building +anything here. + +## How this project works: two phases, two roles + +**Phase 1 - research (this kit).** Close every blocking unknown with fetched primary +sources, then pass the gate. The output is a briefing, not code: `research/DISCOVERY.md` +(the contract), `research/EVIDENCE.md` (claims with cached pages behind them), and +`research/BRIEF.md` (the handoff). Phase 1 never writes product code. + +**Phase 2 - build (someone else's job).** A builder - another agent, a different model, or +a human - takes `research/BRIEF.md` plus `research/` and implements. They should not need +to re-research anything: if they do, phase 1 was incomplete, and the fix is to collect the +missing fact rather than to let the builder guess it. + +The handoff needs PASS, an intact corpus, classified MAP, rewritten Findings, and an +authored brief with its judged sections answered and current stamped inputs. PASS or +file presence alone does not complete phase 1. An authored unstamped brief keeps the +existing approval path with currency unknown; role controls collection, not approval. + +## Two machines, two roles + +The role is machine config - `role: "collector" | "builder"` in +`~/.agents/research-kit.config.json`, default `collector`; declare it with +`node "$HOME/.agents/research-kit/bin/install-hooks.mjs" --role builder`. + +| | **collector** (the operator's PC) | **builder** (a sandbox, a CI box, a laptop) | +|---|---|---| +| holds | the Firecrawl key when using that route; none for a chosen keyless route | no collection key required, no Firecrawl egress | +| runs | `decompose.mjs`, `research.mjs` | `handoff.mjs`, `preflight.mjs`, the build | +| absent Firecrawl CLI/auth is | a **FAIL** for an unchosen or Firecrawl route; passes those findings for an explicitly chosen keyless route | expected: this machine does not collect | +| must | push `research/raw/` including its dotfiles | **not collect** - the collection CLIs refuse (exit 2) | + +**If you are on a builder machine and there is no brief: you do not collect.** A page +fetched by hand is not evidence in this kit, and a builder that "re-collects" a missing +page forges a corpus instead of reporting a gap. Name the missing fact instead. + +A builder's first command is: + +``` +node "$HOME/.agents/research-kit/bin/handoff.mjs" +``` + +The remedy depends on the cause, and the command says which: something that did not +travel is the collector's to push (`git add research/` then `git add -f research/raw/.fetches.jsonl`, +the ledger by name - `-f` on the whole folder also commits the machine-local logs); +a corpus that travelled whole and was rewritten on checkout here is fixed **here**, with +`.gitattributes`, and costs no credits. + +A Git builder-request result marked `collected` delivers captures, not completed +review. The builder reads and judges; the collector records Findings, unknown closures, +the map and current authored brief. Send review-only gaps in prose; request a fetch +only for a missing external fact. Local review/re-packaging of a received package +remains available; it does not permit collection or edits to the collector's Git corpus. + +## Starting in the wrong place: ask which project, do not hunt for it + +For gate and corpus commands, the project is the **current working directory**; those +commands take no project argument. The desktop panel has its own `--project` selector: +confirm that effective project before running its diagnostics. If the cwd is not the +project the operator means - the kit's own repo, a +parent folder, an unrelated checkout - **ask which project**, and wait. Do not search the +filesystem, do not infer it from a name, do not look it up on a remote. Searching a +person's disk to guess his intent reads directories nobody authorised, and the question +it replaces takes one line. + +Signals: no `AGENTS.md`, no `research/` directory, or a `research/` corpus plainly about +something else. An existing corpus about another topic is the signal doing its job. + +## Resuming after an interruption: resume, do not restart + +The gate is a pure function of repository state, so orient from disk before touching +anything. `doctor.mjs` and preflight answer *where this project is*; the last commit +report and `docs/ARCHITECTURE.md` say *what was happening and why*; `git status` answers +what is unfinished, and its uncommitted changes are the interrupted task - partial and +unverified, work in progress, never state to trust. The corpus is already collected and +spent credits are not spent again. + +## Rule 1 - research first, then build + +The sequence is decompose -> contract -> collect -> gate -> brief. + +0. **Decompose** (collector machine): `node "$HOME/.agents/research-kit/bin/decompose.mjs"` - it maps the project's own topic from `research/plan.json`; pass `--topic ""` only while the project is untitled, since a different topic is refused - + drafts `research/MAP.md` seeded with the universal checklist. The tool contains no + judgment. Mark every row COVERED (citing U-## rows), DISMISSED (reason required), or + GAP. +1. **Write the intent** in `research/DISCOVERY.md` under `## Build intent`, and enumerate + the blocking unknowns FROM the map. +2. **Collect**: `node "$HOME/.agents/research-kit/bin/research.mjs" --plan research/plan.json`. Prefer the page + that *owns* the fact over any write-up about it. +3. **Rewrite** each auto-extracted `Finding` cell into a real claim, keeping the `Raw` + cell pointing at the cached page. Where a claim rests on one sentence, add it as + `[quote: the sentence, copied from the capture]` - the gate checks it is really there. +4. **Gate**: `node "$HOME/.agents/research-kit/bin/preflight.mjs"`. **Do not start building until it prints + PASS.** Evidence must be *fetched*, not typed, and by a named transport. +5. **Hand off**: write `research/BRIEF.md`. + +Steps 0, 3 and 5 are the review, and **the review is your job** - classifying the map, +rewriting the findings and answering the brief's **TODO** sections are what make a corpus +approved research. Declare it in the brief with the line `Reviewed by: agent`; a package +carries it as `review.by`. There is no human review step. + +There are exactly three overrides - `git commit --no-verify`, a deliberate +`research/GATE_OFF`, and a repository-local `core.hooksPath`. The last two are recorded in +`research/overrides.log`, a local log that is never committed; `--no-verify` records nothing, +because git does not run the hook it skips. If you take one, say so in your reply. + +## Rule 2 - questions are for intent, never for facts + +At most **3** questions, once, up front, and only about things no document can answer. If +a question's answer is in public documentation, it is a research task. **Never answer +"insufficient info" and stop.** + +## Rule 3 - known unknowns are allowed, silence is not + +Mark a genuinely unreachable fact `KNOWN-UNKNOWN` and write the day-one verification step +in the `Evidence` cell. An unlabeled gap is the failure mode this project exists to +prevent. + +## Rule 4 - source quality + +`P` primary/official carries the design. `S` secondary is context. `L` lead-only is a +hint, never proof. Never invent a citation. Flag contradictions instead of averaging them. + +## Rule 5 - cost discipline + +Firecrawl collection spends credits; chosen keyless pages still use the planned page +allowance and carry policy warnings. Plan queries in `research/plan.json` first, reuse the +cache (`--refresh-days`), and check the budget with `node "$HOME/.agents/research-kit/bin/research.mjs" --status`. + +When the account runs out mid-run, the run stops and exits 2 with what is left (ADR-0129). +That decision is the operator's, not the agent's: report the balance and the uncollected +pages, and ask whether to top up or to finish on the free transports with `--fallback`. +Never pass `--fallback` on your own initiative. + +## Rule 6 - secrets + +The key lives in the CLI config or the environment. Never write it into this repository. + +## The standing protocol + +Five rules for how work here is committed and recorded. Documentation, not enforcement: +no check judges prose, because a gate that judges prose is a gate that will be wrong. + +1. **Architecture map current in the same commit.** A commit touching a declared code + path (`research/kit.json`) stages `docs/ARCHITECTURE.md` with it. Enforced - and a + prompt, not a proof: the gate checks the map was *staged*, not that it is *current*. +2. **Commit report: five named parts** - what changed / why / what it touched / what you + verified / what you got wrong and fixed. Each verification claim says **verified** (run, + result seen), **untested** (written, not run) or **expected** (reasoned, not run), so a + reader can tell evidence from belief. A mistake is reported in six fields - mistake, + where, impact, cause, fix, verified - because what was wrong alone leaves the impact and + the proof of the fix to be guessed. The got-wrong line is not optional; if nothing was + gotten wrong it is written as "nothing to report". A commit that touches no declared + code path (tests, docs, wording) may give the parts as one line each - what changed, + why, verified, got wrong - and keeps the got-wrong line. +3. **An ADR for any design choice with a rejected alternative** - how the project behaves, + what it stores or promises - dated, with the reason a future explorer would need to avoid + re-suggesting it. Test mechanics, wording and a bug fix with one obvious remedy do not + need one; the commit report's *why* names any alternative set aside. +4. **One commit per task - the revert test.** `git revert ` undoes it alone. +5. **A red suite stops work**, reported immediately and alone, **with the cwd recorded**. diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/START_HERE.md b/docs/decisions/2026-10-13-portable-artifact-authorization/START_HERE.md new file mode 100644 index 00000000..c1f21234 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/START_HERE.md @@ -0,0 +1,81 @@ +# Start here + +For me, the operator. Not for the AI. + +New to the kit? The five-minute first run, with no account, is "Try it in five minutes" on +the front page: https://github.com/StepenkoAnatoli/Research-Kit#try-it-in-five-minutes + +--- + +## Where things live + +| What | Where | +|---|---| +| The kit itself | `$HOME/.agents/research-kit` - installed once, never moves | +| Research for THIS project | `\research\` - working files, leave alone | +| My one pasteable file | `\research\audits\` - named by topic + version + date | + +Nothing gets copied between folders. Each project keeps its own research, inside itself. + +The commands below are written for PowerShell, bash and zsh. In cmd.exe, write `%USERPROFILE%` +where a command says `$HOME`. + +--- + +## Starting new research + +1. **Open the project folder** - the actual project, never the kit's own folder. +2. **Say what I want researched.** The agent reads the skill automatically and starts. +3. **When it's done, ask for the audit** - that's my one file: + ``` + node "$HOME/.agents/research-kit/bin/audit.mjs" + ``` + Written to `research\audits\` in the project folder. One attachment instead of ten + pastes: + ``` + node "$HOME/.agents/research-kit/bin/audit.mjs" --zip + ``` + +If I'm ever unsure which folder I'm in, check before starting - the agent should ask if +it isn't sure, not go searching my disk. + +--- + +## Picking up where I left off + +From inside the project folder: + +``` +node "$HOME/.agents/research-kit/bin/doctor.mjs" +node "$HOME/.agents/research-kit/bin/preflight.mjs" +git log --oneline -5 +git status +``` + +Then tell the agent to resume. Nothing committed is ever lost, and credits already spent +are not spent again. + +--- + +## If something looks broken + +``` +node "$HOME/.agents/research-kit/bin/doctor.mjs" +``` + +It names every problem and prints the exact fix. Run it, fix what it says, run it again. +Stop at READY. + +--- + +## Budget + +1,000 Firecrawl credits a month. Resets monthly. Stops cleanly at zero - no surprise +charges. A research cycle is roughly 40 credits. + +--- + +## The rules the AI follows + +Live in `AGENTS.md` inside the kit and every project it touches. I don't need to know +them - any AI that opens the repo is bound by them. diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/docs/ARCHITECTURE.md b/docs/decisions/2026-10-13-portable-artifact-authorization/docs/ARCHITECTURE.md new file mode 100644 index 00000000..86b1e734 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/docs/ARCHITECTURE.md @@ -0,0 +1,36 @@ +# ARCHITECTURE - the map of the code + +What each module owns, the seams between them, and which decision governs each. This is +a map of the **code**, not of the research: `research/MAP.md` is the topic map - a +different artifact with a different job, judged by a different check. + +The rule this map lives by: a commit touching a **declared code path** (declared in +`research/kit.json`, defaulting to `src` / `lib` / `bin` / `scripts` / `app`) carries +this file's update in the **same commit**. The commit gate refuses such commits without +it. + +What the rule proves is deliberately modest: the gate checks this file was **staged** +with the code, not that it is current - a whitespace edit satisfies it. That makes a +stale map a deliberate act rather than mere forgetting. A prompt, not a proof. + +## The one picture + +``` +_Replace this with the shape of the thing you build: the entry points, the modules +they reach, and the seams between them._ +``` + +## Modules - what each one owns + +| Module | Owns | Governed by | +|---|---|---| +| _module_ | _the one concept it is responsible for_ | _ADR or decision_ | + +## The seams + +_Where modules touch, what is injectable, and why testing can stay offline._ + +## Current shape, for the record + +_Counts and invariants a reader can check against the code: how many checks, how many +adapters, which defaults._ diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/BRIEF.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/BRIEF.md new file mode 100644 index 00000000..a6c26c84 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/BRIEF.md @@ -0,0 +1,52 @@ +# Brief - How should portable artifact authorization be derived without consumer policy and while preserving historical brief stamps? + +_Auto-drafted 2026-10-13 by `bin/brief.mjs` from the corpus. Sections marked **TODO** +require the reviewing agent's judgement; everything else is assembled from evidence already +in `research/`. While a **TODO** remains, this brief is **not reviewed** and the +handoff is **not approved** - a structurally valid corpus, a reviewed one, and an +approved handoff are three different states._ + +Reviewed by: agent + +## Intent + +Fix issue #357 by re-deriving claimed artifact approval from its materialized corpus using +the producer's review rules and an unpromoted portable gate baseline. Consumer machine +policy, hooks and credentials must not affect the result. Preserve a declared false +authorization and settle compatibility for unstamped, pre-ADR-0069 and current briefs +against historical source profiles before implementation. This project records the +compatibility decision and its source/fixture evidence; it does not collect vendor data. + +## What we verified + +| Claim | Source | Type | +|---|---|---| +| runPreflight uses an explicitly supplied context instead of verdictContext; runChecks findings are promoted only for CLI strict or machine evidencePolicy strict. A supplied pluralist context with no local hook path evaluates the portable unpromoted baseline without calling the machine context reader. | E-01 `raw.githubusercontent.com` (U-1) | P | +| The shared registry runs each named check over the supplied corpus and options. Unknown closure uses options.maxAgeDays or the fixed 180-day default; the portable context can retain this shared default rather than a consumer customization. | E-02 `raw.githubusercontent.com` (U-1) | P | +| The machine defaults also use maxAgeDays 180, while loadConfig reads the configured machine file and evidencePolicy can promote warnings. Portable validation must avoid those consumer reads, not merely pass an empty environment. | E-03 `raw.githubusercontent.com` (U-1) | P | +| At the pre-0069 commit, briefInputsHash hashes topic, intent, unknown ID/text/status/evidence and evidence ID/date/type/URL/finding/raw fields. The emitted draft marker has body and inputs hashes with no gate field. | E-04 `raw.githubusercontent.com` (U-2) | P | +| The current input hash adds map ID/status/coverage rows and ledger/capture counts. Current markers can include gate=pass, fail or unknown; draftStamp returns unknown both for an absent gate field and for an explicit unknown value, so those profiles must be distinguished from the literal marker. | E-05 `raw.githubusercontent.com` (U-2) | P | +| ADR-0069 expands the hash within the existing format era and adds the gate field; it explicitly retains parsing of old markers without gate, represented as unknown. Thus current-hash mismatch alone cannot establish staleness for that historical profile. | E-06 `raw.githubusercontent.com` (U-2) | P | +| The actual historical artifact writer emits formatVersion 1.0.0 and derives approval from gate, map, brief and findings review. The isolated profile probe generated approved unstamped and pre-0069 packages with this exact writer, using explicitly synthetic passing corpora. | E-07 `raw.githubusercontent.com` (U-3) | P | +| The current validator supports format majors 1 and 2, separates PASS from buildAuthorized and returns authorization only when the incoming claim is true and status is PASS. Re-derivation must preserve a declared false claim instead of upgrading it. | E-08 `raw.githubusercontent.com` (U-3) | P | +| The historical brief regression uses the actual renderer to distinguish an unedited draft from an edited authored brief. The retained profile probe likewise obtains the early marker from that real renderer rather than inventing its syntax or hash. | E-09 `raw.githubusercontent.com` (U-3) | P | + +## Contradictions and how they were resolved + +The historical writer and current reader both accept the early format-1.0 approved fixture, while current producer derivation rejects its old input hash. This is a profile change, not evidence that its covered inputs changed: the actual historical writer emitted a hash covering fewer fields. The current marker can explicitly say gate=unknown, so parsed gate=unknown alone does not identify the old profile. Check the literal absence of that field and verify the historical hash only for format major 1. The profile probe uses disclosed synthetic corpora and actual pinned writers; it does not claim an archived user's corpus. Source authority is the pinned code, supported by the retained probe results. + +## Known unknowns + +None. No unknown is declared KNOWN-UNKNOWN. + +## Decision + +Extract the shared approval derivation to a module usable by producer and validator without an import cycle. The validator re-derives only an incoming true approval from the materialized corpus with a supplied unpromoted pluralist context, no consumer policy or hooks, and the shared 180-day freshness default. A false claim remains false. Preserve current matching stamps and authored unstamped briefs. For a format-1 marker without a gate field, accept currency only when its historical input hash matches the covered inputs, with an explicit compatibility warning; apply current gate and review checks to its actual corpus. Reject mismatched legacy covered inputs and all inconsistent claimed approvals. Use captureOf for blank Raw cells and count unresolved rows as not reviewed. Record this compatibility choice in ADR-0160 and test the actual-writer profile cases before publishing. + +## Next steps + +1. Record ADR-0160 and the research index entry, retaining the nine source captures, ledger, profile records and collection trace. +2. Add red-first forged-approval regressions and shared portable derivation with literal-profile checks; preserve the producer API and declared false package result. +3. Run focused validator/producer/CLI tests, the required full commit gate and the supported CI matrix. Open a separate fix PR without merging. + + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/DISCOVERY.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/DISCOVERY.md new file mode 100644 index 00000000..e13e1b74 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/DISCOVERY.md @@ -0,0 +1,48 @@ +# Discovery Contract - How should portable artifact authorization be derived without consumer policy and while preserving historical brief stamps? + +Started 2026-10-13. This file is the definition of "enough information to build". +`node "$HOME/.agents/research-kit/bin/preflight.mjs"` reads it and blocks the build until every unknown +below is either `CLOSED` with evidence or `KNOWN-UNKNOWN` with a verification step. + +## Build intent + +Fix issue #357 by re-deriving claimed artifact approval from its materialized corpus using +the producer's review rules and an unpromoted portable gate baseline. Consumer machine +policy, hooks and credentials must not affect the result. Preserve a declared false +authorization and settle compatibility for unstamped, pre-ADR-0069 and current briefs +against historical source profiles before implementation. This project records the +compatibility decision and its source/fixture evidence; it does not collect vendor data. + +## Unknowns + +A fact belongs here when guessing it wrong changes the design: API limits and pricing, +auth model, data schemas, rate limits, licensing/ToS, platform behavior, current library +versions, competitor pricing, data availability. + +Status is exactly one of: +- `CLOSED` - proven by an `E-##` row in `research/EVIDENCE.md` (which must point at cached raw text). +- `KNOWN-UNKNOWN` - unreachable now; the `Evidence` cell names the day-one verification step. + +Anything else (`OPEN`, blank, "in progress") fails the gate. + +| ID | Unknown | Why it blocks the build | Status | Evidence | +|---|---|---|---|---| +| U-1 | Can the shared preflight registry run against an explicit context without reading consumer policy or hooks, and which default freshness behavior belongs to that baseline? | An ambient consumer policy would make the same package change authorization between machines. D-2, D-6 and D-8. | CLOSED | E-01, E-02, E-03: an explicit pluralist context bypasses machine context reads and promotion, with the registry default 180-day freshness horizon. Tests must tripwire consumer config/hook access. [single-witness: pinned first-party source code defines its own behavior; another publisher cannot define that implementation.] | +| U-2 | What exact fields did the pre-0069 input hash cover, and which stamp/profile changes distinguish it from the current hash? | Rejecting every old hash loses valid historical approvals; ignoring hashes loses currency checking. D-5 and D-6. | CLOSED | E-04, E-05, E-06: the early hash omits map and count inputs, and the literal marker lacks a gate field. The retained actual-writer probe reproduces a valid format-1.0 early approval whose current hash differs. [single-witness: the historical implementation and its own ADR define this format change.] | +| U-3 | What did the actual historical artifact/brief writers emit, which format majors remain readable, and how is a false authorization preserved? | Compatibility needs historical profiles rather than an invented marker, and an incoming false claim must never be upgraded. D-1 and D-9. | CLOSED | E-07, E-08, E-09: actual historical/current writers generated unstamped, early and current profiles over disclosed synthetic corpora; all claim approval and validate before the fix, while current producer derivation rejects the early hash. Supported majors remain 1 and 2; false claims stay false. Results and brief texts are retained in research/probes/profiles.json. [single-witness: the pinned writers and reader are the authority on their formats.] | + +## Questions for the human (maximum 3) + +Intent questions only - things no document can answer. Facts never go here; they go in +the table above. If a question's answer is in public documentation, it is a research +task, not a question. + +## Already decided + +Locked decisions for this project. Do not revisit these without the human. + +ADR-0032 keeps validity separate from authorization and rejects trusting asserted producer +state. ADR-0069 records the stamp change inside format 1.x. ADR-0138 preserves unstamped +authored briefs with disclosed unknown currency. The qualified rank-2 gap audit requires +unpromoted portable findings and explicit historical-profile verification. No merge is +authorized; the output is a separate fix PR. diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/EVIDENCE.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/EVIDENCE.md new file mode 100644 index 00000000..4fc55adf --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/EVIDENCE.md @@ -0,0 +1,19 @@ +# Evidence + +One row per fetched page. `Raw` points at the cached page text under `research/raw/`, +which is what makes the claim checkable - a row without raw evidence fails preflight. + +The `Finding` cell arrives as an auto-extracted summary. Rewrite it into a real claim: +what the page actually establishes, with the quote or number that proves it. + +| ID | Retrieved | Type | URL | Finding | Raw | +|---|---|---|---|---|---| +| E-01 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs | runPreflight uses an explicitly supplied context instead of verdictContext; runChecks findings are promoted only for CLI strict or machine evidencePolicy strict. A supplied pluralist context with no local hook path evaluates the portable unpromoted baseline without calling the machine context reader. | research/raw/2026-10-13-preflight-mjs-githubusercontent-4b093d0d.md | +| E-02 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs | The shared registry runs each named check over the supplied corpus and options. Unknown closure uses options.maxAgeDays or the fixed 180-day default; the portable context can retain this shared default rather than a consumer customization. | research/raw/2026-10-13-checks-mjs-githubusercontent-c6fb39d5.md | +| E-03 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs | The machine defaults also use maxAgeDays 180, while loadConfig reads the configured machine file and evidencePolicy can promote warnings. Portable validation must avoid those consumer reads, not merely pass an empty environment. | research/raw/2026-10-13-machine-mjs-githubusercontent-2ccae69e.md | +| E-04 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs | At the pre-0069 commit, briefInputsHash hashes topic, intent, unknown ID/text/status/evidence and evidence ID/date/type/URL/finding/raw fields. The emitted draft marker has body and inputs hashes with no gate field. | research/raw/2026-10-13-brief-mjs-githubusercontent-fea9e4f1.md | +| E-05 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs | The current input hash adds map ID/status/coverage rows and ledger/capture counts. Current markers can include gate=pass, fail or unknown; draftStamp returns unknown both for an absent gate field and for an explicit unknown value, so those profiles must be distinguished from the literal marker. | research/raw/2026-10-13-brief-mjs-githubusercontent-37cece34.md | +| E-06 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md | ADR-0069 expands the hash within the existing format era and adds the gate field; it explicitly retains parsing of old markers without gate, represented as unknown. Thus current-hash mismatch alone cannot establish staleness for that historical profile. | research/raw/2026-10-13-0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdi-githubusercontent-56f0ed5d.md | +| E-07 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs | The actual historical artifact writer emits formatVersion 1.0.0 and derives approval from gate, map, brief and findings review. The isolated profile probe generated approved unstamped and pre-0069 packages with this exact writer, using explicitly synthetic passing corpora. | research/raw/2026-10-13-artifact-mjs-githubusercontent-f0a2d470.md | +| E-08 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs | The current validator supports format majors 1 and 2, separates PASS from buildAuthorized and returns authorization only when the incoming claim is true and status is PASS. Re-derivation must preserve a declared false claim instead of upgrading it. | research/raw/2026-10-13-artifact-validator-mjs-githubusercontent-34c19498.md | +| E-09 | 2026-10-13 | P | https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs | The historical brief regression uses the actual renderer to distinguish an unedited draft from an edited authored brief. The retained profile probe likewise obtains the early marker from that real renderer rather than inventing its syntax or hash. | research/raw/2026-10-13-brief-test-mjs-githubusercontent-237028e4.md | diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/MAP.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/MAP.md new file mode 100644 index 00000000..23b3265a --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/MAP.md @@ -0,0 +1,35 @@ +# MAP - topic decomposition + +## Topic + +How should portable artifact authorization be derived without consumer policy and while preserving historical brief stamps? + +## Subtopics + +Statuses are blank on purpose: phase 0 gathers material, it does not judge. Mark each +row COVERED (cite the U-## rows that cover it), DISMISSED (reason required - dismissing +is fine, omitting is not), or GAP, and add topic-specific subtopics where the checklist +is not enough. + +| ID | Subtopic | Why it matters | Status | Covered by | +|---|---|---|---|---| +| D-1 | Access model | Public pages, an official API, an auth-walled app, or a paywall - each is a different collection design | COVERED | U-3 | +| D-2 | Auth and credentials | What accounts, keys, or logins the collection and the product need, and who holds them | COVERED | U-1 | +| D-3 | Rate limits and quotas | Caps every cadence in the design, and caps the research collection itself | DISMISSED | no new service or request cadence; research is bounded to nine pinned public source pages | +| D-4 | ToS, licensing, legality of the intended use | A prohibition on automated collection, storage, or display ends the design for that source - and sometimes the project | DISMISSED | internal repair and historical source reuse in the same MIT-licensed repository; no new third-party code or collection contract | +| D-5 | Data schema and its stability | How the data is shaped, and how often the source changes the shape without asking | COVERED | U-2 | +| D-6 | Freshness and staleness | How fast the data goes stale, and what staleness costs the product that depends on it | COVERED | U-1, U-2 | +| D-7 | Cost at expected volume | The economics at real usage, not the pricing page's first row - this decides viability | DISMISSED | validation remains offline; this research uses an explicitly chosen keyless route and no paid search | +| D-8 | Runtime and platform limits | Where this actually executes - OS, runtime version, desktop app, cloud - and what those limits forbid | COVERED | U-1 | +| D-9 | Output obtainability | Does the data your stated "done" depends on exist, and can you actually get it? Load-bearing: a project whose output cannot be produced should die in phase 1, not phase 2 | COVERED | U-3 | + +## Coverage notes (per dimension) + +_One short paragraph per row once it has a status: what was established, and what the +status rests on._ + +## Candidate material + +Gathered 2026-10-13. + +_No material gathered - run without `--dry-run`, or add URLs to `research/plan.json`._ diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/SOURCES.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/SOURCES.md new file mode 100644 index 00000000..402ae335 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/SOURCES.md @@ -0,0 +1,16 @@ +# Sources + +Every page this project has fetched, and what it was used for. `P` primary/official +carries the design, `S` secondary is context, `L` lead-only is a hint and never proof. + +| URL | Type | Title | Retrieved | Used for | +|---|---|---|---|---| +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs | P | preflight-mjs | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs | P | checks-mjs | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs | P | machine-mjs | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs | P | brief-mjs | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs | P | brief-mjs | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md | P | 0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdi | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs | P | artifact-mjs | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs | P | artifact-validator-mjs | 2026-10-13 | | +| https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs | P | brief-test-mjs | 2026-10-13 | | diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/TIMELINE.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/TIMELINE.md new file mode 100644 index 00000000..80160d12 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/TIMELINE.md @@ -0,0 +1,7 @@ +# Timeline + +Derived, chronological, and deliberately outside the chain: a review aid, never part +of a gate decision. Regenerate with `node "$HOME/.agents/research-kit/bin/timeline.mjs"`. + +| When | What | Detail | +|---|---|---| diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/kit.json b/docs/decisions/2026-10-13-portable-artifact-authorization/research/kit.json new file mode 100644 index 00000000..a1520526 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/kit.json @@ -0,0 +1,7 @@ +{ + "kitPath": "$HOME/.agents/research-kit", + "architecture": { + "codePaths": ["src", "lib", "bin", "scripts", "app"], + "note": "A commit touching one of these stages docs/ARCHITECTURE.md in the same commit. Guarded paths are project configuration - ADR-0008." + } +} diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/plan.json b/docs/decisions/2026-10-13-portable-artifact-authorization/research/plan.json new file mode 100644 index 00000000..82ba7de8 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/plan.json @@ -0,0 +1,24 @@ +{ + "topic": "How should portable artifact authorization be derived without consumer policy and while preserving historical brief stamps?", + "notes": "Issue #357 decision research. Nine pinned first-party source pages only, no search. Use the explicitly selected http-keyless collector; no Firecrawl credits. Historical profiles are also checked against Git objects and retained fixture probes.", + "depth": "normal", + "refreshDays": 30, + "limit": 9, + "perQuery": 1, + "maxScrapes": 9, + "prefer": [ + "raw.githubusercontent.com" + ], + "queries": [], + "urls": [ + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs", + "https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs" + ] +} \ No newline at end of file diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/README.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/README.md new file mode 100644 index 00000000..0aee0b45 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/README.md @@ -0,0 +1,5 @@ +# Historical format profile probe + +Run probe-historical-profiles.mjs with the current kit, the trusted historical kit at 3bac1f26ddbf8cb6a794626e53dc2af313860940, and a scratch output directory as its three arguments. Use a private child HOME/USERPROFILE, config/install-state paths and Git config; remove API credentials from that child. This experiment invokes those known repository writers, never code from a received package. profiles.json records the observed outputs and exact brief texts over disclosed synthetic passing inputs. The original probe driver and ZIP outputs remain in this task's work notes; binary fixtures are not checked in, per ADR-0032. + +Collection trace: ../../../collection-357.log is retained where the collection command created it. The nine captures and their ledger are in this project; nine planned pages were consumed through http-keyless and no Firecrawl credits were spent. diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/probe-historical-profiles.mjs b/docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/probe-historical-profiles.mjs new file mode 100644 index 00000000..a2760482 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/probes/probe-historical-profiles.mjs @@ -0,0 +1,48 @@ +// Research experiment: synthetic passing corpora, actual pinned historical writer. +// Run in an isolated child environment; no collection and no consumer config is used. +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +const [currentKit, historicalKit, outputDir] = process.argv.slice(2).map((value) => path.resolve(value)); +const load = (kit, file) => import(pathToFileURL(path.join(kit, file)).href); +const fixtures = await load(currentKit, 'test/artifact-fixtures.mjs'); +const currentArtifact = await load(currentKit, 'lib/artifact.mjs'); +const historicalArtifact = await load(historicalKit, 'lib/artifact.mjs'); +const currentBrief = await load(currentKit, 'lib/brief.mjs'); +const historicalBrief = await load(historicalKit, 'lib/brief.mjs'); +const { readCorpus } = await load(currentKit, 'lib/corpus.mjs'); +const { validateArtifact } = await load(currentKit, 'lib/artifact-validator.mjs'); +fs.mkdirSync(outputDir, { recursive: true }); +const records = []; +for (const profile of ['unstamped', 'pre-0069', 'current']) { + const root = fixtures.approvedProject(); + const file = path.join(root, 'research', 'BRIEF.md'); + const authored = fs.readFileSync(file, 'utf8').replace(/\n?\s*/g, '\n'); + if (profile === 'unstamped') fs.writeFileSync(file, authored); + else if (profile === 'pre-0069') { + historicalBrief.renderBrief(root, { force: true }); + const stamp = fs.readFileSync(file, 'utf8').match(//)?.[0]; + if (!stamp) throw new Error('the historical writer did not emit its actual stamp'); + fs.writeFileSync(file, `${authored.trimEnd()}\n\n${stamp}\n`); + } + const producer = profile === 'current' ? currentArtifact : historicalArtifact; + const built = producer.createArtifact({ root, ...fixtures.IDENTITY, createdAt: '2026-10-13T00:00:00.000Z' }); + const packageFile = path.join(outputDir, `${profile}.zip`); + fs.writeFileSync(packageFile, built.bytes); + const snapshot = readCorpus(root); + const text = fs.readFileSync(file, 'utf8'); + const stamp = currentBrief.draftStamp(text); + const validation = validateArtifact({ file: packageFile }); + records.push({ profile, syntheticInputs: true, + writerCommit: profile === 'current' ? '3fa03fa1749d591af9cd7197e1c09f66ffacd231' : '3bac1f26ddbf8cb6a794626e53dc2af313860940', + formatVersion: built.manifest.formatVersion, producerState: built.manifest.state, + producerAuthorized: built.manifest.buildAuthorized, consumerStatus: validation.status, + consumerAuthorized: validation.buildAuthorized, stamp, + stampHasGate: /\n" + }, + { + "profile": "current", + "syntheticInputs": true, + "writerCommit": "3fa03fa1749d591af9cd7197e1c09f66ffacd231", + "formatVersion": "2.0.0", + "producerState": "APPROVED_BRIEF", + "producerAuthorized": true, + "consumerStatus": "PASS", + "consumerAuthorized": true, + "stamp": { + "edited": true, + "inputs": "4a70d60bab6fe05e", + "gate": "unknown" + }, + "stampHasGate": true, + "historicalInputs": "e0babc86cec671c9", + "currentInputs": "4a70d60bab6fe05e", + "currentProducerState": "APPROVED_BRIEF", + "briefText": "# Brief - Fixture topic\n\n_Auto-drafted 2026-10-13 by `bin/brief.mjs` from the corpus. Sections marked **TODO**\nrequire the reviewing agent's judgement; everything else is assembled from evidence already\nin `research/`. While a **TODO** remains, this brief is **not reviewed** and the\nhandoff is **not approved** - a structurally valid corpus, a reviewed one, and an\napproved handoff are three different states._\n\nReviewed by: _agent - the agent that classified the map, rewrote the findings and answered the **TODO** sections replaces this line with `Reviewed by: agent`_\n\n**This is the phase-1 to phase-2 handoff.** **Gate state: not evaluated in this run.**\n\n**Gate warnings: not evaluated in this run.**\n\nWhoever you are - another agent, a different model, or a person - read this file\nfirst. You should not need to re-research anything to start work. If something\nhere is not enough to build from, say which fact is missing rather than guessing\nit: that is a phase-1 gap to close, not a phase-2 judgment call.\n\n## Intent\n\nA fixture project that passes its own gate, so a test can prove the gate says PASS for a\ncorpus that deserves it.\n\n## What we verified\n\n**These are declared CLOSED claims; verification has not been evaluated in this run.**\nRead the recorded claims and sources below as a draft for review, not successful verification.\n\n| Claim | Source | Type |\n|---|---|---|\n| The free tier caps this design at 10 requests per minute, which is what sets the collector default; the 1,000 included credits are roughly 25 runs at the quick depth. | E-01 `example.invalid` (U-1) | P |\n\n## Contradictions and how they were resolved\n\nNone. One source, re-read on 2026-10-13, and nothing else in the corpus speaks to the same limit.\n\n## Known unknowns\n\nNone. No unknown is declared KNOWN-UNKNOWN.\n\n## Decision\n\nBuild against a 10-request-per-minute ceiling and a 1,000-credit month. Out of scope: paid tiers, which no row in this corpus touches.\n\n## Next steps\n\n1. Review the **TODO** sections above (Contradictions, Decision) before handing off.\n2. Hand this file to the builder (phase 2). Re-running `node \"C:\\Users\\PC\\Desktop\\gittoken\\Research-Kit-issue-357\\research-kit/bin/brief.mjs\"`\n redrafts this file while it is unedited; after any edit it refuses without `--force`,\n so your judgements are preserved.\n\n\n" + } +] diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/.fetches.jsonl b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/.fetches.jsonl new file mode 100644 index 00000000..c3c9d01e --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/.fetches.jsonl @@ -0,0 +1,9 @@ +{"seq":1,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs","type":"P","raw":"research/raw/2026-10-13-preflight-mjs-githubusercontent-4b093d0d.md","bodySha256":"b3ca4a8671ee73082089846a3eeba2a881bc82f5368d8d46b4b81c3472be4470","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs","prev":"0000000000000000000000000000000000000000000000000000000000000000","entrySha256":"533d42dd7c450dff3a7ace61482d5747e815852aa022e5b28e5b5c9d49a1af48"} +{"seq":2,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs","type":"P","raw":"research/raw/2026-10-13-checks-mjs-githubusercontent-c6fb39d5.md","bodySha256":"f8a57fb940a89268b12d30e943039b0ecae1dd7335033067cdc9dec765f1153a","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs","prev":"533d42dd7c450dff3a7ace61482d5747e815852aa022e5b28e5b5c9d49a1af48","entrySha256":"f721762c4d68b17eacce063559761d3e437f71f33f528c82cdf70e237d7aacb3"} +{"seq":3,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs","type":"P","raw":"research/raw/2026-10-13-machine-mjs-githubusercontent-2ccae69e.md","bodySha256":"c2758b76eb3683675a8f1bf23373a7ea30595ee5b38ba93c54691b84a2f1aa43","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs","prev":"f721762c4d68b17eacce063559761d3e437f71f33f528c82cdf70e237d7aacb3","entrySha256":"d99dce1d9aeb8f428cdb02319098330bed48dbab5f55b7142d25c6eeae838700"} +{"seq":4,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs","type":"P","raw":"research/raw/2026-10-13-brief-mjs-githubusercontent-fea9e4f1.md","bodySha256":"a8b633f06180ab9188b3fa87c0d757a00304f3c697f800aedf0a5799bc9a575c","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs","prev":"d99dce1d9aeb8f428cdb02319098330bed48dbab5f55b7142d25c6eeae838700","entrySha256":"1a3446ff5ecbcce69f10084d6bc49d8753d41babe3fa324ad6a51552d7dea61d"} +{"seq":5,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs","type":"P","raw":"research/raw/2026-10-13-brief-mjs-githubusercontent-37cece34.md","bodySha256":"3a76ddc0a961d241073a76136f3cee558bf0eb706c9fa0404099a848a1029885","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs","prev":"1a3446ff5ecbcce69f10084d6bc49d8753d41babe3fa324ad6a51552d7dea61d","entrySha256":"174ff1835cb9e75f09fd069ff291452a7d33849706abf998dd2994e35910daf5"} +{"seq":6,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md","type":"P","raw":"research/raw/2026-10-13-0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdi-githubusercontent-56f0ed5d.md","bodySha256":"ddb70f0133b78fecb2b4a48ad8d378af297d53af88123ca7a9ba3a3c350e0d36","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md","prev":"174ff1835cb9e75f09fd069ff291452a7d33849706abf998dd2994e35910daf5","entrySha256":"53ad5e3a36865bef3dac2523c661378e86864006fc1fca4c1f50d692d7ba92b9"} +{"seq":7,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs","type":"P","raw":"research/raw/2026-10-13-artifact-mjs-githubusercontent-f0a2d470.md","bodySha256":"8090a54d232b9e0073da9f59e38f0ae24005bd7d4a6511aa3b0aecf0da71259c","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs","prev":"53ad5e3a36865bef3dac2523c661378e86864006fc1fca4c1f50d692d7ba92b9","entrySha256":"f4c9efb2da02c59c613fa3b52d05adf3ff29e05d5dd06369450e2e2faf75a6c8"} +{"seq":8,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs","type":"P","raw":"research/raw/2026-10-13-artifact-validator-mjs-githubusercontent-34c19498.md","bodySha256":"e546515565d079e6946a996b1dd3ca5298d3bef10be1830f2baddb59d8ac551a","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs","prev":"f4c9efb2da02c59c613fa3b52d05adf3ff29e05d5dd06369450e2e2faf75a6c8","entrySha256":"98557b8b3ea8cc82ffc1fc6928ef8c06da39b056225f42b8652c94dd28c3f956"} +{"seq":9,"at":"2026-10-13T00:00:00.000Z","op":"scrape","url":"https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs","type":"P","raw":"research/raw/2026-10-13-brief-test-mjs-githubusercontent-237028e4.md","bodySha256":"79231bc82ab93408eb02185844b96b652c6ea4b13ef12abac463154b51f6cfde","transport":"http-keyless","completeness":"full","cmd":"http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs","prev":"98557b8b3ea8cc82ffc1fc6928ef8c06da39b056225f42b8652c94dd28c3f956","entrySha256":"800a19a6f1d9c62fc9f38fe575c92a93b4fb16d61606054a1e3a2a9cd9b60414"} diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/.gitkeep b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdi-githubusercontent-56f0ed5d.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdi-githubusercontent-56f0ed5d.md new file mode 100644 index 00000000..2dc8ab74 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdi-githubusercontent-56f0ed5d.md @@ -0,0 +1,47 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md +statusCode: 200 +transport: http-keyless +completeness: full +--- +# ADR-0069 — The brief's stamp covers the map, the ledger and the verdict + +- **Date:** 2026-09-27 +- **Status:** accepted +- **Area:** `lib/brief.mjs` (`briefInputsHash`, `draftStamp`), `bin/preflight.mjs` +- **Refines:** ADR-0055 (a drafted brief is stamped with what it was drafted from) + +## Context + +ADR-0055's inputs hash covered the topic, the intent, the unknowns and the evidence rows. +Reviewing it the same day, the case it was written for still failed. A brief drafted while +only the map was incomplete said "Gate: FAIL (9 blocking findings)". After the map rows +were statused, preflight printed PASS, and nothing called the brief stale, because the map +was not in the hash. The ledger was not in it either, although provenance decides the +verdict too. + +## Decision + +- The inputs hash also covers each map row's ID, status and "Covered by" cell, plus the + number of ledger entries and captures. +- The stamp also records the verdict the brief was drafted under (`gate=pass|fail|unknown`). + `preflight` prints a note when that verdict differs from the one it has just printed, and + names the command that redrafts the brief. That catches a verdict change no hashed input + shows, such as an evidence policy switched to strict. +- An old stamp without `gate=` still parses, as `unknown`, and gets no note. + +## Rejected alternatives + +- **Hash every file under `research/`.** A timeline regenerated, or an audit written, would + then call every brief stale. The hash covers what the brief is drafted from, and the + verdict note covers the rest. +- **Compute the verdict inside the hygiene check.** Hygiene is part of the verdict, so it + cannot depend on the verdict's own result. + +## Trigger that would reopen this + +A new check whose inputs are neither in the hash nor able to change the verdict without the +note seeing it. + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-mjs-githubusercontent-f0a2d470.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-mjs-githubusercontent-f0a2d470.md new file mode 100644 index 00000000..ca887d91 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-mjs-githubusercontent-f0a2d470.md @@ -0,0 +1,592 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// artifact.mjs - packaging a project into the portable artifact, without changing it. +// +// THE PRODUCER DOES NOT TAKE AUTHORIZATION AS INPUT. +// +// There is no `--build-authorized` flag and there will not be one. Every field that could +// let a consumer start building - `state`, `kind`, `buildAuthorized`, `review.*`, `gate.*` +// - is DERIVED here from the project on disk, by running the kit's own gate and reading +// the kit's own artifacts. A caller supplies identity (which repository, which run) and +// nothing about permission. +// +// The reason is the whole point of the format. A ZIP travels: from a runner to an +// artifact store to somebody's Downloads folder to an agent's working directory. At every +// hop the only thing standing between "evidence was collected" and "you may build" is +// what this manifest says. If a caller could assert it, the assertion would be the +// easiest field in the system to set and the most expensive one to get wrong. +// +// WHAT IS DERIVABLE, AND WHAT IS NOT. +// +// `mapClassified` and `briefReviewed` have honest machine answers: a map row either +// carries a status or it does not, and `briefState` already distinguishes an authored +// brief from a draft with TODOs in it. `findingsReviewed` has no general answer - ADR-0013 +// and ADR-0017 record that judging whether an agent reasoned properly would be wrong - but +// it has a specific NEGATIVE one, and that is enough: re-run the extractor over the +// cached capture, and a Finding cell still byte-identical to what the extractor produces +// was demonstrably never rewritten. That detects the unreviewed case without pretending +// to grade the reviewed one. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + PATHS, resolve, exists, isDirectory, readText, sha256, canonicalJson, nowIso, listFiles, homeCommand, +} from './core.mjs'; +import { buildZip } from './archive.mjs'; +import { readCorpus, parseCapture } from './corpus.mjs'; +import { verifyHandoff } from './handoff.mjs'; +import { runPreflight } from './preflight.mjs'; +import { briefState } from './brief.mjs'; +import { firstFinding } from './finding.mjs'; +import { validateArtifact, MANIFEST_PATH, MANIFEST_DIGEST_PATH, README_PATH } from './artifact-validator.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const KIT_ROOT = path.resolve(here, '..'); + +const FORMAT = 'research-kit-artifact'; +const FORMAT_VERSION = '1.0.0'; +/** The API version this format's `source.workflowRunId` is defined against. */ +export const GITHUB_API_VERSION = '2026-03-10'; + +/** `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` - refused, never silently rewritten. */ +const CLIENT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +export function checkClientRef(value) { + if (value === null || value === undefined || value === '') return { ok: true, value: null }; + if (typeof value !== 'string' || !CLIENT_REF_RE.test(value)) { + return { + ok: false, + // Rewriting a caller's correlation token into something "safe" is the worst of both + // worlds: the caller polls for a reference the artifact does not carry, and the + // mismatch surfaces as a missing package rather than as a bad argument. + detail: `client_ref ${JSON.stringify(String(value))} is not [A-Za-z0-9][A-Za-z0-9._-]{0,63}; refusing rather than rewriting it into something you did not ask for`, + }; + } + return { ok: true, value }; +} + +/** + * The package name. + * + * THE TOPIC IS NEVER IN IT. A filename survives every privacy boundary the contents + * respect: it appears in an artifact listing that E-08 of the delivery-architecture + * corpus measured as world-readable on a public repository, in a download manager, in a + * shared folder. A package about a layoff plan or a medical question must not announce + * itself before anybody opens it. + */ +export function packageName({ clientRef = null, workflowRunId = null } = {}) { + const suffix = clientRef ?? (workflowRunId ? `run-${workflowRunId}` : 'local'); + return `research-kit-corpus-v1-${suffix}.zip`; +} + +// ---------------------------------------------------------------- roles and media types + +const ROLE_BY_PATH = new Map([ + [README_PATH, 'HUMAN_INSTRUCTIONS'], + ['project/AGENTS.md', 'AGENT_INSTRUCTIONS'], + ['project/START_HERE.md', 'PROJECT_START'], + ['project/research/DISCOVERY.md', 'DISCOVERY_CONTRACT'], + ['project/research/MAP.md', 'RESEARCH_MAP'], + ['project/research/SOURCES.md', 'SOURCE_REGISTER'], + ['project/research/EVIDENCE.md', 'EVIDENCE_REGISTER'], + ['project/research/BRIEF.md', 'RESEARCH_BRIEF'], + ['project/research/plan.json', 'COLLECTION_PLAN'], + ['project/research/kit.json', 'KIT_CONFIGURATION'], + ['project/research/raw/.fetches.jsonl', 'PROVENANCE_LEDGER'], + ['reports/collection-summary.md', 'COLLECTION_SUMMARY'], + ['reports/problems.json', 'PROBLEM_REPORT'], + ['schemas/artifact-manifest.schema.json', 'SCHEMA'], +]); + +export function roleOf(zipPath) { + const known = ROLE_BY_PATH.get(zipPath); + if (known) return known; + if (zipPath.startsWith('project/research/raw/')) return 'RAW_CAPTURE'; + if (zipPath.startsWith('schemas/')) return 'SCHEMA'; + return 'OTHER'; +} + +export function mediaTypeOf(zipPath) { + if (zipPath.endsWith('.md')) return 'text/markdown'; + if (zipPath.endsWith('.jsonl')) return 'application/x-ndjson'; + if (zipPath.endsWith('.json')) return 'application/json'; + return 'application/octet-stream'; +} + +// ---------------------------------------------------------------- what travels + +/** Never packaged: machine-local state, overrides, credentials, vendor caches. */ +export const EXCLUDED = Object.freeze([ + PATHS.usage, PATHS.diagnostics, PATHS.failures, PATHS.lock, PATHS.overrides, PATHS.gateOff, +]); + +const EXCLUDED_NAME = /(^|\/)(\.env(\..*)?|\.git|node_modules|\.firecrawl)(\/|$)|\.pem$|\.key$/i; + +/** The project files that travel, as POSIX-relative paths, deterministically ordered. */ +export function collectProjectFiles(root) { + const out = []; + const walk = (dir, rel) => { + for (const name of listFiles(dir).sort()) { + const childRel = rel ? `${rel}/${name}` : name; + const abs = path.join(dir, name); + if (EXCLUDED.includes(childRel)) continue; + if (EXCLUDED_NAME.test(childRel)) continue; + if (name === '.gitkeep') continue; + if (isDirectory(abs)) { walk(abs, childRel); continue; } + out.push(childRel); + } + }; + for (const rel of ['AGENTS.md', 'START_HERE.md', '.gitattributes', '.gitignore']) { + if (exists(resolve(root, rel)) && !isDirectory(resolve(root, rel))) out.push(rel); + } + if (isDirectory(resolve(root, 'research'))) walk(resolve(root, 'research'), 'research'); + if (isDirectory(resolve(root, 'docs'))) walk(resolve(root, 'docs'), 'docs'); + // Sorted by the ZIP path so two runs over identical bytes lay the archive out + // identically. `listFiles().sort()` already orders each directory; this makes the + // whole inventory's order a property of the names rather than of the walk. + return [...new Set(out)].sort(); +} + +// ---------------------------------------------------------------- derived state + +/** + * `deriveState(root)` -> everything the manifest may say about permission. + * + * Runs the real gate. No caller input reaches this function. + */ +export function deriveState(root, { corpus = null, env = {} } = {}) { + const snapshot = corpus ?? readCorpus(root); + + // The gate. `env` defaults to an EMPTY object rather than process.env: a producer must + // not be able to inherit a GATE_OFF or an override from whatever shell it ran in, and + // reading the ambient environment is exactly how that would happen. + let verdict = null; + let blockingFindings = []; + try { + verdict = runPreflight(root, { corpus: snapshot, env }); + blockingFindings = (verdict.findings ?? []) + .filter((f) => f.severity === 'fail') + .map((f) => ({ + code: `${f.rule}${f.kind ? `/${f.kind}` : ''}`, + message: String(f.detail ?? f.rule), + remedy: f.fix ? String(f.fix) : null, + })); + } catch (err) { + blockingFindings = [{ code: 'preflight/unrunnable', message: `the gate could not run: ${err.message}`, remedy: null }]; + } + + const handoff = verifyHandoff(root, { corpus: snapshot }); + const gatePass = Boolean(verdict?.pass) && blockingFindings.length === 0 && handoff.ok; + + // Review, derived. + const rows = snapshot.subtopics ?? []; + const mapClassified = rows.length > 0 && rows.every((r) => ['COVERED', 'DISMISSED', 'GAP'].includes(r.status)); + const briefReviewed = briefState(snapshot.brief?.text ?? '') === 'authored'; + const { reviewed: findingsReviewed, unrewritten } = findingsReviewState(root, snapshot); + + const collected = (snapshot.evidence ?? []).length; + const collectionFailed = collected === 0 || !(snapshot.ledger?.present) || (snapshot.ledger?.entries ?? []).length === 0; + + const approved = gatePass && mapClassified && briefReviewed && findingsReviewed; + const state = collectionFailed ? 'COLLECTION_FAILED' + : approved ? 'APPROVED_BRIEF' + : gatePass ? 'REVIEW_IN_PROGRESS' + : (mapClassified || briefReviewed || findingsReviewed) ? 'PREFLIGHT_BLOCKED' + : 'HUMAN_REVIEW_REQUIRED'; + + return { + state, + kind: approved ? 'APPROVED_RESEARCH' : 'COLLECTED_CORPUS', + buildAuthorized: approved, + review: { mapClassified, findingsReviewed, briefReviewed }, + gate: { + verdict: verdict === null ? 'NOT_RUN' : gatePass ? 'PASS' : blockingFindings.length ? 'FAIL' : 'INCOMPLETE', + buildAuthorized: approved, + blockingFindings, + }, + handoff, + verdict, + unrewritten, + corpus: snapshot, + }; +} + +/** + * A Finding cell identical to what the extractor produces from the same capture was + * never rewritten. Negative evidence only, and that is stated rather than implied: this + * cannot tell a good claim from a bad one, and does not try. + */ +export function findingsReviewState(root, corpus) { + const rows = corpus.evidence ?? []; + if (!rows.length) return { reviewed: false, unrewritten: [] }; + const unrewritten = []; + for (const row of rows) { + if (!row.raw) continue; + const text = readText(resolve(root, row.raw)); + 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 + // on run 36287211468: run over the whole FILE, the extractor read the front-matter as the + // page, never matched an untouched JSON capture's Finding, and a corpus nobody had + // reviewed was packaged as reviewed - one of the four conditions for buildAuthorized. + const { front, body } = parseCapture(text); + const extracted = String(firstFinding(body, front.title || front.url || row.url || '') ?? '').trim(); + if (!extracted) continue; + if (String(row.finding ?? '').trim() === extracted) unrewritten.push(row.id); + } + return { reviewed: unrewritten.length === 0, unrewritten }; +} + +// ---------------------------------------------------------------- the human file + +function renderReadme(derived, source) { + const { state } = derived; + if (state === 'APPROVED_BRIEF') { + return `# APPROVED RESEARCH — BUILDING IS AUTHORIZED + +Research-Kit collected this evidence, a human reviewed it, and the gate passed. + +\`manifest.json\` carries \`"state": "APPROVED_BRIEF"\`, \`"buildAuthorized": true\` and +\`"gate": { "verdict": "PASS" }\`. Confirm all three before acting on this package — +this file is prose and the manifest is the contract. + +Start by opening: + +\`project/research/BRIEF.md\` +`; + } + + if (state === 'COLLECTION_FAILED') { + const first = derived.gate.blockingFindings[0]; + return `# COLLECTION STOPPED + +Research-Kit did not finish collecting. This package is here so the failure can be +diagnosed; it holds no usable corpus and authorizes nothing. + +${first ? `First blocking finding: ${first.code} — ${first.message}\n` : ''} +See \`reports/problems.json\` for the full list. +`; + } + + return `# COLLECTED CORPUS — HUMAN REVIEW REQUIRED + +Research-Kit collected evidence successfully. + +This package is **not an approved research brief** and does not authorize +an AI or person to begin building. + +The commands below are written for PowerShell, bash and zsh. In cmd.exe, write +\`%USERPROFILE%\` where a command says \`$HOME\`. + +## Three required review steps + +1. Review and classify every row in \`project/research/MAP.md\`. +2. Review every finding in \`project/research/EVIDENCE.md\`. +3. From the \`project\` folder, run preflight until it prints PASS, then draft the + brief and answer its two **TODO** sections in \`project/research/BRIEF.md\`: + +\`\`\` +${homeCommand('preflight.mjs')} +${homeCommand('brief.mjs')} +\`\`\` + +## Then package the reviewed project + +From this folder, the one holding this file: + +\`\`\` +${homeCommand('artifact.mjs', repackageArgs(source))} +\`\`\` + +It writes \`reviewed.zip\` with the identity of this package and a manifest derived from +the project as it now stands. Building is permitted only when that manifest says all of: + +- \`manifest.json\` contains \`"state": "APPROVED_BRIEF"\` +- \`manifest.json\` contains \`"buildAuthorized": true\` +- \`manifest.json\` contains \`"gate": { "verdict": "PASS" }\` + +Start by opening: + +\`project/START_HERE.md\` +`; +} + +/** + * The \`create\` arguments that re-package a reviewed project under the identity it arrived + * with. The package said building needs APPROVED_BRIEF, and nothing in it said how a reviewer + * gets a manifest that says so (found 2026-09-27 on collector run 36300088606). Identity only: + * the new manifest's state is derived from the project, as every manifest's is. + */ +function repackageArgs({ clientRef, repository, ref, commit, workflow, workflowRunId, runAttempt, apiVersion, runUrl, htmlUrl }) { + const bare = (value) => (/^[A-Za-z0-9._\/:@+-]+$/.test(String(value)) ? String(value) : `'${String(value)}'`); + const [owner, name] = String(repository).split('/'); + const args = ['create', '--root project', '--output reviewed.zip', + `--repository ${bare(repository)}`, `--ref ${bare(ref)}`, `--commit ${bare(commit)}`, + `--workflow ${bare(workflow)}`, `--run-id ${workflowRunId}`, `--run-attempt ${runAttempt}`]; + if (clientRef) args.push(`--client-ref ${bare(clientRef)}`); + if (apiVersion !== GITHUB_API_VERSION) args.push(`--api-version ${bare(apiVersion)}`); + if (runUrl !== `https://api.github.com/repos/${owner}/${name}/actions/runs/${workflowRunId}`) args.push(`--run-url ${bare(runUrl)}`); + if (htmlUrl !== `https://github.com/${owner}/${name}/actions/runs/${workflowRunId}`) args.push(`--html-url ${bare(htmlUrl)}`); + return args.join(' '); +} + +function renderSummary(derived, manifestish) { + const c = manifestish.collection; + const lines = [ + '# Collection summary', + '', + `Package \`${manifestish.packageId}\`, written ${manifestish.createdAt}.`, + '', + '| | |', + '|---|---|', + `| state | \`${derived.state}\` |`, + `| build authorized | \`${derived.buildAuthorized}\` |`, + `| gate verdict | \`${derived.gate.verdict}\` |`, + `| captures | ${c.captures} |`, + `| credits used | ${c.creditsUsed === null ? 'not recorded' : c.creditsUsed} |`, + `| fetch provider | ${c.fetchProvider ?? 'none'} |`, + `| completeness | ${c.completeness} |`, + '', + '## Review state', + '', + `- map classified: **${derived.review.mapClassified}**`, + `- findings reviewed: **${derived.review.findingsReviewed}**${derived.unrewritten.length ? ` — still carrying the extractor's own words: ${derived.unrewritten.join(', ')}` : ''}`, + `- brief reviewed: **${derived.review.briefReviewed}**`, + '', + ]; + if (derived.gate.blockingFindings.length) { + lines.push('## Blocking findings', ''); + for (const f of derived.gate.blockingFindings) lines.push(`- \`${f.code}\` — ${f.message}`); + lines.push(''); + } + lines.push( + '## What this file is not', + '', + 'A summary is not a verdict. `manifest.json` is the machine-readable contract, and a', + 'consumer that disagrees with this page should believe the manifest.', + '', + ); + return lines.join('\n'); +} + +function renderProblems(derived) { + return `${canonicalJson({ + state: derived.state, + buildAuthorized: derived.buildAuthorized, + gateVerdict: derived.gate.verdict, + blockingFindings: derived.gate.blockingFindings, + corpusProblems: (derived.corpus.problems ?? []).map((p) => ({ + kind: String(p.kind ?? ''), artifact: String(p.artifact ?? ''), detail: String(p.detail ?? ''), + })), + handoff: { + ok: derived.handoff.ok, + entries: derived.handoff.entries, + findings: derived.handoff.findings.filter((f) => f.severity === 'fail').map((f) => ({ name: f.name, detail: f.detail })), + }, + unrewrittenFindings: derived.unrewritten, + })}\n`; +} + +// ---------------------------------------------------------------- producing + +/** + * `createArtifact({ root, ... })` -> `{ bytes, name, manifest, validation }`. + * + * Returns the archive rather than writing it, so the CLI owns where bytes land and the + * tests never need a temp file to exercise the producer. + */ +export function createArtifact({ + root, + clientRef = null, + repository, + ref, + commit, + workflow, + workflowRunId, + runAttempt = 1, + runUrl = null, + htmlUrl = null, + apiVersion = GITHUB_API_VERSION, + platform = `${process.platform}-${process.arch}`, + runtime = `node-${process.versions.node.split('.')[0]}`, + producerVersion = FORMAT_VERSION, + createdAt = nowIso(), + kitRoot = KIT_ROOT, + env = {}, +} = {}) { + const refCheck = checkClientRef(clientRef); + if (!refCheck.ok) throw new Error(refCheck.detail); + const safeRef = refCheck.value; + + if (!root || !isDirectory(root)) throw new Error(`no project at ${root}`); + if (!Number.isInteger(workflowRunId) || workflowRunId < 1) { + throw new Error('workflowRunId is required and must be the integer the dispatch response returned (pin X-GitHub-Api-Version: 2026-03-10 and read workflow_run_id)'); + } + + // The identity is checked against the manifest schema's own rules BEFORE anything is + // derived or written: `create --commit abc` wrote the package, then reported that the package + // it had just written did not validate, and left it on disk (found 2026-09-27). + const identityProblem = sourceProblem(kitRoot, { + repository, ref, commit, workflow, apiVersion, workflowRunId, + runUrl: runUrl ?? `https://api.github.com/repos/${repository}/actions/runs/${workflowRunId}`, + htmlUrl: htmlUrl ?? `https://github.com/${repository}/actions/runs/${workflowRunId}`, + runAttempt, + }); + if (identityProblem) throw new Error(identityProblem); + + const derived = deriveState(root, { env }); + const corpus = derived.corpus; + + const owner = String(repository ?? '').split('/')[0] || 'OWNER'; + const repoName = String(repository ?? '').split('/')[1] || 'REPO'; + const resolvedRunUrl = runUrl ?? `https://api.github.com/repos/${owner}/${repoName}/actions/runs/${workflowRunId}`; + const resolvedHtmlUrl = htmlUrl ?? `https://github.com/${owner}/${repoName}/actions/runs/${workflowRunId}`; + + // ---- payload ------------------------------------------------------------------ + const entries = []; + const add = (zipPath, data) => entries.push({ name: zipPath, data: Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8') }); + + for (const rel of collectProjectFiles(root)) { + add(`project/${rel}`, fs.readFileSync(resolve(root, rel))); + } + + const ledgerAbs = resolve(root, PATHS.ledger); + if (exists(ledgerAbs) && !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. + add(`project/${PATHS.ledger}`, fs.readFileSync(ledgerAbs)); + } + + const captures = (corpus.evidence ?? []).filter((r) => r.raw).length; + const partial = (corpus.captures?.entries ?? []).filter((c) => c.completeness && c.completeness !== 'full').length; + const total = (corpus.captures?.entries ?? []).length; + + const collection = { + result: derived.state === 'COLLECTION_FAILED' ? 'FAILED' : partial > 0 ? 'PARTIAL' : 'SUCCEEDED', + fetchProvider: transportOf(corpus) ?? null, + searchProvider: transportOf(corpus) ?? null, + captures, + creditsUsed: null, // the usage log is machine-local and never travels; see EXCLUDED + completeness: total === 0 ? 'NONE' : partial === 0 ? 'FULL' : 'PARTIAL', + startedAt: createdAt, + finishedAt: createdAt, + }; + + const packageId = `RK-${sha256(Buffer.from(`${repository}:${workflowRunId}:${runAttempt}:${createdAt}`, 'utf8')).slice(0, 26).toUpperCase()}`; + + const shell = { + format: FORMAT, + formatVersion: FORMAT_VERSION, + packageId, + clientRef: safeRef, + createdAt, + kind: derived.kind, + state: derived.state, + buildAuthorized: derived.buildAuthorized, + topic: String(corpus.map?.topic || corpus.plan?.topic || 'Untitled topic').slice(0, 4096), + producer: { name: 'research-kit', version: producerVersion, platform, runtime }, + source: { + repository: String(repository), ref: String(ref), commit: String(commit), workflow: String(workflow), + apiVersion: String(apiVersion), workflowRunId, runUrl: resolvedRunUrl, htmlUrl: resolvedHtmlUrl, runAttempt, + }, + collection, + review: derived.review, + gate: derived.gate, + nextActions: nextActionsFor(derived), + privacy: { + containsResearchTopic: true, + containsCollectedPages: captures > 0, + containsSecrets: false, // asserted, then PROVEN by the validation pass below + safeForPublicDistribution: false, + }, + files: [], + }; + + add(README_PATH, renderReadme(derived, { ...shell.source, clientRef: safeRef })); + add('reports/collection-summary.md', renderSummary(derived, { ...shell, collection })); + add('reports/problems.json', renderProblems(derived)); + add('schemas/artifact-manifest.schema.json', fs.readFileSync(path.join(kitRoot, 'schemas', 'artifact-manifest.schema.json'))); + + // ---- inventory, then manifest, then digest, in that order ------------------------ + entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + shell.files = entries.map((e) => ({ + path: e.name, + role: roleOf(e.name), + mediaType: mediaTypeOf(e.name), + byteLength: e.data.length, + sha256: sha256(e.data), + })); + + const manifestBytes = Buffer.from(`${canonicalJson(shell)}\n`, 'utf8'); + const digestBytes = Buffer.from(`${sha256(manifestBytes)} manifest.json\n`, 'utf8'); + entries.push({ name: MANIFEST_PATH, data: manifestBytes }); + entries.push({ name: MANIFEST_DIGEST_PATH, data: digestBytes }); + + const bytes = buildZip(entries); + const name = packageName({ clientRef: safeRef, workflowRunId }); + + return { bytes, name, manifest: shell, entries, derived }; +} + +/** The first `source` field the manifest schema would reject, as a sentence - or null. */ +function sourceProblem(kitRoot, source) { + const schema = JSON.parse(fs.readFileSync(path.join(kitRoot, 'schemas', 'artifact-manifest.schema.json'), 'utf8')); + for (const [key, rule] of Object.entries(schema.properties.source.properties)) { + const value = source[key]; + const r = rule.$ref ? { type: 'string', pattern: '^https://' } : rule; + if (r.type === 'integer' && !(Number.isInteger(value) && value >= (r.minimum ?? -Infinity))) { + return `source.${key} must be a whole number${r.minimum !== undefined ? ` of at least ${r.minimum}` : ''}, not ${JSON.stringify(value)}`; + } + if (r.type === 'string') { + if (typeof value !== 'string' || value.length < (r.minLength ?? 0)) return `source.${key} must be a non-empty string, not ${JSON.stringify(value)}`; + if (r.pattern && !new RegExp(r.pattern).test(value)) return `source.${key} must match ${r.pattern}, not ${JSON.stringify(value)}`; + } + } + return null; +} + +function transportOf(corpus) { + const entries = corpus.ledger?.entries ?? []; + for (let i = entries.length - 1; i >= 0; i -= 1) if (entries[i].transport) return entries[i].transport; + return null; +} + +function nextActionsFor(derived) { + if (derived.state === 'APPROVED_BRIEF') { + return [{ id: 'READ_BRIEF', label: 'Read the approved brief and begin building', path: 'project/research/BRIEF.md', required: true }]; + } + if (derived.state === 'COLLECTION_FAILED') { + return [{ id: 'READ_PROBLEMS', label: 'Read what stopped the collection', path: 'reports/problems.json', required: true }]; + } + const out = []; + if (!derived.review.mapClassified) out.push({ id: 'CLASSIFY_MAP', label: 'Review and classify the research map', path: 'project/research/MAP.md', required: true }); + if (!derived.review.findingsReviewed) out.push({ id: 'REVIEW_FINDINGS', label: 'Rewrite collected findings into defensible claims', path: 'project/research/EVIDENCE.md', required: true }); + if (!derived.review.briefReviewed) out.push({ id: 'REVIEW_BRIEF', label: 'Run preflight and review the final brief', path: 'project/research/BRIEF.md', required: true }); + if (!out.length) out.push({ id: 'RUN_PREFLIGHT', label: 'Close the remaining blocking findings, then re-run preflight', path: 'project/research/DISCOVERY.md', required: true }); + return out; +} + +/** + * Write the artifact, then READ IT BACK through the validator before saying it worked. + * + * A producer that reports success from its own intentions has tested nothing. This is the + * only claim of correctness the CLI makes, and it is made by the same code a consumer + * will run - so a package this repository calls good is good by the consumer's standard, + * not by the producer's. + */ +export function writeArtifact(outFile, options) { + const built = createArtifact(options); + fs.mkdirSync(path.dirname(path.resolve(outFile)), { recursive: true }); + fs.writeFileSync(outFile, built.bytes); + const validation = validateArtifact({ file: outFile, expectedClientRef: built.manifest.clientRef ?? null }); + return { ...built, file: outFile, validation }; +} + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-validator-mjs-githubusercontent-34c19498.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-validator-mjs-githubusercontent-34c19498.md new file mode 100644 index 00000000..ffc5f435 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-artifact-validator-mjs-githubusercontent-34c19498.md @@ -0,0 +1,524 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// artifact-validator.mjs - is this package internally valid, and does it authorize a build? +// +// THOSE ARE TWO QUESTIONS AND THIS MODULE KEEPS THEM APART. +// +// `status: 'PASS'` means the container is safe, the manifest matches its digest, every +// declared file is present with the bytes it claims, and the provenance chain verifies. +// It says NOTHING about whether anybody may start building. That is `buildAuthorized`, +// and a caller must read it separately - which is why the two are sibling fields rather +// than one verdict. A collected corpus that is perfectly valid still forbids building, +// and the most likely misuse of this format is an agent reading PASS and starting work. +// +// WHAT THIS MODULE MAY NOT DO, and each of these is enforced by construction rather than +// by intention: +// +// - no network: nothing here imports a transport, and the only I/O is on the file the +// caller named plus a temp directory this module creates +// - no credentials: `process.env` is never read +// - no execution: the ZIP is parsed as data; nothing from it is imported or spawned +// - no shell +// - no write outside a private `fs.mkdtemp` directory, removed in a `finally` +// - the source artifact is opened read-only and never modified +// +// The one thing it does write is a materialised copy of `project/`, because the ledger +// and handoff checks are the kit's existing code and they read a project from disk. +// Rewriting them to read from buffers would mean two implementations of provenance +// verification, and the second one would be the one nobody tested against a real corpus. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { sha256, tempBase, looksBinary } from './core.mjs'; +import { openZip, ZipError, ZIP_LIMITS } from './artifact-zip.mjs'; +import { parseJsonNoDuplicates } from './release/json.mjs'; +import { validateJsonSchema } from './release/schema.mjs'; +import { readCorpus } from './corpus.mjs'; +import { verifyHandoff } from './handoff.mjs'; +import { SECRET_PATTERNS } from './doctor.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const KIT_ROOT = path.resolve(here, '..'); + +export const MANIFEST_PATH = 'manifest.json'; +export const MANIFEST_DIGEST_PATH = 'manifest.sha256'; +export const README_PATH = 'README-FIRST.md'; +const LEDGER_PATH = 'project/research/raw/.fetches.jsonl'; + +/** The major version this validator implements. A different major is UNSUPPORTED, not invalid. */ +export const SUPPORTED_FORMAT_MAJOR = 2; +// 1.x packages stay readable (ADR-0107): 2.0.0 only renamed HUMAN_REVIEW_REQUIRED and retired +// the `human` reviewer, so a package made before is still a package this kit produced. +export const OLDEST_FORMAT_MAJOR = 1; + +/** Files a package must never carry. Machine-local state, overrides, and credentials. */ +const FORBIDDEN_PAYLOAD = Object.freeze([ + 'project/research/raw/.usage.jsonl', + 'project/research/raw/.diagnostics.jsonl', + 'project/research/raw/.failures.jsonl', + 'project/research/raw/.fetches.lock', + 'project/research/overrides.log', + 'project/research/GATE_OFF', +]); + +/** Secret-bearing filenames, refused by NAME as well as by content. */ +const FORBIDDEN_NAME = /(^|\/)\.env(\.|$)|\.pem$|\.key$/i; + +function loadArtifactSchema(root = KIT_ROOT) { + const file = path.join(root, 'schemas', 'artifact-manifest.schema.json'); + return parseJsonNoDuplicates(fs.readFileSync(file, 'utf8')); +} + +// ---------------------------------------------------------------- findings + +function problem(code, message, { path: at = null, remedy = null } = {}) { + // `path` is always the ZIP-relative name so a caller can point at the entry. A value + // NEVER appears here: the secret checks report which pattern matched and where, and + // an error that quotes the match would copy the credential into every log that prints + // the report - which is the thing the check exists to prevent. + return { code, message, path: at, remedy }; +} + +/** Worst-first, so `errors[0]` is the thing to tell a human. */ +const SEVERITY = Object.freeze({ BLOCKED: 3, FAIL: 2, INCOMPLETE: 1, PASS: 0 }); + +function worse(a, b) { return SEVERITY[a] >= SEVERITY[b] ? a : b; } + +// ---------------------------------------------------------------- the check + +/** + * `validateArtifact({ file, expectedClientRef })` -> a structured result. + * + * Never throws for a bad package: an unreadable or hostile artifact is a finding, not an + * exception, because every caller of this function is deciding whether to trust something + * and a thrown error is the easiest thing in the world to catch and ignore. + */ +export function validateArtifact({ + file, + expectedClientRef = null, + limits = ZIP_LIMITS, + schema = null, + tempRoot = tempBase(), +} = {}) { + const errors = []; + const warnings = []; + let status = 'PASS'; + let manifest = null; + let workdir = null; + + const reject = (verdict, ...args) => { status = worse(status, verdict); errors.push(problem(...args)); }; + + try { + if (!file) { + reject('BLOCKED', 'ZIP-READ', 'no artifact file was given', { remedy: 'pass --file ' }); + return report(); + } + + let bytes; + try { + bytes = fs.readFileSync(file); + } catch (err) { + reject('BLOCKED', 'ZIP-READ', `could not read ${path.basename(String(file))}: ${err.code ?? err.message}`, + { remedy: 'check the path, and that the download completed' }); + return report(); + } + + // ---- container ------------------------------------------------------------- + let zip; + try { + zip = openZip(bytes, { limits }); + } catch (err) { + const code = err instanceof ZipError ? err.code : 'ZIP-READ'; + reject('FAIL', code, err.message, { path: err?.entry ?? null, remedy: 'download the artifact again, or regenerate it from the original corpus' }); + return report(); + } + if (zip.problems.length) { + // Structural faults stop everything: nothing below may inflate an entry from a + // container that has already been shown to lie about its own shape. + for (const p of zip.problems) { + reject('FAIL', p.code, p.detail, { path: p.path ?? null, remedy: 'this package is not safe to extract; regenerate it from the original corpus' }); + } + return report(); + } + + // ---- manifest and its digest ------------------------------------------------ + if (!zip.has(MANIFEST_PATH)) { + reject('FAIL', 'MANIFEST-MISSING', `${MANIFEST_PATH} is not in the package`, + { path: MANIFEST_PATH, remedy: 'this is not a Research-Kit artifact; regenerate it with `artifact.mjs create`' }); + return report(); + } + if (!zip.has(MANIFEST_DIGEST_PATH)) { + reject('FAIL', 'MANIFEST-HASH-MISSING', `${MANIFEST_DIGEST_PATH} is not in the package`, + { path: MANIFEST_DIGEST_PATH, remedy: 'without the digest the manifest cannot be shown to be the one that was written' }); + return report(); + } + + const manifestBytes = zip.read(MANIFEST_PATH); + const digestText = zip.read(MANIFEST_DIGEST_PATH).toString('utf8'); + const declared = (digestText.match(/^([0-9a-f]{64})\s{1,2}manifest\.json\s*$/m) ?? [])[1] ?? null; + const actual = sha256(manifestBytes); + if (!declared) { + reject('FAIL', 'MANIFEST-HASH-MISSING', `${MANIFEST_DIGEST_PATH} does not hold a " manifest.json" line`, + { path: MANIFEST_DIGEST_PATH, remedy: 'regenerate the artifact' }); + return report(); + } + if (declared !== actual) { + // Said before the manifest is parsed, deliberately. A manifest whose digest does + // not match is not a manifest to reason about - its contents are exactly what an + // attacker would have edited. + reject('FAIL', 'MANIFEST-HASH-MISMATCH', `${MANIFEST_PATH} does not match the digest in ${MANIFEST_DIGEST_PATH}`, + { path: MANIFEST_PATH, remedy: 'the manifest was modified after it was written; download the artifact again or regenerate it' }); + return report(); + } + + try { + manifest = parseJsonNoDuplicates(manifestBytes.toString('utf8')); + } catch (err) { + const duplicate = /duplicate object key/.test(err.message); + reject('FAIL', duplicate ? 'MANIFEST-DUPLICATE-KEY' : 'MANIFEST-JSON', + `${MANIFEST_PATH} ${duplicate ? 'contains a duplicate key' : 'is not valid JSON'}: ${err.message}`, + { path: MANIFEST_PATH, remedy: 'regenerate the artifact' }); + return report(); + } + + // ---- format version, before schema ------------------------------------------ + // An unsupported MAJOR is reported distinctly from an invalid manifest: a v2 package + // is probably correct and simply newer than this reader, and calling that "invalid" + // would send somebody hunting a corruption that is not there. + const version = String(manifest?.formatVersion ?? ''); + const major = Number(version.split('.')[0]); + if (manifest?.format !== 'research-kit-artifact') { + reject('FAIL', 'MANIFEST-SCHEMA', `${MANIFEST_PATH} does not declare format "research-kit-artifact"`, + { path: MANIFEST_PATH, remedy: 'this is not a Research-Kit artifact' }); + return report(); + } + if (!Number.isInteger(major) || major < OLDEST_FORMAT_MAJOR || major > SUPPORTED_FORMAT_MAJOR) { + reject('INCOMPLETE', 'FORMAT-UNSUPPORTED', + `this package declares format version ${version || '(none)'}; this validator reads majors ${OLDEST_FORMAT_MAJOR} to ${SUPPORTED_FORMAT_MAJOR}`, + { path: MANIFEST_PATH, remedy: `upgrade Research-Kit to a version that reads format ${major || '?'}.x` }); + return report(); + } + + // ---- schema ------------------------------------------------------------------ + const schemaDoc = schema ?? loadArtifactSchema(); + const schemaErrors = validateJsonSchema(manifest, schemaDoc); + for (const e of schemaErrors) { + reject('FAIL', 'MANIFEST-SCHEMA', `${e.path}: ${e.message}`, { path: MANIFEST_PATH, remedy: 'regenerate the artifact' }); + } + if (schemaErrors.length) { + // The schema already refuses a false authorization claim through its if/then/else, + // and reports it as `$.buildAuthorized: must equal false` - true, generic, and no + // use to somebody deciding whether they have just been handed a forged permission. + // + // So the authorization rule is ALSO run here and named in its own words before + // returning. The one rule the whole format exists to carry should never reach a + // reader disguised as a schema detail, whichever check happened to notice first. + checkAuthorization(); + return report(); + } + + // Each major carries its own names (ADR-0107). The schema lists both eras' values, so the + // pairing is checked here, where the message can say which name belongs to which format. + for (const problem of eraProblems(manifest, major)) { + reject('FAIL', 'MANIFEST-SCHEMA', problem, { path: MANIFEST_PATH, remedy: 'regenerate the artifact' }); + } + + // ---- correlation -------------------------------------------------------------- + if (expectedClientRef !== null && expectedClientRef !== undefined) { + if (manifest.clientRef !== expectedClientRef) { + reject('FAIL', 'CLIENT-REF-MISMATCH', + `this package carries clientRef ${JSON.stringify(manifest.clientRef)}, not ${JSON.stringify(expectedClientRef)}`, + { path: MANIFEST_PATH, remedy: 'you are holding a different job\'s artifact; check source.workflowRunId against the run you dispatched' }); + } + } + + // ---- the file inventory -------------------------------------------------------- + const declaredFiles = manifest.files ?? []; + const byPath = new Map(); + for (const entry of declaredFiles) { + if (byPath.has(entry.path)) { + reject('FAIL', 'FILE-DUPLICATE', `${entry.path} is declared more than once in the manifest`, { path: entry.path, remedy: 'regenerate the artifact' }); + continue; + } + byPath.set(entry.path, entry); + if (entry.path === MANIFEST_PATH || entry.path === MANIFEST_DIGEST_PATH) { + reject('FAIL', 'FILE-DUPLICATE', + `${entry.path} must not appear in "files" - the manifest cannot carry its own digest and stay correct about it`, + { path: entry.path, remedy: 'regenerate the artifact' }); + } + } + + const payload = zip.names.filter((n) => n !== MANIFEST_PATH && n !== MANIFEST_DIGEST_PATH); + for (const name of payload) { + if (byPath.has(name)) continue; + reject('FAIL', 'FILE-UNDECLARED', `${name} is in the package but not declared in the manifest`, + { path: name, remedy: 'every payload file must be declared; regenerate the artifact' }); + } + for (const [name] of byPath) { + if (name === MANIFEST_PATH || name === MANIFEST_DIGEST_PATH) continue; + if (zip.has(name)) continue; + reject('FAIL', 'FILE-MISSING', `${name} is declared in the manifest but is not in the package`, + { path: name, remedy: 'the package is incomplete; regenerate it from the original corpus' }); + } + + for (const name of FORBIDDEN_PAYLOAD) { + if (!zip.has(name)) continue; + reject('FAIL', 'FILE-UNDECLARED', `${name} must never be packaged - it is machine-local state, not evidence`, + { path: name, remedy: 'regenerate the artifact with a producer that excludes it' }); + } + for (const name of payload) { + if (!FORBIDDEN_NAME.test(name)) continue; + reject('FAIL', 'SECRET-DETECTED', `${name} is a credential file by name and must never be packaged`, + { path: name, remedy: 'rotate anything this package may have exposed, then regenerate the artifact' }); + } + + // ---- bytes --------------------------------------------------------------------- + const bodies = new Map(); + for (const [name, entry] of byPath) { + if (!zip.has(name)) continue; + let body; + try { + body = zip.read(name); + } catch (err) { + reject('FAIL', 'ZIP-READ', `${name} could not be read: ${err.message}`, { path: name, remedy: 'download the artifact again' }); + continue; + } + bodies.set(name, body); + if (body.length !== entry.byteLength) { + reject('FAIL', 'FILE-SIZE-MISMATCH', `${name} is ${body.length} bytes; the manifest declares ${entry.byteLength}`, + { path: name, remedy: 'download the artifact again or regenerate it from the original corpus' }); + continue; + } + if (sha256(body) !== entry.sha256) { + reject('FAIL', 'FILE-HASH-MISMATCH', `${name} does not match its declared SHA-256.`, + { path: name, remedy: 'download the artifact again or regenerate it from the original corpus' }); + } + if (typeof entry.mediaType !== 'string' || entry.mediaType.trim() === '') { + reject('FAIL', 'MANIFEST-SCHEMA', `${name} declares an empty mediaType`, { path: name, remedy: 'regenerate the artifact' }); + } + } + + // ---- secrets, by content --------------------------------------------------------- + // A finding here is a FAIL rather than a warning. A package that carries a credential + // has already been written to a runner's disk and an artifact store; treating that as + // advisory would let it be forwarded while somebody decides. + for (const [name, body] of bodies) { + if (body.length > 512 * 1024) continue; + // A binary body is outside the scan, as a large one is (ADR-0128): `docs/` travels + // whole, and one vendored DLL carrying the PEM marker it parses had the patterns + // failing a package with no secret in it. + if (looksBinary(body)) continue; + let text; + try { text = body.toString('utf8'); } catch { continue; } + for (const pattern of SECRET_PATTERNS) { + const match = pattern.re.exec(text); + if (!match) continue; + const line = text.slice(0, match.index).split('\n').length; + reject('FAIL', 'SECRET-DETECTED', + `${name}:${line} matches the ${pattern.name} credential pattern`, + { path: name, remedy: 'treat the credential as compromised and rotate it, then regenerate the artifact without it' }); + } + } + if (manifest.privacy?.containsSecrets === true) { + reject('FAIL', 'SECRET-DETECTED', 'the manifest declares privacy.containsSecrets: true', + { path: MANIFEST_PATH, remedy: 'a package that knows it carries a secret must not be distributed; rotate and regenerate' }); + } + + // ---- authorization consistency ----------------------------------------------------- + checkAuthorization(); + + // ---- provenance ----------------------------------------------------------------- + const ledgerRoles = declaredFiles.filter((f) => f.role === 'PROVENANCE_LEDGER'); + if (ledgerRoles.length > 1) { + reject('FAIL', 'LEDGER-INVALID', `the manifest declares ${ledgerRoles.length} files with role PROVENANCE_LEDGER; there is exactly one chain`, + { path: MANIFEST_PATH, remedy: 'regenerate the artifact' }); + } else if (ledgerRoles.length === 1 && ledgerRoles[0].path !== LEDGER_PATH) { + reject('FAIL', 'LEDGER-INVALID', `the provenance ledger is declared at ${ledgerRoles[0].path}; it must be ${LEDGER_PATH}`, + { path: ledgerRoles[0].path, remedy: 'regenerate the artifact' }); + } + + // The ledger is required when the package CLAIMS EVIDENCE, not merely when it carries + // a project directory. + // + // A COLLECTION_FAILED package is the case that makes the distinction necessary: it is + // a scaffolded project and a problem report, collected nothing, and has no chain + // because there was nothing to chain. Demanding a ledger there would refuse the one + // package shape whose entire purpose is to carry a diagnosis home, and would do it + // with a message implying evidence went missing when none was ever fetched. + // + // So the trigger is a claim: a declared RAW_CAPTURE, or a manifest asserting captures. + // Either of those says "there is evidence in here", and evidence without its chain is + // exactly what this format refuses to let travel. + const claimsEvidence = declaredFiles.some((f) => f.role === 'RAW_CAPTURE') + || (manifest.collection?.captures ?? 0) > 0; + if (claimsEvidence) { + if (!zip.has(LEDGER_PATH)) { + reject('FAIL', 'LEDGER-MISSING', `${LEDGER_PATH} is not in the package, but the package claims collected evidence - a corpus without its chain proves nothing`, + { path: LEDGER_PATH, remedy: 'the ledger is a dotfile; a producer that copied the corpus by hand probably skipped it' }); + } else if (!errors.some((e) => e.code === 'FILE-HASH-MISMATCH' || e.code === 'FILE-SIZE-MISMATCH')) { + // Only materialise once the declared bytes are known to be the packaged bytes. + // Running the chain check over content that already failed its own digest would + // report a second, derived failure and bury the first. + workdir = fs.mkdtempSync(path.join(tempRoot, 'rk-artifact-')); + const projectRoot = materializeProject(workdir, bodies); + for (const finding of provenanceProblems(projectRoot)) reject('FAIL', finding.code, finding.message, finding); + } + } + + return report(); + } catch (err) { + // Any unexpected throw is BLOCKED, never PASS. A validator that cannot finish has not + // approved anything, and the one failure mode that must not exist is a crash being + // read as a clean result. + reject('BLOCKED', 'ZIP-READ', `validation could not complete: ${err.message}`, + { remedy: 'report this package and its Research-Kit version' }); + return report(); + } finally { + if (workdir) { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* a temp dir that will not go is the OS's business */ } } + } + + /** Called on both routes out - schema-refused and schema-clean - so the wording cannot drift. */ + function checkAuthorization() { + for (const finding of authorizationProblems(manifest)) { + reject('FAIL', 'AUTHORIZATION-INCONSISTENT', finding, + { path: MANIFEST_PATH, remedy: 'do not build from this package; its authorization fields contradict its review and gate state' }); + } + } + + function report() { + // `buildAuthorized` is false unless the package says true AND nothing was wrong with + // it. A failed package cannot authorize a build however its fields read. + const claimed = manifest?.buildAuthorized === true; + return { + status, + buildAuthorized: status === 'PASS' && claimed, + packageId: manifest?.packageId ?? null, + clientRef: manifest?.clientRef ?? null, + workflowRunId: manifest?.source?.workflowRunId ?? null, + // A 1.x package's HUMAN_REVIEW_REQUIRED is reported under its 2.0.0 name, so a consumer + // switches on one state whichever format it was handed (ADR-0107). + state: manifest?.state === 'HUMAN_REVIEW_REQUIRED' ? 'REVIEW_REQUIRED' : (manifest?.state ?? null), + // The reviewer's own declaration (format 1.1.0, ADR-0074); a 1.0.0 package has none. + reviewedBy: manifest?.review?.by ?? 'undeclared', + errors, + warnings, + }; + } +} + +// ---------------------------------------------------------------- format eras + +/** Values that belong to the other major (ADR-0107), each named with the one that belongs here. */ +function eraProblems(manifest, major) { + const out = []; + if (major >= 2 && manifest?.state === 'HUMAN_REVIEW_REQUIRED') { + out.push('state HUMAN_REVIEW_REQUIRED was renamed REVIEW_REQUIRED in format 2.0.0; a 2.x package cannot carry the old name'); + } + if (major >= 2 && manifest?.review?.by === 'human') { + out.push('review.by "human" was retired in format 2.0.0: the review is the agent\'s, and a 2.x package declares "agent" or "undeclared"'); + } + if (major < 2 && manifest?.state === 'REVIEW_REQUIRED') { + out.push('state REVIEW_REQUIRED is a format 2.0.0 name; a 1.x package says HUMAN_REVIEW_REQUIRED'); + } + return out; +} + +// ---------------------------------------------------------------- authorization + +/** + * The central rule of this format, in one function so it has one implementation. + * + * An AI or application may build only when + * state === 'APPROVED_BRIEF' && gate.verdict === 'PASS' && buildAuthorized === true + * and every supporting field agrees. The schema already refuses most of these through + * if/then/else; this repeats them because a manifest is checked by more than the schema, + * and the one rule the whole format exists to carry should not live only in a JSON file. + */ +export function authorizationProblems(manifest) { + const out = []; + const approved = manifest.state === 'APPROVED_BRIEF'; + const claims = manifest.buildAuthorized === true || manifest.gate?.buildAuthorized === true; + + if (!approved && claims) { + out.push(`state is ${manifest.state} but an authorization field is true; only APPROVED_BRIEF may authorize a build`); + return out; + } + if (!approved) return out; + + const required = [ + [manifest.kind === 'APPROVED_RESEARCH', `kind is ${manifest.kind}, but an approved brief must be APPROVED_RESEARCH`], + [manifest.buildAuthorized === true, 'state is APPROVED_BRIEF but buildAuthorized is not true'], + [manifest.review?.mapClassified === true, 'the research map has not been classified'], + [manifest.review?.findingsReviewed === true, 'the collected findings have not been reviewed'], + [manifest.review?.briefReviewed === true, 'the brief has not been reviewed'], + [manifest.gate?.verdict === 'PASS', `the gate verdict is ${manifest.gate?.verdict}, not PASS`], + [manifest.gate?.buildAuthorized === true, 'gate.buildAuthorized is not true'], + [(manifest.gate?.blockingFindings ?? []).length === 0, `the gate records ${(manifest.gate?.blockingFindings ?? []).length} blocking finding(s)`], + ]; + for (const [ok, message] of required) if (!ok) out.push(message); + return out; +} + +// ---------------------------------------------------------------- provenance + +/** Write `project/` out of the buffers, and nothing else. */ +function materializeProject(workdir, bodies) { + const projectRoot = path.join(workdir, 'project'); + for (const [name, body] of bodies) { + if (!name.startsWith('project/')) continue; + const rel = name.slice('project/'.length); + const target = path.join(projectRoot, ...rel.split('/')); + // Belt and braces. `checkEntryName` already refused every traversal, but this is the + // one place bytes become a filesystem path, and a containment check here costs one + // comparison and removes the need to trust a check made three hundred lines earlier. + const resolved = path.resolve(target); + if (resolved !== path.resolve(projectRoot) && !resolved.startsWith(path.resolve(projectRoot) + path.sep)) { + throw new Error(`refusing to write ${name} outside the temporary project directory`); + } + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, body); + } + return projectRoot; +} + +/** Reuse the kit's own chain and handoff logic rather than reimplementing either. */ +function provenanceProblems(projectRoot) { + const out = []; + let corpus; + try { + corpus = readCorpus(projectRoot); + } catch (err) { + out.push({ code: 'LEDGER-INVALID', message: `the packaged corpus could not be read: ${err.message}`, path: 'project/', remedy: 'regenerate the artifact from a project that passes `handoff.mjs`' }); + return out; + } + + const handoff = verifyHandoff(projectRoot, { corpus }); + for (const finding of handoff.findings) { + if (finding.severity !== 'fail') continue; + const code = finding.name === 'handoff-ledger-missing' ? 'LEDGER-MISSING' + : finding.name === 'handoff-ledger-empty' ? 'LEDGER-INVALID' + : finding.name === 'handoff-capture-missing' ? 'CAPTURE-MISSING' + : finding.kind === 'modified' || finding.kind === 'line-endings' ? 'CAPTURE-HASH-MISMATCH' + : 'LEDGER-INVALID'; + out.push({ + code, + message: finding.detail, + path: 'project/research/', + remedy: code === 'CAPTURE-HASH-MISMATCH' + // Named separately because the cause differs and so does the fix: a rewritten + // line ending is not tampering, and telling somebody to re-collect for it wastes + // credits on evidence that is already correct. + ? 'a capture no longer matches the hash taken when it was fetched; if the package travelled through a tool that rewrites line endings, repackage with the bytes as collected' + : 'regenerate the artifact from a project that passes `handoff.mjs`', + }); + } + return out; +} + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-37cece34.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-37cece34.md new file mode 100644 index 00000000..276981e6 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-37cece34.md @@ -0,0 +1,365 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// brief.mjs - the phase-1 -> phase-2 handoff, and the owner of the brief's SHAPE (ADR-0014). +// +// Six sections, two of which the corpus cannot fill. The renderer writes its headings +// FROM this definition and lib/audit.mjs reads the judged sections through it, so the +// writer and its readers cannot drift. + +import { PATHS, resolve, readText, writeText, today, documentCommand, exists, sha256, foldLineEndings } from './core.mjs'; +import { readCorpus, sectionOf, claimOf, captureOf } from './corpus.mjs'; +import { readPrior } from './prior.mjs'; +import { renderGateWarnings } from './render.mjs'; + +/** `judged: true` marks a section the corpus cannot fill - it needs the reviewer's call. */ +export const BRIEF_SECTIONS = Object.freeze([ + { key: 'intent', heading: 'Intent', judged: false }, + { key: 'verified', heading: 'What we verified', judged: false }, + { key: 'contradictions', heading: 'Contradictions and how they were resolved', judged: true }, + { key: 'unknowns', heading: 'Known unknowns', judged: false }, + { key: 'decision', heading: 'Decision', judged: true }, + { key: 'next', heading: 'Next steps', judged: false }, +]); + +export const JUDGED_SECTIONS = Object.freeze(BRIEF_SECTIONS.filter((s) => s.judged).map((s) => s.key)); +export const TODO_MARK = '**TODO**'; + +/** + * The marker that says "this is still the scaffold". The substitution pass leaves it + * alone; the alternative - matching the template's prose - made a sentence of English + * load-bearing. + */ +export const BRIEF_FILE_MARKER = ''; + +/** + * `template | legacy | draft | authored`. + * Only the first two are safe to draft over, and `legacy` - a scaffold written before + * the marker shipped, which the text cannot tell from a brief someone wrote in - + * refuses loudly rather than guessing. + */ +/** + * Who reviewed this corpus, as its reviewer declared it in the brief (ADR-0074): `agent` + * or `undeclared`. The review is the agent's (ADR-0107); a line naming a person - valid in + * 1.x packages - now reads as `undeclared`. + * + * A DECLARATION, and read as one. The three review steps are checked by what they leave + * behind, never by who did them, so nothing can verify this line - and approval does not + * depend on it. The drafted placeholder (`_agent - ..._`) is not a declaration. + */ +export function reviewedBy(text) { + // An ambiguous declaration is no declaration (found 2026-09-27): a line naming both + // roles - a careless edit of the placeholder reads "agent or human" - or two lines that + // disagree read as `undeclared`, never as whichever came first. A line quoted inside a + // code block is not the reviewer speaking. + const outside = String(text ?? '').replace(/^[ \t]*```[\s\S]*?^[ \t]*```/gm, ''); + const said = new Set(); + for (const [, value] of outside.matchAll(/^[ \t]*Reviewed by:(.*)$/gim)) { + const roles = new Set([...value.toLowerCase().matchAll(/\b(agent|human)\b/g)].map((m) => m[1])); + const first = value.replace(/^[ \t*]+/, '').toLowerCase().match(/^(agent|human)\b/); + if (!first) continue; // the drafted placeholder, or not a role at all + if (roles.size > 1) return 'undeclared'; + said.add(first[1]); + } + // The review is the agent's (ADR-0107): a line naming a person declares nothing the kit reads. + return said.size === 1 && said.has('agent') ? 'agent' : 'undeclared'; +} + +export function briefState(text) { + const body = String(text ?? ''); + if (!body.trim()) return 'template'; + if (body.includes(BRIEF_FILE_MARKER)) return 'template'; + const drafted = body.includes('_Auto-drafted'); + const todos = JUDGED_SECTIONS.filter((key) => { + const section = BRIEF_SECTIONS.find((s) => s.key === key); + return sectionOf(body, section.heading).includes(TODO_MARK); + }); + if (drafted && todos.length) return 'draft'; + if (drafted) return 'authored'; + const hasHeadings = BRIEF_SECTIONS.some((s) => sectionOf(body, s.heading)); + return hasHeadings ? 'legacy' : 'template'; +} + +/** + * The stamp a drafted brief ends with: a hash of its own text and a hash of what it was + * drafted from. The first says whether anybody has edited it since - an untouched draft + * holds no judgement, so it is redrafted without --force. The second says whether the + * corpus has moved on since, which `hygiene/brief-stale` reports (ADR-0055). + */ +const DRAFT_STAMP = /\n?[ \t]*\r?\n?/g; + +const shortHash = (text) => sha256(foldLineEndings(text)).slice(0, 16); + +/** What the brief is drafted FROM, parsed, so a reformatted table is not a change. */ +export function briefInputsHash(snapshot) { + return shortHash(JSON.stringify({ + topic: snapshot.map?.topic || snapshot.plan?.topic || '', + intent: snapshot.intent ?? '', + unknowns: (snapshot.unknowns ?? []).map((u) => [u.id, u.text, u.status, u.evidence]), + evidence: (snapshot.evidence ?? []).map((e) => [e.id, e.retrieved, e.type, e.url, e.finding, e.raw]), + // The map and the ledger decide the verdict too: a brief drafted while only the map was + // incomplete said "Gate: FAIL" after the map was statused, and nothing called it stale + // (found 2026-09-27). + map: (snapshot.subtopics ?? []).map((row) => [row.id, row.status, row.coveredBy]), + fetches: snapshot.ledger?.entries?.length ?? 0, + captures: snapshot.captures?.entries?.length ?? 0, + })); +} + +/** + * `{ edited, inputs, gate }` for a stamped draft, or `null` for anything without the stamp. + * The stamp is found wherever it stands - the last one, if the brief holds several. It was + * anchored to the end of the file until 2026-10-03: a line appended after it (a reviewer's + * note, the `Reviewed by: agent` line AGENTS.md asks for) made the brief read as unstamped, + * so a stale brief re-approved the build and `brief-stale` fell silent (review of the G1 + * fix). Text after the stamp counts as an edit, as text before it always did. + */ +export function draftStamp(text) { + const body = String(text ?? ''); + let m = null; + for (const found of body.matchAll(DRAFT_STAMP)) m = found; + if (!m) return null; + const after = body.slice(m.index + m[0].length); + const rest = body.slice(0, m.index) + (after.trim() ? after : ''); + return { edited: shortHash(rest) !== m[1], inputs: m[2], gate: m[3] ?? 'unknown' }; +} + +export function briefSection(text, key) { + const section = BRIEF_SECTIONS.find((s) => s.key === key); + if (!section) return ''; + return sectionOf(text, section.heading); +} + +export function judgedSection(text, key) { + if (!JUDGED_SECTIONS.includes(key)) return null; + const body = briefSection(text, key); + return { key, body, answered: Boolean(body) && !body.includes(TODO_MARK) }; +} + +// ---------------------------------------------------------------- the renderer + +function verifiedTable(corpus) { + // One row per claim and source, naming every unknown it closes. Two unknowns that lead + // with the same E-row have the same claim (`claimOf`), and listing it once per unknown + // printed it twice, word for word, with nothing saying which copy closed what. + // Every E-row an unknown cites gets its row, not only the first: a closure resting on two + // rows showed one, and the second (MoonAliza 2026-09-29: how Ollama numbers calls) never + // reached the builder. The first keeps `claimOf`'s fallback to the unknown's own text. + const rows = []; + const byKey = new Map(); + for (const unknown of corpus.unknowns) { + if (unknown.status !== 'CLOSED') continue; + const ids = unknown.cites.filter((id) => /^E-\d+$/i.test(id)); + for (const [n, cited] of (ids.length ? ids : ['']).entries()) { + const row = corpus.evidence.find((e) => e.id.toUpperCase() === cited.toUpperCase()); + if (n > 0 && !row?.finding) continue; + const claim = n === 0 ? claimOf(corpus, unknown) : row.finding; + const key = `${cited.toUpperCase()}\u0000${claim}`; + const seen = byKey.get(key); + if (seen) { if (!seen.closes.includes(unknown.id)) seen.closes.push(unknown.id); continue; } + const capture = row ? captureOf(corpus, row) : null; + const entry = { + claim: claim.replace(/\|/g, '\\|'), + cited, + host: row ? ` \`${hostOfUrl(row.url)}\`` : '', + closes: [unknown.id], + type: row?.type ?? '', + partial: capture?.completeness === 'partial', + }; + byKey.set(key, entry); + rows.push(entry); + } + } + for (const r of rows) r.source = `${r.cited}${r.host} (${r.closes.join(', ')})`; + if (!rows.length) return '_No closed unknowns yet - nothing is verified._'; + return [ + '| Claim | Source | Type |', + '|---|---|---|', + ...rows.map((r) => `| ${r.claim}${r.partial ? ' _(partial capture)_' : ''} | ${r.source} | ${r.type} |`), + ].join('\n'); +} + +function hostOfUrl(url) { + try { return new URL(url).host.replace(/^www\./, ''); } catch { return url; } +} + +function knownUnknowns(corpus) { + const rows = corpus.unknowns.filter((u) => u.status === 'KNOWN-UNKNOWN'); + if (!rows.length) { + // Said "closed with primary-source evidence" whatever the Type column held, so a corpus + // resting on one secondary source told its builder the opposite (found 2026-09-27). + const typeOf = (id) => corpus.evidence.find((e) => e.id.toUpperCase() === id.toUpperCase())?.type ?? ''; + const secondhand = corpus.unknowns + .filter((u) => u.status === 'CLOSED' && !u.cites.some((id) => /^E-\d+$/i.test(id) && typeOf(id) === 'P')) + .map((u) => u.id); + const none = 'None. No unknown is declared KNOWN-UNKNOWN.'; + if (!secondhand.length) return none; + return `${none} ${secondhand.join(', ')} ${secondhand.length === 1 ? 'rests' : 'rest'} on no primary (P) source; the Type column above shows what carries ${secondhand.length === 1 ? 'it' : 'them'}.`; + } + // The cell is often written "Day one: ..." already; the label is added once. A cell may + // also say what IS known and label its step mid-sentence (found 2026-09-29 on MoonAliza): + // the text before the label is kept, as what is known so far, and the label is not doubled. + const LABEL = /\bday[- ]one(?:\s+verification)?\s*[:-]\s*/i; + const lines = (cell) => { + const text = String(cell ?? '').trim(); + const at = text.search(LABEL); + if (at < 0) return [`Day-one verification: ${text || '_not stated_'}`]; + // The author may have written the cell as the brief prints it: its own label is not doubled. + const known = text.slice(0, at).trim().replace(/^known so far\s*[:-]\s*/i, ''); + const stepText = text.slice(at).replace(LABEL, '').trim(); + return [...(known ? [`Known so far: ${known}`] : []), `Day-one verification: ${stepText || '_not stated_'}`]; + }; + return rows.map((u) => `- **${u.id}** - ${u.text}\n${lines(u.evidence).map((l) => ` - ${l}`).join('\n')}`).join('\n'); +} + +/** + * Render the brief from the corpus. Refuses a `draft` or `authored` brief without + * `force`, so judgements already written are preserved. + */ +/** + * The prediction registered before collection, quoted verbatim, or nothing. + * + * The drafter emits it rather than leaving it to the author, and that is the point. A + * prior is only worth registering if it gets read back **next to the answer** - left in + * `research/PRIOR.md` it is a file nobody opens, and the brief is the one document phase 2 + * is required to read. Emitting it also removes the quiet edit: an author who has just + * learned they were wrong does not have to decide whether to mention it, because the text + * is already on the page and the ledger already fixed it. + * + * No verdict is drawn here. Whether the corpus confirmed the prior or demolished it is the + * reader's to see - the two cases look identical to this function, which is what keeps it + * from becoming a scoreboard. + */ +function priorBlock(root, snapshot) { + const prior = readPrior(root, { entries: snapshot.ledger?.entries ?? [] }); + if (!prior.present || !prior.text.trim()) return ''; + const late = prior.scrapesBefore + ? `\n\n**Registered late**, at ledger seq ${prior.entry.seq}, after ${prior.scrapesBefore} page(s) were ` + + 'already collected. Read it as hindsight, not as a prediction.' + : ''; + return ` +## The prior, registered before anything was collected + +_Ledger seq ${prior.entry.seq}, chained: neither this text nor its place before the evidence +can be changed now. Read it against the findings below - it may well be wrong, and a wrong +prior that was recorded in advance is worth more than a right one remembered afterwards._ + +> ${prior.text.trim().split('\n').join('\n> ')}${late} +`; +} + +export function renderBrief(root, { force = false, date = today(), corpus = null, verdict = null } = {}) { + const snapshot = corpus ?? readCorpus(root); + const file = resolve(root, PATHS.brief); + const existing = readText(file, ''); + const state = briefState(existing); + + // An untouched draft is the drafter's own output: nothing in it is anybody's judgement. + const untouched = state === 'draft' && draftStamp(existing)?.edited === false; + if (!force && !untouched && (state === 'draft' || state === 'authored')) { + return { written: false, state, reason: `${PATHS.brief} is ${state} - re-run with --force to overwrite the judgements in it` }; + } + if (!force && state === 'legacy') { + return { + written: false, + state, + reason: `${PATHS.brief} is a LEGACY brief: it holds the shape but not the marker, so the text cannot tell a pre-marker scaffold from a brief somebody wrote in. Read it, then re-run with --force if it is safe to replace.`, + }; + } + + const topic = snapshot.map.topic || snapshot.plan?.topic || 'this project'; + // The drafted brief must not ANNOUNCE a completion it cannot see. It knows two things: + // whether the gate passes, and whether the sections only a person can fill are filled. + // Asserting "phase 1 is complete" over unanswered TODOs is the corpus claiming a + // review that never happened. + const gatePasses = verdict?.pass ?? null; + const gateLine = gatePasses === null + ? `**Gate state: not evaluated in this run.**` + : (gatePasses + ? `**Gate: PASS.** The configured research checks passed. Disclosed known unknowns +and gate warnings still apply; PASS does not establish that every claim is correct.` + : `**Gate: FAIL (${verdict.counts.fail} blocking finding${verdict.counts.fail === 1 ? '' : 's'}).** This brief is a +draft of an incomplete research pass: phase 2 does not start until \`${PATHS.discovery}\` +passes. Run \`${documentCommand('preflight.mjs', '', { root })}\` to see what is unproven.`); + + const closureQualification = gatePasses === true ? '' + : `**These are declared CLOSED claims; verification has ${gatePasses === null + ? 'not been evaluated' : 'not succeeded'} in this run.** +Read the recorded claims and sources below as a draft for review, not successful verification. + +`; + + const body = `# Brief - ${topic} + +_Auto-drafted ${date} by \`bin/brief.mjs\` from the corpus. Sections marked ${TODO_MARK} +require the reviewing agent's judgement; everything else is assembled from evidence already +in \`research/\`. While a ${TODO_MARK} remains, this brief is **not reviewed** and the +handoff is **not approved** - a structurally valid corpus, a reviewed one, and an +approved handoff are three different states._ + +Reviewed by: _agent - the agent that classified the map, rewrote the findings and answered the ${TODO_MARK} sections replaces this line with \`Reviewed by: agent\`_ + +**This is the phase-1 to phase-2 handoff.** ${gateLine} + +${renderGateWarnings(verdict)} + +Whoever you are - another agent, a different model, or a person - read this file +first. You should not need to re-research anything to start work. If something +here is not enough to build from, say which fact is missing rather than guessing +it: that is a phase-1 gap to close, not a phase-2 judgment call. +${priorBlock(root, snapshot)} +## ${BRIEF_SECTIONS[0].heading} + +${snapshot.intent || '_The contract states no build intent._'} + +## ${BRIEF_SECTIONS[1].heading} + +${closureQualification}${verifiedTable(snapshot)} + +## ${BRIEF_SECTIONS[2].heading} + +${TODO_MARK} - review the primary sources above for disagreements (pricing pages vs +billing docs, docs vs issue trackers, version-dependent behaviour). Record both +sides and state which you trust and why. State whether independent corroboration +was obtained; a single source does not establish agreement. + +## ${BRIEF_SECTIONS[3].heading} + +${knownUnknowns(snapshot)} + +## ${BRIEF_SECTIONS[4].heading} + +${TODO_MARK} - what to build first, and what is explicitly out of scope. State the +first build step concretely enough that the builder can start from this file +alone. + +## ${BRIEF_SECTIONS[5].heading} + +1. Review the ${TODO_MARK} sections above (${BRIEF_SECTIONS.filter((s) => s.judged).map((s) => s.heading.split(' ')[0]).join(', ')}) before handing off. +2. Hand this file to the builder (phase 2). Re-running \`${documentCommand('brief.mjs', '', { root })}\` + redrafts this file while it is unedited; after any edit it refuses without \`--force\`, + so your judgements are preserved. +`; + + // --force over a brief that holds anybody's judgement keeps it beside the new draft. + // It restored the TODOs and kept nothing of the answers it replaced (found 2026-09-27). + let backup; + if (!untouched && ['draft', 'authored', 'legacy'].includes(state)) { + backup = `${PATHS.brief}.bak-${date}`; + for (let n = 2; exists(resolve(root, backup)); n += 1) backup = `${PATHS.brief}.bak-${date}-${n}`; + writeText(resolve(root, backup), existing); + } + const gate = gatePasses === null ? 'unknown' : (gatePasses ? 'pass' : 'fail'); + const stamped = `${body}\n\n`; + writeText(file, stamped); + return { + written: true, state: briefState(stamped), file: PATHS.brief, reason: '', + ...(untouched ? { replacedUnedited: true } : {}), ...(backup ? { backup } : {}), + }; +} + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-fea9e4f1.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-fea9e4f1.md new file mode 100644 index 00000000..412eecef --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-mjs-githubusercontent-fea9e4f1.md @@ -0,0 +1,289 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// brief.mjs - the phase-1 -> phase-2 handoff, and the owner of the brief's SHAPE (ADR-0014). +// +// Six sections, two of which the corpus cannot fill. The renderer writes its headings +// FROM this definition and lib/audit.mjs reads the judged sections through it, so the +// writer and its readers cannot drift. + +import { PATHS, resolve, readText, writeText, today, documentCommand, exists, sha256 } from './core.mjs'; +import { readCorpus, sectionOf, claimOf, captureOf } from './corpus.mjs'; +import { readPrior } from './prior.mjs'; + +/** `judged: true` marks a section the corpus cannot fill - it needs a person's call. */ +export const BRIEF_SECTIONS = Object.freeze([ + { key: 'intent', heading: 'Intent', judged: false }, + { key: 'verified', heading: 'What we verified', judged: false }, + { key: 'contradictions', heading: 'Contradictions and how they were resolved', judged: true }, + { key: 'unknowns', heading: 'Known unknowns', judged: false }, + { key: 'decision', heading: 'Decision', judged: true }, + { key: 'next', heading: 'Next steps', judged: false }, +]); + +export const JUDGED_SECTIONS = Object.freeze(BRIEF_SECTIONS.filter((s) => s.judged).map((s) => s.key)); +export const TODO_MARK = '**TODO**'; + +/** + * The marker that says "this is still the scaffold". The substitution pass leaves it + * alone; the alternative - matching the template's prose - made a sentence of English + * load-bearing. + */ +export const BRIEF_FILE_MARKER = ''; + +/** + * `template | legacy | draft | authored`. + * Only the first two are safe to draft over, and `legacy` - a scaffold written before + * the marker shipped, which the text cannot tell from a brief someone wrote in - + * refuses loudly rather than guessing. + */ +export function briefState(text) { + const body = String(text ?? ''); + if (!body.trim()) return 'template'; + if (body.includes(BRIEF_FILE_MARKER)) return 'template'; + const drafted = body.includes('_Auto-drafted'); + const todos = JUDGED_SECTIONS.filter((key) => { + const section = BRIEF_SECTIONS.find((s) => s.key === key); + return sectionOf(body, section.heading).includes(TODO_MARK); + }); + if (drafted && todos.length) return 'draft'; + if (drafted) return 'authored'; + const hasHeadings = BRIEF_SECTIONS.some((s) => sectionOf(body, s.heading)); + return hasHeadings ? 'legacy' : 'template'; +} + +/** + * The stamp a drafted brief ends with: a hash of its own text and a hash of what it was + * drafted from. The first says whether anybody has edited it since - an untouched draft + * holds no judgement, so it is redrafted without --force. The second says whether the + * corpus has moved on since, which `hygiene/brief-stale` reports (ADR-0055). + */ +const DRAFT_STAMP = /\n?\s*$/; + +const shortHash = (text) => sha256(String(text).replace(/\r\n/g, '\n')).slice(0, 16); + +/** What the brief is drafted FROM, parsed, so a reformatted table is not a change. */ +export function briefInputsHash(snapshot) { + return shortHash(JSON.stringify({ + topic: snapshot.map?.topic || snapshot.plan?.topic || '', + intent: snapshot.intent ?? '', + unknowns: (snapshot.unknowns ?? []).map((u) => [u.id, u.text, u.status, u.evidence]), + evidence: (snapshot.evidence ?? []).map((e) => [e.id, e.retrieved, e.type, e.url, e.finding, e.raw]), + })); +} + +/** `{ edited, inputs }` for a stamped draft, or `null` for anything without the stamp. */ +export function draftStamp(text) { + const body = String(text ?? ''); + const m = DRAFT_STAMP.exec(body); + if (!m) return null; + return { edited: shortHash(body.slice(0, m.index)) !== m[1], inputs: m[2] }; +} + +export function briefSection(text, key) { + const section = BRIEF_SECTIONS.find((s) => s.key === key); + if (!section) return ''; + return sectionOf(text, section.heading); +} + +export function judgedSection(text, key) { + if (!JUDGED_SECTIONS.includes(key)) return null; + const body = briefSection(text, key); + return { key, body, answered: Boolean(body) && !body.includes(TODO_MARK) }; +} + +// ---------------------------------------------------------------- the renderer + +function verifiedTable(corpus) { + // One row per claim and source, naming every unknown it closes. Two unknowns that lead + // with the same E-row have the same claim (`claimOf`), and listing it once per unknown + // printed it twice, word for word, with nothing saying which copy closed what. + const rows = []; + const byKey = new Map(); + for (const unknown of corpus.unknowns) { + if (unknown.status !== 'CLOSED') continue; + const claim = claimOf(corpus, unknown); + const cited = unknown.cites.find((id) => /^E-\d+$/i.test(id)) ?? ''; + const key = `${cited.toUpperCase()}\u0000${claim}`; + if (byKey.has(key)) { byKey.get(key).closes.push(unknown.id); continue; } + const row = corpus.evidence.find((e) => e.id.toUpperCase() === cited.toUpperCase()); + const capture = row ? captureOf(corpus, row) : null; + const entry = { + claim: claim.replace(/\|/g, '\\|'), + cited, + host: row ? ` \`${hostOfUrl(row.url)}\`` : '', + closes: [unknown.id], + type: row?.type ?? '', + partial: capture?.completeness === 'partial', + }; + byKey.set(key, entry); + rows.push(entry); + } + for (const r of rows) r.source = `${r.cited}${r.host} (${r.closes.join(', ')})`; + if (!rows.length) return '_No closed unknowns yet - nothing is verified._'; + return [ + '| Claim | Source | Type |', + '|---|---|---|', + ...rows.map((r) => `| ${r.claim}${r.partial ? ' _(partial capture)_' : ''} | ${r.source} | ${r.type} |`), + ].join('\n'); +} + +function hostOfUrl(url) { + try { return new URL(url).host.replace(/^www\./, ''); } catch { return url; } +} + +function knownUnknowns(corpus) { + const rows = corpus.unknowns.filter((u) => u.status === 'KNOWN-UNKNOWN'); + if (!rows.length) { + // Said "closed with primary-source evidence" whatever the Type column held, so a corpus + // resting on one secondary source told its builder the opposite (found 2026-09-27). + const typeOf = (id) => corpus.evidence.find((e) => e.id.toUpperCase() === id.toUpperCase())?.type ?? ''; + const secondhand = corpus.unknowns + .filter((u) => u.status === 'CLOSED' && !u.cites.some((id) => /^E-\d+$/i.test(id) && typeOf(id) === 'P')) + .map((u) => u.id); + const none = 'None. Every blocking unknown was closed with cited evidence.'; + if (!secondhand.length) return none; + return `${none} ${secondhand.join(', ')} ${secondhand.length === 1 ? 'rests' : 'rest'} on no primary (P) source; the Type column above shows what carries ${secondhand.length === 1 ? 'it' : 'them'}.`; + } + return rows.map((u) => `- **${u.id}** - ${u.text}\n - Day-one verification: ${u.evidence || '_not stated_'}`).join('\n'); +} + +/** + * Render the brief from the corpus. Refuses a `draft` or `authored` brief without + * `force`, so judgements already written are preserved. + */ +/** + * The prediction registered before collection, quoted verbatim, or nothing. + * + * The drafter emits it rather than leaving it to the author, and that is the point. A + * prior is only worth registering if it gets read back **next to the answer** - left in + * `research/PRIOR.md` it is a file nobody opens, and the brief is the one document phase 2 + * is required to read. Emitting it also removes the quiet edit: an author who has just + * learned they were wrong does not have to decide whether to mention it, because the text + * is already on the page and the ledger already fixed it. + * + * No verdict is drawn here. Whether the corpus confirmed the prior or demolished it is the + * reader's to see - the two cases look identical to this function, which is what keeps it + * from becoming a scoreboard. + */ +function priorBlock(root, snapshot) { + const prior = readPrior(root, { entries: snapshot.ledger?.entries ?? [] }); + if (!prior.present || !prior.text.trim()) return ''; + const late = prior.scrapesBefore + ? `\n\n**Registered late**, at ledger seq ${prior.entry.seq}, after ${prior.scrapesBefore} page(s) were ` + + 'already collected. Read it as hindsight, not as a prediction.' + : ''; + return ` +## The prior, registered before anything was collected + +_Ledger seq ${prior.entry.seq}, chained: neither this text nor its place before the evidence +can be changed now. Read it against the findings below - it may well be wrong, and a wrong +prior that was recorded in advance is worth more than a right one remembered afterwards._ + +> ${prior.text.trim().split('\n').join('\n> ')}${late} +`; +} + +export function renderBrief(root, { force = false, date = today(), corpus = null, verdict = null } = {}) { + const snapshot = corpus ?? readCorpus(root); + const file = resolve(root, PATHS.brief); + const existing = readText(file, ''); + const state = briefState(existing); + + // An untouched draft is the drafter's own output: nothing in it is anybody's judgement. + const untouched = state === 'draft' && draftStamp(existing)?.edited === false; + if (!force && !untouched && (state === 'draft' || state === 'authored')) { + return { written: false, state, reason: `${PATHS.brief} is ${state} - re-run with --force to overwrite the judgements in it` }; + } + if (!force && state === 'legacy') { + return { + written: false, + state, + reason: `${PATHS.brief} is a LEGACY brief: it holds the shape but not the marker, so the text cannot tell a pre-marker scaffold from a brief somebody wrote in. Read it, then re-run with --force if it is safe to replace.`, + }; + } + + const topic = snapshot.map.topic || snapshot.plan?.topic || 'this project'; + // The drafted brief must not ANNOUNCE a completion it cannot see. It knows two things: + // whether the gate passes, and whether the sections only a person can fill are filled. + // Asserting "phase 1 is complete" over unanswered TODOs is the corpus claiming a + // review that never happened. + const gatePasses = verdict?.pass ?? null; + const gateLine = gatePasses === null + ? `**Gate state: not evaluated in this run.**` + : (gatePasses + ? `**Gate: PASS.** Every blocking unknown is closed with evidence, and every claim below +traces to a cached page in \`${PATHS.raw}/\`.` + : `**Gate: FAIL (${verdict.counts.fail} blocking finding${verdict.counts.fail === 1 ? '' : 's'}).** This brief is a +draft of an incomplete research pass: phase 2 does not start until \`${PATHS.discovery}\` +passes. Run \`${documentCommand('preflight.mjs')}\` to see what is unproven.`); + + const body = `# Brief - ${topic} + +_Auto-drafted ${date} by \`bin/brief.mjs\` from the corpus. Sections marked ${TODO_MARK} +require human/agent judgement; everything else is assembled from evidence already +in \`research/\`. While a ${TODO_MARK} remains, this brief is **not reviewed** and the +handoff is **not approved** - a structurally valid corpus, a reviewed one, and an +approved handoff are three different states._ + +**This is the phase-1 to phase-2 handoff.** ${gateLine} + +Whoever you are - another agent, a different model, or a person - read this file +first. You should not need to re-research anything to start work. If something +here is not enough to build from, say which fact is missing rather than guessing +it: that is a phase-1 gap to close, not a phase-2 judgment call. +${priorBlock(root, snapshot)} +## ${BRIEF_SECTIONS[0].heading} + +${snapshot.intent || '_The contract states no build intent._'} + +## ${BRIEF_SECTIONS[1].heading} + +${verifiedTable(snapshot)} + +## ${BRIEF_SECTIONS[2].heading} + +${TODO_MARK} - review the primary sources above for disagreements (pricing pages vs +billing docs, docs vs issue trackers, version-dependent behaviour). Record both +sides and state which you trust and why. Two independent sources agree unless +noted here. + +## ${BRIEF_SECTIONS[3].heading} + +${knownUnknowns(snapshot)} + +## ${BRIEF_SECTIONS[4].heading} + +${TODO_MARK} - what to build first, and what is explicitly out of scope. State the +first build step concretely enough that the builder can start from this file +alone. + +## ${BRIEF_SECTIONS[5].heading} + +1. Review the ${TODO_MARK} sections above (${BRIEF_SECTIONS.filter((s) => s.judged).map((s) => s.heading.split(' ')[0]).join(', ')}) before handing off. +2. Hand this file to the builder (phase 2). Re-running \`${documentCommand('brief.mjs')}\` + redrafts this file while it is unedited; after any edit it refuses without \`--force\`, + so your judgements are preserved. +`; + + // --force over a brief that holds anybody's judgement keeps it beside the new draft. + // It restored the TODOs and kept nothing of the answers it replaced (found 2026-09-27). + let backup; + if (!untouched && ['draft', 'authored', 'legacy'].includes(state)) { + backup = `${PATHS.brief}.bak-${date}`; + for (let n = 2; exists(resolve(root, backup)); n += 1) backup = `${PATHS.brief}.bak-${date}-${n}`; + writeText(resolve(root, backup), existing); + } + const stamped = `${body}\n\n`; + writeText(file, stamped); + return { + written: true, state: briefState(stamped), file: PATHS.brief, reason: '', + ...(untouched ? { replacedUnedited: true } : {}), ...(backup ? { backup } : {}), + }; +} + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-test-mjs-githubusercontent-237028e4.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-test-mjs-githubusercontent-237028e4.md new file mode 100644 index 00000000..7259d507 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-brief-test-mjs-githubusercontent-237028e4.md @@ -0,0 +1,205 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// The brief's shape has one owner (ADR-0014): the renderer writes its headings FROM the +// definition, and the audit reads the judged sections THROUGH it. + +import { test, describe, assert, makePassingProject, corrupt, fs, path } from './harness.mjs'; +import { PATHS, resolve, readText, writeText } from '../lib/core.mjs'; +import { + BRIEF_SECTIONS, JUDGED_SECTIONS, BRIEF_FILE_MARKER, TODO_MARK, + briefState, briefSection, judgedSection, renderBrief, +} from '../lib/brief.mjs'; + +describe('brief'); + +test('six sections, two of them judged', () => { + assert.equal(BRIEF_SECTIONS.length, 6); + assert.deepEqual(JUDGED_SECTIONS, ['contradictions', 'decision']); +}); + +test('the scaffold ships the marker, and the marker is what says "still the scaffold"', () => { + const dir = makePassingProject(); + const text = readText(resolve(dir, PATHS.brief)); + assert.ok(text.includes(BRIEF_FILE_MARKER)); + assert.equal(briefState(text), 'template'); +}); + +test('the marker survives the substitution pass, so no sentence of English is load-bearing', () => { + const dir = makePassingProject(); + assert.match(readText(resolve(dir, PATHS.brief)), /research-kit:brief=scaffold/); +}); + +test('a drafted brief with TODOs reads as draft; one with them answered reads as authored', () => { + const dir = makePassingProject(); + renderBrief(dir); + const drafted = readText(resolve(dir, PATHS.brief)); + assert.equal(briefState(drafted), 'draft'); + + const answered = drafted + .split(TODO_MARK).join('Resolved:') + .replace(/Resolved: - what to build first/, 'Build the collector first;'); + writeText(resolve(dir, PATHS.brief), answered); + assert.equal(briefState(readText(resolve(dir, PATHS.brief))), 'authored'); +}); + +test('a LEGACY brief - the shape without the marker - refuses loudly rather than guessing', () => { + const dir = makePassingProject(); + writeText(resolve(dir, PATHS.brief), '# Brief - something\n\n## Intent\n\nSomebody wrote this by hand.\n\n## Decision\n\nBuild it.\n'); + assert.equal(briefState(readText(resolve(dir, PATHS.brief))), 'legacy'); + + const refused = renderBrief(dir); + assert.equal(refused.written, false); + assert.match(refused.reason, /LEGACY/); + assert.match(readText(resolve(dir, PATHS.brief)), /by hand/, 'the file is untouched'); + + assert.equal(renderBrief(dir, { force: true }).written, true, '--force is the deliberate act'); +}); + +test('drafting over a draft refuses without --force, so judgements are preserved', () => { + const dir = makePassingProject(); + renderBrief(dir); + writeText(resolve(dir, PATHS.brief), readText(resolve(dir, PATHS.brief)).replace(TODO_MARK, 'My own judgement:')); + + const refused = renderBrief(dir); + assert.equal(refused.written, false); + assert.match(readText(resolve(dir, PATHS.brief)), /My own judgement/); +}); + +test('the renderer writes its headings FROM the section definition', () => { + const dir = makePassingProject(); + renderBrief(dir); + const text = readText(resolve(dir, PATHS.brief)); + for (const section of BRIEF_SECTIONS) { + assert.match(text, new RegExp(`^## ${section.heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'), `missing heading: ${section.heading}`); + } +}); + +test('the verified table carries the claim, the source and its type', () => { + const dir = makePassingProject(); + renderBrief(dir); + const verified = briefSection(readText(resolve(dir, PATHS.brief)), 'verified'); + assert.match(verified, /10 requests per minute/); + assert.match(verified, /E-01/); + assert.match(verified, /example\.invalid/); + assert.match(verified, /\| P \|/); +}); + +test('two unknowns closed by the same row make ONE verified row that names both', () => { + // Found 2026-09-26 in docs/decisions/2026-09-26-actions-node24: U-2 and U-4 both led with + // E-02, and the brief listed that row's finding twice, word for word, with nothing saying + // which unknown either copy closed. + const dir = makePassingProject(); + corrupt(dir, PATHS.discovery, (text) => text.replace( + /^(\| U-1 \|.*\|)$/m, + '$1\n| U-2 | Is the limit per key? | Sets the budget per machine | CLOSED | E-01: per key |', + )); + renderBrief(dir, { force: true }); + const verified = briefSection(readText(resolve(dir, PATHS.brief)), 'verified'); + const rows = verified.split('\n').filter((line) => /\| E-01/.test(line)); + assert.equal(rows.length, 1, `the same source is listed ${rows.length} times:\n${rows.join('\n')}`); + assert.match(rows[0], /U-1, U-2/, 'the row does not say which unknowns it closes'); +}); + +test('a claim resting on a partial capture says so in the brief', () => { + const dir = makePassingProject(); + corrupt(dir, `research/raw/${fs.readdirSync(resolve(dir, PATHS.raw)).find((n) => n.endsWith('.md'))}`, + (text) => text.replace('completeness: full', 'completeness: partial\nomitted: chunk 0 of 3')); + renderBrief(dir, { force: true }); + assert.match(briefSection(readText(resolve(dir, PATHS.brief)), 'verified'), /partial capture/); +}); + +test('known unknowns are listed with their day-one steps, or the section says none', () => { + const dir = makePassingProject(); + renderBrief(dir); + assert.match(briefSection(readText(resolve(dir, PATHS.brief)), 'unknowns'), /None\./); + + corrupt(dir, PATHS.discovery, (text) => text.replace('| CLOSED |', '| KNOWN-UNKNOWN |').replace(/E-01:[^|]*/, 'day one: log in and read the billing page ')); + renderBrief(dir, { force: true }); + const unknowns = briefSection(readText(resolve(dir, PATHS.brief)), 'unknowns'); + assert.match(unknowns, /U-1/); + assert.match(unknowns, /Day-one verification: day one: log in/); +}); + +// Found 2026-09-27 reviewing a package the collector returned: from the project folder, the +// brief's "Run node research-kit/bin/preflight.mjs" and "node bin/brief.mjs" named files that +// exist only at the kit repository's root, so each crashed with MODULE_NOT_FOUND. +test('every command the brief prints runs from the project folder', () => { + const dir = makePassingProject(); + const commands = []; + for (const verdict of [{ pass: true }, { pass: false, counts: { fail: 1 } }]) { + renderBrief(dir, { force: true, verdict }); + for (const [, arg] of readText(resolve(dir, PATHS.brief)).matchAll(/\bnode\s+("[^"]+"|\S+?\.mjs)/g)) commands.push(arg); + } + assert.ok(commands.length >= 2, `only ${commands.length} commands found: ${commands.join(', ')}`); + for (const arg of commands) { + const file = path.resolve(dir, arg.replace(/^"|"$/g, '')); + assert.ok(fs.existsSync(file), `the brief says "node ${arg}", and from the project folder that is ${file}, which does not exist`); + } +}); + +// Found the same day: a corpus whose one source was typed S drafted "Every blocking unknown was +// closed with primary-source evidence". +test('a closure resting on no primary source is not called primary', () => { + const dir = makePassingProject(); + corrupt(dir, PATHS.evidence, (text) => text.replace(/\| P \|/, '| S |')); + renderBrief(dir, { force: true }); + const unknowns = briefSection(readText(resolve(dir, PATHS.brief)), 'unknowns'); + assert.doesNotMatch(unknowns, /primary-source evidence/, 'the only source is secondary'); + assert.match(unknowns, /U-1 rests on no primary \(P\) source/); +}); + +// Found 2026-09-27: --force over an answered brief put the TODOs back and kept nothing of the +// judgements it replaced. Forcing is still allowed; losing the answers is not. +test('drafting over a brief with --force keeps the brief it replaced', () => { + const dir = makePassingProject(); + renderBrief(dir); + const answered = readText(resolve(dir, PATHS.brief)).replace(/\*\*TODO\*\* - review the primary sources[\s\S]*?noted here\./, 'We trust the vendor page over the blog.'); + writeText(resolve(dir, PATHS.brief), answered); + const first = renderBrief(dir, { force: true, date: '2026-09-27' }); + assert.ok(first.backup, 'no backup was reported'); + assert.equal(readText(resolve(dir, first.backup)), answered, 'the backup is not the brief that was replaced'); + const second = renderBrief(dir, { force: true, date: '2026-09-27' }); + assert.notEqual(second.backup, first.backup, 'a second force the same day overwrote the first backup'); + assert.equal(readText(resolve(dir, first.backup)), answered); + const fresh = makePassingProject(); + assert.equal(renderBrief(fresh, { force: true }).backup, undefined, 'the scaffold is not worth a backup'); +}); + +test('judgedSection is the reader the audit consumes instead of a regex of its own', () => { + const dir = makePassingProject(); + renderBrief(dir); + const text = readText(resolve(dir, PATHS.brief)); + assert.equal(judgedSection(text, 'decision').answered, false); + assert.equal(judgedSection(text, 'intent'), null, 'only the judged sections are judged'); + + writeText(resolve(dir, PATHS.brief), text.replace(/## Decision\n\n\*\*TODO\*\*[\s\S]*?\n\n## Next steps/, '## Decision\n\nBuild the collector first.\n\n## Next steps')); + assert.equal(judgedSection(readText(resolve(dir, PATHS.brief)), 'decision').answered, true); +}); + +// Found 2026-09-27: a brief drafted while the gate failed said "Gate: FAIL" after the +// project passed, and brief.mjs refused to redraft it without --force - though nobody +// had written a word in it. An untouched draft holds no judgement to protect. +test('an unedited draft is redrafted without --force, and nothing is backed up', () => { + const dir = makePassingProject(); + renderBrief(dir, { verdict: { pass: false, counts: { fail: 3 } } }); + assert.match(readText(resolve(dir, PATHS.brief)), /Gate: FAIL/); + const again = renderBrief(dir, { verdict: { pass: true, counts: { fail: 0 } } }); + assert.equal(again.written, true, again.reason); + assert.equal(again.backup, undefined, 'an untouched draft is not worth a backup'); + assert.match(readText(resolve(dir, PATHS.brief)), /Gate: PASS/); +}); + +test('a draft with any edit still refuses without --force', () => { + const dir = makePassingProject(); + renderBrief(dir); + const text = readText(resolve(dir, PATHS.brief)); + writeText(resolve(dir, PATHS.brief), text.replace('## Intent\n\n', '## Intent\n\nA sentence somebody added.\n\n')); + assert.equal(renderBrief(dir).written, false, 'an edit outside the TODO sections was overwritten'); +}); + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-checks-mjs-githubusercontent-c6fb39d5.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-checks-mjs-githubusercontent-c6fb39d5.md new file mode 100644 index 00000000..94ce2973 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-checks-mjs-githubusercontent-c6fb39d5.md @@ -0,0 +1,1114 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// checks.mjs - the ordered contract-check registry (ADR-0004). +// +// Thirteen checks, in reporting order. Every check is a pure function of the corpus +// snapshot plus options; none reads the filesystem beyond `exists()` on a path the +// corpus already resolved, and `readPrior`, which reads the one file the ledger points at. +// +// A check emits findings. It never decides what a finding MEANS for the build - that is +// the verdict's single judgement, in lib/preflight.mjs. + +import { hostOf, siteOf, PATHS, resolve, exists, ageInDays, urlKey, readText, kitCommand, compareText } from './core.mjs'; +import { captureOf, traceOf, citedIds, parseCapture, ledgerRank } from './corpus.mjs'; +import { documentGroups, closestPair } from './similarity.mjs'; +import { coverageOfUniversals } from './dimensions.mjs'; +import { verifyLedger } from './provenance.mjs'; +import { readPrior, PRIOR_PATH } from './prior.mjs'; +import { quoteAnchors, anchorFound, quoteWords, quoteChars, MIN_QUOTE_WORDS, MIN_QUOTE_CHARS } from './quotes.mjs'; +import { draftStamp, briefInputsHash, briefState } from './brief.mjs'; + +const VALID_STATUSES = ['CLOSED', 'KNOWN-UNKNOWN']; +const MIN_CAPTURE_CHARS = 200; + +function finding(severity, check, rule, detail, extra = {}) { + return { severity, check, rule, detail, ...extra }; +} + +// The calendar day a retrieval value names, or null. The collector writes the same `today()` +// into the capture's front-matter and the table's Retrieved cell, but a capture written +// before 2026-09-19 carries a full timestamp (`2026-09-13T17:38:25.128Z`) against a +// day-only cell, so the two are compared as days, never as strings. +function retrievedDay(value) { + const text = String(value ?? '').trim(); + const iso = text.match(/^\d{4}-\d{2}-\d{2}/); + if (iso) return iso[0]; + const parsed = Date.parse(text); + return Number.isNaN(parsed) ? null : new Date(parsed).toISOString().slice(0, 10); +} + +// The date freshness is judged by. The table's Retrieved cell is typed and hand-editable; +// the capture's `retrieved` was written by the collector at the fetch and is under the +// ledger's hash. Until 2026-10-03 only the cell was read, so editing E-19's cell from +// 2026-09-20 to today made its stale warning disappear while the capture on disk still +// said 2026-09-20 (output-reliability audit, G8). A row that cites no capture, or one +// whose capture carries no parseable date, is still judged by the cell - there is +// nothing better to judge it by, and hygiene says when the two disagree. +function freshnessDate(corpus, row) { + const capture = ownCapture(corpus, row); + return capture && ageInDays(capture.retrieved) !== null ? capture.retrieved : row.retrieved; +} + +// The capture a row NAMES in its Raw cell - not the one `captureOf` finds for a blank cell, +// which is the URL's latest capture and may be a later fetch the row never read. Judged by +// that one, a row with a blank Raw cell and a 2026-01-01 reading borrowed a fresh fetch's +// date, lost its stale warning, and drew a date-mismatch telling the operator to write the +// later date into the row (found 2026-10-03, review of G8). A row that names no capture is +// judged by its cell, and has nothing to reconcile against. +function ownCapture(corpus, row) { + return row?.raw ? (corpus.captures.byFile.get(row.raw) ?? null) : null; +} + +/** The day a row's freshness is judged by, as a comparable string ('' when it has none). */ +function freshnessDay(corpus, row) { + return retrievedDay(freshnessDate(corpus, row)) ?? ''; +} + +// ---------------------------------------------------------------- 1. discovery-contract + +function discoveryContract(corpus) { + const out = []; + if (!corpus.discovery.present) { + return [finding('fail', 'discovery-contract', 'contract-missing', + `${PATHS.discovery} is missing or could not be read. A gated project without its contract fails harder, not open.`)]; + } + if (!corpus.intent.trim()) { + // Missing and empty are different fixes: a renamed heading read as "is empty" while the + // intent sat under its new name (found 2026-09-27). + out.push(finding('fail', 'discovery-contract', 'build-intent', corpus.intentHeading === false + ? `${PATHS.discovery} has no "## Build intent" heading - the gate reads the intent from under that exact heading` + : '## Build intent is empty - state what is being built, for whom, and what "done" means')); + } + if (!corpus.unknowns.length) { + out.push(finding('fail', 'discovery-contract', 'no-unknowns', + 'the contract enumerates no blocking unknowns - a contract with no unknowns makes no claim')); + } + for (const unknown of corpus.unknowns) { + if (VALID_STATUSES.includes(unknown.status)) continue; + out.push(finding('fail', 'discovery-contract', 'status', + `${unknown.id} has status "${unknown.status || '(blank)'}" - must be CLOSED or KNOWN-UNKNOWN`, + { row: unknown.id, line: unknown.line })); + } + if (!out.length) out.push(finding('pass', 'discovery-contract', 'contract', `${corpus.unknowns.length} unknowns, all with a valid status`)); + return out; +} + +// ---------------------------------------------------------------- 2. citations + +function citations(corpus) { + const out = []; + let quoted = 0; + for (const row of corpus.evidence) { + if (!row.url) { + out.push(finding('fail', 'citations', 'row-url', `${row.id} has no URL`, { row: row.id, line: row.line })); + } + if (!row.finding.trim()) { + out.push(finding('fail', 'citations', 'row-finding', `${row.id} has an empty Finding cell`, { row: row.id, line: row.line })); + } + if (!['P', 'S', 'L'].includes(row.type)) { + out.push(finding('warn', 'citations', 'row-type', `${row.id} has source type "${row.type || '(blank)'}" - expected P, S, or L`, { row: row.id, line: row.line })); + } + const capture = captureOf(corpus, row); + if (!capture) { + out.push(finding('fail', 'citations', 'raw-missing', + `${row.id} cites ${row.raw || row.url} with no cached page behind it - a claim without a capture is not evidence`, + { row: row.id, line: row.line })); + continue; + } + if (!exists(resolve(corpus.root, capture.file))) { + out.push(finding('fail', 'citations', 'raw-missing', `${row.id} cites ${capture.file}, which is not on disk`, { row: row.id, line: row.line })); + continue; + } + // The row's URL is what a reader is shown as the source. It was never compared with the + // page its capture was fetched from, so a row could name another page and pass (found + // 2026-09-27). Compared by urlKey: another spelling of the same page is the same page. + if (row.url && capture.url && urlKey(row.url) !== urlKey(capture.url)) { + out.push(finding('fail', 'citations', 'url-mismatch', + `${row.id} names ${row.url}, but its capture ${capture.file} was fetched from ${capture.url} - the URL cell must be the page the claim rests on`, + { row: row.id, line: row.line })); + } + if (capture.bytes < MIN_CAPTURE_CHARS) { + out.push(finding('warn', 'citations', 'raw-thin', + `${row.id}'s capture is ${capture.bytes} bytes - too thin to carry a claim`, { row: row.id, line: row.line })); + } + // A capture of an error page, from a corpus collected before collectOne refused them + // (2026-09-28). A warning, not a block: some corpora cite a 404 on purpose, as the record + // of a lookup that was attempted - and a row that names the status itself has said so, + // so it is not flagged (a string check, like a quote anchor: the row says "404"). + const status = Number(capture.statusCode); + if (Number.isInteger(status) && status >= 400 && !new RegExp(`\\b${status}\\b`).test(row.finding)) { + out.push(finding('warn', 'citations', 'raw-error-status', + `${row.id}'s capture ${capture.file} is an HTTP ${status} page - it records that the page failed, not what the page says`, + { row: row.id, line: row.line })); + } + // Quote anchors (ADR-0087): `[quote: ...]` in the Finding must occur in this row's + // capture. A quote the capture refutes is a claim about the evidence that the evidence + // denies - the same class as an edited capture - so it blocks under every policy. + const anchors = quoteAnchors(row.finding); + if (anchors.length) { + const body = parseCapture(readText(resolve(corpus.root, capture.file)) ?? '').body; + for (const anchor of anchors) { + quoted += 1; + // Short in words AND in characters: a long token of one "word" anchors one exact line. + if (quoteWords(anchor) < MIN_QUOTE_WORDS && quoteChars(anchor) < MIN_QUOTE_CHARS) { + out.push(finding('warn', 'citations', 'quote-too-short', + `${row.id} quotes "${anchor.quote}" - under ${MIN_QUOTE_WORDS} words and ${MIN_QUOTE_CHARS} characters anchors almost nothing; quote the sentence the claim rests on`, + { row: row.id, line: row.line })); + continue; + } + if (!anchorFound(anchor.fragments, body)) { + out.push(finding('fail', 'citations', 'quote-not-found', + `${row.id} quotes "${anchor.quote}", which does not occur in ${capture.file} - copy the passage from the capture, or drop the marker and paraphrase`, + { row: row.id, line: row.line })); + } + } + } + } + if (!out.some((f) => f.severity !== 'pass')) { + out.push(finding('pass', 'citations', 'citations', `${corpus.evidence.length} evidence rows, each with a cached page` + + (quoted ? `; ${quoted} quote${quoted === 1 ? '' : 's'} found in ${quoted === 1 ? 'its' : 'their'} capture` : ''))); + } + return out; +} + +// ---------------------------------------------------------------- 3. provenance + +function provenance(corpus) { + const out = []; + const chain = corpus.chain ?? verifyLedger(corpus.root, { corpus }); + + if (!chain.present) { + return [finding('fail', 'provenance', 'ledger-missing', + `${PATHS.ledger} is absent - evidence must be fetched, not typed, and the ledger is what proves it`)]; + } + // Which repair applies to an unparsed line. `doctor --fix-arity` drops a torn LAST line - an + // interrupted append - and nothing else; a broken line inside the chain is restored from git. + // The finding named neither (found 2026-09-27). + const ledgerLines = String(readText(resolve(corpus.root, PATHS.ledger)) ?? '').split(/\r?\n/).filter((l) => l.trim()).length; + const repairOf = (problem) => (problem.rule !== 'ledger-unparsed' ? '' + : problem.line === ledgerLines + ? ` - the last line is torn (an interrupted append): ${kitCommand('doctor.mjs', '--fix-arity')} drops it` + : ' - a broken line inside the chain cannot be repaired here: restore research/raw/.fetches.jsonl from git'); + for (const problem of chain.problems) { + const rule = problem.rule === 'body-unmodified' && problem.kind === 'line-endings' ? 'body-unmodified' : problem.rule; + out.push(finding('fail', 'provenance', rule === 'seq' || rule === 'prev' || rule === 'entry-hash' ? 'chain-intact' : rule, + problem.detail + (problem.line ? ` (line ${problem.line})` : '') + repairOf(problem), + { line: problem.line, file: problem.file, kind: problem.kind })); + } + + for (const row of corpus.evidence) { + const trace = traceOf(corpus, row); + if (!trace.capture) continue; // citations already named it + if (!trace.fetch) { + out.push(finding('fail', 'provenance', 'fetch-entry-exists', + `${row.id} cites ${trace.capture.file} with no entry in the ledger - a hand-written capture is not evidence`, + { row: row.id, line: row.line })); + continue; + } + const front = trace.capture; + if (!front.command && !trace.fetch.cmd) { + out.push(finding('warn', 'provenance', 'capture-command', + `${row.id}'s capture records no collector command in its front-matter`, { row: row.id, line: row.line })); + } + if (front.retrieved && trace.fetch.at && !String(trace.fetch.at).startsWith(String(front.retrieved).slice(0, 10))) { + out.push(finding('warn', 'provenance', 'timestamp-agreement', + `${row.id}: capture says retrieved ${front.retrieved}, ledger says ${trace.fetch.at}`, { row: row.id, line: row.line })); + } + } + out.push(...priorOrder(corpus, chain)); + + if (!out.some((f) => f.severity !== 'pass')) { + out.push(finding('pass', 'provenance', 'chain-intact', `chain of ${chain.entries.length} entries verifies`)); + } + return out; +} + +/** + * Where the registered prior sits in the chain, when there is one. + * + * A prior is optional and its absence is silent - most corpora do not claim one, and a + * warning that fires on every project is a warning nobody reads. But a corpus that DOES + * claim a prediction has made a checkable claim about ORDER, and this is the check of it: + * a prediction registered after the evidence is not a prediction. `lib/prior.mjs` refuses + * to write one late, and this does not trust that it was used - the chain is the witness, + * not the tool. + * + * Nothing here reads the prediction. Whether it was specific, reasonable, or right is the + * reader's to judge (ADR-0013); a gate that preferred correct predictions would teach + * exactly the wrong habit. The file's immutability needs no code of its own: the prior is + * chained with its `bodySha256`, so editing it after the fact fails `body-unmodified` + * through the same machinery a capture does. + */ +function priorOrder(corpus, chain) { + const prior = readPrior(corpus.root, { entries: chain.entries }); + if (!prior.present) return []; + + const out = []; + if (prior.duplicates > 1) { + out.push(finding('fail', 'provenance', 'prior-single', + `the ledger holds ${prior.duplicates} registered priors - one corpus predicts once, or the prediction ` + + 'that matched gets chosen afterwards', { file: PRIOR_PATH })); + } + if (prior.scrapesBefore) { + out.push(finding('fail', 'provenance', 'prior-precedes-collection', + `${PRIOR_PATH} is registered at seq ${prior.entry.seq}, after ${prior.scrapesBefore} page(s) were already ` + + 'collected - a prediction made with the evidence in hand is not one. Say what you now believe in the ' + + 'brief, where it is labelled honestly', { file: PRIOR_PATH, line: prior.entry.line })); + return out; + } + if (!out.length) { + const firstLine = prior.text.trim().split('\n')[0] ?? ''; + out.push(finding('pass', 'provenance', 'prior-precedes-collection', + `a prior was registered at seq ${prior.entry.seq}, before anything was collected: ` + + (firstLine.length > 140 ? `${firstLine.slice(0, 137)}...` : firstLine), { file: PRIOR_PATH })); + } + return out; +} + +// ---------------------------------------------------------------- 4. transport-provenance + +function transportProvenance(corpus) { + const out = []; + const superseded = supersededRows(corpus); + for (const row of corpus.evidence) { + // A row that has been re-collected and is relied on by nothing is HISTORY (ADR-0026). + // Reporting the provenance of a capture no claim rests on is noise, and worse than + // noise: it buries the rows that DO carry a claim. Six such warnings stood in this + // corpus after the 2026-09-20 refresh, all of them about superseded captures. + if (isReleasedHistory(corpus, row, superseded)) continue; + const trace = traceOf(corpus, row); + if (!trace.fetch) continue; + const transport = trace.fetch.transport || trace.capture?.transport || ''; + if (!transport) { + out.push(finding('warn', 'transport-provenance', 'transport-unrecorded', + `${row.id}'s fetch records no transport - a cmd string is an annotation and proves nothing`, + { row: row.id, line: row.line })); + continue; + } + if (transport === 'firecrawl-cli-anonymous') { + out.push(finding('warn', 'transport-provenance', 'transport-not-metered', + `${row.id} was collected by the Firecrawl CLI with NO credential - keyless access capped per IP, not a metered fetch`, + { row: row.id, line: row.line, transport })); + } else if (transport !== 'firecrawl-cli') { + out.push(finding('warn', 'transport-provenance', 'transport-not-metered', + `${row.id} was collected by "${transport}", not the metered Firecrawl CLI`, + { row: row.id, line: row.line, transport })); + } + } + if (!out.length) out.push(finding('pass', 'transport-provenance', 'transport', 'every cited capture names its transport')); + return out; +} + +// ---------------------------------------------------------------- 5. gate-integrity + +function gateIntegrity(corpus, options = {}) { + const out = []; + if (corpus.gateOff) { + out.push(finding('warn', 'gate-integrity', 'gate-off', + `${PATHS.gateOff} is present - the gate is deliberately off for this repository, and it is recorded`)); + } + if (options.localHooksPath) { + out.push(finding('warn', 'gate-integrity', 'local-hooks-path', + `this repository sets core.hooksPath=${options.localHooksPath}, which displaces the machine-wide commit gate`)); + } + const recent = corpus.overrides.length; + if (recent) { + out.push(finding('warn', 'gate-integrity', 'overrides-recorded', + `${recent} override${recent === 1 ? '' : 's'} recorded in ${PATHS.overrides}`)); + } + // "No override RECORDED" is what was measured. "No override in effect" is what this used + // to say, and the two differ by the escape hatch the kit documents most prominently: + // `git commit --no-verify` leaves nothing behind. doctor.mjs has said so in its header + // since it was written - "the third override is silent by nature, the bypassed hook + // cannot report itself" - while this line quietly claimed the opposite. + // + // Found by sweeping all thirteen pass messages for claims wider than their measurement, + // after two checks in one day turned out to be reporting the existence of a thing as + // proof that the thing was right. This was the only one of the thirteen; the rest state + // what they counted. + // + // Nothing here can be made to detect a bypassed hook, so the fix is the sentence. An + // accurate pass is worth more than a confident one: a reader who finds a claim false once + // discounts the next, and the next may be the true one (ADR-0035). + if (!out.length) { + out.push(finding('pass', 'gate-integrity', 'gate', + 'no override recorded - and `git commit --no-verify` records nothing, so this is the absence of evidence rather than evidence of absence')); + } + return out; +} + +// ---------------------------------------------------------------- 6. unknown-closure + +function unknownClosure(corpus, options = {}) { + const out = []; + const maxAgeDays = options.maxAgeDays ?? 180; + const byId = new Map(corpus.evidence.map((row) => [row.id.toUpperCase(), row])); + + for (const unknown of corpus.unknowns) { + if (unknown.status === 'KNOWN-UNKNOWN') { + if (!unknown.evidence.trim()) { + out.push(finding('fail', 'unknown-closure', 'verification-step', + `${unknown.id} is KNOWN-UNKNOWN with an empty Evidence cell - name the day-one verification step`, + { row: unknown.id, line: unknown.line })); + } + continue; + } + if (unknown.status !== 'CLOSED') continue; // discovery-contract owns a bad status + + const cited = unknown.cites.filter((id) => /^E-\d+$/i.test(id)); + if (!cited.length) { + out.push(finding('fail', 'unknown-closure', 'no-evidence', + `${unknown.id} is CLOSED but cites no E-## row`, { row: unknown.id, line: unknown.line })); + continue; + } + for (const id of cited) { + const row = byId.get(id.toUpperCase()); + if (!row) { + out.push(finding('fail', 'unknown-closure', 'dangling-citation', + `${unknown.id} cites ${id}, which is not a row in ${PATHS.evidence}`, { row: unknown.id, line: unknown.line })); + continue; + } + const dated = freshnessDate(corpus, row); + const age = ageInDays(dated); + if (age !== null && age > maxAgeDays) { + // A refresh that has already happened is a different instruction from one that + // has not: "go and collect" versus "go and read what was collected". + const fresher = corpus.evidence.find((e) => urlKey(e.url) === urlKey(row.url) && freshnessDay(corpus, e) > freshnessDay(corpus, row)); + // When the capture and the cell disagree, the reader looking at the table sees a + // date that does not explain the age - so the judging date is named. + const judged = retrievedDay(dated) !== retrievedDay(row.retrieved) + ? ` by its capture's date, ${retrievedDay(dated)} (the table says ${row.retrieved || '(blank)'})` + : ''; + out.push(finding('warn', 'unknown-closure', 'stale-evidence', + fresher + ? `${unknown.id} rests on ${id}, retrieved ${age} days ago${judged} (limit ${maxAgeDays}) - ${fresher.id} is already a fresher capture of the same URL; re-read it and move the citation` + : `${unknown.id} rests on ${id}, retrieved ${age} days ago${judged} (limit ${maxAgeDays}) - re-collect with --refresh-days`, + { row: unknown.id, line: unknown.line })); + } + if (row.type === 'L') { + out.push(finding('warn', 'unknown-closure', 'lead-only', + `${unknown.id} rests on ${id}, which is lead-only (L) - a hint, never proof`, + { row: unknown.id, line: unknown.line })); + } + } + // The registry promised "a primary row" and only L was checked (found 2026-09-28 by the + // first measurement: three corpora closed everything on S rows with no warning). Rule 4: + // P carries the design, S is context - so a closure with no P row at all is said. If the + // page owns the fact (a vendor's own terms, a spec), its row should be typed P. + const rows = cited.map((id) => byId.get(id.toUpperCase())).filter(Boolean); + if (rows.length && !rows.some((row) => row.type === 'P') && rows.some((row) => row.type === 'S')) { + out.push(finding('warn', 'unknown-closure', 'secondary-only', + `${unknown.id} rests only on secondary (S) rows - S is context, P carries the design; ` + + 'collect the page that owns the fact, or type its row P if it already is that page', + { row: unknown.id, line: unknown.line })); + } + } + if (!out.length) { + const closed = corpus.unknowns.filter((u) => u.status === 'CLOSED').length; + const known = corpus.unknowns.filter((u) => u.status === 'KNOWN-UNKNOWN').length; + out.push(finding('pass', 'unknown-closure', 'closure', `${closed} closed, ${known} known-unknown`)); + } + return out; +} + +// ---------------------------------------------------------------- 7. subtopic-coverage + +function subtopicCoverage(corpus) { + const out = []; + if (!corpus.map.present || !corpus.subtopics.length) { + return [finding('warn', 'subtopic-coverage', 'no-coverage-claim', + `no coverage claim: ${PATHS.map} has no subtopic rows, so the gate cannot tell a thorough contract from a shallow one`)]; + } + + const unknownIds = new Set(corpus.unknowns.map((u) => u.id.toUpperCase())); + for (const row of corpus.subtopics) { + if (row.status === 'GAP' || !row.status) { + out.push(finding('fail', 'subtopic-coverage', 'gap', + `${row.id} (${row.text}) is ${row.status || 'unstatused'} - a GAP is the shape of a question nobody asked`, + { row: row.id, line: row.line })); + continue; + } + if (row.status === 'DISMISSED') { + if (!row.coveredBy.trim()) { + out.push(finding('fail', 'subtopic-coverage', 'dismissed-without-reason', + `${row.id} is DISMISSED with no reason - a dismissal without a reason is a silent gap wearing a label`, + { row: row.id, line: row.line })); + } + continue; + } + if (row.status === 'COVERED') { + const cited = row.cites.filter((id) => /^U-\d+$/i.test(id)); + if (!cited.length) { + out.push(finding('fail', 'subtopic-coverage', 'covered-without-citation', + `${row.id} is COVERED but cites no U-## unknown`, { row: row.id, line: row.line })); + } + for (const id of cited) { + if (unknownIds.has(id.toUpperCase())) continue; + out.push(finding('fail', 'subtopic-coverage', 'covered-by-unknown-that-does-not-exist', + `${row.id} cites ${id}, which is not a row in ${PATHS.discovery}`, { row: row.id, line: row.line })); + } + continue; + } + out.push(finding('fail', 'subtopic-coverage', 'status', + `${row.id} has status "${row.status}" - must be COVERED, DISMISSED, or GAP`, { row: row.id, line: row.line })); + } + + for (const { dimension } of coverageOfUniversals(corpus.subtopics).missing) { + out.push(finding('fail', 'subtopic-coverage', 'dimension-omitted', + `the universal dimension "${dimension.name}" (${dimension.id}) appears nowhere in ${PATHS.map} - dismissing is fine, omitting is not`)); + } + + if (!out.length) out.push(finding('pass', 'subtopic-coverage', 'coverage', `${corpus.subtopics.length} subtopics, every universal dimension present`)); + return out; +} + +// ---------------------------------------------------------------- 8. capture-completeness + +/** A recorded render review must say what was checked; this is the floor that makes it mean something. */ +const MIN_RENDER_REVIEW = 30; + +/** How many render-failure notices this capture's page printed, or 0. */ +function renderFailureMarkers(corpus, capture) { + return corpus.captures.renderFailures?.get(capture.file) ?? 0; +} + +const RENDER_REVIEW_RE = /\[render-reviewed:\s*([^\]]*)\]/i; + +/** + * The reviewer's note that a capture's render-failure notice was checked out, read from + * the EVIDENCE row that cites the capture - never from the capture itself, which the + * ledger hashes whole. + */ +function renderReviewNote(corpus, capture) { + for (const row of corpus.evidence) { + if (captureOf(corpus, row)?.file !== capture.file) continue; + const match = RENDER_REVIEW_RE.exec(String(row.finding ?? '')); + if (match) return match[1].trim(); + } + return ''; +} + + +function captureCompleteness(corpus) { + const out = []; + for (const unknown of corpus.unknowns) { + if (unknown.status !== 'CLOSED') continue; + const rows = unknown.cites + .map((id) => corpus.evidence.find((e) => e.id.toUpperCase() === id.toUpperCase())) + .filter(Boolean); + if (!rows.length) continue; + const captures = rows.map((row) => captureOf(corpus, row)).filter(Boolean); + if (!captures.length) continue; + if (captures.every((c) => c.completeness === 'partial')) { + const omitted = captures.map((c) => c.omitted).filter(Boolean).join('; '); + out.push(finding('warn', 'capture-completeness', 'partial-only', + `${unknown.id} rests solely on partial captures${omitted ? ` (omitted: ${omitted})` : ''}`, + { row: unknown.id, line: unknown.line })); + } + } + for (const capture of corpus.captures.entries) { + if (capture.completeness === 'partial' && !capture.omitted) { + out.push(finding('warn', 'capture-completeness', 'partial-unnamed', + `${capture.file} is graded partial but does not say what was omitted`, { file: capture.file })); + } + } + + // A page that SAYS it failed to render, in its own words. + // + // `completeness` grades the TRANSPORT - how much of the response arrived - and a page + // whose body is built by script arrives whole and empty. That gap was found the hard way: + // a GitHub Discussion graded `full` while carrying page furniture and no discussion. + // + // This does NOT claim a capture is empty, and the distinction is the whole reason it is + // worded the way it is. Measured over the 60 captures in this repository, four carry such + // a marker and THREE OF THOSE WERE USED AND CITED - GitHub renders the main content while + // some side widget fails. So the honest finding is "part of this page reported a failure, + // confirm the text you cite actually arrived", which was true in all four cases. + // + // Detecting emptiness itself was attempted and abandoned; ADR-0037 records the + // measurements, because both obvious heuristics fail in opposite directions. + // Only a capture some unknown cites (through its EVIDENCE row) gets the instruction: on a + // context-only row there is no cited text to confirm, and every phase-0 map collects + // such pages (ADR-0099). An uncited capture is hygiene's to report, not this check's. + const citedFiles = new Set(); + for (const unknown of corpus.unknowns) { + for (const id of unknown.cites ?? []) { + const row = corpus.evidence.find((e) => e.id.toUpperCase() === String(id).toUpperCase()); + const file = row ? captureOf(corpus, row)?.file : null; + if (file) citedFiles.add(file); + } + } + for (const capture of corpus.captures.entries) { + if (!citedFiles.has(capture.file)) continue; + const hits = renderFailureMarkers(corpus, capture); + if (!hits) continue; + + // The finding is a REVIEW INSTRUCTION - "confirm the text you cite is present" - so it + // has to be closable by having done the review. Same bargain `completeness: partial` + // strikes with `omitted:`: you may say the shortfall is accounted for, and you must say + // what you checked. Without it the warning stands forever on three captures this + // repository actively relies on, and a permanent warning is one nobody reads. + // + // THE NOTE LIVES ON THE EVIDENCE ROW, NOT IN THE CAPTURE'S FRONT-MATTER, and the reason + // is worth keeping: `verifyLedger` hashes the WHOLE capture file, front-matter included. + // Annotating a capture would break `body-unmodified` and fail the chain. A capture is + // evidence and stays byte-immutable; a reviewer's judgement about it is corpus prose, + // and corpus prose is where judgements already live (the unknown's status, the map's + // DISMISSED reason). The first design put it in front-matter and the ledger refused it. + const reviewed = renderReviewNote(corpus, capture); + if (reviewed.length >= MIN_RENDER_REVIEW) { + out.push(finding('pass', 'capture-completeness', 'partial-render-reviewed', + `${capture.file} prints ${hits} render-failure notice(s), and the review is recorded: ${reviewed}`, + { file: capture.file })); + continue; + } + // The instruction names the EVIDENCE row on purpose. It used to say "a `renderReview:` + // front-matter line", which is the one place the note must NOT go - the paragraph above + // explains why, and the message contradicted it. A reviewer who followed it literally + // got a BLOCKING `provenance/body-unmodified` failure and kept this warning, because + // editing a capture breaks the hash recorded at fetch. Measured, not reasoned: doing + // exactly what the old text said turned a 1-warning PASS into a 1-blocking FAIL. + out.push(finding('warn', 'capture-completeness', 'partial-render', + `${capture.file} contains ${hits} render-failure notice(s) from the page itself - ` + + 'it arrived whole, so `completeness` cannot see this. Confirm the text cited from it is ' + + `present, then write \`[render-reviewed: what you checked]\` (at least ${MIN_RENDER_REVIEW} ` + + 'characters) into the Finding cell of an EVIDENCE row that cites this capture. Do not edit ' + + 'the capture: the ledger hashes it whole, front-matter included' + + (reviewed ? ` (the current note gives only "${reviewed}")` : ''), + { file: capture.file })); + } + if (!out.length) out.push(finding('pass', 'capture-completeness', 'completeness', 'no closed unknown rests on a partial capture alone')); + return out; +} + +// ---------------------------------------------------------------- 9. evidence-supersession + +/** + * A refresh must trigger CLAIM REVIEW, not a silent substitution (ADR-0026). + * + * Re-collecting a page adds a new row and leaves the old one standing, still cited. The + * claim above it was written against bytes that are no longer what the source says, and + * nothing asked anybody to re-read it - so `--refresh-days` produced a fresher capture + * and left the citation pointing at the stale one, which is the shape of a claim that + * quietly stops being true. + * + * The old capture is KEPT - it is history, and evidence is irreplaceable. What is refused + * is a superseded row still carrying an unknown. + */ +/** + * Which rows have been replaced by a fresher capture of the same URL. + * + * Extracted 2026-09-20 because three checks need the same answer and two of them were + * getting it wrong by not asking. `evidence-supersession` computed it privately, while + * `transport-provenance` and `hygiene` knew nothing about ADR-0026 and warned about the + * exact state that ADR REQUIRES: two rows for one URL, the older one kept as history. + * + * Returns `Map`. + */ +export function supersededRows(corpus) { + const byUrl = new Map(); + for (const row of corpus.evidence) { + if (!row.url) continue; + const key = urlKey(row.url); + if (!byUrl.has(key)) byUrl.set(key, []); + byUrl.get(key).push(row); + } + + // On ONE date the ledger decides, as `newer` (corpus.mjs) decides for the collector: the row + // whose capture the later fetch wrote is current, a capture no fetch recorded ranks below + // every one a fetch did, and only among those does the table's order still break the tie. + // Until 2026-10-03 (output-reliability audit, G4) the sort was by date alone and a same-day + // tie fell to the stable sort's TABLE ORDER: with A fetched, then B, then A again - the + // third fetch reusing A's file - the collector held A current while this said B had + // superseded it, and swapping two evidence rows swapped the verdict. + // The date is the capture's, as freshness is judged (`freshnessDay`), not the table cell's: + // ordered by the cell, one edit to an older row's date made the gate fail the genuinely + // newest row for citing a "superseded" capture and told the operator to cite the older one + // (found 2026-10-03, review of G4 and G8). + const rank = ledgerRank(corpus.ledger?.entries); + const rankOf = (row) => rank.get(row.raw || captureOf(corpus, row)?.file) ?? -1; + + const superseded = new Map(); + for (const rows of byUrl.values()) { + if (rows.length < 2) continue; + const ordered = [...rows].sort((a, b) => compareText(freshnessDay(corpus, a), freshnessDay(corpus, b)) || (rankOf(a) - rankOf(b))); + const current = ordered[ordered.length - 1]; + // A row is never superseded by itself: a row pasted twice read "E-01 has been superseded + // by E-01" (found 2026-09-27). The duplicate ID is hygiene's to name. + for (const row of ordered.slice(0, -1)) { + if (row.id.toUpperCase() !== current.id.toUpperCase()) superseded.set(row.id.toUpperCase(), current); + } + } + return superseded; +} + +/** Is this row superseded AND relied on by nothing? Then it is history, and silent. */ +function isReleasedHistory(corpus, row, superseded = supersededRows(corpus)) { + if (!superseded.has(row.id.toUpperCase())) return false; + return !corpus.unknowns.some((u) => u.cites.some((c) => c.toUpperCase() === row.id.toUpperCase())); +} + +function evidenceSupersession(corpus) { + const out = []; + const superseded = supersededRows(corpus); + if (!superseded.size) { + return [finding('pass', 'evidence-supersession', 'supersession', 'no evidence row has been re-collected')]; + } + + for (const unknown of corpus.unknowns) { + for (const id of unknown.cites) { + const replacement = superseded.get(id.toUpperCase()); + if (!replacement) continue; + out.push(finding('fail', 'evidence-supersession', 'cites-superseded-row', + `${unknown.id} rests on ${id}, which has been superseded by ${replacement.id} - a fresher capture of the same URL ` + + `(${replacement.retrieved}). Re-read it, then move the citation or record why the earlier reading still holds.`, + { row: unknown.id, line: unknown.line, superseded: id, by: replacement.id })); + } + } + + for (const [id, replacement] of superseded) { + if (corpus.unknowns.some((u) => u.cites.some((c) => c.toUpperCase() === id))) continue; + out.push(finding('pass', 'evidence-supersession', 'superseded-and-released', + `${id} was superseded by ${replacement.id} and is cited by nothing - kept as history`)); + } + return out; +} + +// ---------------------------------------------------------------- 10. collection-attempts + +function collectionAttempts(corpus) { + const out = []; + for (const unknown of corpus.unknowns) { + if (unknown.status !== 'KNOWN-UNKNOWN') continue; + // An attempt counts when the ledger holds any entry for a URL the row names - directly, + // or through an E-row it cites. The citation is the stronger proof: an E-row is a + // CAPTURE of its URL. Until 2026-09-26 only a literal URL counted, so a KNOWN-UNKNOWN + // citing the page it reached for was warned as never attempted. + const citedUrls = unknown.cites + .map((id) => corpus.evidence.find((row) => row.id.toUpperCase() === id.toUpperCase())?.url) + .filter(Boolean); + const attempted = corpus.ledger.entries.some((entry) => entry.url + && (unknown.evidence.includes(entry.url) || citedUrls.includes(entry.url))); + if (attempted) continue; + out.push(finding('warn', 'collection-attempts', 'unknown-attempted', + `${unknown.id} is KNOWN-UNKNOWN with no recorded fetch attempt - an unreachable fact should have been reached for`, + { row: unknown.id, line: unknown.line })); + } + if (!out.length) out.push(finding('pass', 'collection-attempts', 'attempts', 'every known-unknown was reached for')); + return out; +} + +// ---------------------------------------------------------------- 11. hygiene + +function hygiene(corpus) { + const out = []; + // "One row per fetched page" is right for a duplicate and WRONG for a refresh. + // + // ADR-0026 requires a re-collection to add a row and leave the old one standing, so + // two rows for one URL is the designed state, not a hygiene problem. This check + // predated that ADR and warned about it anyway - six times, immediately after the + // refresh the ADR exists to govern. What is still worth warning about is two rows for + // one URL fetched on the SAME DAY, which no refresh produces and which is the real + // duplicate this check was written for. + const superseded = supersededRows(corpus); + // Fetches the ledger records per URL per day. Two rows for one URL on one day are a + // refresh when the ledger holds a fetch for each of them - `research.mjs --force` - and + // a duplicate when it does not, which is what a pasted row looks like. + const fetchesOn = new Map(); + for (const entry of corpus.ledger?.entries ?? []) { + if (entry.op !== 'scrape' || !entry.url) continue; + const key = `${urlKey(entry.url)} ${String(entry.at ?? '').slice(0, 10)}`; + fetchesOn.set(key, (fetchesOn.get(key) ?? 0) + 1); + } + const rowsOn = new Map(); + for (const row of corpus.evidence) { + if (!row.url) continue; + const key = `${urlKey(row.url)} ${row.retrieved}`; + rowsOn.set(key, (rowsOn.get(key) ?? 0) + 1); + } + const fetchedEach = (row) => { + const key = `${urlKey(row.url)} ${row.retrieved}`; + return (fetchesOn.get(key) ?? 0) >= (rowsOn.get(key) ?? 0); + }; + const seenUrl = new Map(); + for (const row of corpus.evidence) { + if (!row.url) continue; + const key = urlKey(row.url); + const held = seenUrl.get(key); + if (held) { + const isRefresh = superseded.has(held.id.toUpperCase()) + && (held.retrieved !== row.retrieved || fetchedEach(row)); + // The same ID twice is duplicate-id's to name, not a second row citing the same URL. + if (!isRefresh && held.id.toUpperCase() !== row.id.toUpperCase()) { + out.push(finding('warn', 'hygiene', 'duplicate-url', + `${row.id} cites the same URL as ${held.id} on the same day - one row per fetched page`, + { row: row.id, line: row.line })); + } + } + // The LATEST row for a URL is the one a further duplicate should be compared against. + if (!held || compareText(row.retrieved, held.retrieved) >= 0) seenUrl.set(key, row); + } + + const ids = new Set(); + for (const row of [...corpus.evidence, ...corpus.unknowns, ...corpus.subtopics]) { + const key = row.id.toUpperCase(); + // A fail, not a warn: the ID is how a citation, a COVERED cell or the brief names a + // row, and with two rows under it every such reference resolves to one of them + // silently - lookups keep the last. + if (ids.has(key)) { + out.push(finding('fail', 'hygiene', 'duplicate-id', + `${row.id} is used twice - give the second row its own ID, or delete it if it is a copy`, + { row: row.id, line: row.line })); + } + ids.add(key); + } + + // The brief is the one file phase 2 must read. A draft stamped with inputs that no longer + // match the corpus says whatever the corpus said then - a failing gate, a missing unknown. + const stamp = corpus.brief?.present ? draftStamp(corpus.brief.text) : null; + if (stamp && stamp.inputs !== briefInputsHash(corpus)) { + out.push(finding('warn', 'hygiene', 'brief-stale', + `${PATHS.brief} was drafted before the contract or evidence last changed - ` + + (stamp.edited + ? `redraft with ${kitCommand('brief.mjs', '--force')} (the edited brief is kept as a backup) and carry your judgements over` + : `redraft with ${kitCommand('brief.mjs')}`), + { file: PATHS.brief })); + } + // A drafted brief with no stamp was drafted before ADR-0055 gave drafts one. Its currency + // cannot be checked, so it keeps its approval (ADR-0138) - and this says so, because an + // approval nothing can check is otherwise silent (found 2026-10-03, review of the G1 fix). + const state = corpus.brief?.present ? briefState(corpus.brief.text) : 'template'; + if (!stamp && (state === 'draft' || state === 'authored')) { + out.push(finding('warn', 'hygiene', 'brief-unstamped', + `${PATHS.brief} carries no draft stamp (it was drafted before ADR-0055), so whether it is current with the contract and ` + + `evidence cannot be checked - redraft with ${kitCommand('brief.mjs', '--force')} (the brief is kept as a backup) and carry your judgements over`, + { file: PATHS.brief })); + } + + const cited = new Set(corpus.evidence.map((row) => row.raw || captureOf(corpus, row)?.file).filter(Boolean)); + // "Was collected" is the ledger's to say: a file under research/raw that no scrape entry + // names was planted or copied there, and that is what its reader needs to hear (break-test + // pass 4, F5a, 2026-10-04). The same warning, in the words that are true of the file. + const fetched = new Set((corpus.ledger?.entries ?? []).filter((e) => e.op !== 'fail' && e.raw).map((e) => e.raw)); + for (const capture of corpus.captures.entries) { + if (cited.has(capture.file)) continue; + out.push(finding('warn', 'hygiene', 'uncited-capture', + fetched.has(capture.file) + ? `${capture.file} was collected but no evidence row cites it` + : `${capture.file} is under ${PATHS.raw} but no ledger entry records a fetch of it and no evidence row cites it`, + { file: capture.file })); + } + + for (const row of corpus.evidence) { + if (!row.retrieved || ageInDays(row.retrieved) !== null) continue; + out.push(finding('warn', 'hygiene', 'unparseable-date', + `${row.id} has retrieval date "${row.retrieved}", which does not parse`, { row: row.id, line: row.line })); + } + + // The cell and the capture it cites must name the same day. The collector writes one + // `today()` into both, so a disagreement is an edit to the table after the fetch - and + // until 2026-10-03 nothing said so, while freshness read the cell: editing E-19's date + // alone made its stale warning disappear (output-reliability audit, G8). Freshness now + // reads the capture (`freshnessDate`), and this is the finding that explains why the + // table's date is not the one being judged. A cell that does not parse is + // unparseable-date's to name; a capture with no date gives nothing to reconcile against. + for (const row of corpus.evidence) { + const capture = ownCapture(corpus, row); + const captureDay = retrievedDay(capture?.retrieved); + if (!captureDay) continue; + if (row.retrieved && ageInDays(row.retrieved) === null) continue; + if (retrievedDay(row.retrieved) === captureDay) continue; + out.push(finding('warn', 'hygiene', 'date-mismatch', + `${row.id} has retrieval date ${row.retrieved || '(blank)'}, but its capture ${capture.file} records retrieved ${captureDay} - ` + + 'write the capture\'s date in the row; freshness is judged by the capture, not the table', + { row: row.id, line: row.line })); + } + + // No page was fetched in the future, and a future date hides the row's age from every + // freshness check: 2030-01-01 passed without a word (found 2026-09-27). A day of slack, so + // a date written in a time zone ahead of this machine's is not a failure. + for (const row of corpus.evidence) { + const age = row.retrieved ? ageInDays(row.retrieved) : null; + if (age !== null && age < -1) { + out.push(finding('fail', 'hygiene', 'future-date', + `${row.id} has retrieval date ${row.retrieved}, which is in the future - write the date the page was fetched (its capture records it)`, + { row: row.id, line: row.line })); + continue; + } + // The capture's date is the one freshness is judged by, so a future date there hides the + // row's age just as well, and only date-mismatch had anything to say (found 2026-10-03, + // review of G8). It takes a collector whose clock was ahead; it is still not evidence. + const capture = ownCapture(corpus, row); + const captureAge = capture ? ageInDays(capture.retrieved) : null; + if (captureAge === null || captureAge >= -1) continue; + out.push(finding('fail', 'hygiene', 'future-date', + `${row.id}'s capture ${capture.file} records retrieved ${retrievedDay(capture.retrieved)}, which is in the future - the collector's clock was ahead ` + + 'when the page was fetched, and freshness is judged by that date; re-collect the page', + { row: row.id, line: row.line, file: capture.file })); + } + + // A row whose ID is not in its table's form is a fail, for the same reason duplicate-id is: + // the ID is how every check finds the row, and a row no check can find is a requirement + // that has silently left the gate. `U_99` was read as nothing at all - not an unknown, not a + // problem, not a finding (found 2026-10-03, output-reliability audit G2); readCorpus now + // records it, and this is where it is named. + for (const problem of corpus.problems) { + if (problem.kind !== 'malformed-id' && problem.kind !== 'table-split') continue; + out.push(finding('fail', 'hygiene', problem.kind, + `${problem.artifact}:${problem.line} ${problem.detail}`, { row: problem.row, line: problem.line })); + } + + if (!out.length) out.push(finding('pass', 'hygiene', 'hygiene', 'no duplicate rows, no uncited captures')); + return out; +} + +// ---------------------------------------------------------------- 12. corpus-shape + +function corpusShape(corpus) { + const out = []; + for (const problem of corpus.problems) { + // hygiene names a malformed ID (as a fail, beside duplicate-id); echoing it here as a warn + // would report one typo twice. + if (problem.kind === 'malformed-id' || problem.kind === 'table-split') continue; // hygiene's, as a fail + const blocking = problem.kind === 'raw-dangling' || problem.kind === 'plan-unparsed' || problem.kind === 'kit-unparsed' + || problem.kind === 'capture-outside' || problem.kind === 'raw-outside' || problem.kind === 'capture-too-large' || problem.kind === 'capture-unreadable'; + out.push(finding(blocking ? 'fail' : 'warn', 'corpus-shape', problem.kind, + `${problem.artifact ?? ''}${problem.line ? `:${problem.line}` : ''} ${problem.detail ?? problem.file ?? ''}`.trim(), + { line: problem.line, file: problem.file })); + } + if (!corpus.rawPresent) { + out.push(finding('fail', 'corpus-shape', 'raw-missing', + `${PATHS.raw}/ does not exist - a gated project needs somewhere to keep its captures`)); + } + if (!out.length) out.push(finding('pass', 'corpus-shape', 'shape', 'the corpus parses cleanly')); + return out; +} + + +// ---------------------------------------------------------------- 13. corroboration + +/** + * How many independent readings does a closed unknown rest on? + * + * WHY THIS EXISTS. Every check before it asks whether a claim is BACKED - is there a row, + * is there a capture, does the chain verify, is it fresh, is it primary. None of them asks + * whether it is backed more than once, so a corpus in which every unknown rested on a + * single page passed preflight and `--strict` with nothing to say about it. From inside + * such a corpus, a single CORRECT source and a single LUCKY one are indistinguishable. + * + * Found in this repository's own delivery-architecture corpus on 2026-09-21: nine rows, + * all primary, all complete, all fresh - and eight unknowns each resting on exactly one of + * them. Three of those eight carried architecture decisions. + * + * WHY IT IS A WARNING. Single-sourcing is often the right answer: a vendor's own API + * reference is the authority on that API, and demanding a second opinion about it would be + * ceremony. This check refuses to make that judgement - it reports the shape of the + * support so a reviewer can make it. `evidencePolicy=strict` or `--strict` is where an + * operator says they want the harder rule. + * + * WHY THE HOST MATTERS. Two pages from one vendor are not two witnesses. They are one + * witness read twice, which catches a misreading and cannot catch a vendor being wrong + * about itself. The finding says which of those it found, because the remedies differ: + * one wants another page, the other wants another party. + */ +/** + * A reviewer's recorded judgement that an unknown has ONE witness and cannot have two. + * + * WHY THIS EXISTS. `corroboration` reports the shape of support and leaves the judgement + * to a reviewer (ADR-0036) - but it gave the reviewer nowhere to put the judgement. So a + * claim that had been considered and correctly left single-sourced looked exactly like one + * nobody had looked at twice, forever, and `evidencePolicy=strict` was unsatisfiable by + * any corpus containing an honest one. + * + * WHY A REASON IS REQUIRED, and why that is the whole mechanism: this is the same shape + * `MAP.md` already uses for `DISMISSED` and `completeness: partial` uses for `omitted` - + * you may overrule the default, and you must say why in writing that survives in the + * corpus. The check does not grade the reason (ADR-0013); it requires one to exist and + * shows it in the finding, so the judgement is auditable rather than invisible. + * + * MIN_REASON exists because `[single-witness: n/a]` would otherwise be a silent mute. + */ +const SINGLE_WITNESS_RE = /\[single-witness:\s*([^\]]*)\]/i; +const MIN_WITNESS_REASON = 30; + +function singleWitnessNote(unknown) { + const match = SINGLE_WITNESS_RE.exec(String(unknown.evidence ?? '')); + if (!match) return null; + return { reason: match[1].trim() }; +} + +function corroboration(corpus) { + const out = []; + const byId = new Map(corpus.evidence.map((row) => [row.id.toUpperCase(), row])); + + const weighed = new Set(); + for (const unknown of corpus.unknowns) { + // A KNOWN-UNKNOWN rests on nothing by definition, and unknown-closure already owns + // the case of a CLOSED one citing no row at all. + if (unknown.status !== 'CLOSED') continue; + // Once per ID: a second row under the same ID is hygiene's duplicate-id, and weighing + // it again printed the same finding twice. + if (weighed.has(unknown.id.toUpperCase())) continue; + weighed.add(unknown.id.toUpperCase()); + + // DEDUPED BY ID, and that is load-bearing. `citedIds` returns every E-## mention in the + // cell, so an unknown whose prose names E-10 twice - "E-10 says X … as E-10 also notes" + // - yields the same row twice. Counting it twice would inflate the support, and once + // `documentGroups` folds the duplicate it reports a "republished copy" about a row that + // was only ever mentioned twice. Host-counting masked this: duplicates inflated + // `rows.length` but collapsed in the host Set, so the finding stayed right by accident. + // Found 2026-09-21 by running the new grouping over the delivery-architecture corpus. + const rows = [...new Set(unknown.cites.filter((id) => /^E-\d+$/i.test(id)).map((id) => id.toUpperCase()))] + .map((id) => byId.get(id)) + .filter(Boolean); + if (!rows.length) continue; + + // The SHAPE of the support, computed once. Whether that shape is acceptable is a + // separate question, answered below, because a reviewer may overrule it with a reason. + let shape; + if (rows.length === 1) { + shape = { rule: 'single-source', detail: `rests on ${rows[0].id} alone - one reading, so a correct source and a lucky one look the same` }; + } else { + // By SITE, not hostname: two subdomains of one company are one voice (2026-09-28). + const hosts = new Set(rows.map((row) => siteOf(row.url)).filter(Boolean)); + if (hosts.size === 1) { + shape = { rule: 'one-voice', detail: `cites ${rows.length} rows and all are ${[...hosts][0]} - a second reading of one source, which catches a misreading and not a source that is wrong about itself` }; + } else { + // Several hosts is where the host heuristic used to stop and say `independent`. A + // mirror passes that test while carrying LESS than either genuine page, so the rows + // are grouped into distinct DOCUMENTS before the hosts are counted again. + const sketches = rows.map((row) => corpus.captures.sketches?.get(captureOf(corpus, row)?.file) ?? null); + const groups = documentGroups(sketches); + if (groups.length === 1) { + shape = { rule: 'mirror', detail: `cites ${rows.length} rows across ${hosts.size} hosts but they are the same document - a republished copy is one witness, and the copy may be the stale one` }; + } else { + // Hosts that survive as DISTINCT documents. A three-row unknown where two rows + // mirror each other still counts the third, so this reports what is independent. + const distinctHosts = new Set(groups.map((g) => siteOf(rows[g[0]].url)).filter(Boolean)); + if (distinctHosts.size === 1) { + shape = { rule: 'one-voice', detail: `cites ${rows.length} rows across ${hosts.size} hosts, but after grouping republished copies only ${[...distinctHosts][0]} remains - one voice` }; + } else { + const mirrored = rows.length - groups.length; + const closest = closestPair(sketches); + shape = { + corroborated: true, + rule: 'independent', + // The closest pair is REPORTED, never acted on. The threshold has two measured + // blind spots and containment - the obvious repair - ranks a genuinely + // different pair above a real mirror, so trying harder would report second + // sources as copies. A number a reviewer can act on beats a verdict that + // pretends to more certainty than the measurement supports. + detail: `rests on ${groups.length} distinct documents across ${distinctHosts.size} hosts` + + (mirrored ? ` (${mirrored} of ${rows.length} rows are republished copies and were not counted twice)` : '') + + (closest === null ? '' : `; closest pair ${closest.toFixed(2)}`), + }; + } + } + } + } + + const note = singleWitnessNote(unknown); + + // An acknowledgement left on an unknown that has SINCE been corroborated is reported, + // not silently ignored. It now claims no second witness exists while the corpus holds + // one - which is a stale claim of exactly the kind this repository keeps finding in its + // own prose, and the only moment anything can notice is right here. It stays a warning + // that names its limit: sites are what it counts, and a paper beside its authors' own + // repository is two sites and one voice (found 2026-09-29). + if (shape.corroborated) { + if (note) { + out.push(finding('warn', 'corroboration', 'single-witness-stale', + `${unknown.id} ${shape.detail}, but still carries a [single-witness: ...] note claiming it cannot be corroborated. This check counts sites, not authors: if these pages share an author, the note stands; if not, remove the note or the claim is false`, + { row: unknown.id, line: unknown.line })); + continue; + } + out.push(finding('pass', 'corroboration', shape.rule, `${unknown.id} ${shape.detail}`, + { row: unknown.id, line: unknown.line })); + continue; + } + + if (!note) { + out.push(finding('warn', 'corroboration', shape.rule, `${unknown.id} ${shape.detail}`, + { row: unknown.id, line: unknown.line })); + continue; + } + + // A note with no real reason is a mute button, and gets its own finding rather than + // quietly behaving like the default - otherwise the weakest possible acknowledgement + // and no acknowledgement at all would be indistinguishable. + if (note.reason.length < MIN_WITNESS_REASON) { + out.push(finding('warn', 'corroboration', 'single-witness-unreasoned', + `${unknown.id} ${shape.detail}; its [single-witness: ...] note gives ${note.reason.length ? `only "${note.reason}"` : 'no reason'} - state why no second witness exists, in at least ${MIN_WITNESS_REASON} characters`, + { row: unknown.id, line: unknown.line })); + continue; + } + + out.push(finding('pass', 'corroboration', 'single-witness', + `${unknown.id} ${shape.detail} - accepted on the record: ${note.reason}`, + { row: unknown.id, line: unknown.line })); + } + + if (!out.length) out.push(finding('pass', 'corroboration', 'support', 'no closed unknown to weigh')); + return out; +} + +// ---------------------------------------------------------------- the registry + +/** Order is part of the interface: a failure a person must act on precedes a warning. */ +export const CHECKS = Object.freeze([ + { name: 'discovery-contract', about: 'the contract exists, states intent, and every unknown has a valid status', run: discoveryContract }, + { name: 'citations', about: 'every evidence row has a URL, a claim, and a cached page behind it', run: citations }, + { name: 'provenance', about: 'the chain verifies and every cited capture was actually fetched', run: provenance }, + { name: 'transport-provenance', about: 'which adapter fetched each cited capture', run: transportProvenance }, + { name: 'gate-integrity', about: 'overrides in effect, and whether this repository displaces the gate', run: gateIntegrity }, + { name: 'unknown-closure', about: 'every CLOSED unknown rests on a real, fresh, primary row', run: unknownClosure }, + { name: 'subtopic-coverage', about: 'the map covers every universal dimension, and states a status for each', run: subtopicCoverage }, + { name: 'capture-completeness', about: 'no closed unknown rests solely on a partial capture', run: captureCompleteness }, + { name: 'evidence-supersession', about: 'no unknown still rests on a row a later capture replaced', run: evidenceSupersession }, + { name: 'collection-attempts', about: 'a known-unknown was reached for before it was declared unreachable', run: collectionAttempts }, + { name: 'hygiene', about: 'duplicate rows, uncited captures, unparseable dates', run: hygiene }, + { name: 'corpus-shape', about: 'the corpus parses and the shape is there', run: corpusShape }, + { name: 'corroboration', about: 'how many independent readings a closed unknown rests on', run: corroboration }, +]); + +export const CHECK_NAMES = Object.freeze(CHECKS.map((c) => c.name)); + +export function runCheck(name, corpus, options = {}) { + const check = CHECKS.find((c) => c.name === name); + if (!check) { + const err = new Error(`unknown check "${name}". Known checks: ${CHECK_NAMES.join(', ')}`); + err.code = 'UNKNOWN_CHECK'; + throw err; + } + return check.run(corpus, options); +} + +export function runChecks(corpus, options = {}) { + const only = options.only?.length ? options.only : CHECK_NAMES; + for (const name of only) { + if (!CHECK_NAMES.includes(name)) { + const err = new Error(`unknown check "${name}". Known checks: ${CHECK_NAMES.join(', ')}`); + err.code = 'UNKNOWN_CHECK'; + throw err; + } + } + const findings = []; + for (const check of CHECKS) { + if (!only.includes(check.name)) continue; + // One at a time, never `push(...check.run())`: a spread passes every finding as an + // argument, and past the engine's argument limit - between 60,000 and 130,000 findings + // from one check, depending on the Node line - it throws RangeError out of the verdict + // (2026-10-01, break-test PR #188). A loop has no such ceiling. + for (const finding of check.run(corpus, options)) findings.push(finding); + } + return findings; +} + +export { citedIds }; + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-machine-mjs-githubusercontent-2ccae69e.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-machine-mjs-githubusercontent-2ccae69e.md new file mode 100644 index 00000000..17501088 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-machine-mjs-githubusercontent-2ccae69e.md @@ -0,0 +1,440 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// machine.mjs - everything outside the project (ADR-0002, ADR-0010, ADR-0012, ADR-0020). +// +// The machine config, the install state, git's core.hooksPath, the runtime anchors, and +// the machine role. The ONLY module that reaches past the project root. +// +// The config is read in THREE states - absent / readable / unreadable - because the old +// `try { parse } catch {}` collapsed "never configured" into "configured, and now +// corrupt", so a hardened machine silently reverted to the fail-open defaults. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { exists, readText, writeText, ensureDir, readJson, nowIso, parseJson, homeDir } from './core.mjs'; + +import { kitCommand } from './core.mjs'; +// ---------------------------------------------------------------- anchors (ADR-0012) + +/** + * The single place the kit states a runtime's paths. Every value is a DEFAULT the + * machine config may override - a hardcoded skill root on a machine whose agent reads + * another root installs a skill nothing discovers. + */ +export const RUNTIME_ANCHORS = Object.freeze({ + settingsPath: path.join(homeDir(), '.claude', 'settings.json'), + skillRoots: Object.freeze([path.join(homeDir(), '.claude', 'skills')]), + projectSkillDir: '.claude/skills', +}); + +/** + * The machine's `.agents` root. `RESEARCH_KIT_HOME` outranks the OS-derived profile + * result, whichever profile variables produced it — the githooks wrapper has always + * honoured it, and a module that disagreed with the hook about where the kit lives was + * a split answer to one question. The kit never monkey-patches `os.homedir()`. + */ +export function agentsHome(env = process.env) { + return env.RESEARCH_KIT_HOME + ? path.dirname(path.resolve(env.RESEARCH_KIT_HOME)) + : path.join(homeDir(), '.agents'); +} + +export function kitHome(env = process.env) { + return env.RESEARCH_KIT_HOME ? path.resolve(env.RESEARCH_KIT_HOME) : path.join(homeDir(), '.agents', 'research-kit'); +} + +export const KIT_HOME = kitHome(); +export const CONFIG_PATH = path.join(homeDir(), '.agents', 'research-kit.config.json'); +export const INSTALL_STATE_PATH = path.join(homeDir(), '.agents', 'research-kit.install.json'); +export const SKILL_NAME = 'research-first'; +export const EDIT_GATE_HOOK = 'hooks/edit-gate.mjs'; + +/** Names the kit used to ship. Recognising an old install requires naming it. */ +export const RETIRED_EDIT_GATE_HOOKS = Object.freeze(['hooks/claude-pretooluse.mjs', 'hooks/claude-gate.mjs']); +export const RETIRED_FLAGS = Object.freeze({ '--claude-only': '--edit-only', '--no-claude': '--no-edit-gate' }); +export const RETIRED_PROJECT_SKILL_DIRS = Object.freeze(['.agents/skills']); +export const RETIRED_KIT_FILES = Object.freeze(['hooks/claude-pretooluse.mjs', 'hooks/claude-gate.mjs', 'lib/cli.mjs']); +export const RETIRED_CONFIG_KEYS = Object.freeze({ claudeGate: 'editGate.mode' }); +export const RETIRED_ENV_VARS = Object.freeze({ CLAUDE_SETTINGS_PATH: 'RESEARCH_KIT_EDIT_GATE_SETTINGS' }); + +/** + * Does this registered command name that hook file? + * + * The kit spells its hook paths with forward slashes and the installer writes a NATIVE + * path, so on Windows the registration reads `...\hooks\edit-gate.mjs` while every + * detector looks for `hooks/edit-gate.mjs`. Matching literally made the installer write + * a second registration beside its own and doctor report `none` for a gate that was + * installed - so separators are normalised in the one place that knows these names. + */ +export function namesHook(command, hookPath) { + const normalize = (text) => String(text ?? '').split('\\').join('/'); + return normalize(command).includes(normalize(hookPath)); +} + +export const DEFAULTS = Object.freeze({ + failOpen: true, + editGate: Object.freeze({ mode: 'ask', settingsPath: '' }), + evidencePolicy: 'pluralist', + role: 'collector', + transport: '', + searchTransport: '', + serpapiKey: '', + searxngUrl: '', + skillRoots: Object.freeze([]), + projectSkillDir: '', + maxAgeDays: 180, + browserPath: '', +}); + +export const ROLES = Object.freeze(['collector', 'builder']); +/** `unknown` is a STATE the kit resolves to, never a role an operator may declare. */ +export const ROLE_STATES = Object.freeze([...ROLES, 'unknown']); +export const EVIDENCE_POLICIES = Object.freeze(['pluralist', 'strict']); +export const EDIT_GATE_MODES = Object.freeze(['ask', 'hard-block', 'off']); + +// ---------------------------------------------------------------- config, in three states + +/** + * Precedence, highest first (the path-precedence contract): + * 1. an explicit argument 2. `RESEARCH_KIT_CONFIG` + * 3. `RESEARCH_KIT_HOME`'s `.agents` root 4. the OS profile's `.agents` + * A higher authority is never silently replaced by a lower one. + */ +export function configPath(env = process.env, explicit = '') { + if (explicit) return path.resolve(explicit); + if (env.RESEARCH_KIT_CONFIG) return env.RESEARCH_KIT_CONFIG; + return path.join(agentsHome(env), 'research-kit.config.json'); +} + +function lastGoodPath(file) { + return `${file}.last-good`; +} + +/** + * The restrictive state of each POSTURE key, for a value that is present and not of its + * type (ADR-0121). An operator who typed `"failOpen": "false"` meant to tighten something, + * and resolving the typo to the default loosened it in silence: fail-open, pluralist, ask. + * A config that does not parse already fails closed (ADR-0020) and an unknown role refuses + * to collect (ADR-0023); this is the same rule one level down, per key. Absent keys keep + * their defaults - absence is not a typo. + */ +const RESTRICTIVE = Object.freeze({ failOpen: false, evidencePolicy: 'strict', 'editGate.mode': 'hard-block' }); + +function shape(raw) { + // The posture keys an operator typed and the kit could not read as typed, in config order. + const illTyped = []; + const postureKey = (name, value, valid, fallback) => { + if (value === undefined) return fallback; + if (valid(value)) return value; + illTyped.push(name); + return RESTRICTIVE[name]; + }; + const settings = { + failOpen: postureKey('failOpen', raw.failOpen, (v) => typeof v === 'boolean', DEFAULTS.failOpen), + editGate: { + mode: DEFAULTS.editGate.mode, + // A path the installer hands to the filesystem, so only a non-empty string is one; + // anything else falls back to the anchor, as every other wrong-typed key here does. + // It was the one key passed through raw, and `{"editGate":{"settingsPath":42}}` reached + // `path.dirname` in installEditGate and killed `install-hooks` - the command whose job + // is to bind the gates - with a raw ERR_INVALID_ARG_TYPE stack (found 2026-10-02, + // break-test; the sibling of the 2026-10-01 `skillRoots` check). + settingsPath: typeof raw.editGate?.settingsPath === 'string' && raw.editGate.settingsPath.trim() !== '' + ? raw.editGate.settingsPath + : DEFAULTS.editGate.settingsPath, + }, + evidencePolicy: postureKey('evidencePolicy', raw.evidencePolicy, (v) => EVIDENCE_POLICIES.includes(v), DEFAULTS.evidencePolicy), + // A field that is PRESENT and not a known role reads as `unknown`, not as the + // default: a misspelling ("collecter", "Builder") is a machine whose operator meant + // something, and guessing which is how a builder silently becomes a role that may + // spend credits. Absent stays `collector` (ADR-0010); unknown blocks (ADR-0023). + role: raw.role === undefined ? DEFAULTS.role : (ROLES.includes(raw.role) ? raw.role : 'unknown'), + transport: typeof raw.transport === 'string' ? raw.transport : DEFAULTS.transport, + // The SEARCH side is chosen separately from the fetch side (ADR-0027). Absent means + // "the fetch provider", which is what every machine did before the split. + searchTransport: typeof raw.searchTransport === 'string' ? raw.searchTransport : DEFAULTS.searchTransport, + // A credential, and the only one this config holds. It lives here or in the + // environment and never in a repository (SR-1). Read but never rendered: every + // display path goes through `serpapi.redact()`. + serpapiKey: typeof raw.serpapiKey === 'string' ? raw.serpapiKey : DEFAULTS.serpapiKey, + // The SearXNG instance the searxng search provider asks (ADR-0104). A URL, not a secret. + searxngUrl: typeof raw.searxngUrl === 'string' ? raw.searxngUrl : DEFAULTS.searxngUrl, + // Checked as a list of PATHS, not only as an array. Every other key here is + // type-checked, and this one was not, so `{"skillRoots": [42]}` - or a null left in a + // hand-edited list - reached `path.join` in skillLocations() and killed `doctor` with a + // raw ERR_INVALID_ARG_TYPE stack and Node's version footer: the one command whose whole + // job is to name every problem and print its fix, dead on a config typo it should have + // named (found 2026-10-01, break-test). An entry that is not a path is dropped, which + // leaves the defaults in charge when nothing is left. + skillRoots: Array.isArray(raw.skillRoots) ? raw.skillRoots.filter((root) => typeof root === 'string' && root.trim() !== '') : [], + projectSkillDir: typeof raw.projectSkillDir === 'string' ? raw.projectSkillDir : '', + maxAgeDays: Number.isFinite(raw.maxAgeDays) ? raw.maxAgeDays : DEFAULTS.maxAgeDays, + // The browser transport's executable (ADR-0088), which its own errors tell the operator + // to set. It was missing here until 2026-10-11, so it was never read, and every save - + // `install-hooks --role` among them - rewrote the file without it. + browserPath: typeof raw.browserPath === 'string' ? raw.browserPath : DEFAULTS.browserPath, + }; + // A retired key is READ before it is dropped: a rename that silently relaxes a gate is + // the same defect as a rename that silently stops firing one. + const retired = []; + if (raw.editGate?.mode !== undefined) { + // Present: taken as typed, or resolved to the restrictive mode and named (ADR-0121). + settings.editGate.mode = postureKey('editGate.mode', raw.editGate.mode, (v) => EDIT_GATE_MODES.includes(v), DEFAULTS.editGate.mode); + if (raw.claudeGate !== undefined) retired.push('claudeGate'); + } else if (EDIT_GATE_MODES.includes(raw.claudeGate)) { settings.editGate.mode = raw.claudeGate; retired.push('claudeGate'); } + else if (raw.claudeGate !== undefined) retired.push('claudeGate'); + return { settings, retired, illTyped }; +} + +/** + * Returns `{ settings, state, source, error, retiredKeys, illTypedKeys }`. The settings alone cannot + * say whether fail-closed was configured or is the fallback this machine adopted. + */ +export function readMachineConfig(env = process.env) { + const file = configPath(env); + if (!exists(file)) { + return { settings: shape({}).settings, state: 'absent', source: file, error: null, retiredKeys: [], illTypedKeys: [] }; + } + const text = readText(file); + try { + const raw = parseJson(text ?? ''); + const { settings, retired, illTyped } = shape(raw && typeof raw === 'object' ? raw : {}); + try { writeText(lastGoodPath(file), text); } catch { /* snapshot is best-effort */ } + return { settings, state: 'readable', source: file, error: null, retiredKeys: retired, illTypedKeys: illTyped }; + } catch (err) { + const snapshot = readJson(lastGoodPath(file), null); + if (snapshot) { + const { settings, retired, illTyped } = shape(snapshot); + return { settings, state: 'unreadable', resolvedFrom: 'snapshot', source: lastGoodPath(file), error: err.message, retiredKeys: retired, illTypedKeys: illTyped }; + } + // Nothing to hold: fail CLOSED, and the ROLE becomes `unknown` rather than the + // default. The old "every config was previously snapshotted" rationale does not + // cover a hand-written config, a copied machine, a denied snapshot write, or a + // missing backup - and on every one of those the default `collector` is the role + // that may spend credits. An unknown role refuses metered collection until it is + // resolved (ADR-0023). `failOpen` is still the only knob tightened: the others have + // no safe restrictive default. + const { settings } = shape({}); + return { + settings: { ...settings, failOpen: false, role: 'unknown' }, + state: 'unreadable', + resolvedFrom: 'fallback-closed', + source: file, + error: err.message, + retiredKeys: [], + illTypedKeys: [], + }; + } +} + +export function loadConfig(env = process.env) { + return readMachineConfig(env).settings; +} + +/** Writing drops retired keys, so the migration happens through normal use. */ +export function saveConfig(patch, env = process.env) { + const file = configPath(env); + const current = readMachineConfig(env).settings; + const next = { + ...current, + ...patch, + editGate: { ...current.editGate, ...(patch.editGate ?? {}) }, + }; + ensureDir(path.dirname(file)); + writeText(file, `${JSON.stringify(next, null, 2)}\n`); + writeText(lastGoodPath(file), `${JSON.stringify(next, null, 2)}\n`); + return next; +} + +/** The fail-open / fail-closed answer, carrying where it came from. */ +export function posture(env = process.env) { + const read = readMachineConfig(env); + return { + failOpen: read.settings.failOpen, + configState: read.state, + configSource: read.source, + configError: read.error, + retiredKeys: read.retiredKeys, + illTypedKeys: read.illTypedKeys, + resolvedFrom: read.resolvedFrom ?? (read.state === 'absent' ? 'defaults' : 'config'), + // The code carries the RESOLVED posture, not the state: + // 0 allow + // 1 a config that parses says fail-closed (including a held last-good snapshot) + // 2 unreadable, nothing to hold, and therefore resolved to blocking + // Two readers of one posture that disagree about what a code means is the drift the + // pin exists to catch. + exitCode: read.settings.failOpen ? 0 : (read.resolvedFrom === 'fallback-closed' ? 2 : 1), + }; +} + +// ---------------------------------------------------------------- runtime paths + +export function runtimePaths(env = process.env) { + const cfg = loadConfig(env); + return { + settingsPath: env.RESEARCH_KIT_EDIT_GATE_SETTINGS || cfg.editGate.settingsPath || RUNTIME_ANCHORS.settingsPath, + projectSkillDir: cfg.projectSkillDir || RUNTIME_ANCHORS.projectSkillDir, + }; +} + +/** The personal skill roots: the machine config's `skillRoots`, else the anchored default. */ +export function skillRoots(env = process.env) { + const cfg = loadConfig(env); + return cfg.skillRoots.length ? [...cfg.skillRoots] : [...RUNTIME_ANCHORS.skillRoots]; +} + +export function skillLocations(env = process.env) { + return skillRoots(env).map((root) => path.join(root, SKILL_NAME)); +} + +/** A retired ENVIRONMENT variable is reported, never honoured - no kit write reaches a shell. */ +export function retiredEnvNotes(env = process.env) { + return Object.entries(RETIRED_ENV_VARS) + .filter(([name, replacement]) => env[name] && !env[replacement]) + .map(([name, replacement]) => ({ name, replacement, value: env[name] })); +} + +// ---------------------------------------------------------------- role and collection policy + +export function machineRole(env = process.env) { + return loadConfig(env).role; +} + +/** The single answer to "may this machine collect?". */ +export function collectionPolicy(env = process.env) { + const read = readMachineConfig(env); + const role = read.settings.role; + if (role === 'builder') { + return { + role, + mayCollect: false, + reason: 'this machine is declared role=builder, which does not collect', + remedy: `collect on the collector machine, or declare this one: ${kitCommand('install-hooks.mjs', '--role collector')}`, + }; + } + if (role === 'unknown') { + // Not a default and not a guess: the machine's role could not be established, so the + // metered, credit-spending half of the kit stays shut until somebody says which it is. + return { + role, + mayCollect: false, + reason: read.state === 'unreadable' + ? `this machine's role cannot be established: ${read.source} does not parse (${read.error}) and there is no last-good snapshot` + : `this machine's config names a role the kit does not know ("${readJson(read.source, {})?.role ?? 'unreadable value'}")`, + remedy: `repair the config, or declare the role: ${kitCommand('install-hooks.mjs', '--role collector|builder')}`, + }; + } + return { role, mayCollect: true, reason: '', remedy: '' }; +} + +export function collectionRefusal(policy) { + return [ + `refused: ${policy.reason}.`, + 'A page fetched by hand is not evidence in this kit, and re-collecting forges a corpus', + 'instead of reporting a gap. Name the missing fact and let it be collected.', + `remedy: ${policy.remedy}`, + ].join('\n'); +} + +export function evidencePolicy(env = process.env) { + return loadConfig(env).evidencePolicy; +} + +// ---------------------------------------------------------------- git + +function git(args, { cwd = process.cwd(), env = process.env } = {}) { + try { + return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return null; + } +} + +export function gitVersion(opts = {}) { + return git(['--version'], opts); +} + +/** Scope-aware readers: which hooksPath is set where. */ +export function hooksPath(scope = 'global', opts = {}) { + const flag = { global: '--global', local: '--local', system: '--system' }[scope] ?? '--global'; + return git(['config', flag, '--get', 'core.hooksPath'], opts); +} + +export function hooksPathEffective(opts = {}) { + return git(['config', '--get', 'core.hooksPath'], opts); +} + +export function setHooksPath(value, { scope = 'global', ...opts } = {}) { + const flag = { global: '--global', local: '--local', system: '--system' }[scope] ?? '--global'; + if (value === null) return git(['config', flag, '--unset', 'core.hooksPath'], opts); + return git(['config', flag, 'core.hooksPath', value], opts); +} + +/** + * The hooks folder the kit's install replaced, where the sh hooks can read it (ADR-0112): git + * runs one hooks folder, and `githooks/hand-on.sh` hands each hook on to this one. The install + * state keeps it too, as JSON, which sh cannot parse. + */ +export const PREVIOUS_HOOKS_KEY = 'research-kit.previousHooksPath'; + +/** + * The hooks folder the install replaced, or null: what every kit hook hands on to (ADR-0112). + * Read with `--type=path`, as `hand-on.sh` reads it, so a value typed `~/hooks` resolves to the + * folder git will run; the raw value made doctor call a working `~/custom-hooks` missing and + * prescribe the unset that would have removed it (outside audit, 2026-10-03). + */ +export function previousHooksPath(opts = {}) { + return git(['config', '--global', '--type=path', '--get', PREVIOUS_HOOKS_KEY], opts) || null; +} + +/** The repository's top level from `cwd`, or null outside one: where git runs its hooks from. */ +export function repoTopLevel(opts = {}) { + return git(['rev-parse', '--show-toplevel'], opts) || null; +} + +export function setPreviousHooksPath(value, opts = {}) { + if (value === null) return git(['config', '--global', '--unset', PREVIOUS_HOOKS_KEY], opts); + return git(['config', '--global', PREVIOUS_HOOKS_KEY, value], opts); +} + +export function isGitRepo(dir) { + return exists(path.join(dir, '.git')); +} + +/** + * The third override is silent by nature: it disables the hook that would report it. + * + * Asked of git from `cwd`, not of the filesystem: git finds the repository by walking up, so a + * subdirectory - a nested decision project under docs/decisions/, the folder an operator + * happens to run install-hooks from - is governed by the same local `core.hooksPath` as the + * root. The probe looked for `.git` beside `cwd` and said `null` anywhere below it (outside + * audit, 2026-10-03). Outside any repository `git config --local` fails, and that is null. + */ +export function localHooksPathOverride(cwd = process.cwd(), opts = {}) { + const local = hooksPath('local', { ...opts, cwd }); + return local || null; +} + +// ---------------------------------------------------------------- install state + +export function readInstallState(env = process.env) { + return readJson(env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH, null); +} + +export function writeInstallState(state, env = process.env) { + const file = env.RESEARCH_KIT_INSTALL_STATE || INSTALL_STATE_PATH; + ensureDir(path.dirname(file)); + writeText(file, `${JSON.stringify({ ...state, at: nowIso() }, null, 2)}\n`); + return file; +} + +export { os, path as nodePath, fs }; + diff --git a/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-preflight-mjs-githubusercontent-4b093d0d.md b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-preflight-mjs-githubusercontent-4b093d0d.md new file mode 100644 index 00000000..74cd0cb2 --- /dev/null +++ b/docs/decisions/2026-10-13-portable-artifact-authorization/research/raw/2026-10-13-preflight-mjs-githubusercontent-4b093d0d.md @@ -0,0 +1,103 @@ +--- +url: https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs +retrieved: 2026-10-13 +command: http-keyless scrape https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs +statusCode: 200 +transport: http-keyless +completeness: full +--- +// preflight.mjs - the verdict, and the verdict's input unit. +// +// One function, three callers (the commit gate, the edit gate, the CLI), so they cannot +// disagree. Severity policy is the OPERATOR's machine config, never the agent's pick. + +import { PATHS, resolve, exists } from './core.mjs'; +import { readCorpus } from './corpus.mjs'; +import { verifyLedger } from './provenance.mjs'; +import { runChecks, CHECK_NAMES } from './checks.mjs'; +import { evidencePolicy, loadConfig, localHooksPathOverride } from './machine.mjs'; + +import { kitCommand } from './core.mjs'; +/** + * Findings these checks raise are warnings under `pluralist`, failures under `strict`. + * + * All three are questions an EVIDENCE POLICY should answer rather than the kit: how the + * capture was fetched, how much of the page arrived, and how many independent readings a + * claim rests on. Each has a defensible lenient answer - an agent page-fetch is still a + * fetch, a partial capture can still contain the sentence, and a vendor's own reference + * is the authority on that vendor. `strict` is where an operator says otherwise. + */ +const POLICY_CHECKS = new Set(['transport-provenance', 'capture-completeness', 'corroboration']); + +export function readGateState(root, env = process.env) { + return { + gateOff: exists(resolve(root, PATHS.gateOff)), + localHooksPath: localHooksPathOverride(root, { env }), + }; +} + +/** + * The corpus, the gate state, and the operator's evidence policy, read TOGETHER. + * A caller that injects a corpus gets its gate state from the snapshot's own root - + * never the process cwd - so the same snapshot cannot yield a different verdict. + */ +export function verdictContext(root, { corpus = null, env = process.env } = {}) { + const snapshot = corpus ?? readCorpus(root); + if (!snapshot.chain) snapshot.chain = verifyLedger(snapshot.root, { corpus: snapshot }); + const config = loadConfig(env); + return { + root: snapshot.root, + corpus: snapshot, + gate: readGateState(snapshot.root, env), + evidencePolicy: evidencePolicy(env), + maxAgeDays: config.maxAgeDays, + }; +} + +function promote(finding, policy, strict) { + if (finding.severity !== 'warn') return finding; + if (strict) return { ...finding, severity: 'fail', promoted: 'strict' }; + if (policy === 'strict' && POLICY_CHECKS.has(finding.check)) { + return { ...finding, severity: 'fail', promoted: 'evidencePolicy=strict' }; + } + return finding; +} + +/** + * Run the registry over one snapshot and decide what it means. + * `{ pass, findings, failures, warnings, counts, context }`. + */ +export function runPreflight(root, { corpus = null, env = process.env, only = [], strict = false, context = null } = {}) { + const ctx = context ?? verdictContext(root, { corpus, env }); + const findings = runChecks(ctx.corpus, { + only, + maxAgeDays: ctx.maxAgeDays, + localHooksPath: ctx.gate.localHooksPath, + evidencePolicy: ctx.evidencePolicy, + }).map((f) => promote(f, ctx.evidencePolicy, strict)); + + const failures = findings.filter((f) => f.severity === 'fail'); + const warnings = findings.filter((f) => f.severity === 'warn'); + return { + root: ctx.root, + pass: failures.length === 0, + findings, + failures, + warnings, + counts: { + pass: findings.filter((f) => f.severity === 'pass').length, + warn: warnings.length, + fail: failures.length, + }, + evidencePolicy: ctx.evidencePolicy, + context: ctx, + }; +} + +/** The one line a failing verdict tells an operator to run. */ +export function fixCommand() { + return kitCommand('preflight.mjs'); +} + +export { CHECK_NAMES }; + diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 28b7adf9..6b628dff 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -17,6 +17,7 @@ repository. The retention rule is **ADR-0102**: | Project | Question | Backs | Status | |---|---|---|---| +| [2026-10-13-portable-artifact-authorization](2026-10-13-portable-artifact-authorization/) | How claimed package approval is re-derived without consumer policy, while preserving verified historical brief profiles | ADR-0160; issue #357; nine pinned code captures and actual-writer profile probes | active | | [2026-10-07-break-test-external-facts](2026-10-07-break-test-external-facts/) | What Node documents about synchronous copy/overwrite, Windows write-permission changes, NODE_EXTRA_CA_CERTS, private child IPC and the process.send callback parameter; preserves original partial v22.22.0 captures and appends full v22.22.3/v24.20.0 owner files with measured-versus-documented limits | `docs/build-report-2026-10-07-break-test.md`; primary facts for the installer permission repair, certificate recommendation and scoped child-accounting remedy, with child delivery/error regressions and design records separate | reference | | [2026-10-06-kit-vs-prompt-evaluation](2026-10-06-kit-vs-prompt-evaluation/) | Does the kit measurably beat a strongly prompted agent with the same tools, models and budget? Matched-arm comparison over twelve tasks, protocol and rubrics frozen before any run, simplification per mechanism pre-registered | ADR-0145; will back the keep/simplify/drop ADRs its results produce | pending | | [2026-10-05-agent-reliability](2026-10-05-agent-reliability/) | How the kit can better fulfill its original purpose of reducing unsupported claims and mistakes in agent research, coding and writing; separates provenance checks from semantic support and tested outcomes, and proposes a matched outcome evaluation | the project's AUDIT.md and research/BRIEF.md; recommendation only, ADR-0089 and the freeze (ADR-0117) remain in force | reference | diff --git a/docs/decisions/collection-357.log b/docs/decisions/collection-357.log new file mode 100644 index 00000000..95037504 --- /dev/null +++ b/docs/decisions/collection-357.log @@ -0,0 +1,24 @@ +prior: none registered. What do you expect to find? node C:\Users\PC\Desktop\gittoken\Research-Kit-issue-357\research-kit\bin\prior.mjs "..." - this is the last moment that answer counts +transport: http-keyless - RESEARCH_KIT_TRANSPORT + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/preflight.mjs - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/checks.mjs - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/machine.mjs - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/brief.mjs - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/brief.mjs - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/docs/adr/0069-the-brief-stamp-covers-the-map-the-ledger-and-the-verdict.md - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/lib/artifact.mjs - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3fa03fa1749d591af9cd7197e1c09f66ffacd231/research-kit/lib/artifact-validator.mjs - first capture + collected https://raw.githubusercontent.com/StepenkoAnatoli/Research-Kit/3bac1f26ddbf8cb6a794626e53dc2af313860940/research-kit/test/brief.test.mjs - first capture + +run +--- +depth normal (budget 9 scrapes) +collected 9 +cached 0 +failed 0 +spent 9 (budget consumed: collected + failed) +topic 0.55 best of 11 topic terms, 2 capture(s) >= 0.50 + + +Next: rewrite each auto-extracted Finding cell into a real claim, then run + node C:\Users\PC\Desktop\gittoken\Research-Kit-issue-357\research-kit\bin\preflight.mjs diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..10f9b6af 100644 --- a/research-kit/README.md +++ b/research-kit/README.md @@ -135,7 +135,8 @@ node "$HOME/.agents/research-kit/bin/handoff.mjs" # did the corpus arrive wh | `property-vector-conformance.mjs` + `.py` | exported property vectors, in two languages | | `property-replay.mjs` | replay a captured property failure deterministically | | `evidence-context.mjs` | what one unknown rests on (`--unknown U-5`, `--all`, `--json`), read-only | -| `artifact.mjs` | the portable package: `create` (derives authorization, never takes it) and `validate` (offline, read-only) | +| `artifact.mjs` | create and write portable packages; authorization comes from the shared derivation | +| `artifact-state.mjs` | shared producer/validator gate, handoff, map, findings and brief approval derivation | | `collect-remote.mjs` | run the collector on GitHub and bring the result back (`--repository`, `--topic`, `--query`, `--url`, `--prefer`, `--prior`, `--json`) | | `mcp-server.mjs` | the collector as an MCP server over stdio, for an agent that speaks the protocol | | `disclosure.mjs` | what a stranger can read of a workflow run (`--repository`, `--run`, `--topic`), unauthenticated and read-only | @@ -563,7 +564,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 +1795 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-state.mjs b/research-kit/lib/artifact-state.mjs new file mode 100644 index 00000000..152278e6 --- /dev/null +++ b/research-kit/lib/artifact-state.mjs @@ -0,0 +1,150 @@ +// Shared approval derivation: producer policy and portable package baseline (ADR-0160). +import { PATHS, resolve, relative, projectFile, readText, sha256 } from './core.mjs'; +import { readCorpus, captureOf, parseCapture } from './corpus.mjs'; +import { verifyLedger } from './provenance.mjs'; +import { verifyHandoff } from './handoff.mjs'; +import { runPreflight } from './preflight.mjs'; +import { briefState, reviewedBy, draftStamp, briefInputsHash, judgedSection, JUDGED_SECTIONS } from './brief.mjs'; +import { firstFinding } from './finding.mjs'; + +export function deriveState(root, { corpus = null, env = {} } = {}) { + return derive(root, { corpus, env }); +} + +export function derivePortableState(root, { corpus = null, formatMajor = 2 } = {}) { + const snapshot = corpus ?? readCorpus(root); + if (!snapshot.chain) snapshot.chain = verifyLedger(snapshot.root, { corpus: snapshot }); + const context = { + root: snapshot.root, corpus: snapshot, + gate: { gateOff: false, localHooksPath: null }, evidencePolicy: 'pluralist', maxAgeDays: 180, + }; + return derive(root, { corpus: snapshot, context, legacyFormat: formatMajor === 1 }); +} + +// Exact pre-ADR-0069 algorithm from 3bac1f26; its actual writer was probed first. +export function legacyBriefInputsHash(snapshot) { + return sha256(JSON.stringify({ + topic: snapshot.map?.topic || snapshot.plan?.topic || '', + intent: snapshot.intent ?? '', + unknowns: (snapshot.unknowns ?? []).map((u) => [u.id, u.text, u.status, u.evidence]), + evidence: (snapshot.evidence ?? []).map((e) => [e.id, e.retrieved, e.type, e.url, e.finding, e.raw]), + })).slice(0, 16); +} + +function legacyMarker(text) { + const markers = [...String(text ?? '').matchAll(//g)]; + return markers.length > 0 && markers.at(-1)[3] === undefined; +} + +/** + * `deriveState(root)` -> everything the manifest may say about permission. + * + * Runs the real gate. No caller input reaches this function. + */ +function derive(root, { corpus = null, env = {}, context = null, legacyFormat = false } = {}) { + const snapshot = corpus ?? readCorpus(root); + + // The gate. `env` defaults to an EMPTY object rather than process.env: a producer must + // not be able to inherit a GATE_OFF or an override from whatever shell it ran in, and + // reading the ambient environment is exactly how that would happen. + let verdict = null; + let blockingFindings = []; + try { + verdict = runPreflight(root, { corpus: snapshot, env, context }); + blockingFindings = (verdict.findings ?? []) + .filter((f) => f.severity === 'fail') + .map((f) => ({ + code: `${f.rule}${f.kind ? `/${f.kind}` : ''}`, + message: String(f.detail ?? f.rule), + remedy: f.fix ? String(f.fix) : null, + })); + } catch (err) { + blockingFindings = [{ code: 'preflight/unrunnable', message: `the gate could not run: ${err.message}`, remedy: null }]; + } + + const handoff = verifyHandoff(root, { corpus: snapshot }); + const gatePass = Boolean(verdict?.pass) && blockingFindings.length === 0 && handoff.ok; + + // Review, derived. + const rows = snapshot.subtopics ?? []; + const mapClassified = rows.length > 0 && rows.every((r) => ['COVERED', 'DISMISSED', 'GAP'].includes(r.status)); + // Reviewed means three things (2026-10-03, output-reliability audit G1): the brief is + // authored (drafted, no TODO left); every judged section is PRESENT and answered - a missing + // section holds no TODO, so a drafted brief with no review sections at all had derived + // APPROVED_BRIEF on the repository's own corpus; and a stamped brief was drafted from the + // corpus as it is now - one whose evidence changed after the draft had stayed authorized + // with hygiene's brief-stale warning beside it. A brief with no stamp predates ADR-0055 (the + // root's own and ten decision briefs do): its currency cannot be checked, so it keeps the + // approval and hygiene's brief-unstamped says so (ADR-0138); a redraft with --force stamps it. + const briefText = snapshot.brief?.text ?? ''; + const stamp = draftStamp(briefText); + const briefComplete = JUDGED_SECTIONS.every((key) => judgedSection(briefText, key)?.answered); + const legacyBriefCompatible = legacyFormat && stamp && legacyMarker(briefText) + && stamp.inputs === legacyBriefInputsHash(snapshot); + const briefCurrent = !stamp || stamp.inputs === briefInputsHash(snapshot) || legacyBriefCompatible; + const briefReviewed = briefState(briefText) === 'authored' && briefComplete && briefCurrent; + // Who did it is the reviewer's declaration, not a fact the kit can check (ADR-0074), and + // it is not one of the approval conditions. + const by = reviewedBy(snapshot.brief?.text ?? ''); + const { reviewed: findingsReviewed, unrewritten, unresolved } = findingsReviewState(root, snapshot); + + const collected = (snapshot.evidence ?? []).length; + const collectionFailed = collected === 0 || !(snapshot.ledger?.present) || (snapshot.ledger?.entries ?? []).length === 0; + + const approved = gatePass && mapClassified && briefReviewed && findingsReviewed; + const state = collectionFailed ? 'COLLECTION_FAILED' + : approved ? 'APPROVED_BRIEF' + : gatePass ? 'REVIEW_IN_PROGRESS' + : (mapClassified || briefReviewed || findingsReviewed) ? 'PREFLIGHT_BLOCKED' + : 'REVIEW_REQUIRED'; + + return { + state, + kind: approved ? 'APPROVED_RESEARCH' : 'COLLECTED_CORPUS', + buildAuthorized: approved, + review: { mapClassified, findingsReviewed, briefReviewed, by }, + gate: { + verdict: verdict === null ? 'NOT_RUN' : gatePass ? 'PASS' : blockingFindings.length ? 'FAIL' : 'INCOMPLETE', + buildAuthorized: approved, + blockingFindings, + }, + handoff, + verdict, + unrewritten, + unresolved, + legacyBriefCompatible: Boolean(legacyBriefCompatible), + corpus: snapshot, + }; +} + +/** + * A Finding cell identical to what the extractor produces from the same capture was + * never rewritten. Negative evidence only, and that is stated rather than implied: this + * cannot tell a good claim from a bad one, and does not try. + */ +export function findingsReviewState(root, corpus) { + const rows = corpus.evidence ?? []; + if (!rows.length) return { reviewed: false, unrewritten: [], unresolved: [] }; + const unrewritten = []; + const unresolved = []; + for (const row of rows) { + const capture = captureOf(corpus, row); + if (!capture?.file) { unresolved.push(row.id); continue; } + const named = projectFile(root, capture.file); + const rawRoot = resolve(root, PATHS.raw); + const raw = projectFile(rawRoot, relative(rawRoot, named.abs)); + if (named.problem || raw.problem) { unresolved.push(row.id); continue; } + const text = readText(raw.abs); + if (text === null) { unresolved.push(row.id); 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 + // on run 36287211468: run over the whole FILE, the extractor read the front-matter as the + // page, never matched an untouched JSON capture's Finding, and a corpus nobody had + // reviewed was packaged as reviewed - one of the four conditions for buildAuthorized. + const { front, body } = parseCapture(text); + const extracted = String(firstFinding(body, front.title || front.url || row.url || '') ?? '').trim(); + if (!extracted) continue; + if (String(row.finding ?? '').trim() === extracted) unrewritten.push(row.id); + } + return { reviewed: unrewritten.length === 0 && unresolved.length === 0, unrewritten, unresolved }; +} diff --git a/research-kit/lib/artifact-validator.mjs b/research-kit/lib/artifact-validator.mjs index 656646f9..58027fd7 100644 --- a/research-kit/lib/artifact-validator.mjs +++ b/research-kit/lib/artifact-validator.mjs @@ -35,6 +35,7 @@ import { validateJsonSchema } from './release/schema.mjs'; import { readCorpus } from './corpus.mjs'; import { verifyHandoff } from './handoff.mjs'; import { SECRET_PATTERNS } from './doctor.mjs'; +import { derivePortableState } from './artifact-state.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const KIT_ROOT = path.resolve(here, '..'); @@ -364,6 +365,23 @@ export function validateArtifact({ } } + if (manifest.buildAuthorized === true && status === 'PASS') { + if (!workdir) workdir = fs.mkdtempSync(path.join(tempRoot, 'rk-artifact-')); + const projectRoot = materializeProject(workdir, bodies); + const derived = derivePortableState(projectRoot, { formatMajor: major }); + if (!derived.buildAuthorized) { + reject('FAIL', 'AUTHORIZATION-INCONSISTENT', + `the package claims APPROVED_BRIEF, but its corpus derives ${derived.state}`, + { path: MANIFEST_PATH, remedy: 'complete the packaged corpus gate and review steps, then regenerate the artifact' }); + } else { + warnings.push(problem('PORTABLE-BASELINE', 'approval was re-derived with unpromoted portable findings and the shared 180-day freshness default; producer or consumer policy and hooks were not used')); + if (derived.legacyBriefCompatible) warnings.push(problem('BRIEF-LEGACY-COMPATIBLE', 'the format-1 brief has no gate field and its covered inputs match the pre-0069 hash; current gate and review checks also pass')); + for (const finding of derived.verdict?.warnings ?? []) warnings.push(problem( + `PREFLIGHT-${finding.rule}`, String(finding.detail ?? finding.rule), { remedy: finding.fix ?? null }, + )); + } + } + return report(); } catch (err) { // Any unexpected throw is BLOCKED, never PASS. A validator that cannot finish has not diff --git a/research-kit/lib/artifact.mjs b/research-kit/lib/artifact.mjs index b03796f8..4e02f58d 100644 --- a/research-kit/lib/artifact.mjs +++ b/research-kit/lib/artifact.mjs @@ -33,11 +33,9 @@ import { } from './core.mjs'; import { buildZip } from './archive.mjs'; import { checkEntryName } from './artifact-zip.mjs'; -import { readCorpus, parseCapture } from './corpus.mjs'; -import { verifyHandoff } from './handoff.mjs'; -import { runPreflight } from './preflight.mjs'; -import { briefState, reviewedBy, draftStamp, briefInputsHash, judgedSection, JUDGED_SECTIONS } from './brief.mjs'; -import { firstFinding } from './finding.mjs'; +import { readCorpus } from './corpus.mjs'; +import { deriveState, findingsReviewState } from './artifact-state.mjs'; +export { deriveState, findingsReviewState }; import { validateArtifact, MANIFEST_PATH, MANIFEST_DIGEST_PATH, README_PATH } from './artifact-validator.mjs'; import { renderGateWarnings } from './render.mjs'; @@ -220,110 +218,6 @@ export function collectProjectFiles(root) { // ---------------------------------------------------------------- derived state -/** - * `deriveState(root)` -> everything the manifest may say about permission. - * - * Runs the real gate. No caller input reaches this function. - */ -export function deriveState(root, { corpus = null, env = {} } = {}) { - const snapshot = corpus ?? readCorpus(root); - - // The gate. `env` defaults to an EMPTY object rather than process.env: a producer must - // not be able to inherit a GATE_OFF or an override from whatever shell it ran in, and - // reading the ambient environment is exactly how that would happen. - let verdict = null; - let blockingFindings = []; - try { - verdict = runPreflight(root, { corpus: snapshot, env }); - blockingFindings = (verdict.findings ?? []) - .filter((f) => f.severity === 'fail') - .map((f) => ({ - code: `${f.rule}${f.kind ? `/${f.kind}` : ''}`, - message: String(f.detail ?? f.rule), - remedy: f.fix ? String(f.fix) : null, - })); - } catch (err) { - blockingFindings = [{ code: 'preflight/unrunnable', message: `the gate could not run: ${err.message}`, remedy: null }]; - } - - const handoff = verifyHandoff(root, { corpus: snapshot }); - const gatePass = Boolean(verdict?.pass) && blockingFindings.length === 0 && handoff.ok; - - // Review, derived. - const rows = snapshot.subtopics ?? []; - const mapClassified = rows.length > 0 && rows.every((r) => ['COVERED', 'DISMISSED', 'GAP'].includes(r.status)); - // Reviewed means three things (2026-10-03, output-reliability audit G1): the brief is - // authored (drafted, no TODO left); every judged section is PRESENT and answered - a missing - // section holds no TODO, so a drafted brief with no review sections at all had derived - // APPROVED_BRIEF on the repository's own corpus; and a stamped brief was drafted from the - // corpus as it is now - one whose evidence changed after the draft had stayed authorized - // with hygiene's brief-stale warning beside it. A brief with no stamp predates ADR-0055 (the - // root's own and ten decision briefs do): its currency cannot be checked, so it keeps the - // approval and hygiene's brief-unstamped says so (ADR-0138); a redraft with --force stamps it. - const briefText = snapshot.brief?.text ?? ''; - const stamp = draftStamp(briefText); - const briefComplete = JUDGED_SECTIONS.every((key) => judgedSection(briefText, key)?.answered); - const briefCurrent = !stamp || stamp.inputs === briefInputsHash(snapshot); - const briefReviewed = briefState(briefText) === 'authored' && briefComplete && briefCurrent; - // Who did it is the reviewer's declaration, not a fact the kit can check (ADR-0074), and - // it is not one of the approval conditions. - const by = reviewedBy(snapshot.brief?.text ?? ''); - const { reviewed: findingsReviewed, unrewritten } = findingsReviewState(root, snapshot); - - const collected = (snapshot.evidence ?? []).length; - const collectionFailed = collected === 0 || !(snapshot.ledger?.present) || (snapshot.ledger?.entries ?? []).length === 0; - - const approved = gatePass && mapClassified && briefReviewed && findingsReviewed; - const state = collectionFailed ? 'COLLECTION_FAILED' - : approved ? 'APPROVED_BRIEF' - : gatePass ? 'REVIEW_IN_PROGRESS' - : (mapClassified || briefReviewed || findingsReviewed) ? 'PREFLIGHT_BLOCKED' - : 'REVIEW_REQUIRED'; - - return { - state, - kind: approved ? 'APPROVED_RESEARCH' : 'COLLECTED_CORPUS', - buildAuthorized: approved, - review: { mapClassified, findingsReviewed, briefReviewed, by }, - gate: { - verdict: verdict === null ? 'NOT_RUN' : gatePass ? 'PASS' : blockingFindings.length ? 'FAIL' : 'INCOMPLETE', - buildAuthorized: approved, - blockingFindings, - }, - handoff, - verdict, - unrewritten, - corpus: snapshot, - }; -} - -/** - * A Finding cell identical to what the extractor produces from the same capture was - * never rewritten. Negative evidence only, and that is stated rather than implied: this - * cannot tell a good claim from a bad one, and does not try. - */ -export function findingsReviewState(root, corpus) { - const rows = corpus.evidence ?? []; - if (!rows.length) return { reviewed: false, unrewritten: [] }; - 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)); - 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 - // on run 36287211468: run over the whole FILE, the extractor read the front-matter as the - // page, never matched an untouched JSON capture's Finding, and a corpus nobody had - // reviewed was packaged as reviewed - one of the four conditions for buildAuthorized. - const { front, body } = parseCapture(text); - const extracted = String(firstFinding(body, front.title || front.url || row.url || '') ?? '').trim(); - if (!extracted) continue; - if (String(row.finding ?? '').trim() === extracted) unrewritten.push(row.id); - } - return { reviewed: unrewritten.length === 0, unrewritten }; -} - // ---------------------------------------------------------------- the human file /** How README-FIRST names the reviewer. A declaration, so the undeclared case says so. */ diff --git a/research-kit/test/artifact-producer.test.mjs b/research-kit/test/artifact-producer.test.mjs index b576eb9e..f7e94d8a 100644 --- a/research-kit/test/artifact-producer.test.mjs +++ b/research-kit/test/artifact-producer.test.mjs @@ -14,7 +14,7 @@ import { } from '../lib/artifact.mjs'; import { validateArtifact, MANIFEST_PATH, MANIFEST_DIGEST_PATH } from '../lib/artifact-validator.mjs'; import { openZip } from '../lib/artifact-zip.mjs'; -import { readCorpus, appendRow } from '../lib/corpus.mjs'; +import { readCorpus, appendRow, parseCapture } from '../lib/corpus.mjs'; import { writeRaw } from '../lib/collect.mjs'; import { firstFinding } from '../lib/finding.mjs'; import { collectedProject, approvedProject, IDENTITY } from './artifact-fixtures.mjs'; @@ -29,6 +29,19 @@ function build(root, overrides = {}) { return createArtifact({ root, ...IDENTITY, ...overrides }); } +test('a blank Raw cell cannot make an extractor-identical finding count as reviewed', () => { + const root = approvedProject(); + const corpus = readCorpus(root); + const row = corpus.evidence[0]; + const capture = corpus.captures.byFile.get(row.raw); + const { front, body } = parseCapture(readText(resolve(root, capture.file))); + row.finding = firstFinding(body, front.title || front.url || row.url); + row.raw = ''; + const review = findingsReviewState(root, corpus); + assert.equal(review.reviewed, false); + assert.ok(review.unrewritten.includes(row.id)); +}); + 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/artifact-validator.test.mjs b/research-kit/test/artifact-validator.test.mjs index 077fdc67..ff15eb9d 100644 --- a/research-kit/test/artifact-validator.test.mjs +++ b/research-kit/test/artifact-validator.test.mjs @@ -8,6 +8,8 @@ import { test, describe, assert, fs, path, os, cleanup, tempDir } from './harness.mjs'; import { sha256, canonicalJson } from '../lib/core.mjs'; import { validateArtifact, authorizationProblems, SUPPORTED_FORMAT_MAJOR } from '../lib/artifact-validator.mjs'; +import { legacyBriefInputsHash } from '../lib/artifact-state.mjs'; +import { readCorpus } from '../lib/corpus.mjs'; import { ZIP_LIMITS, openZip, checkEntryName } from '../lib/artifact-zip.mjs'; import { rawZip, seal, basePackage, collectedProject, approvedProject, writeFixture, @@ -18,7 +20,8 @@ describe('artifact-validator'); const scratch = tempDir('rk-fixtures-'); const collected = basePackage(collectedProject()); -const approved = basePackage(approvedProject()); +const approvedRoot = approvedProject(); +const approved = basePackage(approvedRoot); /** Deep clone through canonical JSON, so a mutation cannot leak into the next fixture. */ const clone = (value) => JSON.parse(canonicalJson(value)); @@ -26,6 +29,112 @@ const copyPayload = (payload) => payload.map((e) => ({ name: e.name, data: Buffe const text = (s) => Buffer.from(s, 'utf8'); +function legacyApproved({ formatVersion = '1.0.0', explicitUnknown = false, changedInput = false, unstamped = false } = {}) { + const manifest = clone(approved.manifest); + manifest.formatVersion = formatVersion; + const payload = copyPayload(approved.payload); + const brief = payload.find((entry) => entry.name === 'project/research/BRIEF.md'); + if (unstamped) { + brief.data = text(brief.data.toString('utf8').replace(/\n?\s*/g, '\n')); + } else { + const marker = brief.data.toString('utf8').match(//); + assert.ok(marker, 'approved fixture has a current draft marker'); + const inputs = legacyBriefInputsHash(readCorpus(approvedRoot)); + assert.equal(inputs, 'e0babc86cec671c9', 'the synthetic corpus matches the actual pre-0069 writer probe'); + brief.data = text(brief.data.toString('utf8').replace(marker[0], + ``)); + } + if (changedInput) { + const evidence = payload.find((entry) => entry.name === 'project/research/EVIDENCE.md'); + evidence.data = text(evidence.data.toString('utf8').replace('1,000 included credits', '1,001 included credits')); + } + return { payload, manifest }; +} + +test('claimed approval is refused when the packaged corpus has not earned it', () => { + for (const variant of ['unreviewed-corpus', 'opened-contract', 'blank-raw-extractor-finding']) { + const base = variant === 'unreviewed-corpus' ? collected : approved; + const manifest = clone(base.manifest); + const payload = copyPayload(base.payload); + manifest.state = 'APPROVED_BRIEF'; + manifest.kind = 'APPROVED_RESEARCH'; + manifest.buildAuthorized = true; + manifest.review = { ...manifest.review, mapClassified: true, findingsReviewed: true, briefReviewed: true, by: 'agent' }; + manifest.gate = { ...manifest.gate, verdict: 'PASS', buildAuthorized: true, blockingFindings: [] }; + if (variant === 'opened-contract') { + const entry = payload.find((file) => file.name === 'project/research/DISCOVERY.md'); + entry.data = text(entry.data.toString('utf8').replace('CLOSED', 'OPEN')); + } else if (variant === 'blank-raw-extractor-finding') { + const entry = payload.find((file) => file.name === 'project/research/EVIDENCE.md'); + const auto = collected.payload.find((file) => file.name === entry.name).data.toString('utf8'); + entry.data = text(auto.replace(/\| research\/raw\/[^|\r\n]+ \|/g, '| |')); + const brief = payload.find((file) => file.name === 'project/research/BRIEF.md'); + brief.data = text(brief.data.toString('utf8').replace(/\n?\s*/g, '\n')); + } + const file = writeFixture(scratch, `forged-${variant}`, seal(payload, manifest)); + const result = validateArtifact({ file, tempRoot: scratch }); + assert.equal(result.status, 'FAIL', variant); + assert.equal(result.buildAuthorized, false, variant); + assert.ok(result.errors.some((error) => error.code === 'AUTHORIZATION-INCONSISTENT'), JSON.stringify(result.errors)); + } +}); + +test('a true approval is re-derived from packaged bytes and reports its portable baseline', () => { + const file = writeFixture(scratch, 'portable-approved', seal(copyPayload(approved.payload), clone(approved.manifest))); + const result = validateArtifact({ file, tempRoot: scratch }); + assert.equal(result.status, 'PASS'); + assert.equal(result.buildAuthorized, true); + assert.ok(result.warnings.some((warning) => warning.code === 'PORTABLE-BASELINE'), JSON.stringify(result.warnings)); +}); + +test('a conservative false approval is never upgraded from a package the corpus could authorize', () => { + const manifest = clone(approved.manifest); + manifest.state = 'REVIEW_REQUIRED'; + manifest.kind = 'COLLECTED_CORPUS'; + manifest.buildAuthorized = false; + manifest.review = { ...manifest.review, mapClassified: false, findingsReviewed: false, briefReviewed: false }; + manifest.gate = { ...manifest.gate, verdict: 'NOT_RUN', buildAuthorized: false }; + const file = writeFixture(scratch, 'conservative-not-upgraded', seal(copyPayload(approved.payload), manifest)); + const result = validateArtifact({ file, tempRoot: scratch }); + assert.equal(result.status, 'PASS'); + assert.equal(result.buildAuthorized, false); + assert.ok(!result.warnings.some((warning) => warning.code === 'PORTABLE-BASELINE')); +}); + +test('a format-1 unstamped approved brief retains ADR-0138 compatibility with unknown currency', () => { + const { payload, manifest } = legacyApproved({ unstamped: true }); + const file = writeFixture(scratch, 'legacy-unstamped', seal(payload, manifest)); + const result = validateArtifact({ file, tempRoot: scratch }); + assert.equal(result.status, 'PASS', JSON.stringify(result.errors)); + assert.equal(result.buildAuthorized, true); + assert.ok(result.warnings.some((warning) => warning.code === 'PORTABLE-BASELINE')); + assert.ok(!result.warnings.some((warning) => warning.code === 'BRIEF-LEGACY-COMPATIBLE')); +}); + +test('a pre-0069 format-1 stamp is accepted only with its exact covered-input hash', () => { + const { payload, manifest } = legacyApproved(); + const file = writeFixture(scratch, 'legacy-stamp', seal(payload, manifest)); + const result = validateArtifact({ file, tempRoot: scratch }); + assert.equal(result.status, 'PASS', JSON.stringify(result.errors)); + assert.equal(result.buildAuthorized, true); + assert.ok(result.warnings.some((warning) => warning.code === 'BRIEF-LEGACY-COMPATIBLE'), JSON.stringify(result.warnings)); + + for (const variant of [ + ['changed-covered-input', { changedInput: true, formatVersion: '1.0.0' }], + ['explicit-unknown-is-not-legacy', { explicitUnknown: true, formatVersion: '1.0.0' }], + ['legacy-stamp-is-format-1-only', { formatVersion: '2.0.0' }], + ]) { + const [name, options] = variant; + const altered = legacyApproved(options); + const invalidFile = writeFixture(scratch, name, seal(altered.payload, altered.manifest)); + const refused = validateArtifact({ file: invalidFile, tempRoot: scratch }); + assert.equal(refused.status, 'FAIL', name); + assert.equal(refused.buildAuthorized, false, name); + assert.ok(refused.errors.some((error) => error.code === 'AUTHORIZATION-INCONSISTENT'), JSON.stringify(refused.errors)); + assert.ok(!refused.warnings.some((warning) => warning.code === 'BRIEF-LEGACY-COMPATIBLE'), name); + } +}); + /** * name -> { build, expect }. `expect.code` means "this code must be among the errors", * not "this is the only error": a package can be wrong in more than one way and pinning From b439cf94b14e293d148394559fd91b6dd0a9bc7c Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 19:13:11 +0300 Subject: [PATCH 20/27] fix(dispatch): diagnose GitHub rate-limited responses what changed: Classify rate-limited 403 responses from GitHub headers or message for dispatch and run reads; preserve Retry-After and reset time. why: Rate limits and token permissions both use 403, so diagnostics should identify the actual cause. No alternative design was set aside. what it touched: dispatch library/tests, architecture, changelog and test inventory; no retry policy or public interface. what you verified: verified - red-first 32 passes/1 expected failure, then 33 dispatch tests pass; handoff OK, preflight PASS, doctor READY. Full gate is running. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/dispatch.mjs | 24 ++++++++++++++++++++++++ research-kit/test/dispatch.test.mjs | 23 +++++++++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..d6838a78 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 + +- GitHub rate-limited 403 responses from dispatch and run reads report the wait and reset time instead of a token-permission remedy (#374). - 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..fdd97416 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -182,7 +182,7 @@ is an open decision (2026-09-30): | `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 | | `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 | +| `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). Dispatch and run reads classify a rate-limited 403 from headers or body as RATE_LIMITED, retaining Retry-After seconds and the UTC reset time; permission-only 403 remedies remain unchanged (#374). | 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. | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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/dispatch.mjs b/research-kit/lib/dispatch.mjs index 9d4dd8b3..906f63dd 100644 --- a/research-kit/lib/dispatch.mjs +++ b/research-kit/lib/dispatch.mjs @@ -118,6 +118,24 @@ async function readError(response) { return redact(body); } +async function rateLimitError(response) { + const body = await readError(response); + const remaining = response.headers?.get?.('x-ratelimit-remaining'); + const retry = response.headers?.get?.('retry-after'); + if (remaining !== '0' && retry == null && !/rate[ -]?limit/i.test(body)) return null; + const waits = []; + if (retry != null && /^\d+$/.test(retry)) waits.push(`wait at least ${retry} seconds (Retry-After) before retrying`); + const reset = response.headers?.get?.('x-ratelimit-reset'); + if (reset != null && /^\d+$/.test(reset)) { + const date = new Date(Number(reset) * 1000); + if (!Number.isNaN(date.getTime())) waits.push(`do not retry before ${date.toISOString()} (x-ratelimit-reset: ${reset} UTC epoch seconds)`); + } + return new DispatchError('RATE_LIMITED', `GitHub rate limited the request${body ? `: ${body}` : ''}`, { + status: response.status, + remedy: waits.length ? waits.join('; ') : 'wait at least one minute before retrying; back off further if the rate limit continues', + }); +} + // ---------------------------------------------------------------- dispatch /** @@ -192,6 +210,8 @@ export async function dispatchCollection({ }); } if (response.status === 403) { + const limited = await rateLimitError(response); + if (limited) throw limited; throw new DispatchError('FORBIDDEN', 'the token may not dispatch this workflow', { status: 403, remedy: 'the fine-grained token needs repository permission "Actions" set to read and write', @@ -255,6 +275,10 @@ export async function getRun({ repository, runId, token, fetch: doFetch = global const response = await callApi(doFetch, `${api}/repos/${owner}/${name}/actions/runs/${runId}`, { headers: headers(token) }, { api, remedy: `check connectivity, then retry with --run-id ${runId}` }); if (!response.ok) { + if (response.status === 403) { + const limited = await rateLimitError(response); + if (limited) throw limited; + } // A refusal names its own way out; the caller's generic "pick it up again" would send // somebody back to a run number that does not exist, with a token that cannot see it. const remedy = response.status === 404 ? `no run ${runId} on ${repository}, or the token cannot see it - check the number the dispatch printed` diff --git a/research-kit/test/dispatch.test.mjs b/research-kit/test/dispatch.test.mjs index 1c4aa50d..d43fffbb 100644 --- a/research-kit/test/dispatch.test.mjs +++ b/research-kit/test/dispatch.test.mjs @@ -152,6 +152,29 @@ test('404 and 403 get remedies that name the real cause', async () => { (err) => err.code === 'FORBIDDEN' && /Actions/.test(err.remedy)); }); +test('rate-limited 403 responses name the wait instead of token permissions', async () => { + const cases = [ + [{ 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '2000000000' }, 'Forbidden', /2000000000/], + [{ 'retry-after': '60' }, 'Forbidden', /60 seconds/], + [{}, 'You have exceeded a secondary rate limit.', /at least (?:one minute|60 seconds)/], + ]; + for (const [headerValues, message, wait] of cases) { + for (const call of [ + (fetch) => dispatchCollection({ repository: REPO, token: TOKEN, fetch }), + (fetch) => getRun({ repository: REPO, runId: 7, token: TOKEN, fetch }), + ]) { + const fetch = stubFetch([jsonResponse(403, { message }, { headers: new Headers(headerValues) })]); + await assert.rejects(() => call(fetch), (error) => { + assert.equal(error.code, 'RATE_LIMITED'); + assert.equal(error.status, 403); + assert.match(error.remedy, wait); + assert.doesNotMatch(error.remedy, /permission|Actions/); + return true; + }); + } + } +}); + test('no error message ever carries the token, whatever the server said', async () => { // The server echoing the credential back is the case a hand-written message forgets. const leaky = stubFetch([jsonResponse(500, { message: `bad credentials for ${TOKEN}` })]); From 7a73d9d22c5ab6c6ef3ce78de6475d694700590a Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 19:33:29 +0300 Subject: [PATCH 21/27] fix(config): distinguish held snapshots from broken sources what changed: Keep source as the broken machine config and carry snapshotSource separately. Doctor reports the held snapshot and effective posture/role; collection refusal names an unknown role from that snapshot. why: Snapshot resolution previously named the good file as broken and confused configured fail-closed with fallback. what it touched: machine/doctor libraries and tests, architecture, changelog and README inventory. Resolved settings and collection policy are unchanged. what you verified: verified - red-first 88 passes, 3 expected failures; then 91 doctor/machine tests pass. Handoff OK, preflight PASS, doctor READY. Full gate is running. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/doctor.mjs | 5 ++++- research-kit/lib/machine.mjs | 6 +++--- research-kit/test/doctor.test.mjs | 16 ++++++++++++++++ research-kit/test/machine.test.mjs | 13 ++++++++++++- 7 files changed, 39 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..065e0e6f 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 + +- Broken machine-config diagnostics name the actual config and distinguish settings held by a last-good snapshot from fail-closed fallback (#366). - 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..b0d73ea6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -228,7 +228,7 @@ is an open decision (2026-09-30): | `panel.mjs` | **The desktop panel** (ADR-0147, 2026-10-07): `createPanel({ project, env, kitRoot, kitHome })` serves `research-kit/panel/` (one page, a script, a stylesheet) and a small JSON API on **127.0.0.1 only**, a thin shell over commands that already exist. It owns no verdict: doctor, handoff, preflight and install run as children with the fixed argument lists in `PANEL_COMMANDS`, in the chosen project folder, and their output is shown with the search key redacted. **No command from the page collects** - `research.mjs` and `decompose.mjs` are absent from `PANEL_COMMANDS`; the one way pages are fetched through the panel is auto-collect (`lib/auto-collect.mjs`, ADR-0148), for a builder's validated request, on a collector, within the operator's caps. Its routes are `GET /api/requests` (the queue and the meter), `POST /api/autocollect` (the settings), `/api/requests/check` and `/api/requests/resume`. It writes two things: the search provider's key into the machine config through `transport.mjs`'s `SEARCH_KEY` (so the vendor's name stays in the adapter and the registry, NFR-3), refused when the config resolves inside a git repository (Rule 6); and which project it shows, a full path to an existing folder, never searched for. The topic and `BRIEF.md` are read, never written (ADR-0056). `builderInstructions` is the hand-off text: the collector's push, ledger included, then the builder's own steps in `$HOME` spelling (ADR-0050) - declare the role, handoff, preflight, inspect an authored complete brief and its current inputs, never collect. Its readiness checklist requires classified map rows and rewritten Findings; a file-presence observation grants no build authorization. Commit-report placeholders require actual observed results. A collected Git-request result waits for the collector to record review/closure before dependent building. Every request passes four checks: the bound address, a Host header naming it (DNS rebinding), an Origin, when sent, that is its own, and for `/api/` the per-launch token, initialized through the launch URL fragment and required in x-panel-token. The launcher prints that capability URL; terminal-log and browser-history privacy are not guaranteed by this module. POSTs must be JSON, so a plain cross-site form is refused. Update is refused when the panel runs from the installed copy, which has nothing newer to install. Command runs, the project switch and auto-collect cycles share one lock (`running`, handed to `createAutoCollect` as `lock`): none runs beside another, so no preflight or update reads the corpus or the kit tree while a cycle writes it. | ADR-0147, ADR-0148 | | `requests.mjs` | **A builder's fact request as a file** (ADR-0148, 2026-10-07): `research/requests/.json`, the `fact-request` skill made machine-readable. `requestProblems(request, { perRequestPages })` validates it as untrusted input: the fixed fields (`fact`, `blocks`, `urls`, `queries`, `prefer`, `unknown`, `maxPages`, `topic`), bounded sizes, and `urlProblem` refuses a non-http(s) URL, one with a user name or password, and one naming this machine or an internal address (`isInternal` from `http-transport.mjs`, ADR-0110). A request over the per-request cap is refused, not trimmed. `listRequests` reads the folder, skipping the collector's own `.result.json` and `.plan.json` and ignoring a name that is not an id, and a request whose file, `.plan.json` or `.result.json` is a symbolic link (a builder's link would make the collector read or overwrite a file of its own machine; `writeText` follows links). `applyRequest` and `writeResult` refuse to write through a link as well. `applyRequest` writes the contract row (a new OPEN `U-n`, or an existing non-CLOSED row; it refuses requests naming a closed unknown) and the `plan.json` entries, and the plan for that run alone, with `maxScrapes` set to the request's pages. `contractUnknownStatus` reads the status of a contract row for continuation checks. `writeResult` records the outcome beside the request. Terminal and partial results are trusted only when `recordTrustedResult` has recorded hashes of both request and result in the machine-local `research-kit.autocollect.json`; `isTrustedResult` checks those hashes so a builder cannot submit a result as if the collector wrote it. The auto-collect settings (`off`/`auto`, pages a request, pages a day, interval, the folder for new topics) and the daily meter live in `research-kit.autocollect.json` beside the machine config (`readAutoCollect`, `saveAutoCollect`, `recordSpend`, `setPaused`); no key is in them. | ADR-0148 | | `auto-collect.mjs` | **The collector listening** (ADR-0148, 2026-10-07): `createAutoCollect({ project, env, kitRoot, exec, lock })` returns `cycle`, `status`, `schedule`, `resume`, `stop`. A cycle resolves the project folder to its final path (`fs.realpathSync.native`) before comparing it with git's top level, so a Windows 8.3 short name or a symlinked folder cannot turn a path git stages into one outside the repository. `status()` uses that same canonical project path so its trust lookup matches collector records across alternate Windows path spellings. A cycle refuses unless `collectionPolicy` says the machine may collect, and skips while another run holds the shared `lock` (the panel's; its own when none is given). Otherwise it runs `git pull --ff-only`; a failed pull is surfaced and blocks the cycle before requests are read or processed. On success, it goes through each request. Results received over git are ignored unless their request and result hashes match collector-local trust records, including in `status()`; trusted terminal results retry pending commit/push delivery without collecting again, and trusted `partial` results carry continuation state and resume only while their contract unknown is not CLOSED. An invalid one gets a refused result, committed and pushed. A valid one, within the per-request and daily caps and with `research/` clean, is applied (`requests.mjs`), then its target-specific `research/requests/.plan.json` runs, followed by `preflight.mjs`, whose verdict is recorded, not acted on. It then stages `research/` and the ledger by name (`git add -f`), commits, and pushes when there is an upstream. Accounting travels on the fixed Node research child's private IPC descriptor (ADR-0149), never its human output: exactly one bounded, typed report with measured pages within the admitted allowance and the exhaustion provider. Missing, invalid, duplicate or over-allowance reporting reserves the remaining admitted allowance against the daily meter, labels actual spend unknown, records a terminal failed result and pauses. Earlier measured continuation attempts stay counted once. A valid received report survives failed exit or later interruption; interruption also pauses. Diagnostic output keeps a bounded redacted tail with a truncation notice, and failure detail is bounded separately. The legacy `spentPages`/`creditsStopped` exports remain diagnostic compatibility helpers. It never passes `--fallback`: received exhaustion accounting makes the loop commit the partial corpus and continuation state, pause until the operator resumes, then reuse the saved target, unknown and remaining page allowance (ADR-0129). A `topic` request gets `new-project.mjs` and `decompose.mjs --max-scrapes 0` in a dated folder under the folder the operator approved, never over an existing project (ADR-0056). It is refused when no folder is approved. Diagnostic details, refusal reasons and preflight verdicts are scrubbed with the existing redactor before result files or commit reports are written (2026-10-08). Status also scrubs inherited trusted result details, delivery diagnostics and pause text without rewriting those files or changing their trust hashes; the configured redactor defines which secret is recognized. Every child gets a fixed argument list; `exec` is injectable for tests. The panel labels its unchanged numeric meter as pages used or reserved. **Allowance regression coverage** (2026-10-09): the four-cycle credit exhaustion and resume fixture runs real synthetic Node scripts against persisted request, plan, result and meter files, with a finite injected Git executor. Native round-trip, partial-delivery and topic-resume fixtures retain real Git and corpus-transfer assertions. The test watchdog remains 60 seconds. **Complete Git answers** (ADR-0151): the bounded child byte buffer reports a private `truncated` flag. Repository and Git metadata paths, upstream, ahead count, commit subject and changed paths, dirty state, and pending delivery status/diff all refuse truncated output before it can authorize work; a malformed ahead count or missing quiet-diff exit also blocks delivery. A newly written partial result is trusted before fingerprinting, so refused inspection stages nothing and cannot make a restart collect it again. Exit-only operations retain bounded diagnostic logs. **Admission survives collector termination** (ADR-0152): `reserveCollection` saves the newly admitted allowance and an owned in-flight pause atomically before the fixed research child starts; a failed meter write prevents spawn. `settleCollection` refunds unused allowance once on a valid receipt, preserves earlier spend and distinct pauses, and retains the full charge for unknown/spawn-failed attempts. Whole collector termination leaves its charge/pause across restart or project switch. Continuation reserves only its remaining newly admitted pages. Day-aware settlement never refunds yesterday against today; cross-day completion charging is conservative, and an over-cap completion remains paused. The existing file writer is atomic, while the mutex remains per collector instance; racing independent instances and power-loss durability are not certified by this correction. Successful result.next says captures were delivered: the builder reads/judges, the collector records Git Findings, closures, MAP and the current authored BRIEF; another fetch is only for a missing external fact. ADR-0052 local received-package review remains available, and derived approval stays role-agnostic (2026-10-08, ADR-0150). | ADR-0148, ADR-0149, ADR-0150, ADR-0151, ADR-0152 | -| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). | ADR-0002, ADR-0010, ADR-0012 | +| `machine.mjs` | The config carries `searxngUrl` (2026-09-30, ADR-0104): a URL, not a secret. `shape()` keeps only the non-empty strings of `skillRoots`: every other key it reads is type-checked, and this one was checked as an array and passed its contents to `path.join`, so `{"skillRoots":[42]}` killed `doctor` with a raw ERR_INVALID_ARG_TYPE stack (break-test 2026-10-01). A dropped entry is silent; naming it would be a new finding, which the freeze does not allow. `editGate.settingsPath` is taken only as a non-empty string, else the anchor (2026-10-02, arena #198): it was the one key `shape()` passed through raw, and a numeric value reached `path.dirname` in `installEditGate` and killed `install-hooks` with a raw stack - the sibling of the `skillRoots` check, found a day later because the first fix was never swept to it. **An ill-typed posture key resolves to its restrictive state** (ADR-0121, 2026-10-02): `shape()` resolves a `failOpen`, `evidencePolicy` or `editGate.mode` that is present and not of its type to `false`, `strict` and `hard-block` through one `postureKey` helper, lists it in `illTyped`, and `readMachineConfig`/`posture` carry `illTypedKeys` beside `retiredKeys` on every path, the snapshot one included. Absent keys keep their defaults. Until then a typo fell to the lax default, the only place a typo relaxed a gate in silence. `browserPath` is read and kept (2026-10-11): it was missing from `shape()`, so the key the browser transport's errors name was never read, and every `saveConfig` - `install-hooks --role` among them - erased it. | **Everything outside the project**: machine config (fail-open posture, `editGate` mode and settings path, `evidencePolicy`, `role`), read in **three states** — `absent` (nothing configured; the fail-open default is the answer), `readable`, and **`unreadable`** (the file exists and does not parse), which holds the last config that parsed from a snapshot beside it and, with nothing to hold, **fails closed**. `readMachineConfig()` returns the settings *and* `state`/`source`/`error`, because the settings alone cannot say whether fail-closed was configured or is the fallback this machine adopted (ADR-0020). Install state, git `core.hooksPath` (scope-aware readers), and the **runtime anchors** — `RUNTIME_ANCHORS`, the one place a runtime's paths are named (settings file, personal skill roots, project-relative skill dir), each a default that `runtimePaths()` / `skillLocations()` let the machine config override (ADR-0012); Git readers/writers accept an explicit child environment, preserving production defaults while allowing R1's disposable machine/Git fixture to isolate global/system origins; reads a retired config key's value into the current shape rather than Its six home-based paths (settings, skills, `agentsHome`, `kitHome`, the config, the install state) use `homeDir()`, so `HOME=""` no longer makes them relative (2026-09-28). dropping a setting the operator had tightened, and reports a retired ENVIRONMENT variable (`CLAUDE_SETTINGS_PATH`) without ever honouring it. Owns the machine role and the collection policy that follows from it (`machineRole`, `collectionPolicy`, `collectionRefusal`) — the single answer to "may this machine collect?". The only module that reaches past the project root. **`localHooksPathOverride` asks git from `cwd`** (2026-10-03, outside audit): git finds the repository by walking up, so a subdirectory - a nested decision project, the folder install-hooks is run from - is governed by the same local `core.hooksPath` as the root; the probe looked for `.git` beside `cwd` and said `null` below it, and `doctor`'s `gateHealth` goes through the same probe now. `previousHooksPath(opts)` reads the folder the install replaced (2026-10-03), the reader beside `setPreviousHooksPath`, with `--type=path` as `hand-on.sh` reads it - the raw value made doctor call a working `~/custom-hooks` missing (second audit, 2026-10-03). `repoTopLevel(opts)` is `git rev-parse --show-toplevel` from a cwd, or null outside a repository (2026-10-03). `skillRoots(env)` is the personal skill roots - the config's `skillRoots`, else the anchor's - which `skillLocations()` and the skill set's deploy both read (2026-10-06, ADR-0146). Snapshot resolution keeps source naming the broken config and carries snapshotSource separately; doctor and collection refusal distinguish held settings/role from fallback-closed instead of claiming the snapshot failed to parse (#366). | ADR-0002, ADR-0010, ADR-0012 | | `handoff.mjs` | The **arrival question**: `verifyHandoff(root)` reports a ledger that is absent or empty, an `EVIDENCE.md` that is absent or holds no evidence table (`handoff-evidence-missing`, `handoff-evidence-unparsed`; the report carries `rows`, and the CLI and doctor print the count - 2026-10-02: with no rows there were no citations, so "every cited capture on disk" held vacuously and handoff said OK over a corpus whose table a sync tool had dropped), every capture an evidence row names that is not on disk, and a chain that does not verify — each named. Read-only by design: the machine that asks cannot collect the missing bytes. The report also carries `lineEndings`, the captures whose body hash fails *only* because git rewrote CRLF into them on checkout, and **`handoffRemedy(report)` picks the remedy from the cause** rather than printing one text for every way a corpus can look broken (ADR-0020): `HANDOFF_REMEDY` when something did not travel (the collector must push `research/raw/`, dotfiles included), `lineEndingRemedy` when everything travelled and this machine rewrote it (pin with `.gitattributes`, re-check out, touch no collector and spend no credits), both when a report holds both. Consumed by `bin/handoff.mjs` and doctor (blocker on `role=builder`, silent on a collector; doctor passes the snapshot it already read, so the question is composed once for both). The report carries `nothingCollected` (no ledger entry, no evidence row, no capture) since 2026-09-27, so a caller can tell a project never collected from a corpus that failed to travel. The finding names are unchanged, so the doctor and the artifact validator read them as before. The report also carries `brief: { state, todo }` (`briefReview`, 2026-09-27, ADR-0061): the CLI's "handoff OK" is followed by a note when the brief is a draft (naming the judged sections still TODO) or has not been drafted. It is never a finding, and the exit stays 0. **The line-ending remedy is judged by running it** (2026-09-27, ADR-0062): per affected capture it prints `git rm --cached` (the index entry only) then `git checkout HEAD --`, which rewrites the file as LF through the pin, and it prints the pin (`PIN_LINES`, to add in an editor) only when `.gitattributes` lacks it. Every printed command is a plain git command with no comment, so cmd and PowerShell run it as written (ADR-0070). `--renormalize` had fixed only the index, so the capture handoff reads stayed CRLF. A **third cause** (2026-09-28): a ledger present and empty while captures are on disk lost its entries where the captures are - a one-line ledger whose torn tail `doctor --fix-arity` dropped - so `ledgerLost` gets `ledgerLostRemedy()`: restore it from git, or re-collect with `--force`. It was given the push remedy, which on the collector pushes the same empty ledger. A ledger that is merely **short** is the same cause: a cited capture on disk that no entry records is `handoff-capture-unledgered` (2026-09-28) and gets the same remedy - handoff had said OK and exited 0 while preflight failed it as `fetch-entry-exists`. **`CORPUS_ADD`** (2026-09-28) is how the kit prints putting a corpus into git - `git add research/`, then `git add -f research/raw/.fetches.jsonl` - shared by the push remedy and the commit gate's fix: both said `git add -f` on the whole folder, which also committed the machine-local `.usage.jsonl`, `.failures.jsonl`, `.diagnostics.jsonl` and `.fetches.lock`. A **fourth cause** (2026-10-04, break-test pass 4, F5b): a capture whose body hash fails and NOT by line endings (`body-unmodified/modified`) was changed after its fetch, and the report carries it as `altered`; it no longer counts as "did not travel", and **`alteredRemedy(files, { isRepo })`** prints, per file, `git status --porcelain --` to tell a local change from a committed one, then `git checkout HEAD --` followed by handoff again (no folder-wide checkout, which would discard uncommitted work beside it), and for the committed case `git log --oneline --` to find the commit that held the fetched bytes and restore from it, here or on the collector; without git metadata it prints no command. Judged by running it (2026-10-04, external review): a dirty status says only that this checkout changed the file after the commit - a capture committed altered and edited again is dirty too - so the remedy no longer claims the committed copy is the fetched one; and `--force` is no longer offered, because a new fetch writes a new capture beside this one, or refuses to overwrite a differing source sibling, while `verifyLedger` still checks the entry that names this file. The push remedy it used to get sends the same bytes again when the change was committed, and sends an operator to another machine when the committed copy here was one checkout away. **`safePathspec(file)`** (2026-10-04, two external reviews) decides whether a capture path is printed inside a git command at all: letters, digits, `.`, `_`, `-` and `/`, the alphabet the collector's own names come from. Anything else is named but never printed as a command, because no one spelling is read literally by cmd, PowerShell and sh alike and by git: a space unquoted is two pathspecs, `$copy` inside double quotes is expanded to nothing by PowerShell, and `[12]` is a git glob whatever the shell did, so `git checkout HEAD --` restored a neighbour and discarded its uncommitted work. For those the remedy prints commands that type no name at all: the operator lists them, one per line, in `handoff-names.txt` (`NAMES_FILE`) and runs `git --literal-pathspecs checkout HEAD --pathspec-from-file=handoff-names.txt` (the line-ending remedy adds the `git rm --cached` step the same way) - "quote it for your shell and prefix `:(literal)`" was not enough, because cmd expands `%NAME%` inside double quotes and PowerShell `$name` (third external review, 2026-10-04, verified by running in cmd); `--pathspec-from-file` hands git the bytes of the file with no shell in between, and `--literal-pathspecs` switches pattern syntax off (both git 2.25+). Such a name reaches the ledger rarely but not only by hand: `captureName` embeds the URL's host as it is, so an IPv6 literal or an adapter-supplied host outside DNS's alphabet produces one (third review). The classification runs over EVERY affected file (`classify`), not the five the text names: an unsafe sixth file got no note and "handle the remaining files the same way, each by name" - an instruction to type the glob that restores a neighbour (third external review, 2026-10-04). **`insideRepository(root)`** (2026-10-04, two external reviews) decides whether the runnable remedies are printed, and it asks git: `git -C rev-parse --is-inside-work-tree`. It was `root/.git` alone, so a nested decision project (ADR-0030), which has none of its own, was told it had "no git metadata"; then a walk up for a `.git` entry, which walked past a bare repository the project sat inside and printed a checkout git refused with "this operation must be run in a work tree". The commands are git's, so git decides whether they run; without git on PATH the walk is the fallback: it walks the PHYSICAL path (realpath first - a project reached through a junction into a repository had read as outside it, and one linked out of a repository as inside; third review) and reads `GIT_CEILING_DIRECTORIES` as git does: absolute entries only, each through realpath until an empty entry, after which the rest are taken as written. Git's pathspecs are relative to the cwd, so the printed commands run unchanged from the project folder. The tests that want "no git metadata" from a scratch fixture set `GIT_CEILING_DIRECTORIES` above it and go through `handoffRemedy`, so a detection that always says "repository" fails them. **`repositoryState(root)`** is the full answer, `{ inside, refused }`: when git answered with something other than yes, "not a git repository" or "must be run in a work tree" - `fatal: detected dubious ownership` - `refused` carries its first line and both remedies say git refused to read the repository and to fix that first, where they said "no git metadata, re-copy the corpus" (third review, 2026-10-04); no command is printed either way. | ADR-0011, ADR-0020 | | `bundle.mjs` | The **archive this project arrived as**, and how far the tree has moved from it. `BUNDLE_INDEX.md` lists 80 SHA-256 digests taken at the 2026-09-17 handover; until ADR-0028 **nothing had ever read them**, which is how ADR-0022 could treat them as a constraint for three days without noticing the constraint was already violated. `verifyBundle(root)` sorts every entry into unchanged / drifted / unexpected / missing, where "drifted" means a file in `EXPECTED_TO_DRIFT` — the corpus, the map, the ADR index, the manifest itself, (from 2026-09-20) `CONTEXT.md` and the root `README.md`, which are documentation ABOUT the code and go stale the moment it moves, and (from 2026-09-21) `AGENTS.md`, which is the file the agent protocol is *written in* — caught drifting by this check when ADR-0030 gave decision research a committed home and the rule that sends an agent there had to be where agents read their rules — each of which moves because one of this project's own rules *requires* it to. The list is deliberately narrow: exempting a planning document would leave the report unable to say anything, and a test asserts none is exempted. Hashes **bytes**, not decoded text. Reports; never fails a build — a changed file is a fact about history, and the only thing that makes it a problem is nobody knowing. `bin/bundle.mjs --json` prints `verifyBundle`'s result as JSON (2026-09-28): the flag was accepted and never read, so a caller parsing it got the markdown report. `START_HERE.md` joined `EXPECTED_TO_DRIFT` on 2026-09-29: the archived copy held the author's machine paths, and the portable one is the template rendered with `$HOME`. | ADR-0028 | | `git-config.mjs`, `config.mjs` | Compatibility shims → `machine.mjs`. | ADR-0002 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/doctor.mjs b/research-kit/lib/doctor.mjs index 80353be0..f265306c 100644 --- a/research-kit/lib/doctor.mjs +++ b/research-kit/lib/doctor.mjs @@ -47,8 +47,11 @@ export function machineHealth({ env = process.env, gitPaths = {}, probe = probeF const post = posture(env); if (read.state === 'unreadable') { + const resolution = read.resolvedFrom === 'snapshot' + ? `Posture (${post.failOpen ? 'fail-open' : 'fail-CLOSED'}) and role (${read.settings.role}) held from ${read.snapshotSource}, the last config that parsed.` + : 'There is no last-good snapshot: fail-CLOSED, role unknown.'; out.push(f('critical', 'machine-config', - `${read.source} exists and does not parse (${read.error}). Resolved posture: ${post.failOpen ? 'fail-open from the last config that parsed' : 'fail-CLOSED, because there was nothing to hold'}.`, + `${read.source} exists and does not parse (${read.error}). ${resolution}`, 'repair or delete the config file; the kit keeps a .last-good snapshot beside it')); } else { out.push(f('pass', 'machine-config', `${read.state} (${read.source}); posture: ${post.failOpen ? 'fail-open' : 'fail-closed'}`)); diff --git a/research-kit/lib/machine.mjs b/research-kit/lib/machine.mjs index 59d715ac..494938ab 100644 --- a/research-kit/lib/machine.mjs +++ b/research-kit/lib/machine.mjs @@ -205,7 +205,7 @@ export function readMachineConfig(env = process.env) { const snapshot = readJson(lastGoodPath(file), null); if (snapshot) { const { settings, retired, illTyped } = shape(snapshot); - return { settings, state: 'unreadable', resolvedFrom: 'snapshot', source: lastGoodPath(file), error: err.message, retiredKeys: retired, illTypedKeys: illTyped }; + return { settings, state: 'unreadable', resolvedFrom: 'snapshot', source: file, snapshotSource: lastGoodPath(file), error: err.message, retiredKeys: retired, illTypedKeys: illTyped }; } // Nothing to hold: fail CLOSED, and the ROLE becomes `unknown` rather than the // default. The old "every config was previously snapshotted" rationale does not @@ -318,9 +318,9 @@ export function collectionPolicy(env = process.env) { return { role, mayCollect: false, - reason: read.state === 'unreadable' + reason: read.resolvedFrom === 'fallback-closed' ? `this machine's role cannot be established: ${read.source} does not parse (${read.error}) and there is no last-good snapshot` - : `this machine's config names a role the kit does not know ("${readJson(read.source, {})?.role ?? 'unreadable value'}")`, + : `this machine's ${read.resolvedFrom === 'snapshot' ? `last-good snapshot (${read.snapshotSource})` : 'config'} names a role the kit does not know ("${readJson(read.snapshotSource ?? read.source, {})?.role ?? 'unreadable value'}")`, remedy: `repair the config, or declare the role: ${kitCommand('install-hooks.mjs', '--role collector|builder')}`, }; } diff --git a/research-kit/test/doctor.test.mjs b/research-kit/test/doctor.test.mjs index e05704bc..81bca9b8 100644 --- a/research-kit/test/doctor.test.mjs +++ b/research-kit/test/doctor.test.mjs @@ -151,6 +151,22 @@ test('an unreadable machine config is a blocking CRITICAL that names the resolve assert.match(finding.detail, /fail-CLOSED/); }); +test('a held config names the broken file and distinguishes its snapshot from fallback', () => { + for (const failOpen of [false, true]) { + const { env, configFile } = machine({ config: { failOpen, role: 'builder' } }); + machineHealth({ env, probe: READY }); // Save the last readable config. + writeText(configFile, '{broken'); + const finding = find(machineHealth({ env, probe: READY }), 'machine-config'); + assert.equal(finding.severity, 'critical'); + assert.ok(finding.detail.startsWith(`${configFile} exists and does not parse`)); + assert.ok(finding.detail.includes(`${configFile}.last-good`)); + assert.match(finding.detail, /held from/); + assert.match(finding.detail, failOpen ? /fail-open/ : /fail-CLOSED/); + assert.match(finding.detail, /builder/); + assert.doesNotMatch(finding.detail, /nothing to hold|no last-good snapshot/); + } +}); + test('a retired config key and a retired env var are both reported', () => { const { env, configFile } = machine({}); writeText(configFile, JSON.stringify({ claudeGate: 'hard-block' })); diff --git a/research-kit/test/machine.test.mjs b/research-kit/test/machine.test.mjs index 251d7851..38b1c958 100644 --- a/research-kit/test/machine.test.mjs +++ b/research-kit/test/machine.test.mjs @@ -64,11 +64,13 @@ test('an ABSENT config still reads as collector - ADR-0010\'s default is untouch }); test('unreadable: a truncated config holds the last one that parsed - every knob, not just failOpen', () => { - const { env } = envWith('{"failOpen": fal', { + const { env, file } = envWith('{"failOpen": fal', { snapshot: JSON.stringify({ failOpen: false, role: 'builder', evidencePolicy: 'strict', editGate: { mode: 'hard-block' } }), }); const read = readMachineConfig(env); assert.equal(read.state, 'unreadable'); + assert.equal(read.source, file, 'source names the broken config'); + assert.equal(read.snapshotSource, `${file}.last-good`); assert.equal(read.settings.failOpen, false, 'a hardened machine must not silently revert to fail-open'); assert.equal(read.settings.role, 'builder', 'a builder must not silently become a collector - that role may spend credits'); assert.equal(read.settings.evidencePolicy, 'strict'); @@ -76,6 +78,15 @@ test('unreadable: a truncated config holds the last one that parsed - every knob assert.ok(read.error, 'the parse error is carried, not swallowed'); }); +test('an unknown role held from a snapshot names its value and actual source', () => { + const { env, file } = envWith('{broken', { snapshot: '{"role":"collecter"}' }); + const policy = collectionPolicy(env); + assert.equal(policy.mayCollect, false); + assert.ok(policy.reason.includes(`${file}.last-good`)); + assert.match(policy.reason, /collecter/); + assert.doesNotMatch(policy.reason, /no last-good snapshot|does not parse/); +}); + test('unreadable with nothing to hold fails CLOSED, and tightens exactly two knobs', () => { const { env } = envWith('{"failOpen": fal'); const read = readMachineConfig(env); From 63e0e527053b5a0a30ce52e6229eb51ab065630d Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 19:53:29 +0300 Subject: [PATCH 22/27] fix(artifact): reject invalid format versions as malformed what changed: Require a string numeric dotted version before classifying its major. Missing, ill-typed, malformed and below-oldest versions yield MANIFEST-SCHEMA/FAIL with a regeneration remedy; only newer majors yield FORMAT-UNSUPPORTED/INCOMPLETE. why: Invalid manifests were incorrectly described as newer packages with an upgrade remedy. Preserve the distinction between corruption and an unimplemented format. what it touched: artifact validator/tests, architecture, changelog and README test inventory. No supported format or exit-code mapping changed. what you verified: verified - red-first 45 passed/1 expected failure; then 46 artifact-validator tests passed. Handoff OK, preflight PASS, doctor READY. Full gate is running. what you got wrong and fixed: nothing to report. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/artifact-validator.mjs | 14 ++++++++++---- research-kit/test/artifact-validator.test.mjs | 19 +++++++++++++++++++ 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..3161f135 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 + +- Missing, malformed and below-oldest artifact format versions are invalid manifests; only newer numeric majors receive the upgrade remedy (#361). - 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..041e778f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,7 +178,7 @@ is an open decision (2026-09-30): | `config.mjs` | A **compatibility shim**, nine lines, re-exporting `machine.mjs`'s config surface so an older import path keeps working. It adds nothing and decides nothing. Listed because it was the one module in `lib/` this map did not mention — found by counting files against rows during the 2026-09-20 reconciliation, which is the only way that kind of omission surfaces. | ADR-0002 | | `archive.mjs` | **One container format**: `buildZip(entries)` → bytes, `writeZip(file, entries)` → `{ file, bytes, entries }` (written whole or not at all, through `writeBytes`, since 2026-09-28), `crc32`, `entryName`. ZIP with deflate (store when deflating would only grow the payload), 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-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. Missing, ill-typed, malformed and below-oldest format versions are MANIFEST-SCHEMA/FAIL with regeneration as the remedy; only a numeric format major newer than this reader is FORMAT-UNSUPPORTED/INCOMPLETE (#361). | 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 | | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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-validator.mjs b/research-kit/lib/artifact-validator.mjs index 656646f9..456f08c0 100644 --- a/research-kit/lib/artifact-validator.mjs +++ b/research-kit/lib/artifact-validator.mjs @@ -181,17 +181,23 @@ export function validateArtifact({ } // ---- format version, before schema ------------------------------------------ - // An unsupported MAJOR is reported distinctly from an invalid manifest: a v2 package + // A newer MAJOR is reported distinctly from an invalid manifest: such a package // is probably correct and simply newer than this reader, and calling that "invalid" // would send somebody hunting a corruption that is not there. - const version = String(manifest?.formatVersion ?? ''); - const major = Number(version.split('.')[0]); + const version = manifest?.formatVersion; + const major = typeof version === 'string' && /^\d+\.\d+(?:\.\d+)?$/.test(version) + ? Number(version.split('.')[0]) : NaN; if (manifest?.format !== 'research-kit-artifact') { reject('FAIL', 'MANIFEST-SCHEMA', `${MANIFEST_PATH} does not declare format "research-kit-artifact"`, { path: MANIFEST_PATH, remedy: 'this is not a Research-Kit artifact' }); return report(); } - if (!Number.isInteger(major) || major < OLDEST_FORMAT_MAJOR || major > SUPPORTED_FORMAT_MAJOR) { + if (!Number.isInteger(major) || major < OLDEST_FORMAT_MAJOR) { + reject('FAIL', 'MANIFEST-SCHEMA', `${MANIFEST_PATH} has a missing, malformed or below-oldest formatVersion`, + { path: MANIFEST_PATH, remedy: 'regenerate the artifact' }); + return report(); + } + if (major > SUPPORTED_FORMAT_MAJOR) { reject('INCOMPLETE', 'FORMAT-UNSUPPORTED', `this package declares format version ${version || '(none)'}; this validator reads majors ${OLDEST_FORMAT_MAJOR} to ${SUPPORTED_FORMAT_MAJOR}`, { path: MANIFEST_PATH, remedy: `upgrade Research-Kit to a version that reads format ${major || '?'}.x` }); diff --git a/research-kit/test/artifact-validator.test.mjs b/research-kit/test/artifact-validator.test.mjs index 077fdc67..c33f08d0 100644 --- a/research-kit/test/artifact-validator.test.mjs +++ b/research-kit/test/artifact-validator.test.mjs @@ -431,6 +431,25 @@ test('SUPPORTED_FORMAT_MAJOR is 2, and a 3.x package is INCOMPLETE rather than i 'a newer package is probably correct and merely unreadable here; calling it invalid sends somebody hunting a corruption that is not there'); }); +test('malformed and below-oldest format versions are invalid manifests, not newer formats', () => { + for (const [index, version] of [undefined, null, 3, ['3.0'], '', 'abc', '0.9.0', '1e2.0', '3.nope'].entries()) { + const manifest = clone(collected.manifest); + if (version === undefined) delete manifest.formatVersion; + else manifest.formatVersion = version; + const file = writeFixture(scratch, `invalid-version-${index}`, seal(copyPayload(collected.payload), manifest)); + const result = validateArtifact({ file, tempRoot: scratch }); + assert.equal(result.status, 'FAIL', JSON.stringify(version)); + assert.equal(result.buildAuthorized, false); + assert.ok(result.errors.some((error) => error.code === 'MANIFEST-SCHEMA' && /regenerate/.test(error.remedy))); + assert.ok(result.errors.every((error) => error.code !== 'FORMAT-UNSUPPORTED')); + } + const manifest = clone(collected.manifest); + manifest.formatVersion = '3.0'; + const result = validateArtifact({ file: writeFixture(scratch, 'future-version-short', seal(copyPayload(collected.payload), manifest)), tempRoot: scratch }); + assert.equal(result.status, 'INCOMPLETE'); + assert.ok(result.errors.some((error) => error.code === 'FORMAT-UNSUPPORTED' && /3\.x/.test(error.remedy))); +}); + // ADR-0107: format 2.0.0 renames HUMAN_REVIEW_REQUIRED to REVIEW_REQUIRED and retires the // `human` reviewer. A 1.x package made before stays valid, and is reported under the new // name, so a consumer switches on one state; a 2.x package carrying either old value is not From 87652bf5abc847ea278a11e14d0ea930cfedca78 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 20:18:22 +0300 Subject: [PATCH 23/27] fix(artifact): flush piped reports before exit what changed: Await the existing exitAfterFlush helper for all artifact CLI help, refusal, success and validation endings. Add large-output regressions for JSON and text with 700 missing files. why: Direct process.exit can discard queued stdout on POSIX pipes, truncating reports after 64 KiB. Drain output before preserving the existing exit code. what it touched: artifact CLI/tests, architecture, changelog and README test inventory. No report schema or exit-code change. what you verified: verified - corrected baseline and fixed CLI each passed 23 artifact-cli tests; large JSON parses and text retains 700 errors, both over 64 KiB and exit 1. Handoff OK, preflight PASS, doctor READY. Full gate is running. what you got wrong and fixed: mistake - initial missing-files fixture was rebuilt by the seal helper. where - artifact-cli test. impact - it did not reproduce the truncation defect; no delivery affected. cause - default inventory recomputation; fix - preserve the mutated inventory using recomputeFiles:false; verified - corrected baseline and fixed CLI each passed 23 tests. --- CHANGELOG.md | 2 ++ docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/bin/artifact.mjs | 28 ++++++++++++------------- research-kit/test/artifact-cli.test.mjs | 21 +++++++++++++++++++ 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..de0dde8b 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 + +- The artifact CLI drains stdout and stderr before ending its reports, preserving large piped JSON and text results on POSIX (#359). - 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..858b67cf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -179,7 +179,7 @@ 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` | **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`. The artifact CLI awaits exitAfterFlush for its report, help and refusal endings, preserving queued stdout/stderr and the existing exit status on asynchronous POSIX pipes (#359). | 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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..2d306a1f 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 +1790 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/bin/artifact.mjs b/research-kit/bin/artifact.mjs index a61693fa..71dba3d4 100644 --- a/research-kit/bin/artifact.mjs +++ b/research-kit/bin/artifact.mjs @@ -16,7 +16,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { parseFlags, canonicalJson, checkFlagValues } from '../lib/core.mjs'; +import { parseFlags, canonicalJson, checkFlagValues, exitAfterFlush } from '../lib/core.mjs'; import { requireRuntime } from '../lib/runtime.mjs'; import { validateArtifact } from '../lib/artifact-validator.mjs'; import { writeArtifact, packageName, checkClientRef, GITHUB_API_VERSION } from '../lib/artifact.mjs'; @@ -98,10 +98,10 @@ if (flags.help || !command) { for (const flag of Object.keys(flags).filter((f) => !known.has(f))) process.stderr.write(`unknown option --${flag}\n`); } process.stdout.write(HELP); - process.exit(flags.help ? 0 : 2); + await exitAfterFlush(flags.help ? 0 : 2); } -function refuseUnknownFlags(name) { +async function refuseUnknownFlags(name) { const known = new Set(KNOWN_FLAGS[name] ?? []); const unknown = Object.keys(flags).filter((flag) => !known.has(flag)); if (!unknown.length) return; @@ -113,7 +113,7 @@ function refuseUnknownFlags(name) { } } process.stdout.write(HELP); - process.exit(EXIT.BLOCKED); + await exitAfterFlush(EXIT.BLOCKED); } function printResult(result, { json = false, quiet = false } = {}) { @@ -158,29 +158,29 @@ const VALUE_FLAGS = Object.freeze({ }); if (command === 'validate') { - refuseUnknownFlags('validate'); + await refuseUnknownFlags('validate'); checkFlagValues(flags, VALUE_FLAGS.validate, { exit: EXIT.BLOCKED }); - if (!flags.file) { process.stderr.write('validate needs --file \n\n'); process.stdout.write(HELP); process.exit(EXIT.BLOCKED); } + if (!flags.file) { process.stderr.write('validate needs --file \n\n'); process.stdout.write(HELP); await exitAfterFlush(EXIT.BLOCKED); } const result = validateArtifact({ file: String(flags.file), expectedClientRef: flags['expect-client-ref'] === undefined ? null : String(flags['expect-client-ref']), }); printResult(result, { json: Boolean(flags.json), quiet: Boolean(flags.quiet) }); - process.exit(EXIT[result.status] ?? EXIT.BLOCKED); + await exitAfterFlush(EXIT[result.status] ?? EXIT.BLOCKED); } if (command === 'create') { - refuseUnknownFlags('create'); + await refuseUnknownFlags('create'); checkFlagValues(flags, VALUE_FLAGS.create, { exit: EXIT.BLOCKED }); const required = ['repository', 'ref', 'commit', 'workflow', 'run-id']; const missing = required.filter((name) => flags[name] === undefined); if (missing.length) { process.stderr.write(`create needs ${missing.map((m) => `--${m}`).join(', ')}\n\n`); process.stdout.write(HELP); - process.exit(EXIT.BLOCKED); + await exitAfterFlush(EXIT.BLOCKED); } const refCheck = checkClientRef(flags['client-ref'] === undefined ? null : String(flags['client-ref'])); - if (!refCheck.ok) { process.stderr.write(`${refCheck.detail}\n`); process.exit(EXIT.BLOCKED); } + if (!refCheck.ok) { process.stderr.write(`${refCheck.detail}\n`); await exitAfterFlush(EXIT.BLOCKED); } const runId = Number(flags['run-id']); const root = path.resolve(String(flags.root ?? '.')); @@ -203,7 +203,7 @@ if (command === 'create') { }); } catch (err) { process.stderr.write(`could not package ${root}: ${err.message}\n`); - process.exit(EXIT.BLOCKED); + await exitAfterFlush(EXIT.BLOCKED); } const size = fs.statSync(written.file).size; @@ -214,12 +214,12 @@ if (command === 'create') { if (written.validation.status !== 'PASS') { process.stderr.write('\nthe package this run just wrote does NOT validate:\n'); for (const e of written.validation.errors) process.stderr.write(` ${e.code} ${e.message}\n`); - process.exit(EXIT[written.validation.status] ?? EXIT.BLOCKED); + await exitAfterFlush(EXIT[written.validation.status] ?? EXIT.BLOCKED); } process.stdout.write('re-validated after writing: PASS\n'); - process.exit(EXIT.PASS); + await exitAfterFlush(EXIT.PASS); } process.stderr.write(`unknown command ${JSON.stringify(command)}\n\n`); process.stdout.write(HELP); -process.exit(EXIT.BLOCKED); +await exitAfterFlush(EXIT.BLOCKED); diff --git a/research-kit/test/artifact-cli.test.mjs b/research-kit/test/artifact-cli.test.mjs index 88e3e890..1ce5adf3 100644 --- a/research-kit/test/artifact-cli.test.mjs +++ b/research-kit/test/artifact-cli.test.mjs @@ -208,6 +208,27 @@ test('--quiet prints the verdict line and nothing else', () => { assert.ok(/^PASS\s+build NOT authorized/.test(lines[0]), lines[0]); }); +test('large validation reports drain piped stdout in JSON and text modes', () => { + const { payload, manifest } = basePackage(collectedProject()); + const template = manifest.files.find((entry) => entry.path.startsWith('project/research/raw/')); + assert.ok(template); + for (let index = 0; index < 700; index += 1) { + manifest.files.push({ ...template, path: `project/research/raw/missing-${String(index).padStart(4, '0')}-${'x'.repeat(70)}.md` }); + } + const file = path.join(scratch, 'large-missing.zip'); + fs.writeFileSync(file, seal(payload, manifest, { recomputeFiles: false })); + const json = run(['validate', '--file', file, '--json'], { maxBuffer: 4 * 1024 * 1024 }); + assert.equal(json.code, 1, json.err); + const parsed = JSON.parse(json.out); + assert.equal(parsed.errors.filter((error) => error.code === 'FILE-MISSING').length, 700); + assert.ok(Buffer.byteLength(json.out) > 65536); + const text = run(['validate', '--file', file], { maxBuffer: 4 * 1024 * 1024 }); + assert.equal(text.code, 1, text.err); + assert.equal((text.out.match(/FILE-MISSING/g) ?? []).length, 700); + assert.ok(Buffer.byteLength(text.out) > 65536); + assert.match(text.out, /FAIL\s+build NOT authorized/); +}); + // ---------------------------------------------------------------- create test('create writes a package, re-validates it, and says so', () => { From 62951798539e73efb8c83dea18472417612057ec Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Tue, 13 Oct 2026 23:03:13 +0300 Subject: [PATCH 24/27] fix(artifact): keep hostile metadata on one report line what changed: The validator now exposes manifest state and identity metadata only after schema validation. The artifact CLI escapes control characters in every text diagnostic field, so malformed manifest strings remain on one physical line and cannot forge a verdict. JSON reports retain normal escaping. why: Issue #360 showed package-supplied state, packageId, clientRef and unknown schema keys could inject terminal controls or a second PASS line, including under --quiet. Quiet mode promises a single verdict line and exit status alone cannot protect a human reading output. what it touched: artifact-validator report shape, artifact CLI text rendering, artifact CLI regressions, architecture map, changelog, README test inventory. No format schema or exit-code change. what you verified: verified - red-first 22 passed/2 expected failures; then 69 artifact-cli and artifact-validator tests passed. Coverage includes forged state under --quiet, newline in an unknown manifest key, terminal escape in packageId, newline clientRef, and null metadata for invalid schemas. Architecture-map 21 passed; handoff OK (33 ledger entries, 27 evidence rows); preflight PASS (28 pass, 1 warn, 0 fail); doctor ok with no blocking findings. Cached diff check clean. A retained full gate blocked on the preflight-scope CLI case (1778 passed, one failed, 12 unsupported); a subsequent interrupted focused run returned null child statuses in other CLI cases. The precise cause was not retained by those helpers. An isolated repeat with unchanged assertions passed all 120 CLI/artifact-CLI tests in 396.4 seconds, with no spawn errors. Temporary diagnostics were restored from exact backups before the normal full gate retry. The required full offline suite runs in the commit gate before acceptance. what you got wrong and fixed: mistake - the first green-regression assertion rejected the string PASS anywhere in output, including the escaped key name on its now-safe diagnostic line. where - artifact-cli.test.mjs hostile metadata test. impact - it falsely failed a safe one-line rendering; no product behavior or delivered result was affected. cause - the assertion checked for a substring, not an actual forged physical output line. fix - assert no physical line starts with the forged PASS verdict, and assert the unknown key yields exactly one MANIFEST-SCHEMA line. verified - artifact-cli and artifact-validator groups passed 69 tests, 0 failures. --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/bin/artifact.mjs | 14 +++++----- research-kit/lib/artifact-validator.mjs | 15 +++++++---- research-kit/test/artifact-cli.test.mjs | 35 +++++++++++++++++++++++++ 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..683b9e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 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 +- Artifact validation hides malformed manifest metadata and escapes control characters in text diagnostics, so package-supplied strings cannot forge verdict lines (#360). - 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..2dee0f8d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,7 +178,7 @@ is an open decision (2026-09-30): | `config.mjs` | A **compatibility shim**, nine lines, re-exporting `machine.mjs`'s config surface so an older import path keeps working. It adds nothing and decides nothing. Listed because it was the one module in `lib/` this map did not mention — found by counting files against rows during the 2026-09-20 reconciliation, which is the only way that kind of omission surfaces. | ADR-0002 | | `archive.mjs` | **One container format**: `buildZip(entries)` → bytes, `writeZip(file, entries)` → `{ file, bytes, entries }` (written whole or not at all, through `writeBytes`, since 2026-09-28), `crc32`, `entryName`. ZIP with deflate (store when deflating would only grow the payload), 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-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). Report metadata is surfaced only after the manifest passes schema validation; malformed state and identity fields are null, and the CLI escapes control characters in every text diagnostic so hostile strings cannot forge lines. Reads format majors **1 and 2** (2026-09-30, ADR-0107): a 1.x `HUMAN_REVIEW_REQUIRED` is reported as `REVIEW_REQUIRED`, and `eraProblems` refuses a value from the other major by name - a 2.x package with the old state or `review.by: "human"`, or a 1.x one with the new state. A binary body (NUL in its first 8000 bytes) is not matched against the credential patterns, as a body over 512 KiB is not (ADR-0128, 2026-10-02): `docs/` travels whole, and one vendored DLL carrying the PEM marker it parses would have failed a package with no secret in it. | 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 | | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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/bin/artifact.mjs b/research-kit/bin/artifact.mjs index a61693fa..dd450341 100644 --- a/research-kit/bin/artifact.mjs +++ b/research-kit/bin/artifact.mjs @@ -123,16 +123,18 @@ function printResult(result, { json = false, quiet = false } = {}) { process.stdout.write(`${canonicalJson(result)}\n`); return; } - const line = `${result.status} build ${result.buildAuthorized ? 'AUTHORIZED' : 'NOT authorized'} state=${result.state ?? '(unknown)'}`; + const display = (value) => String(value).replace(/[\x00-\x1f\x7f]/g, + (character) => `\\x${character.charCodeAt(0).toString(16).padStart(2, '0')}`); + const line = `${display(result.status)} build ${result.buildAuthorized ? 'AUTHORIZED' : 'NOT authorized'} state=${display(result.state ?? '(unknown)')}`; if (!quiet) { - process.stdout.write(`\npackage ${result.packageId ?? '(unreadable)'}\n`); - if (result.clientRef) process.stdout.write(`client ${result.clientRef}\n`); - if (result.workflowRunId) process.stdout.write(`run ${result.workflowRunId}\n`); + process.stdout.write(`\npackage ${display(result.packageId ?? '(unreadable)')}\n`); + if (result.clientRef) process.stdout.write(`client ${display(result.clientRef)}\n`); + if (result.workflowRunId) process.stdout.write(`run ${display(result.workflowRunId)}\n`); if (result.errors.length) { process.stdout.write(`\n${result.errors.length} problem(s)\n`); for (const e of result.errors) { - process.stdout.write(` ${e.code} ${e.message}\n`); - if (e.remedy) process.stdout.write(` fix: ${e.remedy}\n`); + process.stdout.write(` ${display(e.code)} ${display(e.message)}\n`); + if (e.remedy) process.stdout.write(` fix: ${display(e.remedy)}\n`); } } if (result.status === 'PASS' && !result.buildAuthorized) { diff --git a/research-kit/lib/artifact-validator.mjs b/research-kit/lib/artifact-validator.mjs index 656646f9..c526d32a 100644 --- a/research-kit/lib/artifact-validator.mjs +++ b/research-kit/lib/artifact-validator.mjs @@ -103,6 +103,7 @@ export function validateArtifact({ const warnings = []; let status = 'PASS'; let manifest = null; + let manifestSchemaValid = false; let workdir = null; const reject = (verdict, ...args) => { status = worse(status, verdict); errors.push(problem(...args)); }; @@ -215,6 +216,7 @@ export function validateArtifact({ checkAuthorization(); return report(); } + manifestSchemaValid = true; // Each major carries its own names (ADR-0107). The schema lists both eras' values, so the // pairing is checked here, where the message can say which name belongs to which format. @@ -391,14 +393,17 @@ export function validateArtifact({ return { status, buildAuthorized: status === 'PASS' && claimed, - packageId: manifest?.packageId ?? null, - clientRef: manifest?.clientRef ?? null, - workflowRunId: manifest?.source?.workflowRunId ?? null, + // Manifest strings are display metadata only after the schema has established their + // type and alphabet. Do not echo malformed values through text or JSON consumers. + packageId: manifestSchemaValid ? manifest.packageId : null, + clientRef: manifestSchemaValid ? manifest.clientRef : null, + workflowRunId: manifestSchemaValid ? manifest.source?.workflowRunId ?? null : null, // A 1.x package's HUMAN_REVIEW_REQUIRED is reported under its 2.0.0 name, so a consumer // switches on one state whichever format it was handed (ADR-0107). - state: manifest?.state === 'HUMAN_REVIEW_REQUIRED' ? 'REVIEW_REQUIRED' : (manifest?.state ?? null), + state: !manifestSchemaValid ? null + : manifest.state === 'HUMAN_REVIEW_REQUIRED' ? 'REVIEW_REQUIRED' : manifest.state, // The reviewer's own declaration (format 1.1.0, ADR-0074); a 1.0.0 package has none. - reviewedBy: manifest?.review?.by ?? 'undeclared', + reviewedBy: manifestSchemaValid ? manifest.review?.by ?? 'undeclared' : 'undeclared', errors, warnings, }; diff --git a/research-kit/test/artifact-cli.test.mjs b/research-kit/test/artifact-cli.test.mjs index 88e3e890..efa993f4 100644 --- a/research-kit/test/artifact-cli.test.mjs +++ b/research-kit/test/artifact-cli.test.mjs @@ -208,6 +208,41 @@ test('--quiet prints the verdict line and nothing else', () => { assert.ok(/^PASS\s+build NOT authorized/.test(lines[0]), lines[0]); }); +test('manifest newlines cannot forge a second quiet verdict line', () => { + const { payload, manifest } = basePackage(collectedProject()); + manifest.state = 'REVIEW_REQUIRED\nPASS build AUTHORIZED state=APPROVED_BRIEF'; + const file = path.join(scratch, 'forged-state.zip'); + fs.writeFileSync(file, seal(payload, manifest)); + const result = run(['validate', '--file', file, '--quiet']); + assert.equal(result.code, 1); + const lines = result.out.split('\n').filter(Boolean); + assert.equal(lines.length, 1, result.out); + assert.ok(/^FAIL\s+build NOT authorized/.test(lines[0]), lines[0]); +}); + +test('malformed manifest metadata is hidden and hostile diagnostic text is escaped', () => { + const { payload, manifest } = basePackage(collectedProject()); + manifest.state = 'REVIEW_REQUIRED\nPASS build AUTHORIZED'; + manifest.packageId = '\u001b[2Jhostile-package'; + manifest.clientRef = 'job\nPASS build AUTHORIZED'; + manifest['unknown\nPASS build AUTHORIZED'] = true; + const file = path.join(scratch, 'hostile-metadata.zip'); + fs.writeFileSync(file, seal(payload, manifest)); + const text = run(['validate', '--file', file]); + assert.equal(text.code, 1); + assert.doesNotMatch(text.out, /\u001b/); + assert.ok(!text.out.split(/\r?\n/).some((line) => /^PASS\s+build AUTHORIZED/.test(line)), text.out); + assert.equal(text.out.split(/\r?\n/).filter((line) => line.includes('MANIFEST-SCHEMA $.unknown')).length, 1, text.out); + assert.match(text.out, /unknown\\x0aPASS/); + const json = run(['validate', '--file', file, '--json']); + assert.equal(json.code, 1); + const report = JSON.parse(json.out); + assert.equal(report.state, null); + assert.equal(report.packageId, null); + assert.equal(report.clientRef, null); + assert.equal(report.workflowRunId, null); +}); + // ---------------------------------------------------------------- create test('create writes a package, re-validates it, and says so', () => { From 40f7f7f8e7bc094416127632e65c2a2163f10c4b Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Wed, 14 Oct 2026 01:29:39 +0300 Subject: [PATCH 25/27] fix(artifact): refuse file and directory path collisions what changed: ZIP inspection now detects a regular file used as another entry's parent directory, independent of entry order, before any inflation or materialization. Explicit file/directory name collisions also receive ZIP-PATH-COLLISION. Added two validator fixtures with stable FAIL results and diagnostics that contain no private temp path or raw filesystem error. why: A readable, self-contradictory archive should be FAIL; it previously threw EEXIST during materialization and became BLOCKED with a temporary path and a misleading kit-bug remedy (#362). This applies the existing structural ZIP refusal boundary. No alternative design was set aside. what it touched: artifact-zip inspection, artifact-validator fixtures, the artifact-zip architecture row, changelog and README test inventory. No schema, public option or exit-code mapping changed. what you verified: verified - red-first 45 passed and two expected collision failures; then 47 artifact-validator tests passed. The final strengthened diagnostics assertion passed in 35.2 seconds. Architecture-map 21 passed; handoff OK (33 ledger entries, 27 evidence rows), preflight PASS (28 passing, one warning, no blockers), doctor READY collector/firecrawl-cli. The issue's artifact-zip test group has no file in this tree; its ZIP regressions are in artifact-validator. The required full offline suite runs in the commit gate before acceptance. what you got wrong and fixed: mistake - the initial temp-path assertion searched for research-kit-artifact- instead of the actual rk-artifact- prefix. where - artifact-validator.test.mjs collision diagnostic assertion. impact - the assertion did not independently detect the real private extraction path; the FAIL/status and ZIP-PATH-COLLISION checks still caught the baseline defect, and no delivered PR was affected. cause - the prefix was written without checking materializeProject. fix - check the actual scratch directory and rk-artifact- prefix, plus EEXIST/ENOTDIR, in all diagnostic messages and remedies. verified - all 47 artifact-validator tests passed in 35.2 seconds. --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 2 +- research-kit/README.md | 2 +- research-kit/lib/artifact-zip.mjs | 34 ++++++++++++++++++- research-kit/test/artifact-validator.test.mjs | 23 +++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..885763fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 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 +- `artifact validate` rejects ZIP entries where a file is also an ancestor directory of another entry, before extraction, with a stable `ZIP-PATH-COLLISION` failure (#362). - 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..b1f8ff3f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -177,7 +177,7 @@ is an open decision (2026-09-30): | `r29-workbook-linkage-validator.mjs` | Cross-reference validator for the R29 reviewer-workbook linkage register: 24 task ids, six expected reference kinds (task/gold/source/calibration/warm/lock), and a status reduction that takes the worst of `PASS < INCOMPLETE < FAIL < REOPEN`. **Zero imports** — it is 58 lines of pure functions over `{ register, catalog, pointer }` passed in as arguments, so it touches no filesystem, no network and no clock, and the caller owns every input. That is also why it needed no sealed-record fixture: its tests synthesise a register into a temp directory rather than shipping one. A revoked lock pointer reduces to `REOPEN` rather than `FAIL`, which is the distinction the rank order exists for — a withdrawn approval is not the same as a broken link. | ADR-0029 | | `config.mjs` | A **compatibility shim**, nine lines, re-exporting `machine.mjs`'s config surface so an older import path keeps working. It adds nothing and decides nothing. Listed because it was the one module in `lib/` this map did not mention — found by counting files against rows during the 2026-09-20 reconciliation, which is the only way that kind of omission surfaces. | ADR-0002 | | `archive.mjs` | **One container format**: `buildZip(entries)` → bytes, `writeZip(file, entries)` → `{ file, bytes, entries }` (written whole or not at all, through `writeBytes`, since 2026-09-28), `crc32`, `entryName`. ZIP with deflate (store when deflating would only grow the payload), 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-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, file/directory-prefix collision, symlink/special entry, encryption, unknown method, counts, sizes, ratio — is decided from the **central directory before one byte is inflated**. The directory is attacker-controlled too, so it is never the only defence: `inflateRawSync` runs with `maxOutputLength`, making the ceiling the runtime's to enforce rather than a number compared against a field the attacker wrote. Names are judged as raw text, never `path.normalize`'d first — normalising would let `a/../../b` become `../b` and then be judged, when the honest answer is that `..` has no business in a research artifact. Touches no filesystem: entries come back as buffers. ZIP64 is refused rather than half-read. | 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 | | `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 | diff --git a/research-kit/README.md b/research-kit/README.md index 4bc12369..5ac159b0 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 +1791 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-zip.mjs b/research-kit/lib/artifact-zip.mjs index 01d2a0ff..652ff60a 100644 --- a/research-kit/lib/artifact-zip.mjs +++ b/research-kit/lib/artifact-zip.mjs @@ -212,6 +212,8 @@ export function inspectEntries(entries, { limits = ZIP_LIMITS } = {}) { const problems = []; const seen = new Map(); // exact name -> count const folded = new Map(); // lowercased name -> first exact name that produced it + const files = new Set(); + const directories = new Set(); let totalBytes = 0; for (const entry of entries) { @@ -242,7 +244,12 @@ export function inspectEntries(entries, { limits = ZIP_LIMITS } = {}) { continue; } - if (entry.isDirectory) continue; // carries no payload and declares no size worth counting + if (entry.isDirectory) { + directories.add(entry.name.replace(/\/$/, '')); + continue; // carries no payload and declares no size worth counting + } + + files.add(entry.name); const count = (seen.get(entry.name) ?? 0) + 1; seen.set(entry.name, count); @@ -281,6 +288,31 @@ export function inspectEntries(entries, { limits = ZIP_LIMITS } = {}) { } } + // An archive may not use one path as both a regular file and the directory + // containing another entry. Otherwise materialization order decides whether mkdir + // or writeFileSync throws, turning a readable contradictory package into BLOCKED. + for (const file of files) { + if (directories.has(file)) { + problems.push({ + code: 'ZIP-PATH-COLLISION', + detail: `file "${file}" is also declared as a directory`, + path: file, + }); + } + let slash = file.indexOf('/'); + while (slash !== -1) { + const parent = file.slice(0, slash); + if (files.has(parent)) { + problems.push({ + code: 'ZIP-PATH-COLLISION', + detail: `file "${parent}" is also a directory prefix of "${file}"`, + path: file, + }); + } + slash = file.indexOf('/', slash + 1); + } + } + if (totalBytes > limits.maxTotalBytes) { problems.push({ code: 'ZIP-SIZE-LIMIT', detail: `the package declares ${totalBytes} uncompressed bytes, over the ${limits.maxTotalBytes} limit` }); } diff --git a/research-kit/test/artifact-validator.test.mjs b/research-kit/test/artifact-validator.test.mjs index 077fdc67..9ceca6e1 100644 --- a/research-kit/test/artifact-validator.test.mjs +++ b/research-kit/test/artifact-validator.test.mjs @@ -152,6 +152,24 @@ const FIXTURES = { }, expect: { status: 'FAIL', code: 'ZIP-CASE-COLLISION' }, }, + '30-file-directory-collision-file-first': { + build: () => { + const payload = copyPayload(collected.payload); + payload.push({ name: 'project/docs/a', data: text('file') }); + payload.push({ name: 'project/docs/a/b.md', data: text('child') }); + return seal(payload, clone(collected.manifest), { recomputeFiles: false }); + }, + expect: { status: 'FAIL', code: 'ZIP-PATH-COLLISION', noTempPath: true }, + }, + '31-file-directory-collision-child-first': { + build: () => { + const payload = copyPayload(collected.payload); + payload.push({ name: 'project/docs/a/b.md', data: text('child') }); + payload.push({ name: 'project/docs/a', data: text('file') }); + return seal(payload, clone(collected.manifest), { recomputeFiles: false }); + }, + expect: { status: 'FAIL', code: 'ZIP-PATH-COLLISION', noTempPath: true }, + }, '18-unsupported-major-version': { build: () => { const manifest = clone(collected.manifest); @@ -300,6 +318,11 @@ for (const [name, fixture] of Object.entries(FIXTURES)) { assert.ok(codes.includes(fixture.expect.code), `${name}: expected error ${fixture.expect.code}, got ${codes.join(', ') || '(none)'}`); } + if (fixture.expect.noTempPath) { + const diagnostics = result.errors.map((error) => `${error.message} ${error.remedy ?? ''}`).join('\n'); + assert.ok(!diagnostics.includes(scratch) && !/rk-artifact-|EEXIST|ENOTDIR/.test(diagnostics), + `${name}: structural ZIP error must not expose a materialization temp path`); + } if ('buildAuthorized' in fixture.expect) { assert.equal(result.buildAuthorized, fixture.expect.buildAuthorized, `${name}: buildAuthorized should be ${fixture.expect.buildAuthorized}`); From 5f6808209fbfef7408aed8e51422cd0f4cdc2458 Mon Sep 17 00:00:00 2001 From: StepenkoAnatoli Date: Wed, 14 Oct 2026 02:35:21 +0300 Subject: [PATCH 26/27] docs(adr): repair links and check decision bodies what changed: Corrected ADR-0121's two stale target slugs. Extended the existing ADR hygiene test to resolve relative file links in every numbered ADR body. Added current context for missing artifact references in the dated benchmark plans and the snapshot review's unretained probe, while keeping the received review unchanged. why: Broken decision links hid the records ADR-0121 refines, while the hygiene test only checked the index (#292). Historical planning references also needed to state that their named artifacts are not shipped in this repository. No new design choice or alternative was set aside. what it touched: ADR-0121, repo-hygiene test, docs/superpowers README and changelog. Expanded an existing test, so the README test count is unchanged. No product code or archived record was removed or rewritten. what you verified: verified - the expanded check failed by naming exactly the two stale ADR-0121 links; after correcting them, repo-hygiene passed 11 tests, zero failures in45.8s. The first full gate blocked with two bundle-integrity failures (1775 passed,12 unsupported) caused by the review edit. After restoring that file, bundle.mjs reports66/80 unchanged,14 expected drift and no unexpected drift. Focused bundle/repo-hygiene/CLI passed141 tests,zero failures,zero unsupported in215.1s after the correction, including both previously failing cases. Handoff OK, preflight PASS (28 passing,one warning,no blockers), doctor READY collector/firecrawl-cli. The probe path is absent from tracked files and available Git history. The required full offline suite runs again in the normal commit gate before acceptance. what you got wrong and fixed: mistake - I edited the received snapshot review to explain a missing probe instead of keeping that archived record unchanged. where - docs/researcher-review-2026-09-16.md line36. impact - two archive-integrity checks failed and the full gate refused the commit; no commit or PR was delivered. cause - I treated a historical clarification as a current-document edit without checking the bundle's frozen planning records. fix - restore the review exactly and move the clarification into the new dated-artifact guide. verified - bundle.mjs reports no unexpected drift and focused bundle/repo-hygiene/CLI passed141 tests,zero failures; the normal full gate retry remains required before publication. --- CHANGELOG.md | 1 + ...e-key-resolves-to-its-restrictive-state.md | 4 ++-- docs/superpowers/README.md | 23 +++++++++++++++++++ research-kit/test/repo-hygiene.test.mjs | 12 ++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b7affd40..99eab959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 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 +- Corrected ADR-0121's links to ADR-0020 and ADR-0023, and check relative file links in every ADR body. Historical benchmark plans and the snapshot review now identify references to artifacts not retained in this repository (#292). - 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/adr/0121-an-ill-typed-posture-key-resolves-to-its-restrictive-state.md b/docs/adr/0121-an-ill-typed-posture-key-resolves-to-its-restrictive-state.md index cd44971d..78a2397f 100644 --- a/docs/adr/0121-an-ill-typed-posture-key-resolves-to-its-restrictive-state.md +++ b/docs/adr/0121-an-ill-typed-posture-key-resolves-to-its-restrictive-state.md @@ -4,8 +4,8 @@ - **Status:** accepted - **Area:** `lib/machine.mjs` (`shape`, `readMachineConfig`, `posture`), `lib/doctor.mjs` (`config-ill-typed`), `githooks/pre-commit` (the sh posture reader) -- **Refines:** [ADR-0020](0020-the-machine-config-has-three-states.md) and - [ADR-0023](0023-a-role-the-kit-cannot-establish-does-not-collect.md), one level down +- **Refines:** [ADR-0020](0020-five-trusted-boundaries-that-did-not-hold.md) and + [ADR-0023](0023-an-unestablished-role-does-not-collect.md), one level down - **Lifts the freeze (ADR-0117)** for one item: the `config-ill-typed` doctor finding. ## Context diff --git a/docs/superpowers/README.md b/docs/superpowers/README.md new file mode 100644 index 00000000..f64dd6df --- /dev/null +++ b/docs/superpowers/README.md @@ -0,0 +1,23 @@ +# Dated design and planning records + +This directory preserves dated specifications and implementation plans. The +2026-09-16 Researcher benchmark records describe proposed worksheets, templates, +sign-off forms and schema files, several of which are not present in this +repository. Their links retain the proposed artifact names; they do not identify +shipped downloads or prove that the named work was completed. + +The original records are kept as historical documents. Their referenced artifacts +are not prerequisites for running the current kit, and their missing targets are +outside the ADR link check. The record of the received bundle is described in +[ADR-0028](../adr/0028-the-bundle-is-a-received-state-not-a-live-constraint.md). + +For the current implementation, read the [architecture map](../ARCHITECTURE.md), +[decision index](../adr/README.md) and [kit README](../../research-kit/README.md). +Later dated records, including the desktop-panel design, retain their own stated +scope and status. + +The related [2026-09-16 snapshot review](../researcher-review-2026-09-16.md) +names `docs/audit-2026-09-16-probes.mjs`, which is not retained in this repository. +Its command and link record the review's past inputs, rather than a script shipped +with the current kit. The received review itself stays byte-identical to the +archive; this guide supplies the current context. diff --git a/research-kit/test/repo-hygiene.test.mjs b/research-kit/test/repo-hygiene.test.mjs index abde38d4..761ef7ae 100644 --- a/research-kit/test/repo-hygiene.test.mjs +++ b/research-kit/test/repo-hygiene.test.mjs @@ -259,6 +259,18 @@ test('the ADR log has one file per number, and the index names them all', () => assert.deepEqual(unlisted, [], 'these ADRs exist but are absent from docs/adr/README.md:\n ' + unlisted.join('\n ')); const dangling = [...links].filter((f) => !fs.existsSync(path.join(dir, f))); assert.deepEqual(dangling, [], 'the index links ADRs that do not exist:\n ' + dangling.join('\n ')); + + const brokenBodyLinks = []; + for (const file of files) { + const source = fs.readFileSync(path.join(dir, file), 'utf8'); + for (const [, href] of source.matchAll(/\]\(([^)]+)\)/g)) { + const target = href.trim().replace(/^<|>$/g, '').split('#', 1)[0]; + if (!target || target.startsWith('/') || /^[a-z][a-z\d+.-]*:/i.test(target)) continue; + const decoded = decodeURIComponent(target); + if (!fs.existsSync(path.resolve(dir, decoded))) brokenBodyLinks.push(`${file}: ${href}`); + } + } + assert.deepEqual(brokenBodyLinks, [], 'the ADRs link files that do not exist:\n ' + brokenBodyLinks.join('\n ')); }); test('the ledger is still not swept up by the broader rules', () => { From 6203f9b58febb8748916e2d091ad4befc3f19d54 Mon Sep 17 00:00:00 2001 From: jay23606 <4602056+jay23606@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:50:28 -0400 Subject: [PATCH 27/27] Fix FI sidecar array index regex so Node accepts indices past 0 --- research-kit/lib/fi-sidecar-conformance.mjs | 4 +-- .../fi-sidecar-conformance-vectors.test.mjs | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/research-kit/lib/fi-sidecar-conformance.mjs b/research-kit/lib/fi-sidecar-conformance.mjs index fb420b81..56298d2f 100644 --- a/research-kit/lib/fi-sidecar-conformance.mjs +++ b/research-kit/lib/fi-sidecar-conformance.mjs @@ -24,13 +24,13 @@ function patch(base, patches) { if (!operation || !['set', 'remove'].includes(operation.op)) throw packetError('patch operation must be set or remove'); const parts = segments(operation.path); let parent = value; for (const part of parts.slice(0, -1)) { - if (Array.isArray(parent) ? !/^0$|^[1-9]\\d*$/.test(part) || Number(part) >= parent.length : !parent || typeof parent !== 'object' || !(part in parent)) throw packetError(`patch path does not exist: ${operation.path}`); + if (Array.isArray(parent) ? !/^(?:0|[1-9]\d*)$/.test(part) || Number(part) >= parent.length : !parent || typeof parent !== 'object' || !(part in parent)) throw packetError(`patch path does not exist: ${operation.path}`); parent = parent[part]; } const key = parts.at(-1); if (operation.op === 'set') parent[key] = clone(operation.value); else { - if (Array.isArray(parent) ? !/^0$|^[1-9]\\d*$/.test(key) || Number(key) >= parent.length : !parent || typeof parent !== 'object' || !(key in parent)) throw packetError(`patch path does not exist: ${operation.path}`); + if (Array.isArray(parent) ? !/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= parent.length : !parent || typeof parent !== 'object' || !(key in parent)) throw packetError(`patch path does not exist: ${operation.path}`); Array.isArray(parent) ? parent.splice(Number(key), 1) : delete parent[key]; } } diff --git a/research-kit/test/fi-sidecar-conformance-vectors.test.mjs b/research-kit/test/fi-sidecar-conformance-vectors.test.mjs index 5003683b..0eea992d 100644 --- a/research-kit/test/fi-sidecar-conformance-vectors.test.mjs +++ b/research-kit/test/fi-sidecar-conformance-vectors.test.mjs @@ -2,6 +2,7 @@ // Every case is embedded synthetic JSON; no workbook, evidence directory, credential, // network, or benchmark fixture is consulted. import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -57,6 +58,36 @@ test('FI Python runner agrees with Node on every vector row and expected code', assertEqual(JSON.stringify(pythonReport.vectors), JSON.stringify(nodeReport.vectors), 'Python and Node rows must agree'); }); +test('FI Node and Python runners agree on patches past the first array index', async () => { + const { loadFiSchemaVectors, runFiSchemaConformance } = await import('../lib/fi-sidecar-conformance.mjs'); + const packet = JSON.parse(fs.readFileSync(VECTOR_FILE, 'utf8')); + const second = { ...packet.documents.manifest.entries[0], evidenceId: 'FI-02-status', fieldLabel: 'Second status', path: 'release-evidence/FI-02-status.json' }; + packet.documents.manifest.entries.push(second); + packet.vectors = [ + { vectorId: 'X-IDX1', target: 'manifest', base: 'manifest', patches: [{ op: 'remove', path: '/entries/1/sha256' }], expectedCode: 'SCHEMA-REQUIRED' }, + { vectorId: 'X-IDX0', target: 'manifest', base: 'manifest', patches: [{ op: 'remove', path: '/entries/0/sha256' }], expectedCode: 'SCHEMA-REQUIRED' }, + ]; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fi-sidecar-idx-')); + try { + const file = path.join(dir, 'packet.json'); + fs.writeFileSync(file, JSON.stringify(packet)); + const nodeRows = runFiSchemaConformance(loadFiSchemaVectors(file)).vectors; + const python = spawnSync(requirePython('FI sidecar array index agreement'), [ + path.join(ROOT, 'bin', 'fi_sidecar_conformance.py'), '--vectors', file, '--json', + ], { encoding: 'utf8' }); + assertEqual(python.status, 0, python.stderr || python.stdout); + const pythonRows = JSON.parse(python.stdout).vectors; + for (const id of ['X-IDX1', 'X-IDX0']) { + const nodeRow = nodeRows.find((row) => row.vectorId === id); + const pythonRow = pythonRows.find((row) => row.vectorId === id); + assertEqual(nodeRow.observedCode, 'SCHEMA-REQUIRED', `Node ${id}: ${JSON.stringify(nodeRow)}`); + assertEqual(pythonRow.observedCode, nodeRow.observedCode, `Python ${id} must match Node`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('FI vector packet remains synthetic and does not carry completed evidence', () => { const packet = JSON.parse(fs.readFileSync(VECTOR_FILE, 'utf8')); const source = JSON.stringify(packet).toLowerCase();