- Date: 2026-09-14
- Status: accepted
- Area: machine policy, collection, diagnostics
The kit was written as if one machine does both halves of the job: collect the evidence with Firecrawl, then build from it. That stopped being true the moment the work moved across two boxes.
COLLECTOR (the operator's PC) BUILDER (a sandbox, a CI box, a second laptop)
Firecrawl key no key, no egress to firecrawl.dev
decompose + research builds from research/BRIEF.md
produces the evidence corpus consumes the corpus that travelled
On a builder machine, doctor reported:
fail firecrawl-auth not authenticated (keyless is blocked from this IP) - run: firecrawl login --api-key fc-...
That is not a defect on that machine. It is the expected state, stated permanently and loudly enough to be ignored — and a gate that is always red is a gate nobody reads. The first FAIL in every run teaches the reader to skim the column, which is how this kit's whole design (a small number of findings that each mean something) stops working.
The mirror image is worse, because it is silent. A builder cannot collect, so a builder
asked to research falls back on whatever transport its agent can reach — a page fetch
tool, a browser, a hand-written file. This repository's ledger carries five
non-Firecrawl transport rows produced exactly that way (transport: 'agent page fetch', no Firecrawl egress). They are honest rows about a machine doing the wrong
job, and nothing in the kit said the job was wrong. Meanwhile doctor said nothing
about the thing that does matter on a builder: that it must not collect at all.
A machine declares its role, and the role changes what the machine is held to.
- The role lives in the machine config:
role: 'collector' | 'builder'in~/.agents/fetchproof.config.json, read bymachineRole()and answered in policy form bycollectionPolicy()— both inlib/machine.mjs, which owns every machine-scoped location (ADR-0002). - The default is
collector. A missing, unknown, or corrupt value reads ascollector. That is the stricter role (a collector without a key is blocked), so a machine that was never told its role is held to the higher standard, and an existing install behaves exactly as it did before this field existed. - collector:
firecrawl-authandfirecrawl-clikeep their current severity — a collector without a key cannot collect, and that is a FAIL. - builder: both become informational (severity
pass, with the reason in the detail). The builder is held instead to the thing that matters on a builder: it must not collect. That rule is a policy, not the absence of a key:bin/research.mjsandbin/decompose.mjsrefuse to run on a builder-role machine (exit 2, before any credential or adapter is consulted), naming the collector and the remedy.--dry-runand--statusstill work: they spend nothing, and a builder needs to be able to see what would happen.doctorreportsmachine-roleon every run, so the role and its consequence are visible rather than inferred.- The way to collect there is to say so:
install-hooks.mjs --role collector. It is a deliberate, recorded act in a config file, not a per-command override.
- The declared role also shapes setup text:
install.mjsstops telling a builder to log in to Firecrawl, and points it at the corpus andbin/handoff.mjsinstead (ADR-0011).
doctorsays something different on each machine about the same project, on purpose: the machine's capability to collect is exactly what differs, and the report is about the machine.- A builder with a Firecrawl key still does not collect — the refusal is the policy, not the credential. That is the point: the five-row defect happened on a keyless machine, through a fallback the kit never forbade.
- The role is machine state, outside the repository, so the same clone keeps one
git-visible shape on both boxes; nothing about the corpus or its gate changes. - Cost: one more machine-level setting to explain, and a builder that genuinely needs a one-off collection must flip the role (deliberately) rather than pass a flag. Accepted: a per-command escape hatch is the silent-bypass shape this kit already refuses elsewhere (the three overrides are all logged).
- One role; live with the noise. Rejected, and this is the alternative the task named. It is cheap and requires no code, but it produces a permanent FAIL on every builder run, and it says nothing about the failure that actually happened there. The FAIL trains people to ignore the column; the silence leaves the five-row defect possible. Both halves of the defect survive.
- Detect the role from the environment (is there a key? is the CLI installed?).
Rejected. A keyless collector between key rotations is indistinguishable from a
builder, so doctor would go quiet on a collector that is genuinely misconfigured — the
worse error. And the prohibition must be stated: a builder whose rule is "you have no
key" is one
firecrawl loginaway from collecting an uncollectable corpus. - Stamp the machine role into every ledger entry, so builder-side collection can be
detected after the fact. Rejected. The field is written by the same tool that would
be bypassed; it cannot attribute entries written before it existed; and a legitimate
git pullof a corpus the collector grew is indistinguishable from local collection, so it would raise false alarms on exactly the machines that behave correctly. Prevention at the CLI is the enforceable half. - A
--allow-collectflag for the odd case. Rejected. An override that leaves no record is the shape ADR-0002 and the overrides log exist to refuse; flipping the role is the recorded version of the same act. - Make the role a project setting (
research/kit.json) instead of machine state. Rejected: the same project is legitimately collected on one box and built on the other, so the role cannot travel with the corpus. (The guarded code paths inkit.jsonare a different kind of setting — see ADR-0008.)