Skip to content

feat: citation gate — provably-alive, honestly-labelled citations (itd-101) - #164

Merged
REPPL merged 19 commits into
mainfrom
feat/itd-101-citation-gate
Jul 28, 2026
Merged

feat: citation gate — provably-alive, honestly-labelled citations (itd-101)#164
REPPL merged 19 commits into
mainfrom
feat/itd-101-citation-gate

Conversation

@REPPL

@REPPL REPPL commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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

  • Zero-network lint family in 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 via internal/urlguard) that writes the committed baseline — final resolved address, checked-when, and whether verification was automatic or manual, never how.
  • Manual queue + 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).
  • Staleness clock: warn at 180 days in the commit gate; the 365-day blocker binds at the release gate only (citation_baseline_overdue), so commits are never calendar-blocked.
  • This repository's real baseline is committed and the gate is armed: 50 of 51 cited URLs verified automatically; the one source that refuses automated fetchers (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, an Answered bit so a wholesale-failure guard never infers connectivity from a preserved receipt). Final security verdict APPROVE.

Scope notes

  • The scheduled-CI refresh wrapper is explicitly out of scope (its own sign-off, per the grill).
  • eval / sh -c string payloads remain a documented gap.
  • Not part of v0.4.1 — this is a separate release once merged.

Gates: make preflight, docs lint (0 blockers, citation gate armed and green), record-lint all clean.

REPPL added 19 commits July 27, 2026 22:08
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
@REPPL
REPPL merged commit 5abe5b3 into main Jul 28, 2026
12 checks passed
@REPPL
REPPL deleted the feat/itd-101-citation-gate branch July 28, 2026 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant