feat: citation gate — provably-alive, honestly-labelled citations (itd-101) - #164
Merged
Conversation
The 2026-07-27 grill settled the design; the spec records the settlements plus the mechanism (offline lint family, committed baseline, one receipt schema across both manual-queue rungs). The freshly minted ids collided with unmerged PR #156 (iss-80 allocator class, second live instance appended there): spec renumbered to spc-17, duplicate issue capture folded into iss-80. Assisted-by: Claude:claude-fable-5
spc-17's committed baseline: one schema-versioned record under .abcd/ that the zero-network lint enforces and (later) `abcd docs cite refresh` writes. Per cited URL it records the final resolved address, when it was last checked, the outcome, and who established it — automatic or manual, with its date. It records nothing about HOW, and that omission is structural rather than conventional: BaselineEntry declares no such field, and the loader runs DisallowUnknownFields so a hand-added method/transcript key is a load-time refusal instead of a silently-dropped one. Both halves are pinned by test, including a reflection assertion over the struct's tags so a future edit that adds a how-shaped field fails. Loading validates every invariant the gate then relies on (schema version, final address, date shapes, outcome and verification enums) and returns a typed *BaselineError, keeping a corrupt record distinguishable from a missing one. An entry may restate its own map key in a url field for diff-readability; a key that disagrees with it is refused. Assisted-by: Claude:claude-opus-4-8
spc-17's commit gate: five rules on the existing docs-lint engine, none of which touches the network. They read committed markdown, committed config, and the committed baseline, so a commit's verdict is a function of the repository rather than of what the network answered that minute. - citation_footnotes: markers and definitions in bijection per page, both directions. An orphan definition is the subtler rot — a source the page still lists but no longer claims anything from. - citation_crosswalk_rows: the rule deferred from spc-15, homed here per DECISIONS 2026-07-27. Every crosswalk body row carries a footnote. - citation_url_syntax: cited URLs and DOIs are well-formed. - citation_source_policy: refused aggregator domains, from committed config. - citation_baseline: no cited URL absent from the baseline, none recorded broken, none whose recorded final address has drifted, and the staleness clock (180-day warn). Three choices worth naming. The citation corpus is a page's footnote DEFINITIONS, not its prose — a URL in a paragraph is links_resolve's business — which is what keeps the syntax and policy rules off ordinary text. A table is a crosswalk only when its nearest preceding heading says so, the narrowest heuristic that selects the real crosswalk and leaves every directory-map table alone. And the 365-day threshold surfaces as a DISTINCT rule id (citation_baseline_overdue) at warn, because the commit gate never calendar-blocks; promoting it is the release gate's job. Lint gains LintAt, which takes the clock as a parameter so the staleness boundaries are testable exactly; Lint keeps its signature and delegates. Assisted-by: Claude:claude-opus-4-8
The four zero-config rules go live here: footnote bijection, crosswalk
rows, URL/DOI syntax, and source policy. All four pass on the corpus as it
stands — 52 citations across docs/reference/terminology.md, in bijection,
every crosswalk row cited, every address and DOI well-formed — so arming
them locks in a property the docs already have rather than demanding new
work.
refused_domains ships EMPTY. The repo's admission rule is stated in prose
("no single-author coinages, no aggregators") but nowhere names a domain,
and a gate must not invent an editorial blacklist its project never agreed
to. The mechanism is armed; the list is the maintainer's to author.
citation_baseline is registered but DISABLED, because `abcd docs cite
refresh` does not exist yet and there is no honest way to populate a
baseline without it. Hand-writing final addresses and check dates would be
fabricating exactly the receipts the record exists to make trustworthy.
Enabling it is part (b)'s job, once refresh can fill it truthfully.
Assisted-by: Claude:claude-opus-4-8
Three defects found by review of the citations family, each with a test watched fail first. The DOI mention matcher took any non-space token after "DOI" plus a delimiter, so "a standard with a DOI and a stable URL" reported a malformed DOI named "and", and the resolvable https://doi.org/10.x form — the recommended way to write a DOI — was rejected outright. At blocker severity that fires on correct citations. Anchoring the match to the "10" registrant prefix keeps ordinary prose out while still catching a token that starts like a DOI and breaks the grammar. A footnote definition wrapped across lines contributed only its first line to the citation corpus, so a URL below the fold was never checked for syntax and never required a baseline entry. That direction of failure is open, not noisy: an address the baseline records as broken would pass the gate. Continuation lines (indented, non-blank) now stay corpus. And one URL cited twice on a line produced two identical baseline findings; one citation site is one problem. Assisted-by: Claude:claude-opus-4-8
Baseline path, the crosswalk identification heuristic, what counts as a page's citation corpus, and why refused_domains ships empty — each with the alternative that was rejected, so part (b) does not re-litigate them. Assisted-by: Claude:claude-opus-4-8
The address predicate, the internal/metadata name refusal, and the connect-time re-check lived inside internal/core/memory, private to the one fetch path that had them. abcd is about to acquire a second outbound fetch — the citation refresh — whose addresses come from committed documentation and are just as attacker-influenceable, and a guard that lives inside one caller is a guard the next caller reimplements slightly differently. Move it to its own package unchanged and have memory compose it. Two deliberate shape changes at the boundary: the address predicate is now a parameter of the name check and the dial control rather than a hard-wired call, so a fetch path can be exercised against an httptest server (which binds loopback) by relaxing the predicate in the test alone while the shipped default is never relaxed; and the guard returns plain errors that memory re-types as IngestError, so every ingest failure a caller sees is still one error type. Behaviour-preserving: the messages are byte-identical, and memory's TestIngestRefusesSSRFTargets regression passes untouched. Assisted-by: Claude:claude-opus-5[1m]
spc-17's live half. `internal/core/cite` fetches every cited URL once and writes the committed baseline the zero-network gate then enforces offline, and `Confirm` records the queue lines a human cleared. The collector is shared, not duplicated: lint.CollectCitedURLs exports the walk LintAt's citation family already does, so the set the refresh fetches is exactly the set the gate demands receipts for. A second scraper with its own idea of what a citation is would produce permanent findings nobody could clear — a URL the gate wants and the refresh never visits. lint.CitationPolicy does the same for the thresholds. The checker refuses three things a link checker usually does. It never retries, so a run's cost cannot become a function of how many links are failing, nor a burst against a struggling host. It never reads a body, so liveness is judged from the status line and no cited page's bytes enter the process. And it separates "dead" from "not readable by a robot": 401/403/406/ 429 route to the manual queue rather than being recorded as broken, because a 403 is evidence the fetcher may not look, not evidence the source is gone, and a committed record the gate enforces must not contain that lie. The merge carries the honesty rules. A current manual receipt is preserved verbatim and not even requested. A stale one is re-checked (AC 3 puts human and machine verifications on one clock) and, if the source still blocks, kept rather than deleted so the gate keeps warning truthfully. A blocked URL with no prior entry writes nothing at all. Entries the docs no longer cite are dropped. Confirm validates every line against the cited set before writing anything, so it cannot become a door for arbitrary entries. Every fetch path is tested against httptest — redirect drift, the redirect bound, the blocked/broken status split, the timeout, one-attempt-per-URL, and the SSRF regression under the shipped policy. No test touches the network. Assisted-by: Claude:claude-opus-5[1m]
…gate The refresh verb is manual, so spc-17's answer to "who remembers to run it" is nagging where a maintainer already looks. All three surfaces land here. `abcd docs cite refresh` and `abcd docs cite confirm` hang off `docs` because the gate is their customer: both resolve their roots and baseline path through the same docs-lint config `docs lint` reads, so the set the refresh fetches is the set the gate demands receipts for. Refresh prints the manual queue as a checklist naming where each blocked URL is cited, and exits zero after recording a broken link — it records, the gate decides. Confirm takes URLs positionally or a receipt file; both assemble the same Receipt, so the later generated checklist page is a different producer of one schema rather than a second pathway. `ahoy` gains a citations signal — coverage and age at a glance, present only in a repo that armed the rule. It is a signal, not a gap: a stale baseline is not something `ahoy install` could close, and surfacing it as resolvable would promise what the apply path cannot deliver. The release gate promotes on the caller's say-so, following ArmReceiptGate's established shape: `abcd docs lint --release-gate` arms lint.ArmCitationOverdue, turning a citation past its threshold from the commit gate's warn into a blocker. The flag is the trust root, so a repo cannot defang its own release by editing the committed config, and an ordinary commit is never calendar-blocked. `abcd launch --dry-run` gains a citation-baseline gate that nags on entries approaching the threshold and refuses on overdue, broken, or unreceipted ones. Its measurement is taken in the CLI and handed in as data because lint imports launch for its semver — the same shape the cobra-tree walk already uses, with the dependency never pointing back. One grading serves all of it: lint.CitationAgeSummary, so the number ahoy shows is by construction the number the release gate acts on. Assisted-by: Claude:claude-opus-5[1m]
The first real run of `abcd docs cite refresh` over docs/: 51 cited URLs, 49
alive, none broken, two blocked. It also found four citations that had
silently drifted behind redirects — x402.org, the LangChain context-
engineering post, Restate's durable-execution concept page, and RFC 9162 —
each now naming the address it actually resolves to. That drift is exactly the
rot the gate exists to catch, and nobody would have noticed it by reading.
The two blocked sources (openai.com, HTTP 403 to any automated fetcher) get no
baseline entry and no fabricated receipt. They sit in the manual queue, and
`abcd docs lint` reports them as unreceipted — which is precisely true. A
maintainer who opens them clears both with:
abcd docs cite confirm \
https://openai.com/index/harness-engineering/ \
https://openai.com/index/unlocking-the-codex-harness/
Until then the docs gate is red on those two lines. That is the gate working,
not a defect: an agent must not sign a receipt asserting a human looked.
Assisted-by: Claude:claude-opus-5[1m]
The `docs` command page and the brief's surface record both said `docs` had one sub-verb and performed zero writes. Neither is true now: `cite refresh` reaches the network and `cite confirm` writes a committed record, and `lint` gained the release-gate promotion. The plugin page also carries two instructions an agent needs and cannot infer: never suggest hand-editing the baseline to clear a blocked source, and never run `confirm` on its own initiative — a receipt asserts that a human looked. Assisted-by: Claude:claude-opus-5[1m]
Both openai.com citations sit behind bot mitigation that answers 403 to an automated fetcher — but not deterministically. Across repeated runs while verifying the refresh is idempotent, one of them returned 200 once, and the merge rule kept the entry: a subsequent 403 is not evidence a source we successfully read today is gone, so a prior automatic receipt survives a blocked re-check rather than being deleted. That is the intended behaviour, not a workaround, and it is worth naming: for Cloudflare-fronted sources the blocked/alive split is not reproducible run to run. It is precisely why the manual queue exists, and why a blocked outcome writes nothing rather than recording the source broken. One citation — https://openai.com/index/harness-engineering/ — has never answered and still has no receipt. It needs a human: abcd docs cite confirm https://openai.com/index/harness-engineering/ Assisted-by: Claude:claude-opus-5[1m]
Findings from the adversarial security review (BLOCK) and the ruthless code review (FIX FIRST), each with a regression test watched fail first. The BLOCK: `citation_baseline.baseline` comes from committed `.abcd/docs-lint.json`, so a contributor controls it, and this branch made it steer a WRITE for the first time — SaveBaseline had no production callers before. A value of "../../../ESCAPED/pwned.json" made `abcd docs cite refresh` MkdirAll and write outside the repository, reproduced against a built binary. Containment now lives at the single choke point every reader and both writers already resolve through, and an escaping path is refused rather than quietly normalised: a config asking to write outside the repo is a mistake or an attack, and rewriting it to something safe would hide both. The worst correctness bug: `Check` collapses DNS failure, refused connection and timeout into the same `broken` as a genuine 404, and the broken branch wrote unconditionally. So a refresh run behind a captive portal rewrote every entry as broken — stale human receipts included, since those are re-checked and therefore never reached the blocked branch that protects them — and committed it before the transcript printed. A run in which nothing at all succeeded is now refused, but only when there was a record to protect; a first run over genuinely dead citations still writes one. Three more. An unrecognised Status from a Checker is an error rather than a fall-through, because the seam is exported for the adapter spc-17 anticipates and falling through dropped the URL from both the baseline and the queue — the one outcome nothing downstream could detect. The missing-entry lint message named only `refresh`, which by construction cannot clear it: that state means a source refuses automated fetchers, so refresh re-queues and writes nothing again, forever. It now names `confirm` too. And a docs-lint config that arms the rule but fails to parse was rendering to the release preflight as "not armed" — a false statement that waved a release through — and is now a distinct unreadable state that refuses. Two consolidations the reviews called: `daysBetween` is the exported lint.DaysBetween, so the verb cannot call an entry current on the day the gate calls it overdue; and the CLI's ad-hoc error helper is dropped for the canonical scrubPaths, which preserves the *PathError type the redactor needs — an unreadable page was leaking an absolute developer path into machine output. The CHANGELOG is corrected on two counts it overclaimed: the release-gate flag is built but not yet passed by release.yml, and one cited source is unreceipted rather than all 51 being covered. Assisted-by: Claude:claude-opus-5[1m]
… guard Second review round. All three findings were reproduced end-to-end by the reviewers, and each fix is verified against the same repro. The first round's containment check was purely LEXICAL, so it did not close what it claimed to. A committed symlinked directory plus a lexically innocent `"baseline": ".abcd/evil/x.json"` reopened the same arbitrary-file-write primitive: WriteFileAtomic MkdirAll's *through* the link. Containment now resolves the filesystem too — EvalSymlinks on the deepest existing ancestor of the target, since on a first run the target does not exist yet, compared against an EvalSymlinks'd root. That is the shape launch/bundle.go already uses for symlinked bundle entries, extended rather than copied a third time. CitationPolicy takes the repo root and returns a policy set carrying both the repo-relative form for messages and the absolute form for I/O. `roots` had no containment at all. That was inert while the lint only reported findings, but this intent made the collector feed a live fetcher whose results are persisted into a baseline the workflow expects to be committed and pushed — so `"roots": ["../private"]` fetched and then published every URL in a sibling directory. The collector now refuses an escaping or symlinked root; the lint's own walk is a separate, pre-existing path. The wholesale-failure guard consulted `res.Preserved == 0`. A preserved receipt was never fetched, so it says nothing about connectivity — and because the manual queue is the designed steady state for robot-refusing sources, any repo that had ever run `confirm` silently lost the protection. The same condition failed in the opposite direction too: a repo whose only citation genuinely 404s could never re-record it, being told forever to check a connection that works. Both faults had one cause: StatusBroken destroys the difference between "the host answered 404" and "nothing answered", and the guard tried to recover that bit from a proxy. The checker now carries it. CheckOutcome.Answered is set wherever a status line came back, the guard reads it, and Preserved is out of the condition entirely. When a guard needs a fact, carry the fact. Assisted-by: Claude:claude-opus-5[1m]
`Answered` is the bit the wholesale-failure guard reads to tell a broken corpus from a broken network, and it was wrong on one path. Reaching CheckRedirect at all means a host replied with a 3xx — but when abcd declines to follow the chain (past the hop bound, or a hop pointing at a blocked address), client.Do surfaces that as an error, so the outcome claimed nothing had answered. A corpus whose citations all end in redirect loops, with a prior baseline, would therefore have been refused with "no host answered — check connectivity", a false diagnosis with no way out. The failure was in the safe direction, but it left the operator stuck. Both refusals are now wrapped in a marker the outcome recognises. The other side is pinned too: a closed port and an SSRF refusal never reach a host, and must not claim otherwise. Assisted-by: Claude:claude-opus-5[1m]
Containing the configured ROOT was only half the exfiltration fix. WalkDir yields a symlinked .md as an ordinary file and os.ReadFile follows it, so a committed `docs/leak.md -> ../private/notes.md` sits inside a perfectly contained root and still drags an outside file's citations into the live fetch and then into the baseline the workflow expects to be committed and pushed. Reproduced end to end. Every collected file is now resolved-contained, not just its root; an in-repo symlink still resolves, because containment is about where a path lands, not about symlinks being suspicious. The CHANGELOG correction is a retraction. Reworded to describe the state after a maintainer clears the queue, the bullet asserted "every URL cited under docs/ carries a receipt" and that the 403 source's "receipt is a human's" — while the committed baseline holds 50 of 51 entries, none of them manual, and the repo's own gate prints the counter-evidence on every run. A changelog entry describes the tree at its commit, never the state it will reach once someone acts. The true count is restored. Assisted-by: Claude:claude-opus-5[1m]
Two robustness notes from the approving security review. Containment answers "does this path land inside the repo" and says nothing about how big what it lands on is — and this collector's output is fetched over the network, so the read is bounded and regular-file-checked now rather than being a bare os.ReadFile. It opens the RESOLVED path, not the literal one: O_NOFOLLOW on the literal path refuses every symlink leaf, which would break the legitimate in-repo bridge that containment has just approved. So the containment helper hands back the path it judged, and the reader reads exactly that. The exported Checker seam had a footgun. An adapter that reported StatusOK and left Answered at its zero value made the whole run refuse with "no host answered" while every check had succeeded. StatusOK and StatusBlocked entail an answer — a 2xx cannot exist without a host replying, and blocked is defined by a status code — so only StatusBroken carries real ambiguity, and only there does the checker's bit decide. That is what those statuses mean rather than inference from a proxy. The interface comment now states both obligations an implementer owes, next to the unrecognised-status refusal that already guarded the other field. Assisted-by: Claude:claude-opus-5[1m]
…citation Maintainer confirmed https://openai.com/index/harness-engineering/ (a source that refuses automated fetchers) via the manual queue; abcd docs cite confirm records the dated manual attestation, clearing the citation_baseline blocker. Verification: manual + date, never how. Assisted-by: Claude:claude-fable-5
…-gate # Conflicts: # .abcd/work/DECISIONS.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements itd-101 (citation gate) per spc-17 — the spec derived from the 2026-07-27 Grill Settlements. Every published citation is provably alive and honestly labelled, and the gate that enforces it never flakes.
What this delivers
abcd docs lint: footnote marker/definition bijection, crosswalk-row footnote coverage (the spc-15 deferral), URL/DOI syntax, source-domain policy, and baseline enforcement (missing/broken/drifted/stale) — all from committed markdown, config, and the committed baseline.abcd docs cite refresh: the on-demand live fetcher (native, bounded redirects/timeouts, one attempt per URL, SSRF-guarded viainternal/urlguard) that writes the committed baseline — final resolved address, checked-when, and whether verification was automatic or manual, never how.abcd docs cite confirm: sources that block automated fetchers are cleared link by link; confirm records a dated manual receipt (one schema for both the checklist and the later generated-page rung).citation_baseline_overdue), so commits are never calendar-blocked.openai.com/index/harness-engineering/) is cleared by a maintainer manual-confirm receipt.Reviews
Multiple ruthless + security review rounds during development (recorded in DECISIONS.md): the security passes caught and closed real arbitrary-file-write and outside-repo-exfiltration primitives in the config-driven path and roots handling (lexical→resolved containment via
os.Root/EvalSymlinks, bounded reads, anAnsweredbit so a wholesale-failure guard never infers connectivity from a preserved receipt). Final security verdict APPROVE.Scope notes
eval/sh -cstring payloads remain a documented gap.Gates:
make preflight, docs lint (0 blockers, citation gate armed and green), record-lint all clean.