diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 5371f647e..2ea3254f8 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1300000, - "measured_tokens_est": 1280867, - "measured_bytes": 4931339, - "measured_at": "35483ff77ca726d81633625eaa816cae14b38d73" + "tokens_est": 1340000, + "measured_tokens_est": 1322032, + "measured_bytes": 5089824, + "measured_at": "b727b969a992532a9e67adb23734914a4f331513" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 390000, - "measured_tokens_est": 382072, - "measured_bytes": 1470980, - "measured_at": "35483ff77ca726d81633625eaa816cae14b38d73" + "measured_tokens_est": 382812, + "measured_bytes": 1473830, + "measured_at": "b727b969a992532a9e67adb23734914a4f331513" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1310000, - "measured_tokens_est": 1289903, - "measured_bytes": 4966127, - "measured_at": "35483ff77ca726d81633625eaa816cae14b38d73" + "tokens_est": 1350000, + "measured_tokens_est": 1331068, + "measured_bytes": 5124612, + "measured_at": "b727b969a992532a9e67adb23734914a4f331513" } } } diff --git a/.abcd/development/brief/04-surfaces/03-embark.md b/.abcd/development/brief/04-surfaces/03-embark.md index ba55e834d..80b67bed2 100644 --- a/.abcd/development/brief/04-surfaces/03-embark.md +++ b/.abcd/development/brief/04-surfaces/03-embark.md @@ -71,6 +71,13 @@ non-directory parent. On any such conflict the core **writes nothing**: it returns the conflict set it found, and the surface renders it. A refusal that writes a file would be a transport-agnostic-core violation. +The write runs under the target's issue-ledger lock (when it writes an issue) +and then its intent store's lock, the order every writer holding both takes, +and every planned write is judged again under them. A record created at a +planned target between the plan and the write — a capture, an intent minted in +the target meanwhile — is a conflict like any other, so it refuses the whole +write rather than being replaced. + ## 3. Scaffold steps Embark is a deterministic Go run: it reads the lifeboat, plans, refuses on any diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 26fb0e80c..f298223d9 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -307,14 +307,14 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | Plan (one intent id) | Plans a draft: mints its native spec, injects the bidirectional link (intent `spec_id` ↔ spec `intent`), stamps an identity onto every unmarked scope condition, and moves the file `drafts/` → `planned/`. An impact given at planning stamps the INTENT's product-impact judgement, because the planning interview is where that judgement is made: validated at the create path's bar (never `internal`), written as the bare scalar the create path writes, refused before anything moves when it disagrees with a judgement the record already carries, and a no-op when it agrees; without one the field is left as found and the judgement stays owed to the close (iss-2609170726457256). A production mode given at planning stamps the MINTED SPEC's disclosure pair; the intent's own stamp was written at create time and is never rewritten. On an intent already in `planned/` it does the identity step alone (no spec, no move), takes an impact under the same rules, and refuses when nothing is unmarked and no judgement is added — except where the planned record's `spec_id` is null, when it mints (or reuses the spec already naming the intent) and links the spec in place on the draft's Acceptance Criteria bar, still with no move, and the readiness gate's remedy for the missing spec names this call (iss-2609211738504433). | `drafts/` → `planned/` (stamp step: no move) | | Plan a bundle (several intent ids, a bundle name) | **The bundle command** (itd-34): plans two or more drafts as ONE shared spec. The name is the human's (the plugin page asks for it; the CLI refuses several ids without one, and a name with one id), kebab-case, and carried by no other record. It refuses a member naming another in `blocked_by`, naming the edge, and any member that is not a plannable draft — held, without criteria, already specced or naming another bundle — before the mint. It mints one spec whose frontmatter lists every member (`intents:` beside `intent:`, and `bundle:`), stamps `kind: bundle-member`, `bundle: `, the scope-condition identities and an impact given at planning onto each member, links each `spec_id` to the shared spec, and moves all of them together; a failure after the mint puts every member back and takes the spec back. The shared spec's close ships every member together. | every member `drafts/` → `planned/` | | Readiness gate (one intent id, optionally with grounds) | **Implement-readiness gate**: reports whether an intent is ready to implement — eight checks, four of which gate: in `planned/`, with acceptance criteria, a bidirectional spec link, and a written spec body. The two claim rows (mechanism prompted-and-nullable, scope conditions with each condition identified) and the grounds row (a discipline record is exempt: it carries no conjecture of its own) are reported as advisory and never withhold readiness, their refusals parked by iss-2609091009111294 until the rethink of the reading work. The steps row is advisory by design: it reports the linked spec's `## Steps` shape — the steps listed and how many have landed, or none and so one step — and names a section that is not a numbered list with the shape it expects (itd-2609212103565953). Exit 0 ready / 1 not ready / 2 fault. Recording grounds, in the form `: `, is the gate's one write: it appends the conjecture behind this decision — what is expected, and what would show it wrong — to the intent's `## Grounds` section, append-only ([adr-57](../../decisions/adrs/0057-grounds-accumulate-as-an-append-only-section.md)), and then reports; a shipped or superseded record is never backfilled. | (no move; recorded grounds append to `## Grounds`) | -| Audit (one intent id) | **Role 1 — single-document fidelity.** Takes a **shipped** intent and nothing else: a record still in `drafts/`, `planned/`, `disciplines/` or `superseded/` is refused by name, because only a shipped intent has a delivered reality to be judged against. Compares the intent's press release + acceptance criteria against delivered reality (code, configs, docs, tests). Per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) appended to the intent's `## Audit Notes`. Aligns with the spec store's `plan-review` / `impl-review` / `completion-review` vocabulary — same operation shape (adversarial second opinion), different opponent (press release vs engineering spec). spc-12 (predecessor store) ships this **manual** verb; spc-28 (predecessor store) ships the on-close hook (move `planned → shipped` + queue a review), which only queues: the queue is paid by the drain row below, on demand, and nothing runs the reviewer on its own (spc-6 (predecessor store) disowned auto-firing). | (stays) | +| Audit (one intent id) | **Role 1 — single-document fidelity.** Takes a **shipped** intent and nothing else: a record still in `drafts/`, `planned/`, `disciplines/` or `superseded/` is refused by name, because only a shipped intent has a delivered reality to be judged against. Compares the intent's press release + acceptance criteria against delivered reality (code, configs, docs, tests). Per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) appended to the intent's `## Audit Notes`. The request it writes for the host states the criteria count, lists every scope condition under the identity the verdict disposes it by, and carries the verdict shape rendered from the structure the ingest decodes, so the request alone is enough to write a verdict against. Its result names the receipt's state and, separately, whether it wrote the request: a re-emit of an owed receipt rewrites it, and a re-emit of an ingested or dead-lettered one writes none and names no request path. Aligns with the spec store's `plan-review` / `impl-review` / `completion-review` vocabulary — same operation shape (adversarial second opinion), different opponent (press release vs engineering spec). spc-12 (predecessor store) ships this **manual** verb; spc-28 (predecessor store) ships the on-close hook (move `planned → shipped` + queue a review), which only queues: the queue is paid by the drain row below, on demand, and nothing runs the reviewer on its own (spc-6 (predecessor store) disowned auto-firing). | (stays) | | Owed reviews (the audit sub-verb with no argument) | **The fidelity-review debt, listed** (itd-2609150819445595). Every close that ships an intent parks an OWED marker, so a review is owed by construction; this reads the first review marker of every intent in `shipped/` — the marker the re-emit also reuses — and lists the debt. The owed set is OWED plus no marker at all (shipped before markers, or a ship whose receipt failed to mint), each named with its receipt, or with none and the note that the re-emit mints one, and the re-emit command. A dead-lettered review is listed under its own heading as unreviewed, with the reason its quarantine block recorded, and is not counted; an ingested review is not listed. The machine-readable form carries one entry per shipped intent — id, state, receipt, and the re-emit where the review is owed — and never a path into the local tier: it names the re-emit, not the request file, which is gitignored and may have been swept. Writes nothing, exits 0, and no gate reads it: the close mints the debt in the same change, so a refusal on it would block by construction. The same reader supplies the owed count on the bare status board and the record dispatcher's next move for a shipped intent. | (no move; read-only) | | Drain (the audit sub-verb's owed form, optionally capped) | **The bounded command that pays the review debt** (itd-53). The owed set is the owed listing's, from the same reader; the ordering and the cap are the intent store's: oldest shipped first — the day the intent entered `shipped/`, read from the site's one history walk, with an intent not yet committed last and ties in the order the ids were minted; a history that cannot be read leaves every day unknown, reported as unknown rather than as not yet committed, and the queue in mint order — at most the cap's count of entries (zero or absent: no cap; negative: refused, naming the value), and the summary names how many remain beyond the cap. It emits the oldest entry's request through the single audit's own emit, minting the receipt if there was none, with the single audit's routing: the route is resolved before anything is written, a route override for the auditor applies as it does to one audit, and the request carries its routing section and the result its routing member. An entry whose request cannot be emitted (a malformed spec id, an unreadable file, a local tier that cannot be written) is listed with its error, the home in any path it names shown as `~` (as the single audit's refusal shows it), and the next entry is emitted instead, so one bad record never blocks the drain; the request is written before the intent file, so a failed emit parks no OWED stub and the entry keeps the receipt state it had. It prints the ordered list and that request's path, so a host without the plugin page drives the drain by hand: audit, ingest, run again. It runs no reviewer: the plugin page runs the loop one audit at a time through the request/ingest pair; with no auditor available every entry stays owed and the summary says why nothing ran; a verdict lands exactly as a single audit's does, and a NOT_MET on an intent the drain reaches — every one of them already shipped — is captured through `capture` naming the receipt, never fixed. A cap without the owed form, and the owed form with an intent id or with the drift check, are refused. Nothing starts the drain on its own: no hook, gate or schedule, and the close hook still only enqueues. | (no move; writes the head's OWED stub and request, as the one-intent audit does) | | Issue drift (the whole corpus, optionally strict) | **The promote join's drift check** (itd-4 AC3, in the predecessor store's spc-23 shape): walks the intent store and the issue ledger, readings included, and reports every join that does not read the same from both ends — an intent naming a record in `related_issues` that does not name it back in `related_intents` (from an issue's end a one-way `related_intents` is a loose relation and stays silent; a reading item carries none, so from its end it is reported), either end naming a record the tree does not hold, a shipped intent naming an issue that is not in `resolved/`, and a record still carrying a retired back-link key. Each finding is a warning on stderr and the run exits 0; the strict form exits 1 on any finding, for a CI gate. Findings land in `.abcd/.work.local/logs/audit/issue-drift-/report.json`. | (no move; writes only its receipt) | -| Audit ingest (a verdict JSON path) | Ingests a host-delegated intent-fidelity verdict JSON, validated fail-closed against the schema and the parked review request, and writes its per-criterion verdict and its disposition of each scope condition the intent carries into the shipped intent's `## Audit Notes`, making it the first writer into the scope-condition disposition surface (or quarantines a bad payload, which records every condition `untested`). A second ingest for the same receipt is a no-op when its payload renders to the block on the record, replaces that block in place when it renders differently, and is refused with nothing written when it does not validate. | (no move; updates `## Audit Notes`) | +| Audit ingest (a verdict JSON path) | Ingests a host-delegated intent-fidelity verdict JSON, validated fail-closed against the schema and the parked review request, and writes its per-criterion verdict and its disposition of each scope condition the intent carries into the shipped intent's `## Audit Notes`, making it the first writer into the scope-condition disposition surface (or quarantines a bad payload, which records every condition `untested`). The machine-readable result says what the ingest recorded — the verdict, a quarantine, or nothing — and carries the acceptance rollup and the disposition split only beside a recorded verdict; a quarantine states the conditions it recorded untested under a name of its own, so its result never reads as a rollup. A second ingest for the same receipt is a no-op when its payload renders to the block on the record, replaces that block in place when it renders differently, and is refused with nothing written when it does not validate. A verdict whose rendered prose cites a record id that names no record is refused, naming the id, with nothing written, wherever the repository's record-lint gates prose citations in the intent store. Each block closes on its own closing line, so prose written below it survives a replacement, and only a marker on a live line of `## Audit Notes` is review state: one in a fenced block or an HTML comment is an example. | (no move; updates `## Audit Notes`) | | Condition disposition (one shipped intent id, optionally one condition id) | **The second writer into the scope-condition disposition surface.** With the intent alone it is read-only: every scope condition the intent carries, with its standing disposition and the block that disposition came from, or `untested (no block)`; the machine-readable form carries the whole history and the fold. With a condition identity it writes one disposition against a **shipped** intent — `survived`, `narrowed`, `falsified` or `untested` — joined to what occasioned it: a reading item at any position, or a delivered intent in `shipped/` whose delivery changed the condition's standing. It appends one dated block to `## Audit Notes`, beside the fidelity verdict's blocks and in the same bullet shape. A condition's standing is its latest reading-occasioned block where it has one, and otherwise its latest verdict block: a verdict overrides a reading-occasioned block only where its rationale names that block's occasion, wherever the two sit in the section; the verdict ingest reports what it leaves standing, and a re-ingest for the same receipt that names the occasion replaces the ingested verdict. Refused, with nothing written: an intent not in `shipped/` (naming its bucket), an identity the intent does not carry or carries twice, a value outside the four, grounds below the substance floor, `narrowed` without a narrowing or a narrowing on any other value, an occasion that does not resolve, and the intent itself as its own occasion. Grounds and narrowing are redacted before the write. When a reading item's `constraint_in_play` cites a different condition's identity, the mismatch is reported and never refused: the reading names the tension and the researcher marks the condition. The block sits under the heading every reading's assembler withholds, so no disposition reaches a reading. | (no move; appends to `## Audit Notes`) | -| Consistency (the whole corpus, or one intent id) | **Role 2 — cross-document fidelity** (itd-48). Assembles the corpus — every brief page, and every intent outside `superseded/` reduced to its title, press release, scope, decisions and rule — into one input under the local tier, and writes the request beside it: the five judgement classes (terminology drift, premise contradictions, scope leakage, sequencing impossibilities, naming conflicts), the rubric, the host-computed provenance pair the audit's request carries, and the commit the tree stood at. With an intent id the pass is that intent against the rest of the corpus, and every finding must have an end in it; a superseded or unknown intent is refused. The judgement rides the host: the intent-auditor's Role 2 reads the corpus and returns findings, each naming exactly two ends, quoted. The receipt is deterministic over the scope and the corpus, so a re-emit over an unchanged corpus reuses it. | (no move; writes the request and the corpus to the local tier) | -| Consistency ingest (a findings JSON path) | Validates the returned findings fail-closed before anything is written: the request was issued here, the corpus has not moved since (the receipt is recomputed), the provenance pair is the one issued, every class and severity is in its set, each end's path is a corpus document whose text holds the end's quote (twelve characters at least), and no finding repeats another. Then it files one capture per finding — an `inconsistency` from an `agent-finding`, found during the pass that names the report, located at its first end, with the report as its evidence — unless an open record already quotes either end and names its document, in which case the finding is linked to that record rather than filed twice; and it writes a dated report on the reviews shelf naming, in its `review_of_commit` pin, the commit the pass read — marked `dirty: true`, with the uncommitted corpus paths named, when the emit or the ingest's own second reading of the tree against that commit finds a corpus document edited, untracked or deleted relative to it — the union of the two, so the mark is never lost to an edited request or a commit made since the emit — since the pass reads the working tree (itd-28's dirty-tree policy: mark, do not block) — then the receipt and every finding with both ends quoted and located and the record it was filed as or linked to. A second run the same day takes the next free suffix; the same findings ingested again are a no-op naming the report. Neither half writes the brief or an intent. | (no move; writes the report and the ledger) | +| Consistency (the whole corpus, or one intent id) | **Role 2 — cross-document fidelity** (itd-48). Assembles the corpus — every brief page, and every intent outside `superseded/` reduced to its title, press release, scope, decisions and rule — into one input under the local tier, and writes the request beside it: the five judgement classes (terminology drift, premise contradictions, scope leakage, sequencing impossibilities, naming conflicts), the rubric, the findings shape rendered from the structure the ingest decodes, the host-computed provenance pair the audit's request carries, and the commit the tree stood at. With an intent id the pass is that intent against the rest of the corpus, and every finding must have an end in it; a superseded or unknown intent is refused. The judgement rides the host: the intent-auditor's Role 2 reads the corpus and returns findings, each naming exactly two ends, quoted. The receipt is deterministic over the scope and the corpus, so a re-emit over an unchanged corpus reuses it. | (no move; writes the request and the corpus to the local tier) | +| Consistency ingest (a findings JSON path) | Validates the returned findings fail-closed before anything is written: the request was issued here, the corpus has not moved since (the receipt is recomputed), the provenance pair is the one issued, every class and severity is in its set, each end's path is a corpus document whose text holds the end's quote (twelve characters at least), and no finding repeats another. A finding it would file whose text cites a record id that names no record is refused too, naming the finding and the id, wherever the repository's record-lint gates prose citations in the issue ledger — every finding is checked before the first is filed, so nothing is written. Then it files one capture per finding — an `inconsistency` from an `agent-finding`, found during the pass that names the report, located at its first end, with the report as its evidence — unless an open record already quotes either end and names its document, in which case the finding is linked to that record rather than filed twice; and it writes a dated report on the reviews shelf naming, in its `review_of_commit` pin, the commit the pass read — marked `dirty: true`, with the uncommitted corpus paths named, when the emit or the ingest's own second reading of the tree against that commit finds a corpus document edited, untracked or deleted relative to it — the union of the two, so the mark is never lost to an edited request or a commit made since the emit — since the pass reads the working tree (itd-28's dirty-tree policy: mark, do not block) — then the receipt and every finding with both ends quoted and located and the record it was filed as or linked to. A second run the same day takes the next free suffix; the same findings ingested again are a no-op naming the report. Neither half writes the brief or an intent. | (no move; writes the report and the ledger) | | `/abcd:intent shape []` | **Role 3 — kind classification.** Examines whether an intent's declared `kind` (the noun) still fits the corpus. Surfaces *suggested* reclassifications across three live types: `kind_change`, `bundle`, `supersession`. **Bare** scans the corpus; **with ``** checks one intent. Pairs with the reclassify step (action verb that commits a `shape` finding). On-demand only per spc-29 (predecessor store; a later phase); findings land in `.abcd/.work.local/logs/audit/shape-/report.{json,md}`. Concurrency via `flock(2)` on `.abcd/coordination/shape.lock` (see § 7). Scheduled / continuous invocation is a deferred follow-up. | (stays) | | Reclassify (one intent id, its new kind) | **Late reclassification** (itd-34). A kind change — standalone ↔ bundle-member, joining a bundle another record already names — on a draft or planned record rewrites the kind (and the bundle, set or cleared) in place. A supersession, naming the successor (an intent `itd-M`, or an ADR `adr-M` when a decision redecided the question) and a reason, moves the file to `superseded/` with `superseded_by`, `kind_at_supersession` and the supersession note, and appends the record to the successor's `supersedes` in the same write; superseding one member of a bundle of two leaves the other a bundle-member whose history says the bundle now has one member. Every change appends a `reclassification_history` entry; the reason is one line, redacted. Refused with nothing written: a shipped intent's kind change (the remedy for a rule found after the fact is a discipline that supersedes it), any move into disciplines/, a planned member leaving its bundle's shared spec, a missing or superseded successor, and a held record. The result names every path moved and written. | `→ superseded/` for a supersession; otherwise no move | | Hold (one intent id and a reason) | Holds a draft or planned intent: writes `held: ""` — the reason is required, single-line and redacted through the store's scanner before the write, and the JSON reports `redacted` like the other write verbs. Planning and closing a spec refuse a held record before anything moves, naming the reason and the unhold that lifts it; `abcd ` reports the hold as the next move. Refused on a record already held (naming the standing reason — an updated reason is an unhold then a hold) and on a shipped, superseded or discipline record. The `record_provenance` lint rule reports a `held` value in a shape the verb never writes; a legal hand-typed line is byte-identical to the write and is not reported. | (no move; writes `held`) | diff --git a/.abcd/development/brief/04-surfaces/23-reading.md b/.abcd/development/brief/04-surfaces/23-reading.md index 1966bef38..ab3de0041 100644 --- a/.abcd/development/brief/04-surfaces/23-reading.md +++ b/.abcd/development/brief/04-surfaces/23-reading.md @@ -239,6 +239,18 @@ before that point and leave no refusal record at all. That is deliberate: a refu record is a record about a run, and a payload that has not yet shown which run it belongs to has nothing to be recorded against. +One refusal after that point is deliberately unrecorded. An item whose pattern or +body field cites a record id that names no record — wherever the repository's +record-lint gates prose citations in the reading-record store, which reads every +record the ingest would write — refuses the whole run, naming the item, the field +and the id, before the orphan sweep and before anything is staged. It is the gate +the verdict ingest asks, reached through the same registration. Recording it would +give the run an outcome, and the same run re-worded would then be refused as a +rerun; left parked, it is ingested again once its prose describes the record +rather than citing an id that does not exist. Like every recorded refusal, it +rolls back what an earlier, interrupted attempt at the same run left in the +ledger, so a refused run leaves no reading records. + Writes are staged. Nothing durable is written or deleted until the whole payload validates; the reading records land as one batch; and the run metadata is written **last**, as the commit marker, so a run without one never happened. An interrupted diff --git a/.abcd/development/intents/shipped/itd-100-alice-reads-one-page-and-knows-exactly-where-abcd-stands-in.md b/.abcd/development/intents/shipped/itd-100-alice-reads-one-page-and-knows-exactly-where-abcd-stands-in.md index d1181c114..04ed281cd 100644 --- a/.abcd/development/intents/shipped/itd-100-alice-reads-one-page-and-knows-exactly-where-abcd-stands-in.md +++ b/.abcd/development/intents/shipped/itd-100-alice-reads-one-page-and-knows-exactly-where-abcd-stands-in.md @@ -92,4 +92,4 @@ Gap audit: - missing: - spc-15's conditional promise that deferring the structural docs-lint rule ("every table row carries at least one footnote") would be "recorded, not silent" — no such rule ships in the diff and no implement-time deferral record appears in the delivered DECISIONS.md line or ledger captures evidence: .abcd/development/specs/closed/spc-15-alice-reads-one-page-and-knows-exactly-where-abcd-stands-in.md:46 — "If deferred, that is recorded, not silent" - evidence: .abcd/work/DECISIONS.md (delivered grill line, 2026-07-26) — "no mention of the structural lint-rule deferral" \ No newline at end of file + evidence: .abcd/work/DECISIONS.md (delivered grill line, 2026-07-26) — "no mention of the structural lint-rule deferral" diff --git a/.abcd/development/intents/shipped/itd-101-every-citation-abcd-publishes-is-provably-alive-and-honestly.md b/.abcd/development/intents/shipped/itd-101-every-citation-abcd-publishes-is-provably-alive-and-honestly.md index 5c724f99c..c1f4ee69a 100644 --- a/.abcd/development/intents/shipped/itd-101-every-citation-abcd-publishes-is-provably-alive-and-honestly.md +++ b/.abcd/development/intents/shipped/itd-101-every-citation-abcd-publishes-is-provably-alive-and-honestly.md @@ -109,4 +109,4 @@ Gap audit: - missing: - the generated, disposable checklist page that hands back a receipt file evidence: .abcd/development/specs/closed/spc-17-every-citation-abcd-publishes-is-provably-alive-and-honestly.md:105 — "The generated checklist page itself may land as the later rung" - evidence: internal/surface/cli/cite.go:165 — "the format the generated checklist page emits" \ No newline at end of file + evidence: internal/surface/cli/cite.go:165 — "the format the generated checklist page emits" diff --git a/.abcd/development/intents/shipped/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md b/.abcd/development/intents/shipped/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md index da0c55fd8..f1218ef65 100644 --- a/.abcd/development/intents/shipped/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md +++ b/.abcd/development/intents/shipped/itd-102-your-repo-says-the-same-thing-about-itself-everywhere-becaus.md @@ -86,4 +86,4 @@ Gap audit: - diverged: - Onboarding is described as an install-or-prepare interview, but abcd currently onboards only via the prepare-this-repo bridge (host asks the questions); there is no separate install-time interview beyond the CLI `identity init` it drives — a narrower-than-worded but functionally complete path evidence: internal/core/positioning/init.go:35 — "InitRequest is the onboarding interview's outcome. The host asks the questions (the prepare surface carries the wording); this is what it hands over." -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md b/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md index 8bfb3f5b4..9c9556553 100644 --- a/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md +++ b/.abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md @@ -90,4 +90,4 @@ Gap audit: - missing: - Press-release 'two planes': the teaching plane — the rules loader injecting the matched safety rules before shell-heavy work — is not wired in this delivery; only the execution-time guard plane ships (a guard/safety rules domain does not exist in internal/core/rules/) evidence: .abcd/development/intents/shipped/itd-103-abcd-teaches-repo-agents-the-shell-commands-they-must-never.md:13 — "The rules loader injects the matched safety rules before shell-heavy work" - evidence: internal/core/rules/rules.go:401 — "no guard/safety/hazard domain is registered — the only `guard` here is the stemming short-token guard" \ No newline at end of file + evidence: internal/core/rules/rules.go:401 — "no guard/safety/hazard domain is registered — the only `guard` here is the stemming short-token guard" diff --git a/.abcd/development/intents/shipped/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md b/.abcd/development/intents/shipped/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md index 4cda65b8e..602df5d81 100644 --- a/.abcd/development/intents/shipped/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md +++ b/.abcd/development/intents/shipped/itd-104-abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md @@ -86,4 +86,4 @@ Gap audit: - The adversarial leg's fresh-context/off-policy/unknown-authorship conduct is codified in the orchestrating prompt but not enforceable by the binary at ingest — the recorded artefact cannot prove the evaluator was a fresh, off-policy session evidence: internal/core/ideate/record.go:367 — "the binary cannot observe how an agent was run, and pretending to check it would be theatre" evidence: commands/abcd/ideate.md:66 — "The evaluator must not be the session that ran legs 1 and 2. Dispatch it as a separate agent with its own context." -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-114-abcd-mints-collision-proof-record-ids-across-parallel-agents.md b/.abcd/development/intents/shipped/itd-114-abcd-mints-collision-proof-record-ids-across-parallel-agents.md index 0ad533f0b..5a588226a 100644 --- a/.abcd/development/intents/shipped/itd-114-abcd-mints-collision-proof-record-ids-across-parallel-agents.md +++ b/.abcd/development/intents/shipped/itd-114-abcd-mints-collision-proof-record-ids-across-parallel-agents.md @@ -283,4 +283,4 @@ Gap audit: - missing: - In-Scope bullet 3: the optional forge-backed allocator — and with it the offline-under-forge loud-fallback behaviour ruled at the interview — is not in the delivery; spc-33 defers it by ruling to a later adapter behind the same seam, so the press release's forge-registry promise remains owed evidence: .abcd/development/specs/closed/spc-33-abcd-mints-collision-proof-record-ids-across-parallel-agents.md:18 — "adapter behind the same seam and is **out of this spec's delivery**" - evidence: .abcd/development/decisions/adrs/0045-record-ids-are-timestamp-numeric-and-capture-stable.md:43 — "4. **The optional forge allocator allocates and never stores**" \ No newline at end of file + evidence: .abcd/development/decisions/adrs/0045-record-ids-are-timestamp-numeric-and-capture-stable.md:43 — "4. **The optional forge allocator allocates and never stores**" diff --git a/.abcd/development/intents/shipped/itd-130-abcd-update-completes-a-chosen-update-in-one-verb-it-fetches.md b/.abcd/development/intents/shipped/itd-130-abcd-update-completes-a-chosen-update-in-one-verb-it-fetches.md index 0958f6c9a..9877912dd 100644 --- a/.abcd/development/intents/shipped/itd-130-abcd-update-completes-a-chosen-update-in-one-verb-it-fetches.md +++ b/.abcd/development/intents/shipped/itd-130-abcd-update-completes-a-chosen-update-in-one-verb-it-fetches.md @@ -376,4 +376,4 @@ Gap audit: evidence: $GOMODCACHE/github.com/minio/selfupdate@v0.6.0/apply.go:78 — "_, err = io.Copy(fp, bytes.NewReader(newBytes))" - The CA-canary assertion spc-32 promised ('asserted via a canary file whose read would be observable') — the tests assert the env is unset, not that a planted CA file is never read evidence: .abcd/development/specs/closed/spc-32-abcd-update-completes-a-chosen-update-in-one-verb-it-fetches.md:78 — "asserted via a canary file whose read would be observable" - evidence: internal/core/update/update_test.go:396 — "fetcher := envRecordingFetcher{onCall: func() {" \ No newline at end of file + evidence: internal/core/update/update_test.go:396 — "fetcher := envRecordingFetcher{onCall: func() {" diff --git a/.abcd/development/intents/shipped/itd-132-hook-binary-to-persistent-data-dir.md b/.abcd/development/intents/shipped/itd-132-hook-binary-to-persistent-data-dir.md index 1bf64d4f1..3bd062b7f 100644 --- a/.abcd/development/intents/shipped/itd-132-hook-binary-to-persistent-data-dir.md +++ b/.abcd/development/intents/shipped/itd-132-hook-binary-to-persistent-data-dir.md @@ -292,4 +292,4 @@ Gap audit: evidence: internal/surface/cli/bootstrap_cache_test.go:330 — "func TestBootstrapNewReleaseSkipsAbsentPathCopy" - A test pinning the cache-mode steady state for a cache-provisioned root (data dir set, binary present, no .binary-meta) at zero network; the no-network fast-path test runs without a data dir and the migration test's second run has a .binary-meta root evidence: hooks/bootstrap.sh:205 — "[ -f \"$root_meta\" ] || exit 0" - evidence: internal/surface/cli/bootstrap_test.go:369 — "func TestBootstrapFastPathTouchesNoNetwork" \ No newline at end of file + evidence: internal/surface/cli/bootstrap_test.go:369 — "func TestBootstrapFastPathTouchesNoNetwork" diff --git a/.abcd/development/intents/shipped/itd-133-abcd-has-a-face-a-block-pixel-duckling-is-the-mascot-the-off.md b/.abcd/development/intents/shipped/itd-133-abcd-has-a-face-a-block-pixel-duckling-is-the-mascot-the-off.md index 66bd2b888..e1800aa4e 100644 --- a/.abcd/development/intents/shipped/itd-133-abcd-has-a-face-a-block-pixel-duckling-is-the-mascot-the-off.md +++ b/.abcd/development/intents/shipped/itd-133-abcd-has-a-face-a-block-pixel-duckling-is-the-mascot-the-off.md @@ -172,4 +172,4 @@ Gap audit: - diverged: - the decision is linked from this intent — referenced in prose, not linked evidence: .abcd/development/intents/shipped/itd-133-abcd-has-a-face-a-block-pixel-duckling-is-the-mascot-the-off.md:55 — "recorded in the decision log" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-150-agent-worktrees-commit-without-the-private-name-guard-abcd-w.md b/.abcd/development/intents/shipped/itd-150-agent-worktrees-commit-without-the-private-name-guard-abcd-w.md index 69bbe2bcf..2c3ee5a16 100644 --- a/.abcd/development/intents/shipped/itd-150-agent-worktrees-commit-without-the-private-name-guard-abcd-w.md +++ b/.abcd/development/intents/shipped/itd-150-agent-worktrees-commit-without-the-private-name-guard-abcd-w.md @@ -84,4 +84,4 @@ Gap audit: - spc-43 said a duplicate key from the local store overrides the primary entry; the delivered hook merges the two stores as a union with no override, so both patterns are enforced (strictly more refusals, never fewer) evidence: internal/core/ahoy/defaults/pre-commit:453 — "The two stores are a UNION, never an override." evidence: .abcd/development/specs/closed/spc-43-agent-worktrees-commit-without-the-private-name-guard-abcd-w.md:60 — "a duplicate key from the local store overrides the primary entry" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-151-five-agent-prompts-read-attacker-influenceable-input-without.md b/.abcd/development/intents/shipped/itd-151-five-agent-prompts-read-attacker-influenceable-input-without.md index dab5dfafd..29dc2dba3 100644 --- a/.abcd/development/intents/shipped/itd-151-five-agent-prompts-read-attacker-influenceable-input-without.md +++ b/.abcd/development/intents/shipped/itd-151-five-agent-prompts-read-attacker-influenceable-input-without.md @@ -88,4 +88,4 @@ Gap audit: - the per-agent changelog check was promised as diff-driven ('added or changed in a diff'); delivered as a tree-shaped entry-per-prompt_version check plus a diff-armed unbumped-edit check, so the diff half fires only where CI arms a range evidence: internal/core/lint/agentcontract.go:238 — "The DIFF-shaped part runs only when a range is armed" evidence: cmd/record-lint/main.go:27 — "agentDiff := flag.String("agent-diff"" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-153-abcd-s-remote-config-apply-verb-should-enable-github-native.md b/.abcd/development/intents/shipped/itd-153-abcd-s-remote-config-apply-verb-should-enable-github-native.md index 25f3199c6..6914062e0 100644 --- a/.abcd/development/intents/shipped/itd-153-abcd-s-remote-config-apply-verb-should-enable-github-native.md +++ b/.abcd/development/intents/shipped/itd-153-abcd-s-remote-config-apply-verb-should-enable-github-native.md @@ -82,4 +82,4 @@ Gap audit: - the intent title promises enabling 'by default on every managed repo'; delivered as an explicit, confirmed verb that `ahoy install` never runs — the spec's Decisions record this as the adr-44 ruling evidence: internal/core/ahoy/remote.go:242 — "a remote write happens only through a dedicated verb the user invokes AND CONFIRMS" evidence: .abcd/development/specs/closed/spc-46-abcd-s-remote-config-apply-verb-should-enable-github-native.md:96 — "the verb acts only on explicit invocation against a managed repo" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-154-s4-gate-failed-bootstrap-provisioned-no-binary.md b/.abcd/development/intents/shipped/itd-154-s4-gate-failed-bootstrap-provisioned-no-binary.md index 17640b845..a47cd9cbe 100644 --- a/.abcd/development/intents/shipped/itd-154-s4-gate-failed-bootstrap-provisioned-no-binary.md +++ b/.abcd/development/intents/shipped/itd-154-s4-gate-failed-bootstrap-provisioned-no-binary.md @@ -87,4 +87,4 @@ Gap audit: evidence: hooks/bootstrap.sh:527 — "announcement is therefore held and spent only where it is the only thing a" - the 'answers in about a second' timing is not asserted by the gate evidence: internal/surface/cli/bootstrap_freshinstall_test.go:150 — "the five-second budget that once stood here, widened for" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-155-scanner-adjacency-galloping-probe-structural-fix.md b/.abcd/development/intents/shipped/itd-155-scanner-adjacency-galloping-probe-structural-fix.md index bb49130f3..64f7083b5 100644 --- a/.abcd/development/intents/shipped/itd-155-scanner-adjacency-galloping-probe-structural-fix.md +++ b/.abcd/development/intents/shipped/itd-155-scanner-adjacency-galloping-probe-structural-fix.md @@ -82,4 +82,4 @@ Gap audit: - the promise 'a match end is never a truncation artefact' holds under a per-line growth budget; when it is exhausted the probe reverts to the fixed 512-byte window, so on an adversarial many-junction line the old truncation shape can recur (a deliberate resource-exhaustion trade-off recorded in code, not in the intent) evidence: internal/adapter/scanner/scanner.go:606 — "reverts to exactly the fixed-window behaviour, which is bounded and was never" evidence: internal/adapter/scanner/scanner.go:583 — "if *budget < hi-at {" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-156-the-guard-tokenizer-does-not-perform-brace-expansion-so-a-fl.md b/.abcd/development/intents/shipped/itd-156-the-guard-tokenizer-does-not-perform-brace-expansion-so-a-fl.md index 8d6f23226..4b6f0e665 100644 --- a/.abcd/development/intents/shipped/itd-156-the-guard-tokenizer-does-not-perform-brace-expansion-so-a-fl.md +++ b/.abcd/development/intents/shipped/itd-156-the-guard-tokenizer-does-not-perform-brace-expansion-so-a-fl.md @@ -76,4 +76,4 @@ Gap audit: evidence: internal/core/guard/brace_test.go:26 — "`rm -rf dir{1..9}`," evidence: internal/core/guard/brace_test.go:44 — "`git push \${--force,} origin main`," - diverged: (none) -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-157-by-links-layout-publishes-overlapping-positions.md b/.abcd/development/intents/shipped/itd-157-by-links-layout-publishes-overlapping-positions.md index 20511a1ef..1bf156e30 100644 --- a/.abcd/development/intents/shipped/itd-157-by-links-layout-publishes-overlapping-positions.md +++ b/.abcd/development/intents/shipped/itd-157-by-links-layout-publishes-overlapping-positions.md @@ -85,4 +85,4 @@ Gap audit: - the overlap gate flags a by-links overlap as a red result — delivered as a non-zero printed count and a test-suite assertion; site build and site check both stay green on a non-zero count evidence: internal/surface/cli/site.go:201 — "layout: %d overlapping bubbles across both arrangements" evidence: internal/core/site/check.go:264 — "func Check(req CheckRequest) (CheckResult, error)" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-158-provenance-lacks-pass-b-exemption-marker.md b/.abcd/development/intents/shipped/itd-158-provenance-lacks-pass-b-exemption-marker.md index e3687e078..6b7c0d00d 100644 --- a/.abcd/development/intents/shipped/itd-158-provenance-lacks-pass-b-exemption-marker.md +++ b/.abcd/development/intents/shipped/itd-158-provenance-lacks-pass-b-exemption-marker.md @@ -81,4 +81,4 @@ Gap audit: - the consumer reclassifies the Pass-B section as exempt rather than listing it among the coverage blanks — delivered as one record-level declaration line; the blanks listing is unchanged evidence: internal/core/lifeboat/embark_render.go:85 — "if ex := cov.PassBExemption; ex != nil {" evidence: internal/core/lifeboat/embark_test.go:1167 — "func TestEmbarkUnmarkedProvenanceReadsAsBefore" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-160-dangling-supersedes-and-spec-targets-nothing-checks-them.md b/.abcd/development/intents/shipped/itd-160-dangling-supersedes-and-spec-targets-nothing-checks-them.md index e93d8e816..1080a1254 100644 --- a/.abcd/development/intents/shipped/itd-160-dangling-supersedes-and-spec-targets-nothing-checks-them.md +++ b/.abcd/development/intents/shipped/itd-160-dangling-supersedes-and-spec-targets-nothing-checks-them.md @@ -79,4 +79,4 @@ Gap audit: evidence: internal/core/site/check_test.go:708 — "func TestSupersedesToAPrunedRecordStillCounts" evidence: internal/core/site/check_test.go:773 — "func TestCheckPassesWhenABaselinedTargetArrives" - diverged: (none) -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-161-record-lint-cannot-see-a-decision-shaped-document-filed-outs.md b/.abcd/development/intents/shipped/itd-161-record-lint-cannot-see-a-decision-shaped-document-filed-outs.md index d8eed8dc2..7e00a92a2 100644 --- a/.abcd/development/intents/shipped/itd-161-record-lint-cannot-see-a-decision-shaped-document-filed-outs.md +++ b/.abcd/development/intents/shipped/itd-161-record-lint-cannot-see-a-decision-shaped-document-filed-outs.md @@ -81,4 +81,4 @@ Gap audit: evidence: internal/core/lint/crossstore_test.go:207 — "func TestCrossStoreIDClaimSkipsUntrackedFiles" evidence: internal/core/lint/crossstore_test.go:167 — "func TestCrossStoreIDClaimIgnoresFencedStatus" - diverged: (none) -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-162-adoption-templates-outside-record.md b/.abcd/development/intents/shipped/itd-162-adoption-templates-outside-record.md index a20b256de..652f31d7b 100644 --- a/.abcd/development/intents/shipped/itd-162-adoption-templates-outside-record.md +++ b/.abcd/development/intents/shipped/itd-162-adoption-templates-outside-record.md @@ -73,4 +73,4 @@ Gap audit: - the pre-commit config (secrets + absolute-path gate) resolves from the record or the binary — delivered as a substitution: step 5 scaffolds the private name guard instead, and the embedded hook carries no secrets or absolute-path gate evidence: commands/prepare-this-repo.md:171 — "5. **Commit gates.** Scaffold them from the binary" evidence: internal/core/ahoy/defaults/pre-commit:159 — "# --- itd-74 private name guard" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-179-the-reasoning-behind-what-was-pursued-no-longer-evaporates-a.md b/.abcd/development/intents/shipped/itd-179-the-reasoning-behind-what-was-pursued-no-longer-evaporates-a.md index 042824855..9f8a21b6f 100644 --- a/.abcd/development/intents/shipped/itd-179-the-reasoning-behind-what-was-pursued-no-longer-evaporates-a.md +++ b/.abcd/development/intents/shipped/itd-179-the-reasoning-behind-what-was-pursued-no-longer-evaporates-a.md @@ -128,4 +128,4 @@ Gap audit: evidence: .abcd/development/specs/closed/spc-57-the-reasoning-behind-what-was-pursued-no-longer-evaporates-a.md:178 — "- Any semantic judgement of whether a grounds text really names a conjecture." evidence: .abcd/development/intents/disciplines/itd-192-an-acceptance-criterion-whose-producer-does-not-exist-is-jud.md:27 — "- **Wired in this phase** — the criterion is `MET_WITH_CONCERNS`, and the" - The docs still spell the closed vocabulary by hand even though the behavioural copy is now derived — deliberately, and recorded. The behavioural half is genuinely closed (the six user-facing spellings render from `grounds.UsageSpelling()`/`ProseList()`), so what is missing is the executable enumeration the record itself prescribes as the remedy and declines to apply. - evidence: .abcd/work/issues/open/iss-2608301918362294-a-prose-enumeration-of-where-a-value-is-copied-cannot-be-mai.md:46 — "Remedy, deliberately NOT applied tonight: make the enumeration executable." \ No newline at end of file + evidence: .abcd/work/issues/open/iss-2608301918362294-a-prose-enumeration-of-where-a-value-is-copied-cannot-be-mai.md:46 — "Remedy, deliberately NOT applied tonight: make the enumeration executable." diff --git a/.abcd/development/intents/shipped/itd-184-four-cold-reading-definitions-one-blindness-core-each-positi.md b/.abcd/development/intents/shipped/itd-184-four-cold-reading-definitions-one-blindness-core-each-positi.md index 03668e36f..9e937f854 100644 --- a/.abcd/development/intents/shipped/itd-184-four-cold-reading-definitions-one-blindness-core-each-positi.md +++ b/.abcd/development/intents/shipped/itd-184-four-cold-reading-definitions-one-blindness-core-each-positi.md @@ -221,4 +221,4 @@ Gap audit: evidence: .abcd/work/issues/open/iss-2608311039586922-commands-reading-md-describes-the-bare-verb-s-definitions-fi.md:14 — "The plugin surface should say resolved rather than present, and say that a malformed definition is a refusal. The itd-184 builder's lane did not include commands/, so the correction was captured rather than taken. — OPEN" - Nothing dispatches a reading. The four definitions ship unrun for the whole cycle by spc-62's own declaration, and the regime a definition states is never rendered to an operator: Describe reports only the definition NAMES, not the regime or the file hash the locator computes. Not a criterion gap — every criterion is about the definitions and the surfaces, not about a run — but it is the bound on what any of this has been measured against, and the instruments sit at prompt_version 0.1.0, declared shipped and honestly unmeasured. evidence: internal/core/reading/status.go:31 — "Definitions []string `json:\"definitions\"` — names only; Regime and SHA256 resolved at status.go:56 are discarded" - evidence: .abcd/development/specs/closed/spc-62-four-cold-reading-definitions-one-blindness-core-each-positi.md:246 — "**Running a reading.** The instrument ships unrun for the whole cycle: the definitions are written, linted and tested, and none is dispatched." \ No newline at end of file + evidence: .abcd/development/specs/closed/spc-62-four-cold-reading-definitions-one-blindness-core-each-positi.md:246 — "**Running a reading.** The instrument ships unrun for the whole cycle: the definitions are written, linted and tested, and none is dispatched." diff --git a/.abcd/development/intents/shipped/itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md b/.abcd/development/intents/shipped/itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md index d874ac3f3..8ff1083ce 100644 --- a/.abcd/development/intents/shipped/itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md +++ b/.abcd/development/intents/shipped/itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md @@ -361,4 +361,4 @@ Gap audit: evidence: .abcd/development/intents/shipped/itd-185-one-ingest-verb-validates-every-cold-reading-output-includin.md:102 — "Whether the regime signatures lint cleanly — untested; the degradation path exists precisely because of it. — still untested at 1d84ce26" - A run refused inside the write phase leaves an empty directory standing in the COMMITTED tier. rollbackRun removes the ledger run directory only from inside the closure it runs when there were entries to remove, so a run that reached capture.IngestReading, had its directory created, and was then refused by the size invariant leaves `.abcd/work/issues/readings//` empty and permanent — the next invocation's sweep clears the stage and walks past it. Harmless in git, which does not track empty directories, but it is a durable artefact of a run the design says never happened, and it is the visible half of the same gap as the missing refusal record in ac-10. evidence: internal/core/reading/ingest.go:764 — "_ = root.Remove(ledgerRel) — inside unlink, which is only called when len(entries) > 0" - evidence: internal/core/reading/ingest.go:767 — "if len(entries) > 0 { if err := underLedgerLock(root.Name(), unlink); err != nil { — measured: after the size-refused run and a subsequent sweep, the empty committed directory survives" \ No newline at end of file + evidence: internal/core/reading/ingest.go:767 — "if len(entries) > 0 { if err := underLedgerLock(root.Name(), unlink); err != nil { — measured: after the size-refused run and a subsequent sweep, the empty committed directory survives" diff --git a/.abcd/development/intents/shipped/itd-186-the-read-block-eval-falsifies-the-firewall-planted-warm-cont.md b/.abcd/development/intents/shipped/itd-186-the-read-block-eval-falsifies-the-firewall-planted-warm-cont.md index a71358ef0..bf80db3ab 100644 --- a/.abcd/development/intents/shipped/itd-186-the-read-block-eval-falsifies-the-firewall-planted-warm-cont.md +++ b/.abcd/development/intents/shipped/itd-186-the-read-block-eval-falsifies-the-firewall-planted-warm-cont.md @@ -172,4 +172,4 @@ Gap audit: evidence: evals/README.md:82 — "`coldreading_coverage_test.go` is the matrix: one row per rule, the mutation that removes it, and the plants that die. — the same claim, to the reader" - The always-run lane is not a merge gate. Adding `cold-reading-evals` to the branch ruleset and to its committed mirror is the step that turns the delivered job into the protection spc-64's ruling describes, and it has not been taken in this range. evidence: .abcd/work/rulesets/main-protection.json:23 — "\"required_status_checks\": [ — eight contexts follow; cold-reading-evals is not one of them" - evidence: .abcd/work/issues/open/iss-2608311051046981-the-new-cold-reading-evals-ci-job-is-not-a-required-status-c.md:14 — "Add cold-reading-evals to the ruleset and to its committed mirror. — OPEN" \ No newline at end of file + evidence: .abcd/work/issues/open/iss-2608311051046981-the-new-cold-reading-evals-ci-job-is-not-a-required-status-c.md:14 — "Add cold-reading-evals to the ruleset and to its committed mirror. — OPEN" diff --git a/.abcd/development/intents/shipped/itd-187-amnesia-is-a-repository-property-proven-by-an-eval-the-same.md b/.abcd/development/intents/shipped/itd-187-amnesia-is-a-repository-property-proven-by-an-eval-the-same.md index 878d0924c..30cac44d9 100644 --- a/.abcd/development/intents/shipped/itd-187-amnesia-is-a-repository-property-proven-by-an-eval-the-same.md +++ b/.abcd/development/intents/shipped/itd-187-amnesia-is-a-repository-property-proven-by-an-eval-the-same.md @@ -168,4 +168,4 @@ Gap audit: evidence: .abcd/work/issues/open/iss-2608311331229368-spc-65-and-itd-187-both-prescribe-watching-testassembledinpu.md:13 — "Run on a copy of the tree, that mutation does not make it red — OPEN; neither record was corrected" - No assertion anywhere in this eval compares the two manifests modulo run_id, which is the cheap closure iss-2608311331273317 itself names. The manifest is held to two weaker properties and otherwise excluded, so within itd-187's own delivery a manifest-only nondeterminism is invisible; the backstop is spc-61's package test, which is a different lane, runs in one directory, and is not what itd-187 promised. evidence: evals/coldreading_determinism_test.go:113 — "compareArtefacts(bundleFile, a.BundleRaw, b.BundleRaw) — the only comparison; ManifestRaw is never compared between the two runs" - evidence: .abcd/work/issues/open/iss-2608311331273317-internal-core-reading-manifest-go-documents-that-two-assembl.md:13 — "The cheap closure is a manifest comparison modulo run_id in the same eval. — OPEN" \ No newline at end of file + evidence: .abcd/work/issues/open/iss-2608311331273317-internal-core-reading-manifest-go-documents-that-two-assembl.md:13 — "The cheap closure is a manifest comparison modulo run_id in the same eval. — OPEN" diff --git a/.abcd/development/intents/shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md b/.abcd/development/intents/shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md index 3748157b6..5686edbdc 100644 --- a/.abcd/development/intents/shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md +++ b/.abcd/development/intents/shipped/itd-189-what-the-widening-reading-proposes-is-admitted-or-declined-o.md @@ -149,4 +149,4 @@ Gap audit: evidence: internal/core/lint/readingoutstanding.go:654 — "checkReadingOutstanding renders the report as findings, every one of them at severityInfo whatever the configuration says." evidence: internal/core/lint/reading_outstanding_test.go:523 — "func TestAdmissionLegSeverityIsInfoNotBlocker(t *testing.T) {" - `abcd ` dispatch does not resolve an adm-N or an srp-N — the cited-id grammar still covers the four id-bearing families only. Declared by the spec as a residual it shares with spc-58. - evidence: .abcd/development/specs/closed/spc-67-what-the-widening-reading-proposes-is-admitted-or-declined-o.md:1 — "Dispatching `abcd ` on `adm-N` or `srp-N`, which shares spc-58's residual" \ No newline at end of file + evidence: .abcd/development/specs/closed/spc-67-what-the-widening-reading-proposes-is-admitted-or-declined-o.md:1 — "Dispatching `abcd ` on `adm-N` or `srp-N`, which shares spc-58's residual" diff --git a/.abcd/development/intents/shipped/itd-198-an-assembly-reports-what-it-would-cost-before-a-reading-is.md b/.abcd/development/intents/shipped/itd-198-an-assembly-reports-what-it-would-cost-before-a-reading-is.md index 186fc6c45..75ff2c395 100644 --- a/.abcd/development/intents/shipped/itd-198-an-assembly-reports-what-it-would-cost-before-a-reading-is.md +++ b/.abcd/development/intents/shipped/itd-198-an-assembly-reports-what-it-would-cost-before-a-reading-is.md @@ -195,4 +195,4 @@ Scope-condition dispositions: evidence: internal/core/reading/size_test.go:271 — "wantItem := map[string]bool{\"item_key\": true, \"kind\": true, \"text\": true}" - cond-2608311949589261 — survived: The assumption held over the delivery: itd-194 has not landed — it remains in the drafts bucket — and the include table still admits Go source under its own row, so no percentage in this intent rests on a narrowing that occurred. evidence: .abcd/development/intents/drafts/itd-194-the-reading-include-table-admits-only-what-the-exclusion-flo.md:1 — "itd-194 is in the drafts bucket, not shipped" - evidence: internal/core/reading/include.go:318 — "Match: []string{\".go\"}, Kind: KindSource," \ No newline at end of file + evidence: internal/core/reading/include.go:318 — "Match: []string{\".go\"}, Kind: KindSource," diff --git a/.abcd/development/intents/shipped/itd-199-a-reading-is-about-something-narrower-than-everything-its.md b/.abcd/development/intents/shipped/itd-199-a-reading-is-about-something-narrower-than-everything-its.md index 9bf1e7605..477eac953 100644 --- a/.abcd/development/intents/shipped/itd-199-a-reading-is-about-something-narrower-than-everything-its.md +++ b/.abcd/development/intents/shipped/itd-199-a-reading-is-about-something-narrower-than-everything-its.md @@ -255,4 +255,4 @@ Scope-condition dispositions: - cond-2608312031020321 — survived: The impact stayed `fix` and the delivery does not disturb the reasoning: the verb gained a required third operand, which is the textbook breaking shape, but the pre-existing output was unusable at every position and comparative was returning a corpus that was not its object. The record carries the label unchanged. evidence: .abcd/development/intents/shipped/itd-199-a-reading-is-about-something-narrower-than-everything-its.md:9 — "impact: fix" evidence: internal/surface/cli/reading.go:97 — "if scope == \"\" {" - evidence: internal/surface/cli/regime_surface_test.go:86 — "\"abcd reading assemble\": {\"dry-run\", \"out\", \"position\", \"scope\", \"target\"}," \ No newline at end of file + evidence: internal/surface/cli/regime_surface_test.go:86 — "\"abcd reading assemble\": {\"dry-run\", \"out\", \"position\", \"scope\", \"target\"}," diff --git a/.abcd/development/intents/shipped/itd-4-issue-capture.md b/.abcd/development/intents/shipped/itd-4-issue-capture.md index 10c015c68..39e04b931 100644 --- a/.abcd/development/intents/shipped/itd-4-issue-capture.md +++ b/.abcd/development/intents/shipped/itd-4-issue-capture.md @@ -187,4 +187,4 @@ Gap audit: evidence: .abcd/work/DECISIONS.md:586 — "no dead migration code built" evidence: internal/core/capture/capture.go:2 — "a per-repo issue ledger that replaces the free-form" - brief § 5 reserved-meta-command table covering /abcd:dredge and /abcd:reflect — the brief reserves /abcd:audit alone - evidence: .abcd/development/brief/04-surfaces/16-lint.md:16 — "`/abcd:audit` stays reserved for itd-16's hash-chain fidelity surface." \ No newline at end of file + evidence: .abcd/development/brief/04-surfaces/16-lint.md:16 — "`/abcd:audit` stays reserved for itd-16's hash-chain fidelity surface." diff --git a/.abcd/development/intents/shipped/itd-40-folder-classification.md b/.abcd/development/intents/shipped/itd-40-folder-classification.md index 469233104..e694b648d 100644 --- a/.abcd/development/intents/shipped/itd-40-folder-classification.md +++ b/.abcd/development/intents/shipped/itd-40-folder-classification.md @@ -102,4 +102,4 @@ Gap audit: - diverged: - Provenance-only deviation: spc-5 is record catch-up (engine predates the spec) and the report cut was delegated to a sub-agent worker with the orchestrator re-running the gate; this is a signed-off process note, not a behaviour divergence from the ACs. evidence: .abcd/development/specs/closed/spc-5-folder-classification.md:44 — "Deviation: none in behaviour, one in provenance" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-46-abcd-intent-quoted-text-create-symmetric.md b/.abcd/development/intents/shipped/itd-46-abcd-intent-quoted-text-create-symmetric.md index 95684866c..38417a674 100644 --- a/.abcd/development/intents/shipped/itd-46-abcd-intent-quoted-text-create-symmetric.md +++ b/.abcd/development/intents/shipped/itd-46-abcd-intent-quoted-text-create-symmetric.md @@ -161,4 +161,4 @@ Gap audit: evidence: .abcd/development/specs/closed/spc-7-abcd-intent-quoted-text-create-symmetric.md:61 — "Typo-guard asymmetry accepted for now" - missing: - No promote-path regression test landed with the spec despite the intent's Open Questions flagging it should; promote's issue-text-to-create handoff is exercised only via the shared create-engine tests, not a promote-specific test. - evidence: .abcd/development/intents/shipped/itd-46-abcd-intent-quoted-text-create-symmetric.md:102 — "regression tests for it should land with this intent's spec, not as a side note" \ No newline at end of file + evidence: .abcd/development/intents/shipped/itd-46-abcd-intent-quoted-text-create-symmetric.md:102 — "regression tests for it should land with this intent's spec, not as a side note" diff --git a/.abcd/development/intents/shipped/itd-67-installable-versioned-plugin.md b/.abcd/development/intents/shipped/itd-67-installable-versioned-plugin.md index 812e46ab4..e4af9fba0 100644 --- a/.abcd/development/intents/shipped/itd-67-installable-versioned-plugin.md +++ b/.abcd/development/intents/shipped/itd-67-installable-versioned-plugin.md @@ -161,4 +161,4 @@ Gap audit: - an explicit --version < x.0.0> override on the ship verb evidence: internal/surface/cli/ship.go:262 — "cmd.Flags().StringVar(&changelogJSON, "changelog-json"" - a launch report naming the completed phase - evidence: internal/surface/cli/ship.go:143 — "return string(cut.Impact) + ": " + strings.Join(cut.DecidedBy, ", ")" \ No newline at end of file + evidence: internal/surface/cli/ship.go:143 — "return string(cut.Impact) + ": " + strings.Join(cut.DecidedBy, ", ")" diff --git a/.abcd/development/intents/shipped/itd-73-derived-versioning.md b/.abcd/development/intents/shipped/itd-73-derived-versioning.md index e3d2d94ff..43b632e52 100644 --- a/.abcd/development/intents/shipped/itd-73-derived-versioning.md +++ b/.abcd/development/intents/shipped/itd-73-derived-versioning.md @@ -123,4 +123,4 @@ Gap audit: - missing: - the marketplace manifest carrying the derived version as a published artefact — the stamped payload is optional and unpublished evidence: internal/surface/cli/ship.go:224 — "if payloadDir != "" && ingested.Written {" - evidence: .github/workflows/release.yml:327 — "gh release create "${TAG}" bin/abcd-* bin/checksums.txt" \ No newline at end of file + evidence: .github/workflows/release.yml:327 — "gh release create "${TAG}" bin/abcd-* bin/checksums.txt" diff --git a/.abcd/development/intents/shipped/itd-80-intent-lifecycle-automation.md b/.abcd/development/intents/shipped/itd-80-intent-lifecycle-automation.md index b34cfd08a..311fa59f1 100644 --- a/.abcd/development/intents/shipped/itd-80-intent-lifecycle-automation.md +++ b/.abcd/development/intents/shipped/itd-80-intent-lifecycle-automation.md @@ -143,4 +143,4 @@ Gap audit: evidence: internal/core/intent/review.go:326 — "if state == \"INGESTED\"" - missing: - Automated capture of the delivered diff into the review request is not implemented (the host supplies the range) — the only promise-relevant gap, cross-referenced by ac-3's concern; deliberate under adr-25. - evidence: internal/core/intent/review.go:168 — "func emitReviewForIntent" \ No newline at end of file + evidence: internal/core/intent/review.go:168 — "func emitReviewForIntent" diff --git a/.abcd/development/intents/shipped/itd-88-lifeboat-coverage-experiment.md b/.abcd/development/intents/shipped/itd-88-lifeboat-coverage-experiment.md index 56403148c..f920a126c 100644 --- a/.abcd/development/intents/shipped/itd-88-lifeboat-coverage-experiment.md +++ b/.abcd/development/intents/shipped/itd-88-lifeboat-coverage-experiment.md @@ -149,4 +149,4 @@ Gap audit: evidence: .abcd/development/research/notes/2026-07-26-itd-88-coverage-experiment.md:134-137 — "suggestive but not yet a trustworthy population — the finding here is a first reading" - missing: - Pass B ships as a declared exemption in `_provenance.json`, never a silent gap — no exemption field or marker exists anywhere in the lifeboat package or the Provenance struct - evidence: internal/core/lifeboat/plan.go:65-80 — "type Provenance struct { SchemaVersion … Omissions } — no exemption field; grep 'exemption' across internal/core/lifeboat/ returns nothing" \ No newline at end of file + evidence: internal/core/lifeboat/plan.go:65-80 — "type Provenance struct { SchemaVersion … Omissions } — no exemption field; grep 'exemption' across internal/core/lifeboat/ returns nothing" diff --git a/.abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md b/.abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md index e0a23cf2b..0a7ce65b4 100644 --- a/.abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md +++ b/.abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md @@ -131,4 +131,4 @@ Gap audit: evidence: internal/surface/cli/cli.go:944 — "Run `/abcd:ahoy install` (or `abcd ahoy install`) to start recording." - missing: - "stores your session transcripts as they happen" — a session ended by hard crash or SIGKILL fires no SessionEnd and is never captured; declared as a deliberate trade-off in the intent, not closed by the delivery - evidence: .abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md:71 — "`SessionEnd` does **not** fire on a hard crash or `SIGKILL`" \ No newline at end of file + evidence: .abcd/development/intents/shipped/itd-89-start-the-transcript-clock.md:71 — "`SessionEnd` does **not** fire on a hard crash or `SIGKILL`" diff --git a/.abcd/development/intents/shipped/itd-94-an-intent-that-is-not-planned-cannot-be-implemented-abcd-gai.md b/.abcd/development/intents/shipped/itd-94-an-intent-that-is-not-planned-cannot-be-implemented-abcd-gai.md index 1ee07904a..50e802b76 100644 --- a/.abcd/development/intents/shipped/itd-94-an-intent-that-is-not-planned-cannot-be-implemented-abcd-gai.md +++ b/.abcd/development/intents/shipped/itd-94-an-intent-that-is-not-planned-cannot-be-implemented-abcd-gai.md @@ -118,4 +118,4 @@ Gap audit: - diverged: - The intent title promises the gate "refuses with a remedy"; terminal buckets (shipped, disciplines, superseded) deliberately refuse without one — remedies attach only to fixable states evidence: internal/core/intent/ready.go:101-102 — "Terminal buckets carry no remedy: there is nothing to fix, the answer is simply no." -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-95-disembark-grounds-a-lifeboat-s-open-questions-on-a-repo-s-to.md b/.abcd/development/intents/shipped/itd-95-disembark-grounds-a-lifeboat-s-open-questions-on-a-repo-s-to.md index 35c1a17b6..c2f294716 100644 --- a/.abcd/development/intents/shipped/itd-95-disembark-grounds-a-lifeboat-s-open-questions-on-a-repo-s-to.md +++ b/.abcd/development/intents/shipped/itd-95-disembark-grounds-a-lifeboat-s-open-questions-on-a-repo-s-to.md @@ -140,4 +140,4 @@ Gap audit: - markers may ground or partially ground the section — delivered with a hard partial ceiling; volume moves confidence only evidence: internal/core/lifeboat/sources_conventions_test.go:357 — "TestConvOpenQuestionsCeilingIsPartial" evidence: internal/core/lifeboat/sources_conventions.go:771 — "const convMarkerMediumConfidence = 10" -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/development/intents/shipped/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md b/.abcd/development/intents/shipped/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md index 5c1fa7030..74813efbc 100644 --- a/.abcd/development/intents/shipped/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md +++ b/.abcd/development/intents/shipped/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md @@ -146,4 +146,4 @@ Gap audit: - real documentation -> grounded — delivered with a partial ceiling for both sections; documentation quality moves confidence, not status evidence: internal/core/lifeboat/sources_conventions.go:606 — "The ceiling is StatusPartial by construction" evidence: internal/core/lifeboat/sources_conventions.go:518 — "Status: StatusPartial," -- missing: (none) \ No newline at end of file +- missing: (none) diff --git a/.abcd/docs-lint.json b/.abcd/docs-lint.json index 7bd3c040b..825ae5cf1 100644 --- a/.abcd/docs-lint.json +++ b/.abcd/docs-lint.json @@ -254,7 +254,28 @@ "rules": { "links_resolve": { "enabled": true, - "severity": "blocker" + "severity": "blocker", + "extra_roots": [ + "AGENTS.md", + "CONTRIBUTING.md", + "CHANGELOG.md", + "ACKNOWLEDGEMENTS.md", + "SECURITY.md", + "RELEASE.md", + ".abcd/README.md", + ".github", + "agents", + "commands", + "evals/README.md", + "evals/data/README.md", + "internal/README.md", + "site-src/README.md" + ] + }, + "persona_registry": { + "enabled": true, + "severity": "blocker", + "registry": ".abcd/development/personas.json" }, "link_anchors": { "enabled": true, diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 91cac9a5d..217d28bd8 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2566,6 +2566,7 @@ together (the script's header says why there is no escape hatch). - 2026-09-26 — Three departures the scribe lane (itd-2609020625402599, spc-2609020626045177) made from its closed spec, which its review found recorded only in code, the chapter or the lane report, recorded here (implementer of lane fix2-scribe, autonomous run A). First, the surface chapter is `04-surfaces/31-scribe.md`, not the `24-scribe.md` the spec names: row 24 is `decide`'s, taken before the lane landed, so the chapter took the next free row. The spec is closed and keeps its text; four open lanes claim row 31 (build, lab, source ledger and this one), so the integration step renumbers three of them. Second, the transcript store's check is `SessionSeparation(repoRoot, rootSHA)`, not the `SessionSeparation(rootSHA)` the spec names, because it reads through `history.List`, which takes the repository root to find a checkout's opt-in per-repo transcript store; the report is unchanged. Third, the scribe's context is assembled from the ledger as it stands in the working tree, uncommitted records included, while the intent's scope condition (cond-2609020626046719) says committed ledger content. The working-tree read is what the code does today and the chapter says so. Whether the condition or the code should move is not decided here: it is captured as iss-2609261056373310, a ruling owed. - 2026-09-25 — itd-2609211913453478's acceptance criterion 4 ships under two readings the intent's scope line does not state. A glossary entry's `not_to_be_confused_with` passes when at least one member names a family row on the record-families page or the page itself, where the scope line says the field "may name only a family on the page"; the stricter reading would force nonsense pairs such as warm against intent, and the entries keep their real confusion pairs. The family-key rule (`record_family_key`, warn) reports a record frontmatter key only when the glossary already marks that word superseded or forbidden, so a brand-new grouping word with no row (the intent's own Mechanism case, e.g. an `initiative:` key) is not detected by construction, and the stores the page does not row (adr, rdi, dsp, rdg, adm, srp) are not reported. The six `grandfathered_at_phase` warnings on itd-20, 27, 28, 63, 69 and 72 are history and stay. Recorded for the product thinker to confirm or widen (autonomous run A, glossary lane review, orchestrator abcd-39). - 2026-09-26 — The lab store is keyed `~/.abcd/lab///`, with one `index.jsonl` registry per root-sha lane beside the lab homes (lane implementer, autonomous run A, on review-lab's third finding against spc-2609212141418943 for itd-2609212137128014). This supersedes two recorded texts: the spec's literal `~/.abcd/lab//` (scope item 1), and the 2026-08-31 lab-convention entry's hand-run keying `~/.abcd/lab/-/` with a single top-level `~/.abcd/lab/index.jsonl`, whose stated divergence from root-sha keying is withdrawn. Why: the intent's scope condition keys the store "as the other machine-scoped stores are", and the worktree and transcript stores key on the repository's root commit, because a checkout moves, is renamed and is cloned twice on one machine while its root commit does none of that; a lab's identity is still its intention, carried by its id `lab--` (the UTC mint time and the pin), so several labs share one baseline inside one lane. The hand-run labs that predate the verb stay where they are, beside the root-sha lanes, and the verb neither reads nor writes them or the top-level registry, so no real lab is moved or migrated by the change. A later text naming `~/.abcd/lab//` (the open spc-2609221011151661's `pairs.jsonl` among them) means the lab home inside its root-sha lane. +- 2026-09-26 — The reading ingest's prose-citation refusal departs from "every refusal past the identity point is recorded" (the rule `refuse()` in internal/core/reading/ingest.go enforces, from iss-2608311518250688): it is returned unrecorded, because a recorded refusal gives the run an outcome and `refuseARerun` would then refuse the same run re-worded, where the run left parked is ingested again once its prose describes the record rather than citing a missing id — the stance the verdict ingest takes. It keeps the rest of the refusal contract: it rolls back this run id's own half-landed records and stage as `refuse()` does, and runs no sweep of other runs' orphans. `04-surfaces/23-reading.md` states it (implementer of lane fix2-drainL, autonomous run A, on review-drainL's design point for iss-2609261835118276). - 2026-09-26 — Three of the rules loader's security records close, and one stays owed to the product thinker (autonomous run A orchestrator's lane brief, taken by the implementer of lane drainS2). (1) The home directory is never a session's repo root (iss-2609020219198779, answering the owed question "is a home-directory git toplevel a legitimate config scope, or excluded outright?" as the brief rules it): its `.abcd/` is the user layer, so the root walk passes over the home and a toplevel that is the home resolves like a non-repo directory. The lane narrowed the brief's "or an ancestor of HOME": a toplevel that contains the home, the shape of a hermetic harness that points `HOME` inside its checkout, stays the root because git vouched for it and its own `.abcd/` is its own; only the stop at the home is removed. A session whose working directory is the home still reads a `.abcd/` there as the working directory's, the posture question recorded on 2026-09-25. (2) A bundled guardrail that an override withholds is named on every load (iss-174): for COMMITTING, LOAD and PII, each bundled recall keyword, alias or rule missing from a list an override set goes to stderr with the file whose list is in force, and the merge stays per field. The other bundled domains are left out because a repository restates them in its own words, and a note on every restatement would bury the one that matters. Still owed to the product thinker: whether security-bearing lists should union with the bundled entries or take a replace-versus-extend marker instead (itd-117's finer-grained-merging follow-up). (3) The foreign-uid refusal says what it still reads (iss-2609251522588539): the note, the configuration chapter and the install how-to now say that a `.abcd/` at the working directory is read, as AGENTS.md has since 0434d475. (4) iss-2609020219265817 is deferred past v0.11.0, not closed. Every CommonMark heading construct in a rule body (ATX on any line, the first line included, setext, and HTML h1-h6) can be closed only by a code-safe rendering that flattens legitimate structure, or by a fence-aware escaper that is complete only by enumeration and changes the raw text the model reads. So "escaped, fenced, or left to the line-start contract" is the product thinker's ruling. - 2026-09-26 — The build loop's worktree store is keyed on the FULL root sha: a lane lives at `~/.abcd/worktrees//-` with the 40-hex root commit, the form the history, transcript and voyage stores use and the one the store's draft (itd-2609091014076309) specifies. The lanes of autonomous run A made by hand under the abbreviated key (`~/.abcd/worktrees/488a0aa9//`) are the pre-verb convention, not a second form of the store: the loop never reads or adopts a lane under that key, and those worktrees are retired with `git worktree remove` like any other (implementer of fix round fix2-loop2, autonomous run A, on item 4 of the loop2 review; spc-2609202134338445 piece 6). - 2026-09-28 — Rulings Z, AR and the cancel policy, given by the user as technical facilitator at 15:10:37Z (autonomous run A, recorded by lane cap45 for orchestrator abcd-a8). (Z) The macOS leg of ci.yml's `check` job and the main ruleset's merge-queue `check_response_timeout_minutes` both rise from 30 to 45 minutes, because a 30-minute cap cancelled passing macOS runs (#728, #730 twice, #733; iss-2609281514435020). This amends the standing rule that never raises the check job's timeout or edits the ruleset, for exactly this one change: 45 minutes, those two settings; every other timeout, every required-check name or split and every other ruleset field stays as it is, and the ubuntu leg keeps 30. The workflow and the `.abcd/work/rulesets/main-protection.json` mirror change through a reviewed pull request; the live ruleset is changed with `gh api` by the run's orchestrator after that pull request merges, and until then the live queue still fails a group at 30 minutes. (AR) All three speed-ups of iss-2609261924541555 are built: a test-only switch that skips the disk flush in the atomic write code and the slowest -race package first in the race step (lane ciSpeed), and the scanner's per-identity git calls folded into one (lane scanFold), a trust path that is security-reviewed before it lands. (Cancel policy) The rerun-once rule stands: a check cancelled at the cap is rerun once, and a second cancellation for the same reason stops that pull request and opens a speed lane, never another raise of the timeout; and a step on the macOS leg warns, in the log and the step summary, once the check has run past 35 minutes, without ever failing the job, so a speed lane opens before any cancellation. diff --git a/.abcd/work/issues/open/iss-2609262011091645-the-fidelity-review-request-s-delivered-line-still-leaves.md b/.abcd/work/issues/open/iss-2609262011091645-the-fidelity-review-request-s-delivered-line-still-leaves.md new file mode 100644 index 000000000..9df72afef --- /dev/null +++ b/.abcd/work/issues/open/iss-2609262011091645-the-fidelity-review-request-s-delivered-line-still-leaves.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609262011091645" +slug: "the-fidelity-review-request-s-delivered-line-still-leaves" +severity: "minor" +category: "ux" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/audit.go" +--- + +The fidelity review request's delivered line still leaves the diff range to the host: auditPromptBody in internal/core/intent/audit.go writes 'the diff/commit range that realised ALL of spc-… (host supplies the range)', and an orchestrator hand-composes the base-to-merge range into the auditor's brief. This is the third addendum of iss-2609181121301638, left open when that record's scope-condition identities and criterion count were fixed, because it needs a design call the other two did not: the range is knowable from the specs' close commits and the merge commit, but a value read from git inside the hashed prompt body breaks the ingest's byte-for-byte prompt_hash recomputation unless the emit pins it (in the receipt marker, say), while a range stated outside the prompt, beside the Routing section, is unattested. Wanted: the request states the delivered range, attested or plainly marked as not. diff --git a/.abcd/work/issues/open/iss-46-lint-scope-holes.md b/.abcd/work/issues/open/iss-46-lint-scope-holes.md index 658f8597a..3cd7a64f8 100644 --- a/.abcd/work/issues/open/iss-46-lint-scope-holes.md +++ b/.abcd/work/issues/open/iss-46-lint-scope-holes.md @@ -7,6 +7,8 @@ category: "process" source: "agent-finding" found_during: "2026-07-08 multi-agent review" found_at: "Makefile" +deferred_after: "v0.11.0" +deferral_reason: "Four of the five holes are closed: link-lint walks every committed markdown file (TestEveryCommittedMarkdownFileHasItsLinksChecked), docs-lint arms the persona rule, make preflight runs fmt-check and record-lint armed as CI does on CI's toolchain, and CONTRIBUTING.md documents the hooks' activation and the banlist the name guard reads. What remains is the warn-baseline ratchet in record-lint and the scope matrix beyond links, a design that needs a ruling: which warn rules freeze their current findings, where the frozen baseline lives, and how it may only shrink." --- lint scope holes and gate parity: link-lint does not cover all committed markdown; the persona rule is absent from docs-lint; record-lint blocking semantics are inconsistent between local and CI and there is no warn-baseline ratchet; gofmt is missing from make preflight though attributed to it; repo-local hooks activation and its provisioning dependency are undocumented. Detector (per ratchet-not-big-bang): a lint scope matrix (which rule covers which tree, checked into the record) plus baseline-ratchet support in record-lint so new rules arm immediately against frozen violations. Acceptance corpus: the five holes above. \ No newline at end of file diff --git a/.abcd/work/issues/open/iss-2609020529185438-markerre-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md b/.abcd/work/issues/resolved/iss-2609020529185438-markerre-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md similarity index 58% rename from .abcd/work/issues/open/iss-2609020529185438-markerre-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md rename to .abcd/work/issues/resolved/iss-2609020529185438-markerre-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md index 42dba7420..8644fcdfa 100644 --- a/.abcd/work/issues/open/iss-2609020529185438-markerre-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md +++ b/.abcd/work/issues/resolved/iss-2609020529185438-markerre-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "The marker is matched on one line at a time, and only on live lines of the live Audit Notes section as condition.AuditNotes reads them through mdrecord.Mask, so a marker in a fence (backtick or tilde), an HTML comment span or another section is not state; the writer, the dead-letter reason reader and condition.ReadDispositions read through the same mask. TestOnlyALiveMarkerCounts, TestAFencedMarkerIsNotASolicitation, TestAppendLandsInTheLiveAuditNotes and TestReadDispositionsReadsOnlyLiveBlocks pin it; moving the state into frontmatter remains the durable option and is not attempted." +impact: fix +resolved_by: + commit: "55964951091ab03bd9c07167ab6a26f0cb7df7c7" --- markerRe is a byte pattern, not a grammar: the intent audit's review marker (an HTML comment naming a state and a receipt id) is matched by a regex over the record's raw bytes, so the ledger's notion of review state is whatever the bytes look like rather than what a markdown reader would parse. It is now line-anchored and whole-line, and termsafe breaks the comment delimiters in every field the ingest writes, so a marker can no longer be forged from a verdict payload. What remains is that the pattern still cannot tell a fenced code block, a quoted example in the brief, or an indented literal from a live marker: a marker-shaped line at column zero inside a fence counts. A durable fix parses the Audit Notes section as markdown, or moves the state out of a comment into frontmatter the record schema owns. Evidence symbol: markerRe in internal/core/intent/audit.go, read by existingMarker, markerState and upsertReviewBlock. + +## Grounds + +- pursued: only a marker a markdown reader parses as a live comment in Audit Notes carries review state; a fenced or commented marker that solicits, routes or silences a verdict would show it wrong diff --git a/.abcd/work/issues/open/iss-2609021152026246-preflight-runs-record-lint-unarmed-so-the-agent-contract-changelog-check-is-ci-only.md b/.abcd/work/issues/resolved/iss-2609021152026246-preflight-runs-record-lint-unarmed-so-the-agent-contract-changelog-check-is-ci-only.md similarity index 67% rename from .abcd/work/issues/open/iss-2609021152026246-preflight-runs-record-lint-unarmed-so-the-agent-contract-changelog-check-is-ci-only.md rename to .abcd/work/issues/resolved/iss-2609021152026246-preflight-runs-record-lint-unarmed-so-the-agent-contract-changelog-check-is-ci-only.md index a90d32703..9498cc0dd 100644 --- a/.abcd/work/issues/open/iss-2609021152026246-preflight-runs-record-lint-unarmed-so-the-agent-contract-changelog-check-is-ci-only.md +++ b/.abcd/work/issues/resolved/iss-2609021152026246-preflight-runs-record-lint-unarmed-so-the-agent-contract-changelog-check-is-ci-only.md @@ -9,6 +9,14 @@ found_during: "pr-606-merge-2026-09-02" origin: researcher-authored production_mode: hand-written found_at: "Makefile" +resolution: "The record-lint target passes -agent-diff origin/main...HEAD, the merge-base range CI's step passes from its base, so agent_contract's unbumped-prompt check arms under make preflight as it does in CI. TestPreflightArmsRecordLintAsCIDoes holds the recipe to CI's step." +impact: internal +resolved_by: + commit: "525dd9ee1" --- make preflight runs record-lint without -agent-diff, so the agent_contract sub-check that asks whether a changed agent prompt bumped its prompt_version and wrote its agents/CHANGELOG.md entry is a no-op locally and arms only in CI, which passes -agent-diff. PR 606 passed a green local preflight three times and was refused in the merge queue for exactly that check: agents/cold-reading-widening.md changed with prompt_version still 0.1.0. Same shape as iss-2608311632382737 (preflight blind to the eval lanes, fixed by PR 607): a gate the push relies on that the local run cannot see. Fix: preflight passes -agent-diff origin/main...HEAD (or the merge-base) to record-lint, and the gate-roster test that pins preflight's contents asserts it. + +## Grounds + +- pursued: a changed agent prompt with an unbumped prompt_version fails make preflight before it reaches the merge queue; a green preflight on such a change would show it wrong diff --git a/.abcd/work/issues/open/iss-2609090951287096-pinned-gofmt-merges-toolchain-download-output-into-goroot.md b/.abcd/work/issues/resolved/iss-2609090951287096-pinned-gofmt-merges-toolchain-download-output-into-goroot.md similarity index 72% rename from .abcd/work/issues/open/iss-2609090951287096-pinned-gofmt-merges-toolchain-download-output-into-goroot.md rename to .abcd/work/issues/resolved/iss-2609090951287096-pinned-gofmt-merges-toolchain-download-output-into-goroot.md index 8de7dae77..ef1fc72cd 100644 --- a/.abcd/work/issues/open/iss-2609090951287096-pinned-gofmt-merges-toolchain-download-output-into-goroot.md +++ b/.abcd/work/issues/resolved/iss-2609090951287096-pinned-gofmt-merges-toolchain-download-output-into-goroot.md @@ -9,6 +9,14 @@ found_during: "adversarial-review" origin: researcher-authored production_mode: hand-written found_at: "Makefile" +resolution: "The toolchain resolution moved into scripts/pinned-toolchain.sh, which prints the declared GOROOT on stdout alone and lets the fetch's progress and errors pass through on stderr; a fetch that failed and a root without go or gofmt refuse separately. TestPinnedToolchainResolverIgnoresDownloadProgress drives it with a stub go that prints download progress on stderr." +impact: internal +resolved_by: + commit: "128e3b4ae" --- The format gate resolves the pinned toolchain by capturing the go environment query for the toolchain root with stderr merged into stdout, so on the first run on a machine that does not yet have the declared toolchain cached, the download progress line is prepended to the captured value. The executable test on the resulting path then fails and the target refuses, saying the toolchain could not be resolved and the fetch needs network, while the fetch has in fact just succeeded. Reproduced with an uncached toolchain in an isolated module cache: the captured value comes back as two lines, the first the download notice and the second the real root, and the executable test on that value is false. A second run succeeds because the toolchain is now cached, so the gate self-heals, but the failure lands on the run that matters most, on a new machine or a fresh CI image, and it diagnoses the opposite of what happened. It matters because this loud refusal was chosen deliberately over a silent fallback, and a loud refusal naming the wrong cause spends the trust that choice was buying. Fix direction: capture stdout alone and leave stderr for the diagnostic, or take the last line of the captured value, and make the refusal distinguish a fetch that failed from one that merely printed. Detector: with the pinned toolchain absent from the module cache, the format gate must resolve it and run, not refuse. + +## Grounds + +- pursued: with the declared toolchain uncached, the format gate resolves it and runs on the first attempt; a first-run refusal that blames the network after a successful fetch would show it wrong diff --git a/.abcd/work/issues/open/iss-2609091433422134-rs001-misattributes-a-queue-collision-to-pre-divergence-history.md b/.abcd/work/issues/resolved/iss-2609091433422134-rs001-misattributes-a-queue-collision-to-pre-divergence-history.md similarity index 71% rename from .abcd/work/issues/open/iss-2609091433422134-rs001-misattributes-a-queue-collision-to-pre-divergence-history.md rename to .abcd/work/issues/resolved/iss-2609091433422134-rs001-misattributes-a-queue-collision-to-pre-divergence-history.md index 59f174b87..7df51f069 100644 --- a/.abcd/work/issues/open/iss-2609091433422134-rs001-misattributes-a-queue-collision-to-pre-divergence-history.md +++ b/.abcd/work/issues/resolved/iss-2609091433422134-rs001-misattributes-a-queue-collision-to-pre-divergence-history.md @@ -9,6 +9,14 @@ found_during: "adversarial-review" origin: researcher-authored production_mode: hand-written found_at: "scripts/check-issue-resolution.sh" +resolution: "RS001 and RS005 recognise a base that is an ancestor of the head, the merge-queue shape, and name the base-side commit that made the record terminal after the branch diverged as a competitor's landing, with rebase-and-reconcile as the remedy; a record terminal before the branch was cut keeps the drop-the-trailer message. Queue-shaped fixture cases in scripts/check-issue-resolution-cases.sh cover both, for issues and intents." +impact: internal +resolved_by: + commit: "17bf7424c" --- RS001 refuses correctly on a merge-queue collision and explains it wrongly. When a competitor lands a record's resolution while this entry waits in the queue, the record is already terminal at the entry's base, so it never enters a terminal folder across the range and the trailer is refused, which is the right verdict. The message the refusal carries is the stale-branch one, saying the record already sat in the resolved folder before this branch diverged and advising that the trailer be dropped. The probe behind that wording walks the range from the head back to the base, and in the queue the base is always an ancestor of the head, so that walk is empty and the stale-branch arm is the only one selectable there; the competitor's landing is therefore reported as pre-divergence history that never happened. The cost is a wrong remedy at the worst moment: an author told the record was terminal before they branched will drop a trailer that is correct and re-push, where the real answer is that someone else resolved it while they queued and the two changes need reconciling. The gate only became reachable in the queue when the range gates started resolving a base there, so the arm has never been exercised on this shape. Fix: give the probe a branch that recognises a base that is an ancestor of the head and name the competitor's landing, so the message distinguishes a record that was already terminal when the branch was cut from one that became terminal while it waited. Detector: a queue-shaped range whose record went terminal after the branch point is refused with a message naming the landing rather than the divergence, and a genuinely pre-divergence record keeps the message it has. + +## Grounds + +- pursued: a merge-queue entry refused because a competitor resolved the same record while it waited is told so, and a pre-divergence resolution in the queue is still told to drop the trailer; a queue refusal that still calls a competitor's landing pre-divergence history would show it wrong diff --git a/.abcd/work/issues/open/iss-2609181121301638-the-fidelity-review-request-lists-the-acceptance-criteria-bu.md b/.abcd/work/issues/resolved/iss-2609181121301638-the-fidelity-review-request-lists-the-acceptance-criteria-bu.md similarity index 77% rename from .abcd/work/issues/open/iss-2609181121301638-the-fidelity-review-request-lists-the-acceptance-criteria-bu.md rename to .abcd/work/issues/resolved/iss-2609181121301638-the-fidelity-review-request-lists-the-acceptance-criteria-bu.md index 5fc7d1919..125cfc946 100644 --- a/.abcd/work/issues/open/iss-2609181121301638-the-fidelity-review-request-lists-the-acceptance-criteria-bu.md +++ b/.abcd/work/issues/resolved/iss-2609181121301638-the-fidelity-review-request-lists-the-acceptance-criteria-bu.md @@ -9,6 +9,10 @@ found_during: "Gropius managed-repo session gropiusllm-56, seven fidelity audits origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "The request prints the criterion count K beside its Acceptance Criteria block and carries a Scope Conditions block listing every condition under its cond- identity with its text, read through ParseClaims as the ingest's coverage check reads them. The third addendum, the delivered range the host still supplies, needs an attestation design call and is captured apart as iss-2609262011091645." +impact: fix +resolved_by: + commit: "d3a4bd667760f980f44e549c1a2dbafe122021fd" --- The fidelity review request lists the acceptance criteria but never the scope-condition identities the verdict must dispose. auditPromptBody in internal/core/intent/audit.go writes an Acceptance Criteria block numbered ac-1..ac-K and the rubric, while the verdict schema (verdictCondition, keyed on condition_id) and the intent-auditor contract require scope_conditions to cover the intent's conditions exactly, one disposition per cond-… identity; the identities reach the auditor only as HTML comments in the intent record that the request does not quote. Relayed from the Gropius managed-repo session gropiusllm-56 on 2026-09-18 after seven fidelity audits at v0.9.0: the reviewer scraped the cond comments by hand, and a miscount quarantined the verdict at ingest. Wanted: the request carries a Scope Conditions block listing each cond-… identity with its text, as it carries the criteria, so the auditor disposes exactly the set the ingest will check. The prompt body is hashed for provenance, so the block is part of the pure composition and the prompt_hash policy version moves with it. @@ -28,3 +32,7 @@ auditor's brief. The range is knowable to the verb that lands the PR (the implement verb, itd-2609201916151817) and, after the merge, to `intent audit` itself from the spec's close commit and the branch's merge commit. Same composer, same fix family as the cond ids and the criterion count. + +## Grounds + +- pursued: an auditor disposes exactly the identity set the ingest checks without reading the record's HTML comments; a request whose Scope Conditions block names a set other than the one validateConditionDispositions enforces would show it wrong diff --git a/.abcd/work/issues/open/iss-2609181121305984-the-fidelity-review-request-carries-no-verdict-json-schema-a.md b/.abcd/work/issues/resolved/iss-2609181121305984-the-fidelity-review-request-carries-no-verdict-json-schema-a.md similarity index 73% rename from .abcd/work/issues/open/iss-2609181121305984-the-fidelity-review-request-carries-no-verdict-json-schema-a.md rename to .abcd/work/issues/resolved/iss-2609181121305984-the-fidelity-review-request-carries-no-verdict-json-schema-a.md index 800319b6e..8274866d6 100644 --- a/.abcd/work/issues/open/iss-2609181121305984-the-fidelity-review-request-carries-no-verdict-json-schema-a.md +++ b/.abcd/work/issues/resolved/iss-2609181121305984-the-fidelity-review-request-carries-no-verdict-json-schema-a.md @@ -9,6 +9,10 @@ found_during: "Gropius managed-repo session gropiusllm-56, seven fidelity audits origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "The request carries a Verdict shape section rendered by reflection from the verdict struct the ingest decodes with DisallowUnknownFields, so it is the schema rather than a copy, and the ingest's --verdict-json help names the section. The dry-run validator the addendum offers is the alternative remedy the record names as sufficient either way, and is not added." +impact: fix +resolved_by: + commit: "d3a4bd667760f980f44e549c1a2dbafe122021fd" --- The fidelity review request carries no verdict-JSON schema and names no place to read one. auditPromptBody ends with "Run the intent-auditor agent over the criteria and the delivered diff, then ingest its verdict JSON", and abcd intent audit ingest --help documents a single flag; the concrete verdict shape (verdict enum, scope_conditions with condition_id and disposition, the two sha256 provenance fields) lives only in the bundled intent-auditor agent definition. A host without that agent, or a reviewer working from the request alone, has nothing to write against and learns the shape from ingest refusals. Relayed from the Gropius managed-repo session gropiusllm-56 on 2026-09-18 after seven fidelity audits at v0.9.0. Wanted, either: the request quotes the verdict shape beside the rubric, or a read-only abcd intent audit schema verb prints it, and the ingest help points at whichever exists. Filed apart from the scope-condition identities finding because the remedies differ: that one is about which items the verdict must cover, this one about the shape it must take. @@ -19,3 +23,7 @@ validator, every lane in a forty-lane sweep rewrote the same pre-ingest checker. A validate-only mode on the ingest (parse and shape-check, write nothing, exit as the ingest would) is the same remedy family as printing the shape, and either one retires the hand-written checkers. + +## Grounds + +- pursued: a reviewer working from the request alone writes a verdict the ingest decodes; a field on the verdict struct absent from the stated shape, or a stated field the ingest refuses as unknown, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609190337545165-the-ingest-json-for-a-dead-lettered-verdict-carries-a-rollup.md b/.abcd/work/issues/resolved/iss-2609190337545165-the-ingest-json-for-a-dead-lettered-verdict-carries-a-rollup.md similarity index 73% rename from .abcd/work/issues/open/iss-2609190337545165-the-ingest-json-for-a-dead-lettered-verdict-carries-a-rollup.md rename to .abcd/work/issues/resolved/iss-2609190337545165-the-ingest-json-for-a-dead-lettered-verdict-carries-a-rollup.md index 315511c36..2b2e0501a 100644 --- a/.abcd/work/issues/open/iss-2609190337545165-the-ingest-json-for-a-dead-lettered-verdict-carries-a-rollup.md +++ b/.abcd/work/issues/resolved/iss-2609190337545165-the-ingest-json-for-a-dead-lettered-verdict-carries-a-rollup.md @@ -9,6 +9,14 @@ found_during: "Gropius autonomous sweep, session gropiusllm-66, relayed to abcd- origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "The ingest's JSON states what it recorded (recorded: verdict, quarantine or nothing) and carries the acceptance rollup and the disposition split only beside a recorded verdict; a quarantine states its untested split as conditions_untested. The Go result fields are unchanged." +impact: fix +resolved_by: + commit: "15a138c651e1ced5030e36f3f6fe3a81249d02b2" --- The ingest JSON for a dead-lettered verdict carries a rollup that reads as a result. abcd intent audit ingest --verdict-json --json returns one result struct for every outcome, so a dead_letter response carries criteria, conditions and the per-verdict counters beside its reason: a lane in the Gropius sweep read "criteria: 0" beside "conditions: 11" on a quarantined verdict and took it for a rollup. The text render is right (it prints the rollup only under ingested and the DEAD_LETTER reason otherwise), the JSON is not: the counters are zero-valued members with no omitempty, and conditions is populated from the intent before the verdict is judged. Reproducible by reading the IngestResult struct in internal/core/intent/audit.go against the --json path. Relayed from session gropiusllm-66 on 2026-09-19 at v0.9.0. Wanted: the rollup members omitted (or nulled) on dead_letter and noop, so a quarantined verdict's JSON says only what happened and where the raw payload went. Separately, the same session reported the ingest refusing a digest of "sha256:unknown" while accepting an empty digest; that is the documented contract (empty where the digest is not known, otherwise sha256:<64 hex>) and the refusal says so, so it is not filed. + +## Grounds + +- pursued: a reader of a dead-lettered verdict's JSON no longer finds a rollup to misread; a dead_letter or noop result carrying criteria or met members, or an ingested one missing a zero count, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609190337598356-a-re-emit-of-a-fidelity-review-request-reports-already-owed.md b/.abcd/work/issues/resolved/iss-2609190337598356-a-re-emit-of-a-fidelity-review-request-reports-already-owed.md similarity index 67% rename from .abcd/work/issues/open/iss-2609190337598356-a-re-emit-of-a-fidelity-review-request-reports-already-owed.md rename to .abcd/work/issues/resolved/iss-2609190337598356-a-re-emit-of-a-fidelity-review-request-reports-already-owed.md index c0081df19..1ce87e433 100644 --- a/.abcd/work/issues/open/iss-2609190337598356-a-re-emit-of-a-fidelity-review-request-reports-already-owed.md +++ b/.abcd/work/issues/resolved/iss-2609190337598356-a-re-emit-of-a-fidelity-review-request-reports-already-owed.md @@ -9,6 +9,14 @@ found_during: "Gropius autonomous sweep, session gropiusllm-66, relayed to abcd- origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "The emit result names the act beside the state: request_written is true when the emit wrote the request (an owed re-emit rewrites it, rendered as request rewritten), and request_path names only a request this emit wrote, so a terminal receipt names none." +impact: fix +resolved_by: + commit: "15a138c651e1ced5030e36f3f6fe3a81249d02b2" --- A re-emit of a fidelity review request reports already_owed and says nothing about the request it just rewrote. abcd intent audit on a shipped intent whose marker is OWED takes the already_owed branch of emitAuditForIntent, which rewrites the request file through writeAuditRequest and then returns with status already_owed; the status names the receipt's state, which is true, and omits the write, which is what the caller asked for. A lane in the Gropius sweep read already_owed as "nothing happened" and looked for a failure. Relayed from session gropiusllm-66 on 2026-09-19 at v0.9.0. Wanted: the result names the request path it wrote (re_emitted: true, or the path member populated) so the status and the action are both readable from the JSON. + +## Grounds + +- pursued: a caller reads from the result alone whether a request was written and where; an already_owed result without request_written, or a terminal one naming a request path, would show it wrong diff --git a/.abcd/work/issues/open/iss-2609231011136579-abcd-intent-audit-ingest-writes-the-shipped-intent-back-with.md b/.abcd/work/issues/resolved/iss-2609231011136579-abcd-intent-audit-ingest-writes-the-shipped-intent-back-with.md similarity index 67% rename from .abcd/work/issues/open/iss-2609231011136579-abcd-intent-audit-ingest-writes-the-shipped-intent-back-with.md rename to .abcd/work/issues/resolved/iss-2609231011136579-abcd-intent-audit-ingest-writes-the-shipped-intent-back-with.md index cae148730..f20c8b615 100644 --- a/.abcd/work/issues/open/iss-2609231011136579-abcd-intent-audit-ingest-writes-the-shipped-intent-back-with.md +++ b/.abcd/work/issues/resolved/iss-2609231011136579-abcd-intent-audit-ingest-writes-the-shipped-intent-back-with.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23 fidelity audit" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "Every write of a review block returns a record ending in a newline (withFinalNewline in upsertReviewBlock and appendToAuditNotes), including a record that reached the ingest without one; the 39 intents the earlier writer left without one gained it in 3d866104. TestIngestedRecordEndsInANewline and TestEveryTreeReviewBlockRoundTrips pin it." +impact: fix +resolved_by: + commit: "55964951091ab03bd9c07167ab6a26f0cb7df7c7" --- abcd intent audit ingest writes the shipped intent back with no trailing newline: upsertReviewBlock (internal/core/intent/audit.go:956-980) splits the record on newline, and when the OWED marker's block runs to end of file it sets end = len(lines), so the trailing empty element that carried the file's final newline is dropped from lines[end:] and strings.Join returns a body ending at the last byte of the new block. The appendToAuditNotes path (audit.go:985-1041) re-adds the separator, so only the replace-in-place path (every OWED -> INGESTED transition) loses the newline. Observed on itd-157 and the batch-0 audits before it: the file ends in '- missing: (none)' with no newline. Every other record writer in the tree ends its file with a newline; the fix is to preserve the trailing empty element (or TrimRight and append one newline) in upsertReviewBlock, with a test asserting the ingested record ends in a newline + +## Grounds + +- pursued: every ingested, dead-lettered or re-ingested intent ends in exactly one newline; an ingest that leaves a record ending on its last block byte would show it wrong diff --git a/.abcd/work/issues/open/iss-2609231036448320-abcd-intent-audit-ingest-copies-the-verifier-s-rationale.md b/.abcd/work/issues/resolved/iss-2609231036448320-abcd-intent-audit-ingest-copies-the-verifier-s-rationale.md similarity index 60% rename from .abcd/work/issues/open/iss-2609231036448320-abcd-intent-audit-ingest-copies-the-verifier-s-rationale.md rename to .abcd/work/issues/resolved/iss-2609231036448320-abcd-intent-audit-ingest-copies-the-verifier-s-rationale.md index e0ffad307..54dd9fe2a 100644 --- a/.abcd/work/issues/open/iss-2609231036448320-abcd-intent-audit-ingest-copies-the-verifier-s-rationale.md +++ b/.abcd/work/issues/resolved/iss-2609231036448320-abcd-intent-audit-ingest-copies-the-verifier-s-rationale.md @@ -9,6 +9,14 @@ found_during: "autonomous run 2026-09-23 fidelity audit" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "Refused, not sanitised: the ingest asks record-lint's own prose_citation_resolves reading (lint.UnresolvedProseCitationsInRecord, registered through intent.SetProseCitationGate) over the rendered block before any write, and refuses a verdict, re-ingest or dead letter citing an id that names no record, naming the id and its line, where the repository arms the rule over the intent store. The verdict schema cannot mark an id illustrative and termsafe neutralises syntax, not meaning, so a rewrite would be a second sanitiser." +impact: fix +resolved_by: + commit: "55964951091ab03bd9c07167ab6a26f0cb7df7c7" --- abcd intent audit ingest copies the verifier's rationale prose verbatim into the shipped intent's Audit Notes, and that record is lint-bound: a rationale that names a test's illustrative id (an auditor citing TestCheckRefusesADanglingSpecTarget wrote the spec id the fixture dangles to) makes record-lint refuse the whole tree with a prose_citation_resolves BLOCKER on the ingested line, and the verdict schema offers no way to carry the '' marker the rule asks for. The ingest validates the verdict against the rubric but not against the record gates the record it writes must pass, so a valid verdict can produce an uncommittable record; the auditor's only remedy is to re-word and re-ingest. Either the ingest should run the prose-citation check over the rendered block and refuse the verdict with the offending id named, or the verdict shape should let a rationale mark an id as illustrative + +## Grounds + +- pursued: no verdict the ingest accepts can make record-lint refuse the intent record it writes; an ingested block carrying an unresolvable, unbaselined id in an armed repository would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md b/.abcd/work/issues/resolved/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md similarity index 52% rename from .abcd/work/issues/open/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md rename to .abcd/work/issues/resolved/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md index 391139be1..a100e2494 100644 --- a/.abcd/work/issues/open/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md +++ b/.abcd/work/issues/resolved/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "The block's extent is bounded: new blocks close on their own abcd-review-end line, a legacy OWED stub is its marker and one sentence, and any other legacy block stops above a trailing run of link-reference definitions, so neither a first ingest nor a replacement takes the references with it. TestFirstIngestKeepsLinkRefsBelowTheOwedStub and TestReplacementKeepsLinkRefsBelowALegacyBlock pin it." +impact: fix +resolved_by: + commit: "55964951091ab03bd9c07167ab6a26f0cb7df7c7" --- intent audit ingest: the first ingest, and any replacement, deletes trailing link-reference definitions under Audit Notes when the owed stub sits above them (the itd-114 shape appendToAuditNotes parks a stub above, per iss-2608210737265820). Same root cause as the unbounded review-block extent; same fix (a bounded extent). No record in the tree has a stub followed by refs today. + +## Grounds + +- pursued: link-reference definitions under Audit Notes survive every ingest and replacement; a write that drops one would show it wrong diff --git a/.abcd/work/issues/open/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md b/.abcd/work/issues/resolved/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md similarity index 52% rename from .abcd/work/issues/open/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md rename to .abcd/work/issues/resolved/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md index e6dff7759..591c815e3 100644 --- a/.abcd/work/issues/open/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md +++ b/.abcd/work/issues/resolved/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25" origin: researcher-authored production_mode: hand-written found_at: "internal/core/intent/audit.go" +resolution: "Every renderer closes its block on an abcd-review-end line and the reader ends the block there, so a note below it is not the block's: an identical re-ingest is a noop and a replacement keeps the note (TestReingestKeepsAHumanNoteBelowTheBlock). A block written before the closing line keeps the recorded fallback rule, as this record's own fix states: prose a human writes directly under such a legacy INGESTED or DEAD_LETTER block is still inside its extent, and no tree record has that shape." +impact: fix +resolved_by: + commit: "55964951091ab03bd9c07167ab6a26f0cb7df7c7" --- intent audit ingest: reviewBlockRange treats any text under an INGESTED review block, up to the next marker, heading or end of file, as part of the block, so a re-ingest of an IDENTICAL payload on a record with hand-written prose under the block reports replaced:true and deletes that prose, where the documented behaviour is a noop. No record in the tree has the shape today. Fix: bound the block's extent with a closing marker, keeping the current rule as the fallback for blocks written without one. + +## Grounds + +- pursued: prose written below a block the ingest wrote survives any re-ingest; a re-ingest that deletes or absorbs it would show it wrong diff --git a/.abcd/work/issues/open/iss-2609261254247117-relink-repoint-outside-the-mint-lock.md b/.abcd/work/issues/resolved/iss-2609261254247117-relink-repoint-outside-the-mint-lock.md similarity index 54% rename from .abcd/work/issues/open/iss-2609261254247117-relink-repoint-outside-the-mint-lock.md rename to .abcd/work/issues/resolved/iss-2609261254247117-relink-repoint-outside-the-mint-lock.md index 926807672..3276a383d 100644 --- a/.abcd/work/issues/open/iss-2609261254247117-relink-repoint-outside-the-mint-lock.md +++ b/.abcd/work/issues/resolved/iss-2609261254247117-relink-repoint-outside-the-mint-lock.md @@ -9,6 +9,14 @@ found_during: "autonomous run A resumed 2026-09-25: review2-itd34 low note b" origin: researcher-authored production_mode: hand-written found_at: "internal/core/relink/relink.go" +resolution: "Every record-moving verb's link repoint now runs under the intent store's lock: intent verbs through repointUnderLock after their own hold, an issue transition through the exported intent.WithMintLock inside the ledger lock (ledger, then intent: the one order)." +impact: fix +resolved_by: + commit: "3a3b69895" --- relink.Repoint rewrites the link text in every record naming a moved path as a read-modify-write outside the intent mint lock, on every verb that moves a record (intent plan, spec close, intent reclassify): a concurrent edit to a linking record between the read and the rename is lost. The move itself is judged under the lock; only the repointing that follows it is not. + +## Grounds + +- pursued: a concurrent intent writer arriving while a repoint runs waits for its write and the linking record keeps both edits; a locked writer landing inside the repoint window, or a resolve finishing while another holder has the intent lock, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609261835118276-the-consistency-ingest-and-the-reading-ingest-copy.md b/.abcd/work/issues/resolved/iss-2609261835118276-the-consistency-ingest-and-the-reading-ingest-copy.md new file mode 100644 index 000000000..31d8cd844 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609261835118276-the-consistency-ingest-and-the-reading-ingest-copy.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609261835118276" +slug: "the-consistency-ingest-and-the-reading-ingest-copy" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/consistency.go" +resolution: "The consistency ingest and the reading ingest now ask record-lint's prose-citation gate through the one registration the front doors make (intent.UnresolvedProseCitations, fail closed), and refuse naming the finding or item, the field and the id, with nothing written — the verdict ingest's behaviour, with no illustrative escape." +impact: fix +resolved_by: + commit: "e8593b899" +--- + +The consistency ingest and the reading ingest copy host-delegated prose into lint-bound records with no prose-citation check: capture.IngestConsistency files each finding's summary, quotes and explanation as an issue record, and the reading ingest writes reading items into the rdi store, and both stores are read by record-lint's prose_citation_resolves, so a finding or item citing a record id that names no record is written and then refuses the whole tree. The verdict ingest holds its rendered block to the gate before writing (intent.SetProseCitationGate over lint.UnresolvedProseCitationsInRecord, iss-2609231036448320); these two writers take no such check. capture cannot import core/lint (lint's tests import capture), so the fix is the same front-door seam, asked over the text each writer renders. + +## Grounds + +- pursued: a finding or reading item citing a record id that names no record is refused before any write, so no ingested record can fail prose_citation_resolves; a record written by either ingest that the gate then refuses would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md b/.abcd/work/issues/resolved/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md new file mode 100644 index 000000000..db12faa99 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609261850045839" +slug: "preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: #728 CI failure" +origin: researcher-authored +production_mode: hand-written +found_at: "Makefile" +resolution: "make preflight exports GOTOOLCHAIN from go.mod's go directive to every Go step it makes, and runs fmt-check second, whose resolver scripts/pinned-toolchain.sh (the one resolver the format gate uses) refuses naming the skew when the declared toolchain cannot be fetched. TestPreflightRunsTheDeclaredToolchain holds the export, the single resolver and fmt-check's place." +impact: internal +resolved_by: + commit: "67a2adf31" +--- + +make preflight runs its Go steps (go build, go vet, go test, the race lane, and every go run and go test its prerequisites make) on the go found on PATH, which is go1.27.1 on this machine, while CI builds and tests with the toolchain go.mod declares (go 1.26.7) through setup-go's go-version-file. iss-2609081953452204 closed exactly this skew for gofmt alone, by resolving the format gate through the declared toolchain; the build and test half was left on PATH. On 2026-09-26 pull request 728 failed CI on a test whose assertion depended on the go 1.27 encoding/json error wording, after a clean local preflight: the push gate judged the tree with a toolchain CI does not use, so a green preflight vouched for nothing on that point. Fix: run preflight's Go steps under the declared toolchain, resolved by the same resolver the format gate uses rather than a second one, and refuse loudly, naming the skew, when it cannot be fetched, exactly as the format gate refuses. Detector: a test holding every Go step under preflight to the declared toolchain. + +## Grounds + +- pursued: a test whose assertion depends on standard-library wording that differs between the PATH go and go.mod's release fails preflight as it fails CI; a green preflight on such a test followed by a red CI would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md b/.abcd/work/issues/resolved/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md new file mode 100644 index 000000000..ce7e14d31 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609261935343851" +slug: "intent-audit-ingest-outside-the-mint-lock" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainA1" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/audit.go" +resolution: "The verdict ingest now resolves its receipt, judges the record and writes it (the dead-letter's two writes included) in one hold of the intent store lock, so a verdict or disposition landing first is read, not erased." +impact: fix +resolved_by: + commit: "96fe3813c" +--- + +intent audit ingest is not under the intent mint lock: IngestVerdictBytes, reingestVerdict and deadLetter (internal/core/intent/audit.go) resolve the receipt, validate and write the shipped intent as a read-modify-write guarded only by a per-file atomic write, and deadLetter writes two files (the retained payload and the record); every other intent writer holds withIntentMintLock. Two concurrent ingests on one intent, or an ingest beside a condition disposition, each write the bytes they read: the later write erases the earlier one and both exit 0 (a verdict replaced while reporting a fresh ingest, or a dead-letter written over a verdict that had just landed). + +## Grounds + +- pursued: two ingests on one intent end with exactly one verdict standing and the second reporting the replacement; a verb landed in the window through the lock seam and then erased by the ingest's write would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md b/.abcd/work/issues/resolved/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md new file mode 100644 index 000000000..d809bd419 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609261935407925" +slug: "fidelity-review-emit-outside-the-mint-lock" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: fix2-drainA1 sweep" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/audit.go" +resolution: "The fidelity-review emit now reads the shipped intent, judges its marker and parks the OWED stub in one hold of the intent store lock, so a disposition or verdict landing first is kept." +impact: fix +resolved_by: + commit: "a27d06e08" +--- + +The fidelity-review emit is not under the intent mint lock: emitAuditWith (internal/core/intent/audit.go), reached from the spec-close ship move, the bundle close, abcd intent audit and the audit drain, reads a shipped intent, parks the OWED stub and writes the record back as a read-modify-write outside withIntentMintLock. A condition disposition or verdict ingest landing on the same record between the emit's read and its write is erased, and both verbs exit 0. + +## Grounds + +- pursued: a condition disposition landed in the window before a re-emit survives the parked stub; a disposition erased by the emit's write would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md b/.abcd/work/issues/resolved/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md new file mode 100644 index 000000000..21fe299a1 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609261935407995" +slug: "related-issue-and-link-writes-outside-the-mint-lock" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: fix2-drainA1 sweep" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/lifecycle.go" +resolution: "AddRelatedIssue and Link now read and write the intent in one hold of the store lock; AddRelatedIssue judges the existing list on the locked bytes, so concurrent edges both stand." +impact: fix +resolved_by: + commit: "44ec7c60b" +--- + +intent.AddRelatedIssue (the intent half of capture promote --intent, any bucket including shipped/) and intent.Link (abcd intent link, planned/) rewrite the intent's frontmatter as a read-modify-write outside the intent mint lock (internal/core/intent/lifecycle.go): a hold, condition disposition, verdict ingest or review emit landing on the same record between the read and the write is erased, and both verbs exit 0. Every other intent writer holds withIntentMintLock. + +## Grounds + +- pursued: an edge or a hold landed in the window before the write is kept; a second edge overwriting the first would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609261941039204-capture-migrate-writes-intents-outside-the-mint-lock.md b/.abcd/work/issues/resolved/iss-2609261941039204-capture-migrate-writes-intents-outside-the-mint-lock.md new file mode 100644 index 000000000..34fd8477b --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609261941039204-capture-migrate-writes-intents-outside-the-mint-lock.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609261941039204" +slug: "capture-migrate-writes-intents-outside-the-mint-lock" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: fix2-drainA1 sweep" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/migrate.go" +resolution: "capture migrate --apply now scans and writes under the intent store's lock, taken inside the ledger lock through intent.WithMintLock (ledger, then intent: the order every path holding both takes; the intent package cannot take the ledger lock)." +impact: fix +resolved_by: + commit: "3a3b69895" +--- + +capture migrate --apply rewrites intent records (the related_issues back-edge, any bucket including shipped/) under the ledger lock only, not the intent mint lock (internal/core/capture/migrate.go, Migrate): an intent writer holding withIntentMintLock (a hold, a condition disposition, a verdict ingest, a review emit, a related-issue edge) landing on the same record between migrate's scan and its write is erased. The intent package exports no seam for another package to take its lock, the same gap iss-2609261254247117 names for relink.Repoint. + +## Grounds + +- pursued: an intent writer landing between the migration's scan and its write waits and its edit survives the rewrite; the edit missing from the migrated record, or the apply finishing while another holder has the intent lock, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262011046013-the-consistency-review-request-carries-no-findings-json.md b/.abcd/work/issues/resolved/iss-2609262011046013-the-consistency-review-request-carries-no-findings-json.md new file mode 100644 index 000000000..59b28be90 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262011046013-the-consistency-review-request-carries-no-findings-json.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262011046013" +slug: "the-consistency-review-request-carries-no-findings-json" +severity: "minor" +category: "documentation" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/consistency.go" +resolution: "The consistency request carries a Findings shape section rendered from the payload struct the consistency ingest decodes, through the renderer the fidelity request uses, with both ends of a finding shown." +impact: fix +resolved_by: + commit: "e0d6dcad054be076025937c219be4a8a89004a9a" +--- + +The consistency review request carries no findings-JSON shape and names no place to read one. consistencyPromptBody in internal/core/intent/consistency.go states the five classes and the rubric, while the findings shape (_type, receipt_id, verifier, policy, and findings carrying class, severity, summary, explanation and two ends of path and quote) is published only in the Role 2 section of agents/intent-auditor.md, so a reviewer working from the request alone learns the shape from ingest refusals. It is the Role 2 sibling of iss-2609181121305984 on the fidelity review request, confirmed while fixing that record; the same remedy applies: the request states the shape rendered from the struct the ingest decodes, never a second copy. + +## Grounds + +- pursued: a reviewer working from the consistency request alone writes findings the ingest decodes; a stated shape that fails the ingest's strict decode would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262104070666-the-consistency-ingest-s-refusal-for-findings-whose-policy.md b/.abcd/work/issues/resolved/iss-2609262104070666-the-consistency-ingest-s-refusal-for-findings-whose-policy.md new file mode 100644 index 000000000..05bc3d576 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262104070666-the-consistency-ingest-s-refusal-for-findings-whose-policy.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262104070666" +slug: "the-consistency-ingest-s-refusal-for-findings-whose-policy" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainA2" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/consistency.go" +resolution: "Both ingests refuse unissued policy hashes through one wording, issuedPolicyRefusal, whose remedy is the re-emit as spelled for the receipt (abcd intent audit , or abcd intent consistency with its scope) and running the pass again; TestStalePolicyRefusalNamesTheReEmit." +impact: fix +resolved_by: + commit: "9a2ff9718" +--- + +The consistency ingest's refusal for findings whose policy hashes the request never issued tells the auditor to echo the two values the request's Provenance block states, and never names the re-emit. For a request emitted by an earlier binary (before the findings shape joined the prompt body) the auditor DID echo them, so the remedy it gives cannot succeed: the only way through is to re-emit with abcd intent consistency and run the pass again. The sibling refusal in the fidelity ingest (checkIssuedPolicy) names the re-emit; the two should share one derivation of that wording. + +## Grounds + +- pursued: an auditor answering a request an earlier binary wrote is told to re-emit rather than to echo again; a consistency refusal for unissued hashes that names no re-emit, or names one spelled without the receipt's scope, would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262143209970-an-intent-verb-s-link-repoint-plan-spec-close-bundle-plan.md b/.abcd/work/issues/resolved/iss-2609262143209970-an-intent-verb-s-link-repoint-plan-spec-close-bundle-plan.md new file mode 100644 index 000000000..468a5e171 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262143209970-an-intent-verb-s-link-repoint-plan-spec-close-bundle-plan.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262143209970" +slug: "an-intent-verb-s-link-repoint-plan-spec-close-bundle-plan" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: drainL sibling sweep of iss-2609261254247117" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/lifecycle.go" +resolution: "Every intent verb's repoint (plan, spec close, bundle plan and close, reclassify, through repointUnderLock) takes the ledger lock and then the intent store's lock through intent.WithLedgerThenMintLock. The capture package registers its ledger lock with the intent package from init; a tree with no ledger takes no ledger lock and grows none, and a ledger with no lock registered refuses the repoint, reported as the verb's relink error. The spec half is captured separately as iss-2609262218309668: no spec writer takes a lock a repoint could share." +impact: fix +resolved_by: + commit: "665826f29" +--- + +An intent verb's link repoint (plan, spec close, bundle plan and close, reclassify, through repointUnderLock in internal/core/intent/lifecycle.go) rewrites every markdown record linking the moved path, issue and reading records included, under the intent store's lock alone: a ledger writer (a resolve, a link, a disposition) landing on a linking ledger record between the repoint's read and its write is erased, and the same holds for a spec record against a spec writer. The intent package cannot take the ledger lock (capture imports it), so closing this needs a registered ledger-lock seam taken OUTSIDE the intent lock (ledger, then intent, the order capture's transition and migrate take), and a stance on a tree with no ledger, which taking the lock would create. + +## Grounds + +- pursued: a ledger writer arriving while an intent verb's repoint runs now waits for its write and the linking issue keeps both edits (TestRepointHoldsTheLedgerLockAgainstALedgerWriter, TestAnIntentVerbRepointTakesTheLedgerLock); a ledger edit erased by a repoint would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262143265180-abcd-embark-writes-intent-records-into-the-target-repository.md b/.abcd/work/issues/resolved/iss-2609262143265180-abcd-embark-writes-intent-records-into-the-target-repository.md new file mode 100644 index 000000000..bdee7c7b2 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262143265180-abcd-embark-writes-intent-records-into-the-target-repository.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262143265180" +slug: "abcd-embark-writes-intent-records-into-the-target-repository" +severity: "nitpick" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: drainL sibling sweep of iss-2609261941039204" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lifeboat/embark.go" +resolution: "embark writes under the target's intent store's lock (after its ledger lock, through intent.WithLedgerThenMintLock) and classifies every planned write again under it, so an intent created at a planned target in the window is a conflict that refuses the whole write." +impact: fix +resolved_by: + commit: "9c210450c" +--- + +abcd embark writes intent records into the target repository outside the intent store's lock (internal/core/lifeboat/embark.go: classifyEmbark judges each target path, then writeEmbark writes the set): an intent created at a path the classification found absent, between the classify and the write, is replaced without a conflict. The window is a same-id create during an embark, so it is narrow, but it is the one intent-record writer the drainL sweep found outside intent.WithMintLock. + +## Grounds + +- pursued: an intent landing at a planned target between embark's plan and its write now refuses the embark and survives (TestEmbarkRejudgesARecordThatLandedAfterThePlan/intents), and an intent writer during the write waits (TestEmbarkWritesUnderTheLedgerAndIntentLocks); a replaced intent would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262218059995-a-capture-transition-s-link-repoint-repointmovedissue-in.md b/.abcd/work/issues/resolved/iss-2609262218059995-a-capture-transition-s-link-repoint-repointmovedissue-in.md new file mode 100644 index 000000000..79305ba4f --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262218059995-a-capture-transition-s-link-repoint-repointmovedissue-in.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262218059995" +slug: "a-capture-transition-s-link-repoint-repointmovedissue-in" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainL" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/workflow.go" +resolution: "intent.WithLedgerThenMintLock never holds the ledger lock while it waits for the intent lock: it asks for the intent lock briefly, lets the ledger go and rests on contention, and retries to the intent lock's budget. The capture transition repoints in a fresh pair after its move's hold is released, and migrate --apply runs under the pair." +impact: fix +resolved_by: + commit: "665826f29" +--- + +A capture transition's link repoint (repointMovedIssue in internal/core/capture/workflow.go) waits up to 5 s for the intent store's lock INSIDE the ledger lock, and the ledger's own acquisition budget (lockTimeout in internal/core/capture/alloc.go) is also 5 s, so an intent hold of 5 s or more makes a third process's ledger writer (a capture, a resolve, a link) fail with ErrAllocatorContention while the transition itself only reports a RelinkError. capture migrate --apply waits on the intent lock inside the ledger lock the same way. Bounded, not a deadlock: the two waits chain into a spurious failure on a slow machine. + +## Grounds + +- pursued: with the intent lock held longer than a ledger writer's whole budget, a capture arriving while a resolve or a migrate apply waits now lands (TestAWaitingVerbLeavesTheLedgerToOtherWriters); a ledger writer failing with allocator contention during such a wait would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262218306589-abcd-embark-writes-issue-records-into-the-target-repository.md b/.abcd/work/issues/resolved/iss-2609262218306589-abcd-embark-writes-issue-records-into-the-target-repository.md new file mode 100644 index 000000000..273032803 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262218306589-abcd-embark-writes-issue-records-into-the-target-repository.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262218306589" +slug: "abcd-embark-writes-issue-records-into-the-target-repository" +severity: "nitpick" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: fix2-drainL sibling sweep of iss-2609262143265180" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/lifeboat/embark.go" +resolution: "embark takes the target's ledger lock when its plan creates an issue record (and none otherwise, since taking it plants a ledger), and classifies every planned write again under it, so an issue captured at a planned target in the window is a conflict that refuses the whole write." +impact: fix +resolved_by: + commit: "9c210450c" +--- + +abcd embark writes issue records into the target repository's ledger outside the ledger lock (internal/core/lifeboat/embark.go: classifyEmbark judges each target path, then writeEmbark writes the set): an issue captured at a path the classification found absent, between the classify and the write, is replaced without a conflict. The ledger-side twin of iss-2609262143265180, which names the intent half. + +## Grounds + +- pursued: an issue landing at a planned target between embark's plan and its write now refuses the embark and survives (TestEmbarkRejudgesARecordThatLandedAfterThePlan/issues), and a ledger writer during the write waits (TestEmbarkWritesUnderTheLedgerAndIntentLocks); a replaced issue would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262218309668-a-link-repoint-rewrites-spec-records-that-link-to-a-moved.md b/.abcd/work/issues/resolved/iss-2609262218309668-a-link-repoint-rewrites-spec-records-that-link-to-a-moved.md new file mode 100644 index 000000000..f0f8692d6 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262218309668-a-link-repoint-rewrites-spec-records-that-link-to-a-moved.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262218309668" +slug: "a-link-repoint-rewrites-spec-records-that-link-to-a-moved" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25: fix2-drainL sibling sweep of iss-2609262143209970" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/relink/relink.go" +resolution: "The spec store's flock is the one lock every spec writer takes: mint, close, discard, and through intent.WithLedgerThenMintLock every repoint, embark and migrate apply, in the order ledger -> intent -> spec" +impact: internal +resolved_by: + commit: "bf867d6d3" +--- + +A link repoint rewrites spec records that link to a moved path with no spec lock, and no spec writer takes one either: the spec store's only lock is the mint's (withMintLock in internal/core/spec/store.go). spec.Close renames a spec open/ to closed/ while a concurrent repoint (an intent plan, a capture resolve) that read the spec at open/ writes it back there through an atomic rename, which lands the spec in both status folders; a spec edit landing between the repoint's read and its write is erased. Closing it needs a spec-store writer lock every spec writer takes, ordered against the ledger and intent locks. + +## Grounds + +- pursued: a repoint racing spec close never leaves a spec in both status folders and a spec edit racing a repoint is kept; TestARepointRacingASpecCloseLeavesTheSpecInOneFolder, TestASpecEditRacingARepointIsNotLost or TestTheRecordLocksAreTakenLedgerThenIntentThenSpec failing would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262257227538-the-pair-acquisition-s-ledger-rest-does-not-outlast-the.md b/.abcd/work/issues/resolved/iss-2609262257227538-the-pair-acquisition-s-ledger-rest-does-not-outlast-the.md new file mode 100644 index 000000000..a8a858110 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262257227538-the-pair-acquisition-s-ledger-rest-does-not-outlast-the.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262257227538" +slug: "the-pair-acquisition-s-ledger-rest-does-not-outlast-the" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: verify-fix2-drainL" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/ledgerlock.go" +resolution: "fsutil.LockPollCeiling names the poll's ceiling and the loop holds at it; the pair's ledger rest is 2x the ceiling; two tests pin both" +impact: internal +resolved_by: + commit: "58a513ac6" +--- + +The pair acquisition's ledger rest does not outlast the ledger writer's poll interval it was chosen to outlast. WithLedgerThenMintLock's comment says fsutil's lock-poll backoff tops out at 100ms and pairLedgerRest is 150ms to exceed it, but acquireFlock doubles the backoff after the sleep while it is below 100ms, so 80ms becomes 160ms and the real poll ceiling is 160ms: a ledger writer sleeping 160ms can miss the whole 150ms window the pair leaves the ledger free. No test pins the ceiling or the rest, so either can move and break the guarantee silently. + +## Grounds + +- pursued: a ledger writer polling through the pair's wait always wakes inside the window the pair leaves the ledger free; TestTheLockPollNeverSleepsPastItsCeiling or TestThePairRestOutlastsTheLedgerPoll failing would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609262342345159-spec-close-on-a-tree-with-no-spec-store-plants-an-empty-abcd.md b/.abcd/work/issues/resolved/iss-2609262342345159-spec-close-on-a-tree-with-no-spec-store-plants-an-empty-abcd.md new file mode 100644 index 000000000..06f8ca824 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609262342345159-spec-close-on-a-tree-with-no-spec-store-plants-an-empty-abcd.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609262342345159" +slug: "spec-close-on-a-tree-with-no-spec-store-plants-an-empty-abcd" +severity: "minor" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25: review-drainSpec" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/spec/store.go" +resolution: "Close and Discard check for the spec store before taking its lock: with none, Close refuses the id as not found and Discard has nothing to remove, and neither plants .abcd/development/specs/." +impact: internal +resolved_by: + commit: "94cecdf7e" +--- + +spec.Close on a tree with no spec store plants an empty .abcd/development/specs/ before failing 'not found': Close takes the store lock through withStoreLock, whose ensureDir creates the store to lock it, contradicting the rule the spec store states beside WithStoreLock (a verb that writes no spec must not plant an empty store). spec.Discard has the same shape. Unreachable through any in-tree verb today (both Close callers load the spec first), but the writer itself must not plant the store. + +## Grounds + +- pursued: TestAWriterOnATreeWithNoSpecStorePlantsNone asserts that Close and Discard on a tree with no spec store leave no .abcd/development/specs/ behind, and that Close still refuses the id; a writer that plants the store, or a Close that stops refusing, turns it red. diff --git a/.githooks/pre-push b/.githooks/pre-push index e4090550f..6e57120c4 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -28,17 +28,17 @@ # CI on the commit; here it earns no receipt, so the push is refused. # # What the preflight runs: the load check first (load-check, a warning, -# never a failure), then the six lint gates (lint-reviews, lint-issues, -# lint-decisions, record-lint, issue-drift, docs-lint), the site-render -# gate and both tagged eval lanes (smoke, evals-cold-reading), then build, -# vet, test and the race-enabled internal tests. The eval lanes are named -# because the untagged `go test` step cannot compile them, so a defect -# there would otherwise reach a push unopposed (iss-2608311632382737). CI's -# check job adds the `make fmt-check` format gate on top of those Go steps — -# run it before pushing, since the preflight does not; the secret-scan, -# workflow-audit, dependency-review and govulncheck lanes run in Actions -# only, and the record-lint job there repeats the reviews-charter, -# issue-resolution and decisions-append gates preflight already ran here. +# never a failure), then the `make fmt-check` format gate, the six lint +# gates (lint-reviews, lint-issues, lint-decisions, record-lint, +# issue-drift, docs-lint), the site-render gate and both tagged eval lanes +# (smoke, evals-cold-reading), then build, vet, test and the race-enabled +# internal tests, every Go step on the toolchain go.mod declares, as CI's +# are. The eval lanes are named because the untagged `go test` step cannot +# compile them, so a defect there would otherwise reach a push unopposed +# (iss-2608311632382737). The secret-scan, workflow-audit, +# dependency-review and govulncheck lanes run in Actions only, and the +# record-lint job there repeats the reviews-charter, issue-resolution and +# decisions-append gates preflight already ran here. # # `git push --no-verify` skips this hook, as it skips every hook; CI re-runs # every gate on the pushed commit and is the authority either way. diff --git a/AGENTS.md b/AGENTS.md index 2e0421bf5..2026bd4d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,12 +108,13 @@ Run from the repo root. ```bash make preflight # the pre-push gate: the load check first (load-check, - # a warning, never a failure), then lint-reviews + + # a warning, never a failure), then fmt-check + + # lint-reviews + # lint-issues + lint-decisions + record-lint + # issue-drift + docs-lint + site-render + # smoke + evals-cold-reading, # then build + vet + - # test + race (internal) + # test + race (internal), all on the go.mod toolchain make build # cross-compiles bin/abcd-- (there is no plain bin/abcd) make fmt-check # format gate, run through the go.mod toolchain's gofmt make fmt # rewrite what fmt-check names, with that same gofmt @@ -312,9 +313,9 @@ irreversible; guessing downward costs nothing.** ## Definition of done -- `make preflight` is clean — the seven gates (`lint-reviews`, `lint-issues`, - `lint-decisions`, `record-lint`, `issue-drift`, `docs-lint`, `site-render`), - both tagged eval +- `make preflight` is clean — the eight gates (`fmt-check`, `lint-reviews`, + `lint-issues`, `lint-decisions`, `record-lint`, `issue-drift`, `docs-lint`, + `site-render`), both tagged eval lanes (`smoke`, `evals-cold-reading`), plus `go build ./...`, `go vet ./...`, `go test ./...`, and `go test -race -timeout 20m ./internal/...`. The load @@ -322,13 +323,19 @@ irreversible; guessing downward costs nothing.** it exits 0 whatever it finds. The eval lanes are named separately because their files carry a build tag, so `go test ./...` compiles none of them; each costs about five seconds. -- `make fmt-check` reports nothing. The format gate is CI's own step, outside - `make preflight`, so run it before pushing. It resolves gofmt from the - toolchain `go.mod` declares rather than from PATH, because gofmt's rules move - between releases and a bare `gofmt` on a newer machine names files CI - considers correctly formatted (iss-2609081953452204); `make fmt` rewrites what - it names, with that same binary. If the pinned toolchain cannot be fetched the - target refuses and names the skew — it never falls back to the local gofmt. +- **Preflight judges with CI's toolchain.** `make fmt-check`, the format gate + CI's check job runs, is preflight's first gate, straight after the load + check. It resolves gofmt from the toolchain `go.mod` declares rather than from + PATH, because gofmt's rules move between releases and a bare `gofmt` on a + newer machine names files CI considers correctly formatted + (iss-2609081953452204); `make fmt` rewrites what it names, with that same + binary. Every Go step preflight makes — build, vet, test, race, and each + `go run` and `go test` of its gates — runs on that same declared toolchain, + which is the one CI's `setup-go` installs, so a test that asserts + standard-library wording cannot pass preflight on a newer local `go` and fail + CI (iss-2609261850045839). One resolver, `scripts/pinned-toolchain.sh`, serves + both; if the declared toolchain cannot be fetched it refuses and names the + skew — it never falls back to the local `go`. - Every new behaviour has a test watched fail before the change and pass after. - **A user-facing change is accompanied by a RECORD, not by a hand-written CHANGELOG entry.** The changelog is derived: `launch ship` composes the dated diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ede809f1..1a46d4ec4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,17 +49,21 @@ inbound = outbound statement is the whole of it. time — review attention is the scarce resource this protects. - **Local gates.** `make preflight` runs the load check first (load-check, a warning, never a failure), then the same build, vet, test and race - steps locally, together with the lint-reviews, lint-issues, lint-decisions, - record-lint, issue-drift, + steps locally, together with the fmt-check format gate, the lint-reviews, + lint-issues, lint-decisions, record-lint, issue-drift, docs-lint and site-render gates and both tagged eval lanes (smoke, evals-cold-reading, about five seconds each — the untagged test step compiles - neither) — but not the format gate, so run `make fmt-check` before pushing - (it runs the gofmt from the toolchain `go.mod` declares, which is the one CI - runs; `make fmt` applies it). The repository + neither). Every Go step it makes runs on the toolchain `go.mod` declares, + which is the one CI runs, and the format gate runs that toolchain's gofmt + (`make fmt` applies it); when the declared toolchain cannot be fetched, + preflight refuses and names the skew rather than falling back to the `go` on + PATH. The repository ships its hooks in [`.githooks/`](.githooks/); they are per-machine opt-in — run `git config core.hooksPath .githooks` once per clone to arm the - pre-commit name guard, the commit-msg outbound check (it refuses a live - agent-session URL or a tool's attribution footer in a commit message, through + pre-commit name guard (it reads this machine's private banlist, + `.abcd/.work.local/private-names.txt`, which `abcd banlist add --private` + provisions; with no banlist it warns loudly and lets the commit through), + the commit-msg outbound check (it refuses a live agent-session URL or a tool's attribution footer in a commit message, through this checkout's own `abcd lint outbound`, built from `./cmd/abcd`, and refuses the commit when it cannot run the check) and the pre-push receipt check: a push of a commit the remote does not hold yet needs a passing `make preflight` run on that commit with nothing diff --git a/Makefile b/Makefile index 57705e8bb..42e0bcd63 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,8 @@ LDFLAGS := -s -w$(if $(VERSION), -X github.com/intentdriven/abcd/internal/core.V # The Go toolchain version go.mod declares, read from the declaration rather # than spelled here: a second spelling is a second thing to bump, and the one -# that falls behind is the one nothing runs. Drives the format gate below. +# that falls behind is the one nothing runs. Drives the format gate and every +# Go step `preflight` makes, below. GO_TOOLCHAIN_VERSION := $(shell sed -n 's/^go \([0-9][0-9.]*\)$$/\1/p' go.mod) .PHONY: build test vet clean preflight load-check lint-reviews lint-issues lint-decisions record-lint issue-drift docs-lint site-render smoke \ @@ -76,10 +77,12 @@ evals-cold-reading: # neither direction is visible in the output, which names a file and never says # which toolchain judged it. # -# `GOTOOLCHAIN=go go env GOROOT` fetches and caches the declared -# toolchain if the machine lacks it, then reports where it landed; the gofmt -# under that GOROOT is the one CI runs. `fmt` applies the same binary, so the -# remedy and the diagnosis can never disagree. +# scripts/pinned-toolchain.sh is the one resolver: `GOTOOLCHAIN=go go +# env GOROOT` fetches and caches the declared toolchain if the machine lacks it, +# then reports where it landed, and the gofmt under that GOROOT is the one CI +# runs. `fmt` applies the same binary, so the remedy and the diagnosis can never +# disagree, and `preflight` runs this gate before any other and then exports the +# same GOTOOLCHAIN to every Go step it makes (see below). # # It REFUSES rather than falling back when the toolchain cannot be resolved # (offline, or the fetch declined). A fallback would print a filename judged by @@ -89,26 +92,7 @@ evals-cold-reading: define pinned_gofmt @set -eu; \ version='$(GO_TOOLCHAIN_VERSION)'; \ - if [ -z "$$version" ]; then \ - echo "gofmt: REFUSING — go.mod declares no \`go \` line, so the format gate has no toolchain to resolve." >&2; \ - exit 2; \ - fi; \ - local_version="$$(go env GOVERSION 2>/dev/null || echo unknown)"; \ - if ! goroot="$$(GOTOOLCHAIN=go$$version go env GOROOT 2>&1)" || [ ! -x "$$goroot/bin/gofmt" ]; then \ - echo "gofmt: REFUSING to judge this tree." >&2; \ - echo "gofmt: go.mod declares go$$version; the go on PATH is $$local_version." >&2; \ - echo "gofmt: the go$$version toolchain could not be resolved (the fetch needs network):" >&2; \ - echo "$$goroot" | sed 's/^/gofmt: /' >&2; \ - echo "gofmt: NOT falling back to the gofmt on PATH — a different gofmt version judges this" >&2; \ - echo "gofmt: tree differently, so the fallback would name files CI considers correct." >&2; \ - exit 2; \ - fi; \ - resolved="$$("$$goroot/bin/go" version 2>/dev/null | awk '{print $$3}')"; \ - if [ "$$resolved" != "go$$version" ]; then \ - echo "gofmt: REFUSING — go.mod declares go$$version, but the resolved toolchain reports $$resolved." >&2; \ - echo "gofmt: GOTOOLCHAIN did not switch, so the gate would run the wrong gofmt." >&2; \ - exit 2; \ - fi; \ + goroot="$$(scripts/pinned-toolchain.sh "$$version")" || exit 2; \ case '$(1)' in \ check) \ unformatted="$$("$$goroot/bin/gofmt" -l .)"; \ @@ -161,8 +145,16 @@ check-attribution: # Deterministic drift gate for the .abcd/development design record (first slice # of internal/core/lint). Blocking: any record drift (stale tool names, dropped # concepts, lifecycle or reference breakage) fails preflight and CI. +# +# `-agent-diff` arms agent_contract's unbumped-edit check — a changed agent +# prompt must bump its prompt_version and add its agents/CHANGELOG.md entry — +# over the branch's own changes, the merge-base range `origin/main...HEAD`. CI's +# step passes the same three-dot range from its base commit. Unarmed, the check +# is a no-op, and a prompt edit passed three green preflights to be refused in +# the merge queue (iss-2609021152026246). Like lint-issues and lint-decisions, +# it needs origin/main. record-lint: - @go run ./cmd/record-lint + @go run ./cmd/record-lint -agent-diff origin/main...HEAD # Promote-join drift gate (itd-4 AC3). Blocking: an intent naming a record in # `related_issues` that does not name it back, a dangling id on either side, a @@ -235,8 +227,12 @@ lint-decisions: # Deterministic docs-currency gate (itd-60): the same internal/core/lint engine, # driven over docs/ and the repo root via the transport-agnostic `abcd lint docs` -# verb. Blocking: change-narration in a doc body, a broken relative link, or a -# stray root markdown file fails preflight and CI. +# verb. Blocking: change-narration in a doc body, a broken relative link, a +# persona the roster does not hold, or a stray root markdown file fails +# preflight and CI. The link check also walks every other committed markdown +# file record-lint does not — the root prose, the agent prompts, the plugin +# command pages and the READMEs — through links_resolve's extra_roots in +# .abcd/docs-lint.json (iss-46). docs-lint: @go run ./cmd/abcd lint docs @@ -282,14 +278,15 @@ scaffold-sync-check: # Pre-push gate (run before a push, never by it: .githooks/pre-push checks the # receipt the last step mints, below): the load check first (a -# warning, never a failure: load-check), then the six lint gates +# warning, never a failure: load-check), then the format gate (fmt-check), the +# six lint gates # (lint-reviews, lint-issues, lint-decisions, record-lint, issue-drift, # docs-lint), the # site-render gate and both tagged eval lanes (smoke, evals-cold-reading) as # prerequisites, then build, vet, test, -# and race-enabled internal tests natively. CI's check job runs those same four -# Go steps plus the `fmt-check` format gate this target does not, so run -# `make fmt-check` separately before pushing. Host-native `go build` (not the +# and race-enabled internal tests natively — every Go step on the toolchain +# go.mod declares, which is the one CI's check job runs those same four Go +# steps and its format gate on. Host-native `go build` (not the # cross-compiling build target) because it mirrors CI. # # The eval lanes are prerequisites because the untagged `go test ./...` step @@ -306,7 +303,7 @@ scaffold-sync-check: # file reaching for a smoke-only helper compiles under one and not the other, # which is the split CI's two jobs cover. About five seconds each on a warm # cache, against roughly a minute for the gates already here. -preflight: load-check lint-reviews lint-issues lint-decisions record-lint issue-drift docs-lint site-render smoke evals-cold-reading +preflight: load-check fmt-check lint-reviews lint-issues lint-decisions record-lint issue-drift docs-lint site-render smoke evals-cold-reading go build ./... go vet ./... go test ./... @@ -337,6 +334,17 @@ endif # prerequisite run on its own. preflight: export ABCD_LOAD_CHECKED := preflight +# Every Go step preflight makes — its own recipe lines and every go run and go +# test its prerequisites make — runs on the toolchain go.mod declares, the one +# CI's setup-go installs, never the go on PATH (iss-2609261850045839). A test +# that asserts standard-library wording passed preflight on a newer local go and +# failed CI (pull request 728). The go on PATH switches to the declared release +# and puts its bin first on PATH for anything it runs, so a `go` a test execs is +# the declared one too. `fmt-check` runs second, straight after the load check, +# and its resolver (scripts/pinned-toolchain.sh) refuses, naming the skew, when +# the release cannot be fetched, before any gate runs on it. +preflight: export GOTOOLCHAIN := go$(GO_TOOLCHAIN_VERSION) + # The load check (itd-2609231434459890): reads the machine's load and process # table once and warns about programs left running and extreme load. It exits 0 # on every status, and the leading `-` ignores even a failure to build it: a diff --git a/agents/CHANGELOG.md b/agents/CHANGELOG.md index bce873684..b743f52c9 100644 --- a/agents/CHANGELOG.md +++ b/agents/CHANGELOG.md @@ -12,6 +12,27 @@ over the brief's earlier `1.0.0`-at-close expectation). The four M6 synthesis agents below entered at `0.1.0`, wired to their `abcd disembark` verbs and unmeasured; `lifeboat-oracle` has since become `lifeboat-reviewer` at `0.1.1`. +## 2026-09-26 (iss-2609181121301638, iss-2609181121305984, iss-2609262011046013 — the requests state the conditions and the shapes) + +The fidelity review request lists every scope condition under its `cond-…` +identity and carries a `## Verdict shape` section rendered from the structure +the ingest decodes, and the consistency request carries a `## Findings shape` +section the same way, so the auditor no longer scrapes identities out of the +record or learns either shape from refusals. + +### intent-auditor 0.5.0 + +MINOR: the `scope_conditions` input names the request's `## Scope Conditions` +block as where the identities are listed, and the Role 1 output format names the +request's `## Verdict shape` section as the same shape, winning where the two +differ; the Role 2 output format names the request's `## Findings shape` section +the same way. The precedence clause is a new instruction — where the prompt's +example and the request's rendered shape part, the agent follows the request — +so this is a behaviour change, not a non-behavioural edit, even though the two +shapes agree today. Both rubrics, both shapes and every ingest rule are +untouched, so a verdict or a findings payload that was valid before stays valid. +Unmeasured, as before. + ## 2026-09-26 (itd-48 — the intent auditor gains its cross-document role) `abcd intent consistency` assembles the brief and every live intent into one diff --git a/agents/intent-auditor.md b/agents/intent-auditor.md index 50210e031..b8ed3eb2a 100644 --- a/agents/intent-auditor.md +++ b/agents/intent-auditor.md @@ -9,7 +9,7 @@ description: >- file:line evidence pointer. Role 2 (cross-document): reads the assembled brief-and-intents corpus and emits one findings JSON naming each contradiction between two documents, both ends quoted verbatim. -prompt_version: 0.4.0 +prompt_version: 0.5.0 reads_untrusted_input: true capability_scope: task_classes: [intent_audit, intent_consistency] @@ -51,7 +51,8 @@ color: green - `delivered` — a diff and/or commit range that constitutes the delivered work, plus read access to the repository at that state. - `scope_conditions` — the intent's `## Scope Conditions` bullets with the - `cond-…` identity each one carries. Echo every identity **verbatim**; never + `cond-…` identity each one carries, listed in the request's own + `## Scope Conditions` block. Echo every identity **verbatim**; never invent one, never renumber them, and never key a disposition on your own paraphrase of a condition. If the intent records none, the block is empty. - `policy` — `rubric_hash` and `prompt_hash`, stated verbatim in the review @@ -120,6 +121,9 @@ in every bucket carries at least one cited `evidence` pointer. ## Output format (emit EXACTLY this — one fenced json block, no prose around it) +The request's `## Verdict shape` section states the same shape, rendered from +the structure the ingest decodes; where the two ever differ, the request wins. + ```json { "_type": "abcd/intent-fidelity-verdict/v1", @@ -313,6 +317,9 @@ the wording is. ## Output format (emit EXACTLY this — one fenced json block, no prose around it) +The request's `## Findings shape` section states the same shape, rendered from +the structure the ingest decodes; where the two ever differ, the request wins. + ```json { "_type": "abcd/intent-consistency-findings/v1", diff --git a/commands/embark.md b/commands/embark.md index 54cd26871..70370e0d8 100644 --- a/commands/embark.md +++ b/commands/embark.md @@ -60,7 +60,9 @@ and the `marker` action. `from` is **conflict-safe**: if the plan carries **any** conflict, it writes **nothing** — not a partial set — and exits non-zero with one bulk conflict report. A conflict is per-file: a target that merely holds unrelated files is fine; a file -that already matches byte-for-byte is an idempotent skip. Relay the bulk report so +that already matches byte-for-byte is an idempotent skip. The plan is judged again +under the target's ledger and intent locks just before the write, so a record +created at a planned target while the embark ran is a conflict too. Relay the bulk report so the user resolves the conflicts and re-runs. A re-run over an already-embarked target is a clean no-op (all `unchanged`, marker `current`). diff --git a/commands/intent.md b/commands/intent.md index b84beccbd..e73dd69fd 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -693,11 +693,24 @@ sibling worktree or a local branch, see `/abcd:peers`) the refusal names the peer's branch, path and bucket instead of answering not found. Ingest is fail-closed: report the returned status (`ingested`, `dead_letter`, -or `noop`) and, for `dead_letter`, the reason. A second ingest for a receipt +or `noop`) and, for `dead_letter`, the reason. The `--json` result's `recorded` +says what the ingest wrote into the record: `verdict`, `quarantine` or +`nothing`. The acceptance rollup and the disposition split (`criteria`, `met`, +`met_with_concerns`, `not_met`, `inconclusive`, `conditions`, `survived`, +`narrowed`, `falsified`, `untested`) appear only beside a recorded verdict; a +quarantine carries `conditions_untested` (every scope condition it recorded +untested), `dead_letter_path` and `reason` instead. A second ingest for a receipt already ingested is a `noop` when its payload renders to the block on the record, replaces that block in place when it renders differently (`ingested`, reported as `replaced`), and is refused with nothing written when it does not validate: -a bad re-ingest never dead-letters a verdict already ingested. +a bad re-ingest never dead-letters a verdict already ingested. A verdict whose +prose cites a record id that names no record is refused too, naming the id, with +nothing written, wherever the repository's record-lint gates prose citations in +the intent store: re-word the prose to describe the record and ingest again. +Each review block closes on its own `` +line, and only a marker on a live line of `## Audit Notes` counts: a note written +below a block stays when the block is replaced, and a marker quoted in a fenced +example or an HTML comment is not review state. **Model-tier routing.** Both `intent audit ` and `intent audit ingest` dispatch the `intent-auditor` agent, and each resolves that agent's model tier @@ -729,8 +742,16 @@ anything is written. With no table accepted and no `--route`, the step asks for `host-decides` and nothing is printed. **Hand the auditor the whole request file.** `intent audit` writes it to the -reported `request_path`, and its `## Provenance` block states the -`rubric_hash` and `prompt_hash` the host computed. The auditor echoes both +reported `request_path`. It states the criteria count, lists every scope +condition under the `cond-…` identity the verdict disposes it by, and carries a +`## Verdict shape` section rendered from the structure the ingest decodes, so a +reviewer working from the request alone has the shape to write against. The +result's `status` names the receipt's state and `request_written` the act: a +re-emit of an owed receipt rewrites its request (`already_owed`, +`request_written: true`, text `request rewritten:`), and a re-emit of an +ingested or dead-lettered receipt writes none and names no `request_path`. Its +`## Provenance` block states the `rubric_hash` and `prompt_hash` the host +computed. The auditor echoes both verbatim into `policy`; it never computes either itself. The ingest recomputes them and refuses a verdict carrying any other value, leaving the receipt parked so the request can be re-emitted and the audit re-run — so a made-up hash costs @@ -891,7 +912,8 @@ title, press release, scope, decisions and rule — and the second narrows it to one intent against the rest. Neither judges anything: each assembles the corpus into `corpus_path` and writes the request to `request_path`, both under `.abcd/.work.local/reviews/`, names the commit the tree stood at -(`review_of_commit`), and writes nothing else. A superseded or unknown intent is +(`review_of_commit`), and writes nothing else. The request carries a +`## Findings shape` section rendered from the structure the ingest decodes. A superseded or unknown intent is refused. The corpus is read from the working tree, so when a corpus document is edited, untracked or deleted relative to that commit the emit says `dirty: true`, names the paths in `dirty_paths`, and the report carries the mark beside its pin @@ -911,11 +933,16 @@ Then run the pass, one request at a time: The ingest validates before it writes anything. It refuses, with nothing written: a receipt no request here was issued for, a corpus that moved since the -request (re-emit and run the pass again), provenance hashes the request did not -state, a class or severity outside its set, an end whose path is not a corpus +request (re-emit and run the pass again), provenance hashes the host does not +issue for the receipt (likewise: a request an earlier binary wrote no longer +matches, so re-emit and run the pass again), a class or severity outside its set, an end whose path is not a corpus document or whose quote is not in it (twelve characters at least), and a finding with fewer or more than two ends or one that repeats another. A scoped -run also refuses a finding with no end in its intent. +run also refuses a finding with no end in its intent. And a finding it would +file whose text cites a record id that names no record is refused, naming the +finding and the id, wherever the repository's record-lint gates prose +citations in the issue ledger: every finding is checked before the first is +filed, so re-word the prose to describe the record and ingest again. A payload that validates is written in two places. Each finding is filed as one issue (`inconsistency`, from an `agent-finding`, located at its first end, with diff --git a/commands/reading.md b/commands/reading.md index d69c8e14d..4fc9c1f61 100644 --- a/commands/reading.md +++ b/commands/reading.md @@ -339,6 +339,16 @@ a wrong `_type`, a run id that resolves to nothing, a manifest hash that disagrees — writes nothing durable anywhere, because there is no proven run to record against. +**An item citing a record id that names no record refuses the whole run, and +writes nothing.** Wherever the repository's record-lint gates prose citations in +the reading-record store, an item whose pattern or body field cites an `adr`, +`itd`, `iss` or `spc` id that names no record refuses the ingest, naming the +item, the field and the id — before the orphan sweep and before anything is +staged. No refusal record is written, because one would give the run an outcome +and turn away the same run re-worded: the run stays parked, so re-word the prose +to describe the record rather than cite an id that does not exist, and ingest it +again. + **A rerun is a new run with a new run id, never an amendment.** Once a run id has an outcome — a commit marker or a refusal record — ingesting it again is refused. Assemble again, and ingest the run that assembly parked. diff --git a/docs/how-to/install.md b/docs/how-to/install.md index 70dfbf929..983a9048d 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -298,10 +298,10 @@ someone else's hook, a fence without its markers reads as drifted, and ```bash make preflight # the pre-push gate: the load check first (load-check, a - # warning, never a failure), then lint-reviews, lint-issues, - # lint-decisions, record-lint, issue-drift, docs-lint, - # site-render, smoke and evals-cold-reading, then build, vet, - # test and race + # warning, never a failure), then fmt-check, lint-reviews, + # lint-issues, lint-decisions, record-lint, issue-drift, + # docs-lint, site-render, smoke and evals-cold-reading, then + # build, vet, test and race, all on the toolchain go.mod declares go run ./cmd/abcd # bare status board for the current directory go run ./cmd/abcd --version # print the version make build # cross-compile bin/abcd-- diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 1117f91ce..40128c753 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1682,7 +1682,7 @@ Ingest an intent-audit verdict into the shipped intent: Writes its Audit Notes; ``` --route stringArray route one agent for this run: =[@][?k=v,...], tier one of local | economy | frontier | host-decides (one per agent this invocation dispatches, and each invocation dispatches one; wins over every accepted routing table for this run alone, and the receipt records it verbatim) - --verdict-json string path to the intent-audit verdict JSON + --verdict-json string path to the intent-audit verdict JSON, in the shape the Verdict shape section of its review request states ``` **Example:** diff --git a/internal/core/ahoy/docslint_seed_test.go b/internal/core/ahoy/docslint_seed_test.go index 39855bf4d..039b5609f 100644 --- a/internal/core/ahoy/docslint_seed_test.go +++ b/internal/core/ahoy/docslint_seed_test.go @@ -139,6 +139,10 @@ var deliberateSeedOmissions = map[string]string{ // it is seeded once it has run there, not into every prepared repository // on its first release. "link_anchors": "warn-first in abcd's own tree before it is seeded", + // The persona rule reads abcd's persona roster + // (.abcd/development/personas.json), which a prepared repository does not + // have; armed there, it refuses to load for want of a registry. + "persona_registry": "abcd's persona roster", } // deliberatelyOmitted reports whether a canonical token id or rule name is named diff --git a/internal/core/banlist/public_test.go b/internal/core/banlist/public_test.go index 338d83de5..bbca53b8e 100644 --- a/internal/core/banlist/public_test.go +++ b/internal/core/banlist/public_test.go @@ -137,6 +137,7 @@ func TestAddPublicEntryGatesUserFacingContent(t *testing.T) { for _, r := range []string{".abcd/README.md", "AGENTS.md", "CONTRIBUTING.md", "scripts/README.md"} { write(r, "# t\n") } + provisionDocsLintTrees(t, cfg, docs) write("docs/named.md", "# t\n\nBuilt with widgetworks.\n") write("docs/allowed.md", "# t\n\n widgetworks is named deliberately.\n") write("docs/clean.md", "# t\n\nBuilt with a generic term.\n") @@ -447,6 +448,7 @@ func TestAddPublicIsCaseInsensitiveLikeTheCuratedEntries(t *testing.T) { t.Fatal(err) } } + provisionDocsLintTrees(t, cfg, docs) findings, err := lint.Lint(cfg, docs) if err != nil { t.Fatal(err) @@ -645,3 +647,30 @@ func TestConcurrentPublicAddsAllLand(t *testing.T) { t.Errorf("entries = %d, want %d — a concurrent add was lost", len(after.Entries), len(before.Entries)+n) } } + +// provisionDocsLintTrees creates, in a fixture repository, every tree the real +// docs-lint config reads beyond its roots and name_roots: links_resolve's extra +// roots and the persona roster (iss-46). A configured tree that does not resolve +// is a load error, so a fixture that lints with the real config needs them, and +// reading them from the config means a new one needs no edit here. +func provisionDocsLintTrees(t *testing.T, cfg lint.Config, root string) { + t.Helper() + write := func(rel, body string) { + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for _, r := range cfg.Rules["links_resolve"].ExtraRoots { + if !strings.HasSuffix(r, ".md") { + r += "/README.md" + } + write(r, "# t\n") + } + if reg := cfg.Rules["persona_registry"].Registry; reg != "" { + write(reg, `{"personas": [{"name": "Kira"}]}`+"\n") + } +} diff --git a/internal/core/capture/alloc.go b/internal/core/capture/alloc.go index 4b8ce9e47..035917280 100644 --- a/internal/core/capture/alloc.go +++ b/internal/core/capture/alloc.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" @@ -244,6 +245,13 @@ func WithLedgerLock(repoRoot string, fn func() error) error { return withLedgerLock(rr, issuesRoot, fn) } +// init registers this ledger's lock with the intent package, which cannot +// import this one: an intent verb's link repoint rewrites ledger records that +// link to the record it moved, and takes the lock for that, before the intent +// store's (iss-2609262143209970). Registering here rather than in a front door +// is what arms every binary that links a ledger at all. +func init() { intent.SetLedgerLock(WithLedgerLock) } + // minter is the capture family's mint seam (adr-45; mechanics per spc-33). The // zero value is the production configuration — real clock, crypto entropy; // tests inject both so same-instant and race cases are deterministic. diff --git a/internal/core/capture/citationgate.go b/internal/core/capture/citationgate.go new file mode 100644 index 000000000..c2434124d --- /dev/null +++ b/internal/core/capture/citationgate.go @@ -0,0 +1,149 @@ +package capture + +import ( + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/issueschema" +) + +// citationgate.go holds the two ledger ingests that copy host-delegated prose +// into lint-bound records — the consistency pass's findings and a reading's +// items — to record-lint's prose_citation_resolves before they write +// (iss-2609261835118276). It is the gate the verdict ingest asks +// (iss-2609231036448320), reached through the one registration the front doors +// make (intent.SetProseCitationGate), because this package cannot import +// core/lint: lint's tests import this package. +// +// It behaves exactly as the verdict ingest does. It REFUSES rather than +// sanitises, names every id it refuses, and nothing is written: a payload has +// no way to say an id is illustrative, and deciding what the host meant is not +// the ingest's to do. A repository whose record-lint does not arm the rule over +// the store refuses nothing, and a front door that registered no gate is +// refused outright. +// +// The question is asked of the text as the ingest hands it to the writer, +// before the redactor runs: the redactor replaces secret spans and never +// introduces an id, so the only difference it could make is an id inside a +// redacted span, which this refuses where the gate would not — the safe side. + +// citedPart is one piece of host text bound for a record, as the record will +// carry it, and whose words it is, for the refusal. +type citedPart struct { + what string + text string +} + +// refuseUnresolvedCitations asks the gate ONCE of every part, bound for records +// under dirRel (the repo-relative directory they land in), and refuses naming +// each part that cites an id the gate refuses and the ids it cites. The parts +// are asked as one text, so each must begin outside a fence exactly as it will +// in its record: a frontmatter line always does, and a body is asked after +// its own record's frontmatter and nothing else. +func refuseUnresolvedCitations(repoRoot, dirRel string, parts []citedPart) error { + var b strings.Builder + var owner []int // owner[i] is the part holding line i+1 + for i, p := range parts { + text := strings.TrimSuffix(strings.ReplaceAll(p.text, "\r\n", "\n"), "\n") + for _, line := range strings.Split(text, "\n") { + b.WriteString(line) + b.WriteByte('\n') + owner = append(owner, i) + } + } + // The gate judges a record by the store its path lies in; the record's own + // name is not minted yet and does not bear on the answer. + rel := filepath.ToSlash(filepath.Join(dirRel, "pending.md")) + cites, err := intent.UnresolvedProseCitations(repoRoot, rel, b.String()) + if errors.Is(err, intent.ErrNoProseCitationGate) { + return errors.New("capture: no prose-citation gate is registered, so the ingested text cannot be checked " + + "against the record gate its records must pass; refusing to ingest (nothing written)") + } + if err != nil { + return fmt.Errorf("capture: the prose-citation check over the ingested text: %w", err) + } + if len(cites) == 0 { + return nil + } + byPart := map[int][]string{} + var order []int + for _, c := range cites { + if c.Line < 1 || c.Line > len(owner) { + continue + } + i := owner[c.Line-1] + if _, seen := byPart[i]; !seen { + order = append(order, i) + } + if !containsString(byPart[i], c.ID) { + byPart[i] = append(byPart[i], c.ID) + } + } + named := make([]string, 0, len(order)) + for _, i := range order { + named = append(named, parts[i].what+" cites "+strings.Join(byPart[i], ", ")) + } + return fmt.Errorf("%w: %s — a record id that names no record in this repository; record-lint's "+ + "prose_citation_resolves refuses a record under %s that carries one, and an ingest has no way to mark "+ + "an id illustrative. Re-word the prose to describe the record rather than cite an id that does not "+ + "exist, and ingest again (nothing written)", ErrUnresolvedCitation, strings.Join(named, "; "), dirRel) +} + +// ErrUnresolvedCitation is the refusal of host prose citing a record id that +// names no record. +var ErrUnresolvedCitation = errors.New("capture: unresolved record citation") + +// repoRelDir is dir relative to repoRoot as a slash path; a directory outside +// the repository (a custom issues root) keeps its own path, which no store of +// the repository's record-lint holds. +func repoRelDir(repoRoot, dir string) string { + if rel, err := filepath.Rel(repoRoot, dir); err == nil && filepath.IsLocal(rel) { + return filepath.ToSlash(rel) + } + return filepath.ToSlash(dir) +} + +// CheckReadingCitations holds every free-text value of a reading's items — the +// pattern and each body field, the host's own words — to the record gate the +// item's record must pass, and refuses naming the item, the field and the ids. +// Each value is asked as the frontmatter line its record carries it on (the +// writer renders every value on one line), so what is judged is what the gate +// will read. IngestReading asks it before it writes anything; the reading +// ingest asks it too, before its orphan sweep and its stage, so a refusal there +// leaves the tree exactly as it found it. +func CheckReadingCitations(req IngestReadingRequest) error { + repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) + if err != nil { + return err + } + dirRel := repoRelDir(repoRoot, filepath.Join(issuesRoot, issueschema.ReadingsDir, req.Run)) + var parts []citedPart + for i, item := range req.Items { + keys := make([]string, 0, len(item.Body)) + for k := range item.Body { + keys = append(keys, k) + } + sort.Strings(keys) + values := []kv{{"pattern", item.Pattern}} + for _, k := range keys { + values = append(values, kv{k, item.Body[k]}) + } + for _, v := range values { + line, err := yamlScalar(v.val) + if err != nil { + // A value the writer cannot render is refused by the writer, on + // its own terms; it is not this check's to answer. + continue + } + parts = append(parts, citedPart{what: fmt.Sprintf("reading item %d's %s", i+1, v.key), text: v.key + ": " + line}) + } + } + if len(parts) == 0 { + return nil + } + return refuseUnresolvedCitations(repoRoot, dirRel, parts) +} diff --git a/internal/core/capture/citationgate_test.go b/internal/core/capture/citationgate_test.go new file mode 100644 index 000000000..5ae285262 --- /dev/null +++ b/internal/core/capture/citationgate_test.go @@ -0,0 +1,197 @@ +package capture + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/gittest" +) + +// armLedgerProseCitations writes a record-lint configuration arming +// prose_citation_resolves over the ledger's stores, as this repository's does. +func armLedgerProseCitations(t *testing.T, repo string) { + t.Helper() + writeTree(t, repo, ".abcd/record-lint.json", `{ + "roots": [".abcd/work"], + "rules": { + "prose_citation_resolves": { + "enabled": true, + "severity": "blocker", + "record_stores": {"iss": ".abcd/work/issues", "rdi": ".abcd/work/issues/readings"} + } + } +} +`) +} + +// withNoProseGate unregisters the gate for one test. +func withNoProseGate(t *testing.T) { + t.Helper() + intent.SetProseCitationGate(nil) + t.Cleanup(func() { intent.SetProseCitationGate(lintProseGate) }) +} + +// ledgerFiles lists every file under the ledger, repo-relative, so a test can +// prove a refusal left it exactly as it was. +func ledgerFiles(t *testing.T, repo string) []string { + t.Helper() + var out []string + root := filepath.Join(repo, LedgerRelPath) + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !d.IsDir() { + rel, _ := filepath.Rel(repo, p) + out = append(out, rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +// A reading's items are the host's words, copied into records record-lint's +// prose_citation_resolves reads; an item citing a record id that names no +// record is refused before anything is written, naming the item, the field and +// the id (iss-2609261835118276). A resolving citation lands, and a repository +// that does not arm the rule gates nothing. +func TestIngestReadingRefusesAnUnresolvedCitation(t *testing.T) { + const dangling = "iss-2609999999999999" + req := func(repo, ir, pattern string) IngestReadingRequest { + return IngestReadingRequest{ + RepoRoot: repo, IssuesRoot: ir, Run: "rdg-2608300000000001", Manifest: "sha256:beef", + Position: "detection", Regime: "registrative", + Items: []ReadingItem{ + {Pattern: "a clean constraint", Body: bodyFor("detection")}, + {Pattern: pattern, Body: bodyFor("detection")}, + }, + } + } + + t.Run("armed: refused, nothing written", func(t *testing.T) { + repo, ir := ledger(t) + armLedgerProseCitations(t, repo) + _, err := IngestReading(req(repo, ir, "the constraint "+dangling+" states")) + if !errors.Is(err, ErrUnresolvedCitation) || !strings.Contains(err.Error(), dangling) || + !strings.Contains(err.Error(), "reading item 2's pattern") || !strings.Contains(err.Error(), "prose_citation_resolves") { + t.Fatalf("err = %v, want a refusal naming item 2's pattern, %s and the rule", err, dangling) + } + if got := ledgerFiles(t, repo); len(got) != 0 { + t.Fatalf("a refused ingest wrote %v", got) + } + }) + t.Run("armed: a body field is judged too", func(t *testing.T) { + repo, ir := ledger(t) + armLedgerProseCitations(t, repo) + r := req(repo, ir, "a clean pattern") + body := bodyFor("detection") + var field string + for field = range body { + break + } + body[field] = "see " + dangling + r.Items[1].Body = body + _, err := IngestReading(r) + if !errors.Is(err, ErrUnresolvedCitation) || !strings.Contains(err.Error(), "reading item 2's "+field) { + t.Fatalf("err = %v, want a refusal naming item 2's %s", err, field) + } + }) + t.Run("armed: a resolving citation ingests", func(t *testing.T) { + repo, ir := ledger(t) + armLedgerProseCitations(t, repo) + held, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, + Category: "bug", Source: "user-observation", FoundDuring: "t"}) + if err != nil { + t.Fatal(err) + } + res, err := IngestReading(req(repo, ir, "the constraint "+held.ID+" states")) + if err != nil || len(res.Records) != 2 { + t.Fatalf("ingest = %+v %v, want both items written", res, err) + } + }) + t.Run("unarmed: the repository does not gate prose citations", func(t *testing.T) { + repo, ir := ledger(t) + res, err := IngestReading(req(repo, ir, "the constraint "+dangling+" states")) + if err != nil || len(res.Records) != 2 { + t.Fatalf("ingest = %+v %v, want both items written", res, err) + } + }) + t.Run("no gate registered: refused, nothing written", func(t *testing.T) { + withNoProseGate(t) + repo, ir := ledger(t) + _, err := IngestReading(req(repo, ir, "a clean pattern")) + if err == nil || !strings.Contains(err.Error(), "no prose-citation gate is registered") { + t.Fatalf("err = %v, want the unregistered gate refused", err) + } + if _, err := os.Stat(filepath.Join(ir, issueschema.ReadingsDir)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a refused ingest provisioned the readings store (%v)", err) + } + }) +} + +// consistencyArmedRepo is consistencyLedgerRepo with the ledger's prose +// citations armed, committed with the fixture so the emit reads a clean tree. +func consistencyArmedRepo(t *testing.T) string { + t.Helper() + r := gittest.NewRepo(t) + r.Write(cxA, "---\nid: itd-10\nslug: one-spec\nkind: standalone\nspec_id: spc-1\n---\n\n# One spec\n\n## Press Release\n\n"+cxQuoteA+"\n") + r.Write(cxB, "---\nid: itd-11\nslug: many-specs\nkind: standalone\nspec_id: spc-2\n---\n\n# Many specs\n\n## Decisions\n\n1. "+cxQuoteB+"\n") + armLedgerProseCitations(t, r.Root()) + r.Commit("fixture") + return r.Root() +} + +// The consistency pass files each finding as an issue carrying the host's +// summary, quotes and explanation; a finding citing a record id that names no +// record is refused before the first finding is filed and before the report is +// written, naming the finding and the id (iss-2609261835118276). +func TestConsistencyIngestRefusesAnUnresolvedCitation(t *testing.T) { + const dangling = "adr-2609999999999999" + cite := func(payload []byte) []byte { + return []byte(strings.Replace(string(payload), "the other says one or more.", + "the other says one or more, as "+dangling+" ruled.", 1)) + } + + t.Run("armed: refused, nothing written", func(t *testing.T) { + root := consistencyArmedRepo(t) + _, err := IngestConsistency(root, cite(consistencyPayload(t, root)), "2026-09-26") + if !errors.Is(err, ErrUnresolvedCitation) || !strings.Contains(err.Error(), dangling) || + !strings.Contains(err.Error(), "consistency finding 1") { + t.Fatalf("err = %v, want a refusal naming finding 1 and %s", err, dangling) + } + for _, f := range ledgerFiles(t, root) { + if filepath.Base(f) != lockFilename { + t.Fatalf("a refused ingest wrote %s", f) + } + } + if _, err := os.Stat(filepath.Join(root, intent.ReviewsShelfRelDir)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a refused ingest wrote a report (%v)", err) + } + }) + t.Run("armed: a clean finding is filed", func(t *testing.T) { + root := consistencyArmedRepo(t) + res, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + if err != nil || len(res.Filed) != 1 { + t.Fatalf("ingest = %+v %v, want the finding filed", res, err) + } + }) + t.Run("no gate registered: refused", func(t *testing.T) { + withNoProseGate(t) + root := consistencyLedgerRepo(t) + _, err := IngestConsistency(root, consistencyPayload(t, root), "2026-09-26") + if err == nil || !strings.Contains(err.Error(), "no prose-citation gate is registered") { + t.Fatalf("err = %v, want the unregistered gate refused", err) + } + }) +} diff --git a/internal/core/capture/consistency.go b/internal/core/capture/consistency.go index 3bb5c678a..03acc87a2 100644 --- a/internal/core/capture/consistency.go +++ b/internal/core/capture/consistency.go @@ -22,21 +22,22 @@ import ( func IngestConsistency(repoRoot string, payload []byte, date string) (intent.ConsistencyIngestResult, error) { var open []Issue loaded := false - filer := func(f intent.ConsistencyFinding, reportRel string) (intent.ConsistencyFiling, error) { - // The open records are read once, on the first finding, and only records - // that were open BEFORE this pass count: two findings of one pass that - // share an end are two findings, not one. - if !loaded { - list, err := List(ListRequest{RepoRoot: repoRoot, State: StateOpen}) - if err != nil { - return intent.ConsistencyFiling{}, err - } - open, loaded = list.Issues, true + // The open records are read once, on the first finding, and only records + // that were open BEFORE this pass count: two findings of one pass that + // share an end are two findings, not one. + loadOpen := func() error { + if loaded { + return nil } - if id := openRecordHolding(open, f); id != "" { - return intent.ConsistencyFiling{IssueID: id, Linked: true}, nil + list, err := List(ListRequest{RepoRoot: repoRoot, State: StateOpen}) + if err != nil { + return err } - res, err := Capture(CaptureRequest{ + open, loaded = list.Issues, true + return nil + } + filed := func(f intent.ConsistencyFinding, reportRel string) CaptureRequest { + return CaptureRequest{ RepoRoot: repoRoot, Text: consistencyIssueText(f, reportRel), Severity: Severity(f.Severity), @@ -45,14 +46,41 @@ func IngestConsistency(repoRoot string, payload []byte, date string) (intent.Con FoundDuring: fmt.Sprintf("abcd intent consistency, finding %d of %s", f.Number, reportRel), FoundAt: endLocator(f.Ends[0]), RelatedIntents: f.IntentIDs(), - }) + } + } + // A finding an open record already holds is linked, and writes nothing, so + // only a finding that would be FILED is held to the gate — as the record + // it would be filed as: its free-text frontmatter, then its body. + check := func(f intent.ConsistencyFinding, reportRel string) error { + if err := loadOpen(); err != nil { + return err + } + if openRecordHolding(open, f) != "" { + return nil + } + req := filed(f, reportRel) + text, err := buildIssueText([]kv{{"found_during", req.FoundDuring}, {"found_at", req.FoundAt}}, req.Text) + if err != nil { + return err + } + return refuseUnresolvedCitations(repoRoot, LedgerRelPath+"/"+statusDirName[StateOpen], + []citedPart{{what: fmt.Sprintf("consistency finding %d", f.Number), text: text}}) + } + filer := func(f intent.ConsistencyFinding, reportRel string) (intent.ConsistencyFiling, error) { + if err := loadOpen(); err != nil { + return intent.ConsistencyFiling{}, err + } + if id := openRecordHolding(open, f); id != "" { + return intent.ConsistencyFiling{IssueID: id, Linked: true}, nil + } + res, err := Capture(filed(f, reportRel)) if err != nil { return intent.ConsistencyFiling{}, err } return intent.ConsistencyFiling{IssueID: res.ID}, nil } return intent.IngestConsistency(intent.ConsistencyIngestRequest{ - RepoRoot: repoRoot, Payload: payload, Date: date, File: filer, + RepoRoot: repoRoot, Payload: payload, Date: date, File: filer, Check: check, }) } diff --git a/internal/core/capture/gate_test.go b/internal/core/capture/gate_test.go new file mode 100644 index 000000000..289c7f1bf --- /dev/null +++ b/internal/core/capture/gate_test.go @@ -0,0 +1,23 @@ +package capture + +import ( + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/lint" +) + +// init registers record-lint's prose-citation gate for this package's tests, +// as the front doors register it for every ingest they run. +func init() { + intent.SetProseCitationGate(lintProseGate) +} + +// lintProseGate is lint.UnresolvedProseCitationsInRecord in the intent +// package's vocabulary, named so a test that unregisters it can put it back. +func lintProseGate(repoRoot, rel, text string) ([]intent.UnresolvedCitation, error) { + cites, err := lint.UnresolvedProseCitationsInRecord(repoRoot, rel, text) + out := make([]intent.UnresolvedCitation, 0, len(cites)) + for _, c := range cites { + out = append(out, intent.UnresolvedCitation(c)) + } + return out, err +} diff --git a/internal/core/capture/intentlock_test.go b/internal/core/capture/intentlock_test.go new file mode 100644 index 000000000..c00c1f613 --- /dev/null +++ b/internal/core/capture/intentlock_test.go @@ -0,0 +1,293 @@ +package capture + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/intent" +) + +// ledgerLockHeld reports whether some descriptor holds the ledger lock right +// now, by asking for it without waiting and letting it go at once. +func ledgerLockHeld(t *testing.T, ir string) bool { + t.Helper() + f, err := os.OpenFile(filepath.Join(ir, lockFilename), os.O_RDWR, 0) + if errors.Is(err, os.ErrNotExist) { + return false + } + if err != nil { + t.Fatalf("opening the ledger lock: %v", err) + } + defer f.Close() + err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if errors.Is(err, syscall.EWOULDBLOCK) { + return true + } + if err != nil { + t.Fatalf("probing the ledger lock: %v", err) + } + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + return false +} + +// lockedIntentAppend is an intent writer as every writer in the intent store +// is one: it takes the store's lock, reads the record, and writes it back with +// one line more. +func lockedIntentAppend(repo, rel, line string) error { + return intent.WithMintLock(repo, func() error { + abs := filepath.Join(repo, filepath.FromSlash(rel)) + data, err := os.ReadFile(abs) + if err != nil { + return err + } + return os.WriteFile(abs, append(data, []byte(line)...), 0o644) + }) +} + +// landsWithin starts fn and reports whether it finished within d, and a +// channel its result arrives on. +func landsWithin(d time.Duration, fn func() error) (bool, chan error) { + done := make(chan error, 1) + go func() { done <- fn() }() + select { + case err := <-done: + done <- err + return true, done + case <-time.After(d): + return false, done + } +} + +// issueLinkedFromAnIntent captures one issue and lays a draft intent linking to +// it in open/, returning the issue id, its filename and the intent's path. +func issueLinkedFromAnIntent(t *testing.T) (repo, ir, id, name, itdRel string) { + t.Helper() + repo, ir = ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, + Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "alpha"}) + if err != nil { + t.Fatal(err) + } + name = filepath.Base(res.Path) + itdRel = ".abcd/development/intents/drafts/itd-7-seven.md" + writeTree(t, repo, itdRel, "---\nid: itd-7\nslug: seven\nspec_id: null\nkind: null\n---\n\n# Seven\n\n"+ + "Occasioned by [alpha](../../../work/issues/open/"+name+").\n") + return repo, ir, res.ID, name, itdRel +} + +// A verb that needs the ledger lock and the intent store's lock never holds +// the first while it waits for the second (iss-2609262218059995). Waiting on +// the intent lock inside the ledger lock chained two five-second budgets: an +// intent hold of five seconds made a third process's ledger writer fail with +// ErrAllocatorContention while the verb itself waited on. So, with the intent +// lock held elsewhere for longer than a ledger writer's whole budget, a resolve +// and a migrate apply each wait, a capture arriving meanwhile lands, and each +// verb finishes once the intent lock is released. +func TestAWaitingVerbLeavesTheLedgerToOtherWriters(t *testing.T) { + for _, tc := range []struct { + name string + run func(t *testing.T) (repo, ir string, verb func() error) + }{ + {"resolve", func(t *testing.T) (string, string, func() error) { + repo, ir, id, _, _ := issueLinkedFromAnIntent(t) + return repo, ir, func() error { + res, err := Resolve(ResolveRequest{Grounds: testGrounds, RepoRoot: repo, IssuesRoot: ir, ID: id, Resolution: "fixed", Impact: "fix"}) + if err == nil && (res.RelinkError != "" || len(res.Relinked) != 1) { + err = fmt.Errorf("the resolve must repoint its one link once the intent lock is free: %+v %q", res.Relinked, res.RelinkError) + } + return err + } + }}, + {"migrate --apply", func(t *testing.T) (string, string, func() error) { + repo, ir, _ := migrateFixture(t) + return repo, ir, func() error { + _, err := Migrate(MigrateRequest{RepoRoot: repo, IssuesRoot: ir, Apply: true}) + return err + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + repo, ir, verb := tc.run(t) + old := lockTimeout + lockTimeout = 400 * time.Millisecond + t.Cleanup(func() { lockTimeout = old }) + + held, release := make(chan struct{}), make(chan struct{}) + holder := make(chan error, 1) + go func() { + holder <- intent.WithMintLock(repo, func() error { + close(held) + <-release + return nil + }) + }() + <-held + done := make(chan error, 1) + go func() { done <- verb() }() + time.Sleep(150 * time.Millisecond) + + _, capErr := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "a third writer", Severity: SeverityMinor, + Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "third"}) + select { + case err := <-done: + close(release) + t.Fatalf("%s finished while another holder had the intent lock (err %v): it wrote intent records without taking it", tc.name, err) + default: + } + close(release) + if err := <-holder; err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatalf("%s after the intent lock was released: %v", tc.name, err) + } + if capErr != nil { + t.Fatalf("a ledger writer arriving while %s waited for the intent lock failed: %v", tc.name, capErr) + } + }) + } +} + +// An intent verb's repoint rewrites ledger records that link to the record it +// moved, so it takes the ledger lock — registered with the intent package by +// this one, which owns it — before the intent store's lock +// (iss-2609262143209970). With the ledger lock held elsewhere, a plan whose +// draft an issue links to waits for it, and repoints the issue's link once it +// is released. +func TestAnIntentVerbRepointTakesTheLedgerLock(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, + Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "alpha"}) + if err != nil { + t.Fatal(err) + } + issueRel := filepath.ToSlash(res.Path) + writeTree(t, repo, ".abcd/development/intents/drafts/itd-7-seven.md", "---\nid: itd-7\nslug: seven\nspec_id: null\nkind: null\n---\n# seven\n\n"+ + "## Acceptance Criteria\n\n- **Given** a user, **when** they act, **then** it works.\n") + abs := filepath.Join(repo, filepath.FromSlash(issueRel)) + data, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, append(data, []byte("\nOccasioned [itd-7](../../../development/intents/drafts/itd-7-seven.md).\n")...), 0o644); err != nil { + t.Fatal(err) + } + + held, release := make(chan struct{}), make(chan struct{}) + holder := make(chan error, 1) + go func() { + holder <- WithLedgerLock(repo, func() error { + close(held) + <-release + return nil + }) + }() + <-held + landed, done := landsWithin(300*time.Millisecond, func() error { + pr, err := intent.Plan(repo, "itd-7", intent.PlanOptions{}) + if err == nil && pr.RelinkError != "" { + err = fmt.Errorf("plan's repoint: %s", pr.RelinkError) + } + return err + }) + close(release) + if err := <-holder; err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatal(err) + } + if landed { + t.Error("plan finished while another holder had the ledger lock: its repoint rewrote a ledger record without taking it") + } + if got := readTree(t, repo, issueRel); !strings.Contains(got, "(../../../development/intents/planned/itd-7-seven.md)") { + t.Errorf("the issue's link must be repointed to the planned intent:\n%s", got) + } +} + +// A resolve's repoint rewrites every intent linking the issue, and it does so +// with the intent store's lock held inside the ledger lock: an intent writer +// arriving while the repoint runs waits for its write instead of racing it, +// and the record ends up carrying both edits (iss-2609261254247117). +func TestIssueRepointHoldsTheIntentLock(t *testing.T) { + repo, ir, id, name, itdRel := issueLinkedFromAnIntent(t) + const edit = "\nA concurrent intent edit.\n" + var landedEarly, ledgerHeld bool + var writer chan error + duringIssueRepoint = func() { + ledgerHeld = ledgerLockHeld(t, ir) + landedEarly, writer = landsWithin(300*time.Millisecond, func() error { + return lockedIntentAppend(repo, itdRel, edit) + }) + } + t.Cleanup(func() { duringIssueRepoint = nil }) + + res, err := Resolve(ResolveRequest{Grounds: testGrounds, RepoRoot: repo, IssuesRoot: ir, ID: id, Resolution: "fixed", Impact: "fix"}) + if err != nil { + t.Fatal(err) + } + if writer == nil { + t.Fatal("the resolve never reached its repoint") + } + if err := <-writer; err != nil { + t.Fatal(err) + } + if !ledgerHeld { + t.Error("the repoint ran without the ledger lock") + } + if landedEarly { + t.Error("an intent writer landed while the repoint ran: the repoint does not hold the intent store's lock") + } + got := readTree(t, repo, itdRel) + if !strings.Contains(got, "(../../../work/issues/resolved/"+name+")") || !strings.Contains(got, edit) { + t.Errorf("the intent must carry both the repointed link and the concurrent edit:\n%s", got) + } + if res.RelinkError != "" || len(res.Relinked) != 1 { + t.Errorf("the resolve must report the one rewrite: %+v %q", res.Relinked, res.RelinkError) + } +} + +// A migrate apply rewrites intent records from what its scan read, so an +// intent writer landing between the scan and the write was erased by it. Under +// the intent store's lock that writer waits, and the record keeps both the +// migrated back-edge and its edit (iss-2609261941039204). +func TestMigrateApplyKeepsAConcurrentIntentEdit(t *testing.T) { + repo, ir, _ := migrateFixture(t) + const rel = ".abcd/development/intents/planned/itd-3-three.md" + const edit = "\nA concurrent intent edit.\n" + var landedEarly, ledgerHeld bool + var writer chan error + afterMigrateScan = func() { + ledgerHeld = ledgerLockHeld(t, ir) + landedEarly, writer = landsWithin(300*time.Millisecond, func() error { + return lockedIntentAppend(repo, rel, edit) + }) + } + t.Cleanup(func() { afterMigrateScan = nil }) + + if _, err := Migrate(MigrateRequest{RepoRoot: repo, IssuesRoot: ir, Apply: true}); err != nil { + t.Fatal(err) + } + if writer == nil { + t.Fatal("the apply never reached its writes") + } + if err := <-writer; err != nil { + t.Fatal(err) + } + if !ledgerHeld { + t.Error("the migration wrote without the ledger lock") + } + if landedEarly { + t.Error("an intent writer landed between the migration's scan and its write") + } + got := readTree(t, repo, rel) + if !strings.Contains(got, "\nrelated_issues: [iss-3]\n") || !strings.Contains(got, edit) { + t.Errorf("the intent must carry both the migrated back-edge and the concurrent edit:\n%s", got) + } +} diff --git a/internal/core/capture/migrate.go b/internal/core/capture/migrate.go index 11cc7271f..e56fb08ca 100644 --- a/internal/core/capture/migrate.go +++ b/internal/core/capture/migrate.go @@ -83,8 +83,9 @@ type migrateRecord struct { // // Nothing else moves: a loose `related_intents` entry is kept where it is, and a // record carrying no retired key and joined to nothing that did is left -// byte-identical. The run is idempotent. It holds the ledger lock for the whole -// apply, so no verb writes the ledger between the read and the rewrite. +// byte-identical. The run is idempotent. It holds the ledger lock and, inside it, +// the intent store's lock for the whole apply, so no verb writes either store +// between the read and the rewrite. func Migrate(req MigrateRequest) (MigrateResult, error) { repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) if err != nil { @@ -101,6 +102,9 @@ func Migrate(req MigrateRequest) (MigrateResult, error) { if !req.Apply { return nil } + if afterMigrateScan != nil { + afterMigrateScan() + } for _, r := range records { if r.retired == "" && len(r.additions) == 0 { continue @@ -126,7 +130,16 @@ func Migrate(req MigrateRequest) (MigrateResult, error) { if err := mutationPreamble(repoRoot, issuesRoot); err != nil { return MigrateResult{}, err } - err = withLedgerLock(repoRoot, issuesRoot, run) + // The run rewrites intent records too (the related_issues back-edge, + // in any bucket), so the scan and every write run under the intent + // store's lock as well, taken after the ledger lock — the one order + // every path holding both takes (intent.WithLedgerThenMintLock). Under + // the ledger lock alone, an intent writer landing between the scan and + // the write was erased (iss-2609261941039204). The pair never holds the + // ledger lock while it waits for the intent lock, so a long intent hold + // cannot fail a third process's ledger writer (iss-2609262218059995). + ledger := func(fn func() error) error { return withLedgerLock(repoRoot, issuesRoot, fn) } + err = intent.WithLedgerThenMintLock(repoRoot, ledger, run) } else { err = run() } @@ -139,6 +152,12 @@ func Migrate(req MigrateRequest) (MigrateResult, error) { return res, nil } +// afterMigrateScan is a test seam, nil outside tests: called on an apply +// between the scan and the first write, with every lock the apply takes held, +// so a test can land a concurrent intent writer in that window and prove it +// waits rather than being erased by the write that follows. +var afterMigrateScan func() + // migrateScan reads every record that can carry either half of the join. It // reads RAW frontmatter rather than through the ledger reader, because the // reader refuses exactly the records this exists to repair. diff --git a/internal/core/capture/reading.go b/internal/core/capture/reading.go index 84b72a1d4..481b6af4c 100644 --- a/internal/core/capture/reading.go +++ b/internal/core/capture/reading.go @@ -165,6 +165,13 @@ func IngestReading(req IngestReadingRequest) (IngestReadingResult, error) { // mint is never called, and the result carries the run with an empty record // list. The run's ledger directory is still provisioned, so a reader finds an // empty bucket rather than an absence it has to interpret. + // + // The items are the host's words, bound for records record-lint reads, so + // they are held to its prose-citation gate before anything is touched + // (iss-2609261835118276). + if err := CheckReadingCitations(IngestReadingRequest{RepoRoot: repoRoot, IssuesRoot: issuesRoot, Run: req.Run, Items: req.Items}); err != nil { + return IngestReadingResult{}, err + } if err := mutationPreamble(repoRoot, issuesRoot); err != nil { return IngestReadingResult{}, err } diff --git a/internal/core/capture/speclock_test.go b/internal/core/capture/speclock_test.go new file mode 100644 index 000000000..91dd0f191 --- /dev/null +++ b/internal/core/capture/speclock_test.go @@ -0,0 +1,64 @@ +package capture + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/spec" +) + +// A resolve's repoint rewrites every spec linking the issue, under the spec +// store's lock as well as the ledger's and the intent store's: a spec close +// arriving while it runs waits for its write, so the spec is never written back +// to open/ after the close moved it to closed/ — one record in both status +// folders (iss-2609262218309668) — and ends in closed/ carrying the repointed +// link. +func TestIssueRepointHoldsTheSpecLockAgainstASpecClose(t *testing.T) { + repo, ir := ledger(t) + res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, + Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "alpha"}) + if err != nil { + t.Fatal(err) + } + name := filepath.Base(res.Path) + const specName = "spc-5-five.md" + writeTree(t, repo, ".abcd/development/specs/open/"+specName, "---\nid: spc-5\nslug: five\nintent: itd-5\n---\n# five\n\n"+ + "Occasioned by [alpha](../../../work/issues/open/"+name+").\n") + + var landedEarly bool + var closer chan error + duringIssueRepoint = func() { + landedEarly, closer = landsWithin(300*time.Millisecond, func() error { + _, err := spec.Close(repo, "spc-5") + return err + }) + } + t.Cleanup(func() { duringIssueRepoint = nil }) + + rr, err := Resolve(ResolveRequest{Grounds: testGrounds, RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Resolution: "fixed", Impact: "fix"}) + if err != nil { + t.Fatal(err) + } + if closer == nil { + t.Fatal("the resolve never reached its repoint") + } + if err := <-closer; err != nil { + t.Fatal(err) + } + if landedEarly { + t.Error("spec close landed while the repoint ran: the repoint does not hold the spec store's lock") + } + if _, err := os.Lstat(filepath.Join(repo, ".abcd/development/specs/open", specName)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the closed spec is still in open/ (err %v): it sits in both status folders", err) + } + if got := readTree(t, repo, ".abcd/development/specs/closed/"+specName); !strings.Contains(got, "(../../../work/issues/resolved/"+name+")") { + t.Errorf("the closed spec must carry the repointed link:\n%s", got) + } + if rr.RelinkError != "" || len(rr.Relinked) != 1 { + t.Errorf("the resolve must report the one rewrite: %+v %q", rr.Relinked, rr.RelinkError) + } +} diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 1a21fb389..faead5fe2 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -11,6 +11,7 @@ import ( "github.com/intentdriven/abcd/internal/core/changelog" "github.com/intentdriven/abcd/internal/core/grounds" + "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/issuerecord" "github.com/intentdriven/abcd/internal/core/issueschema" "github.com/intentdriven/abcd/internal/core/provenance" @@ -526,7 +527,10 @@ func transition(repoRoot, issuesRoot, issID, verb, field, note string, extra []k // resolve and a wontfix on one issue) serialize: the second sees the issue // already moved out of open/ and conflicts, instead of both passing the // checksum re-read and landing the issue in two status dirs (split-brain). - var result TransitionResult + var ( + result TransitionResult + movedFrom string + ) err = withLedgerLock(rr, ir, func() error { src, status, err := findIssue(ir, issID) if err != nil { @@ -599,15 +603,18 @@ func transition(repoRoot, issuesRoot, issID, verb, field, note string, extra []k } result = TransitionResult{ID: issID, Path: dst, FromStatus: StateOpen, ToStatus: target, Redacted: redacted, Degraded: degraded} - // Repoint every link that named the issue in open/, still under the - // ledger lock because the links it rewrites include other issues'. A - // failure is reported, not raised: the issue has moved. - result.Relinked, result.RelinkError = repointMovedIssue(rr, src, dst) + movedFrom = src return nil }) if err != nil { return TransitionResult{}, err } + // Repoint every link that named the issue in open/, in a fresh hold of + // the ledger lock (the links it rewrites include other issues') and the + // intent store's, taken after the move's hold is released — as the intent + // verbs repoint after theirs. A failure is reported, not raised: the issue + // has moved. + result.Relinked, result.RelinkError = repointMovedIssue(rr, ir, movedFrom, result.Path) // Machine output carries a repo-relative locator, never an absolute // developer-identity path (iss-81). result.Path = fsutil.RepoRel(rr, result.Path) @@ -618,19 +625,50 @@ func transition(repoRoot, issuesRoot, issID, verb, field, note string, extra []k // transition moved it, through the one primitive every record-moving verb // shares. A ledger outside the repository (a custom issues root) is linked from // nowhere the repository's links can reach, so there is nothing to repoint. -func repointMovedIssue(repoRoot, src, dst string) ([]relink.Rewrite, string) { +// +// The repoint rewrites other ledger records, intents and specs that link to +// the issue, so it runs under this ledger's lock, then the intent store's, +// then the spec store's (intent.WithLedgerThenMintLock, the one order every +// path holding more than one takes). Outside the intent lock, an intent writer landing on a linking +// intent between the repoint's read and its write was erased +// (iss-2609261254247117). The caller has RELEASED the ledger lock its move +// held: waiting for the intent lock inside that hold chained two five-second +// budgets and failed a third process's ledger writer (iss-2609262218059995), +// and the pair never holds the ledger lock while it waits. A pair that cannot +// be taken is reported as the repoint's error, with nothing repointed, and the +// front door names record-lint's links_resolve as what finds each stale link. +func repointMovedIssue(repoRoot, issuesRoot, src, dst string) ([]relink.Rewrite, string) { from, err1 := filepath.Rel(repoRoot, src) to, err2 := filepath.Rel(repoRoot, dst) if err1 != nil || err2 != nil || !filepath.IsLocal(from) || !filepath.IsLocal(to) { return nil, "" } - rw, err := relink.Repoint(repoRoot, []relink.Move{{From: from, To: to, MovedNow: true}}) - if err != nil { - return rw, err.Error() + var ( + rw []relink.Rewrite + rpErr error + ) + ledger := func(fn func() error) error { return withLedgerLock(repoRoot, issuesRoot, fn) } + if err := intent.WithLedgerThenMintLock(repoRoot, ledger, func() error { + if duringIssueRepoint != nil { + duringIssueRepoint() + } + rw, rpErr = relink.Repoint(repoRoot, []relink.Move{{From: from, To: to, MovedNow: true}}) + return nil + }); err != nil { + return nil, err.Error() + } + if rpErr != nil { + return rw, rpErr.Error() } return rw, "" } +// duringIssueRepoint is a test seam, nil outside tests: called with the +// ledger lock, the intent store's lock and the spec store's held, before the +// repoint reads anything, so a test can prove the order they are taken in and +// that a concurrent intent or spec writer waits for the repoint's write. +var duringIssueRepoint func() + // removeSourceHook, when non-nil, replaces os.Remove(src) inside // commitTransition. It is a test-only seam (nil in production, zero overhead) // used to force a deterministic non-ENOENT remove failure without relying on diff --git a/internal/core/condition/condition.go b/internal/core/condition/condition.go index 90e20487f..9a951f487 100644 --- a/internal/core/condition/condition.go +++ b/internal/core/condition/condition.go @@ -59,6 +59,13 @@ var ( // ReviewMarkerRe is the verdict ingest's block marker: one line, whole-line, // carrying the receipt state and id. ReviewMarkerRe = regexp.MustCompile(`(?m)^\r?$`) + // ReviewEndRe is the line a review block closes on, naming the receipt of + // the block it closes. Everything below it is not the block's, so a note a + // human writes under a verdict is never read as part of the verdict, nor + // replaced with it (iss-2609251451434656). A block written before the + // closing line existed has none, and its reader falls back to the block's + // known extent. + ReviewEndRe = regexp.MustCompile(`(?m)^\r?$`) // BlockMarkerRe is the condition verb's block marker: the one identity the // block dispositions and what occasioned it, a reading item or a delivered // intent and nothing else. @@ -84,6 +91,24 @@ func IsBlockMarker(line string) bool { return ReviewMarkerRe.MatchString(line) || BlockMarkerRe.MatchString(line) } +// ReviewEndLine renders the closing line of the review block for rcp. +func ReviewEndLine(rcp string) string { + return "" +} + +// AuditNotes returns content's lines, their liveness mask (mdrecord's one +// fence-and-comment rule) and the [start, end) bounds of the live +// `## Audit Notes` section's body. A marker, a closing line or a disposition +// bullet counts only on a line inside those bounds whose mask is zero: one in a +// fenced block, in an HTML comment span, or under another heading is an example +// a human wrote, not state (iss-2609020529185438). +func AuditNotes(content string) (lines []string, mask []uint8, start, end int, ok bool) { + lines = strings.Split(content, "\n") + mask = mdrecord.Mask(lines) + start, end, ok = mdrecord.SectionLineRangeIn(lines, mask, auditHeadingRe) + return lines, mask, start, end, ok +} + // Disposition is one disposition as a block records it. Occasion is set only // for an entry from a condition block, and is what tells the two sources apart; // Date likewise. @@ -101,10 +126,11 @@ type Disposition struct { // ReadDispositions returns every disposition the `## Audit Notes` section // records, in document order. A bullet whose value is outside the enum is not a // disposition and is skipped, as is a condition-block bullet naming an identity -// other than the one its marker names: the marker is the block's key. +// other than the one its marker names: the marker is the block's key. Only live +// lines are read (AuditNotes), and a review block's closing line ends it, so +// nothing written below a verdict is read as the verdict's. func ReadDispositions(content string) []Disposition { - lines := strings.Split(content, "\n") - start, end, ok := mdrecord.SectionLineRange(lines, auditHeadingRe) + lines, mask, start, end, ok := AuditNotes(content) if !ok { return nil } @@ -117,8 +143,15 @@ func ReadDispositions(content string) []Disposition { date string lastIndex = -1 // the entry a narrowing line attaches to ) - for _, raw := range lines[start:end] { - ln := strings.TrimRight(raw, "\r") + for i := start; i < end; i++ { + if mask[i] != 0 { + continue + } + ln := strings.TrimRight(lines[i], "\r") + if ReviewEndRe.MatchString(ln) { + source, inList, blockID, occasion, date, lastIndex = "", false, "", "", "", -1 + continue + } if m := ReviewMarkerRe.FindStringSubmatch(ln); m != nil { source, inList, blockID, occasion, date, lastIndex = "verdict "+m[2], false, "", "", "", -1 continue diff --git a/internal/core/condition/condition_test.go b/internal/core/condition/condition_test.go index ef3919e93..f1738108d 100644 --- a/internal/core/condition/condition_test.go +++ b/internal/core/condition/condition_test.go @@ -192,3 +192,39 @@ func TestVerdictNamingTheOccasionOverridesWhereverItSits(t *testing.T) { t.Errorf("standing = %+v, want the later condition block (the verdict names rdi-7, not rdi-8)", got) } } + +// TestReadDispositionsReadsOnlyLiveBlocks: a verdict block quoted in a fenced +// example, or parked in an HTML comment span, is an example a human wrote, not +// a disposition the record holds (iss-2609020529185438). +func TestReadDispositionsReadsOnlyLiveBlocks(t *testing.T) { + for name, body := range map[string]string{ + "fenced": "```markdown\n" + verdictBlock + "```\n", + // The comment opened above closes on the marker's own `-->`, so the + // marker line is inside the span and the bullets below it are not a block. + "commented": "` in every field it writes, code span or not); this - // is the second, so a marker has to occupy a line of its own to count. + // markerRe matches a parked review marker LINE. It is line-anchored and + // whole-line on purpose: the marker is the ledger's own review state, and an + // unanchored pattern would find one anywhere in the record's bytes — + // mid-sentence inside a rendered verdict field, for instance, where an + // untrusted payload put it. termsafe's cleaner is the first defence (it + // breaks `` in every field it writes, code span or not); this is + // the second, so a marker has to occupy a line of its own to count. // - // It is still a byte pattern rather than a grammar: it does not know a fenced - // block from prose, so a marker-shaped line inside a fence still matches - // (iss-2609020529185438). Both defences are needed; neither is sufficient. + // It is a pattern over ONE line, never over the record: readReviewBlocks is + // the only caller, and it offers only the live lines of the live Audit Notes + // section, so a marker-shaped line in a fence, a comment span or another + // section is never matched (iss-2609020529185438). // // The grammar is core/condition's ReviewMarkerRe, shared with the condition // block's reader. @@ -193,11 +199,18 @@ type verdictGapAudit struct { // --------------------------------------------------------------------------- // AuditEmitResult reports one emit (OWED stub + request file). +// +// Status names the receipt's state and RequestWritten names the act, because +// the two differ: an emit on a receipt already OWED rewrites its request, and +// reported only already_owed, which a caller read as "nothing happened" +// (iss-2609190337598356). RequestPath is the request this emit wrote, so a +// terminal receipt — whose emit writes nothing — names none. type AuditEmitResult struct { - ReceiptID string `json:"receipt_id"` - IntentID string `json:"intent_id"` - Status string `json:"status"` // owed | already_owed | already_ingested | already_dead_letter - RequestPath string `json:"request_path"` + ReceiptID string `json:"receipt_id"` + IntentID string `json:"intent_id"` + Status string `json:"status"` // owed | already_owed | already_ingested | already_dead_letter + RequestPath string `json:"request_path,omitempty"` + RequestWritten bool `json:"request_written"` } // IngestVerdictResult reports one verdict ingest. @@ -230,6 +243,62 @@ type IngestVerdictResult struct { ReadingOccasionedStanding []condition.Disposition `json:"reading_occasioned_standing,omitempty"` } +// MarshalJSON writes the result the way its outcome reads (iss-2609190337545165). +// One struct serves every status, so its counters are zero-valued members on a +// quarantine and a noop, and a reader took a dead letter's "criteria: 0" beside +// the conditions it recorded untested for a rollup. The JSON therefore states +// what the ingest recorded — `verdict` (ingested), `quarantine` (dead_letter) or +// `nothing` (noop) — and carries the rollup only beside a recorded verdict, +// where a zero is a count. A quarantine states the one split it did record, +// every scope condition untested, under a name of its own. +func (r IngestVerdictResult) MarshalJSON() ([]byte, error) { + type rollup struct { + Criteria int `json:"criteria"` + Met int `json:"met"` + MetWithConcern int `json:"met_with_concerns"` + NotMet int `json:"not_met"` + Inconclusive int `json:"inconclusive"` + Conditions int `json:"conditions"` + Survived int `json:"survived"` + Narrowed int `json:"narrowed"` + Falsified int `json:"falsified"` + Untested int `json:"untested"` + } + out := struct { + Status string `json:"status"` + ReceiptID string `json:"receipt_id"` + IntentID string `json:"intent_id"` + Recorded string `json:"recorded"` + // A nil embedded pointer contributes no members at all. + *rollup + ConditionsUntested *int `json:"conditions_untested,omitempty"` + DeadLetterPath string `json:"dead_letter_path,omitempty"` + Reason string `json:"reason,omitempty"` + Replaced bool `json:"replaced,omitempty"` + ReadingOccasionedStanding []condition.Disposition `json:"reading_occasioned_standing,omitempty"` + }{ + Status: r.Status, ReceiptID: r.ReceiptID, IntentID: r.IntentID, + DeadLetterPath: r.DeadLetterPath, Reason: r.Reason, Replaced: r.Replaced, + ReadingOccasionedStanding: r.ReadingOccasionedStanding, + } + switch r.Status { + case "ingested": + out.Recorded = "verdict" + out.rollup = &rollup{ + Criteria: r.Criteria, Met: r.Met, MetWithConcern: r.MetWithConcern, NotMet: r.NotMet, + Inconclusive: r.Inconclusive, Conditions: r.Conditions, Survived: r.Survived, + Narrowed: r.Narrowed, Falsified: r.Falsified, Untested: r.Untested, + } + case "dead_letter": + out.Recorded = "quarantine" + n := r.Untested + out.ConditionsUntested = &n + default: + out.Recorded = "nothing" + } + return json.Marshal(out) +} + // --------------------------------------------------------------------------- // Emit (called by Reconcile; also the manual re-emit verb) // --------------------------------------------------------------------------- @@ -271,6 +340,23 @@ func emitAuditWith(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmit if !spec.HasNum(it.SpecID) { return AuditEmitResult{}, fmt.Errorf("intent: spec id %q must carry a spec number (spc-N)", it.SpecID) } + // The read, the marker judgement and the writes are ONE critical section + // under the store's advisory lock (iss-2609261935407925): the emit parks its + // stub on the bytes it read, so a condition disposition or a verdict ingest + // landing between an unlocked read and the write would be erased, with both + // verbs exiting 0. Held here, the emit judges the record that writer left. + var res AuditEmitResult + err := withIntentMintLock(repoRoot, func() error { + var err error + res, err = emitLocked(repoRoot, it, opts) + return err + }) + return res, err +} + +// emitLocked is emitAuditWith's critical section, called under the intent +// store lock. +func emitLocked(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmitResult, error) { abs := filepath.Join(repoRoot, it.Path) data, err := readRepoFile(abs, it.Path) if err != nil { @@ -286,7 +372,6 @@ func emitAuditWith(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmit // second stub. The parked marker is the authority the ingest resolves against. if rcp, state, ok := existingMarker(content); ok { res := AuditEmitResult{ReceiptID: rcp, IntentID: it.ID} - res.RequestPath = filepath.Join(reviewsRelDir, rcp+".request.md") switch state { case "INGESTED": res.Status = "already_ingested" @@ -300,6 +385,8 @@ func emitAuditWith(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmit if err := writeAuditRequest(repoRoot, it, rcp, content, opts); err != nil { return res, err } + res.RequestPath = filepath.Join(reviewsRelDir, rcp+".request.md") + res.RequestWritten = true } return res, nil } @@ -322,6 +409,7 @@ func emitAuditWith(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmit } res.Status = "owed" res.RequestPath = filepath.Join(reviewsRelDir, rcp+".request.md") + res.RequestWritten = true return res, nil } @@ -414,20 +502,167 @@ func auditPromptBody(it Intent, rcp, content string, realised []string) string { fmt.Fprintf(&b, "- intent: %s\n", it.Path) fmt.Fprintf(&b, "- specs: %s\n", specs) fmt.Fprintf(&b, "- delivered: the diff/commit range that realised ALL of %s (host supplies the range)\n\n", specs) - b.WriteString("## Acceptance Criteria (authority; numbered ac-1..ac-K in order)\n\n") + // The counts are the ingest's own: K is the bullet count validateVerdict + // judges against, so the request never leaves the auditor to count + // (iss-2609181121301638). + switch k := countAcceptanceCriteria(content); k { + case 0: + b.WriteString("## Acceptance Criteria (authority; no bullets found)\n\n") + default: + fmt.Fprintf(&b, "## Acceptance Criteria (authority; %d %s, numbered ac-1..ac-%d in order)\n\n", + k, plural(k, "criterion", "criteria"), k) + } if ac == "" { b.WriteString("(none found)\n") } else { b.WriteString(ac + "\n") } + writeScopeConditions(&b, content) b.WriteString("\n## Rubric (authority; the contract the ingest enforces)\n\n") b.WriteString(rubricText()) - b.WriteString("\nRun the intent-auditor agent over the criteria and the delivered\n") - b.WriteString("diff, then ingest its verdict JSON:\n\n") + b.WriteString("\n## Verdict shape (authority; the fields the ingest decodes, and no other)\n\n") + b.WriteString(verdictShape(rcp)) + b.WriteString("\nRun the intent-auditor agent over the criteria, the scope conditions and\n") + b.WriteString("the delivered diff; its verdict JSON takes the shape above. Ingest it with:\n\n") fmt.Fprintf(&b, " abcd intent audit ingest --verdict-json # receipt %s\n", rcp) return b.String() } +// writeScopeConditions renders the request's Scope Conditions block: every +// scope condition the intent carries, by the minted identity the verdict must +// dispose it under, with its text (iss-2609181121301638). The identities used to +// reach the auditor only as HTML comments in the record, which the request did +// not quote, so an auditor scraped them by hand and a miscount quarantined the +// verdict. They are read through ParseClaims, the reader the ingest's coverage +// check reads them through, so the set stated here is the set it enforces. An +// unstamped condition is listed as one, since the ingest refuses a verdict for +// an intent carrying it and the auditor should see why. +func writeScopeConditions(b *strings.Builder, content string) { + conds := ParseClaims(content).Conditions + if len(conds) == 0 { + b.WriteString("\n## Scope Conditions (authority; none recorded, so scope_conditions is an empty list)\n\n") + b.WriteString("(none recorded)\n") + return + } + fmt.Fprintf(b, "\n## Scope Conditions (authority; %d %s, each disposed exactly once under its identity verbatim)\n\n", + len(conds), plural(len(conds), "condition", "conditions")) + for _, c := range conds { + text := strings.Join(strings.Fields(c.Text), " ") + if c.ID == "" { + fmt.Fprintf(b, "- (condition %d carries no minted identity) — %s\n", c.Ordinal, text) + continue + } + fmt.Fprintf(b, "- %s — %s\n", c.ID, text) + } +} + +// plural picks the noun form for a count. +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + +// verdictShapeHints are the placeholders the stated shape shows for the fields +// that have one, keyed by JSON name; every other string shows ``. The +// vocabularies come from the enum maps the validator consults, as the rubric's +// do, and the receipt is the one this request issued. +func verdictShapeHints(rcp string) map[string]string { + return map[string]string{ + "_type": VerdictType, + "receipt_id": rcp, + "rubric_hash": "sha256:", + "prompt_hash": "sha256:", + "digest": "sha256:<64 lowercase hex, or empty where not known>", + "criterion_id": "ac-", + "verdict": strings.Join(sortedKeys(verdictEnum), " | "), + "condition_id": "cond-", + "disposition": strings.Join(sortedKeys(dispositionEnum), " | "), + "narrowing": "", + } +} + +// verdictShape renders the verdict the ingest decodes as one example object +// (iss-2609181121305984): the verdict struct itself — the type validateVerdict +// decodes into with DisallowUnknownFields — rendered by renderShape, so it is +// that schema rather than a copy of it. acceptance_rollup shows every +// acceptance verdict as a key. +func verdictShape(rcp string) string { + return renderShape(reflect.TypeOf(verdict{}), shapeSpec{ + hints: verdictShapeHints(rcp), + mapKeys: map[string][]string{"acceptance_rollup": sortedKeys(verdictEnum)}, + }) +} + +// shapeSpec says what a stated JSON shape shows beyond the struct's own fields, +// each keyed by JSON name. +type shapeSpec struct { + hints map[string]string // a string field's placeholder; any other shows + mapKeys map[string][]string // the keys a map field shows + lens map[string]int // the elements a list shows; any other shows one +} + +// renderShape renders the struct type t as one example object, indented as a +// markdown code block, for a request to state the shape its ingest decodes. It +// is built by reflection over the very struct the ingest decodes into, so a +// field added to or dropped from that struct moves the stated shape, and the +// prompt_hash with it: the request states the schema, never a copy of it. Every +// list shows at least one element so its members are named. +func renderShape(t reflect.Type, spec shapeSpec) string { + v := reflect.New(t).Elem() + fillShape(v, "", spec) + var buf strings.Builder + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) // the placeholders' angle brackets stay readable + enc.SetIndent(" ", " ") + if err := enc.Encode(v.Interface()); err != nil { + // The value is strings, ints, slices and string-keyed maps, which always + // encode; the branch keeps the composition total. + return " (the shape could not be rendered: " + err.Error() + ")\n" + } + return " " + buf.String() +} + +// fillShape populates v with the placeholder for each field, by JSON name. +// +// A field reflection cannot set is an unexported one, which encoding/json never +// decodes either, so it is no part of the shape and is skipped rather than +// panicking the emit. The guard sits on the writes, not on the struct walk: an +// unexported EMBEDDED struct is itself unsettable while its exported fields are +// promoted, settable and decoded, so they still show. +func fillShape(v reflect.Value, name string, spec shapeSpec) { + if v.Kind() != reflect.Struct && !v.CanSet() { + return + } + switch v.Kind() { + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + tag, _, _ := strings.Cut(v.Type().Field(i).Tag.Get("json"), ",") + fillShape(v.Field(i), tag, spec) + } + case reflect.String: + if h, ok := spec.hints[name]; ok { + v.SetString(h) + } else { + v.SetString("") + } + case reflect.Slice: + n := max(spec.lens[name], 1) + s := reflect.MakeSlice(v.Type(), n, n) + for i := 0; i < n; i++ { + fillShape(s.Index(i), name, spec) + } + v.Set(s) + case reflect.Map: + m := reflect.MakeMap(v.Type()) + for _, k := range spec.mapKeys[name] { + m.SetMapIndex(reflect.ValueOf(k), reflect.Zero(v.Type().Elem())) + } + v.Set(m) + } +} + // --------------------------------------------------------------------------- // Host-issued provenance (iss-2609100505140261) // --------------------------------------------------------------------------- @@ -595,6 +830,8 @@ func auditProvenanceBlock(p auditPolicy) string { // validate (see reingestVerdict); // - schema/semantic validation failure on a resolvable receipt -> DEAD_LETTER // (marker + INCONCLUSIVE criteria + retained raw payload), never partial; +// - a rendered block citing a record id the repository's record gate +// refuses -> reject, nothing written (checkReviewCitations); // - otherwise -> INGESTED (OWED stub replaced by the rendered verdict). func IngestVerdict(repoRoot, verdictPath string) (IngestVerdictResult, error) { raw, err := readVerdictFile(verdictPath) @@ -635,6 +872,35 @@ func IngestVerdictBytes(repoRoot string, raw []byte) (IngestVerdictResult, error } rcp := lenient.ReceiptID + // The receipt's resolution, every check judged on the record, and the + // write(s) are ONE critical section under the store's advisory lock, as every + // other intent writer's are (iss-2609261935343851). Two ingests on one + // intent, or an ingest beside a condition disposition, would otherwise each + // write the bytes they read: the later write erases the earlier one and both + // exit 0. Held here, the ingest reads the record another writer just left — + // a verdict that landed first makes this a re-ingest, not a fresh one — and + // the dead-letter's two writes land inside the same hold. + // + // Taking the lock creates the intent store, and a repository without one + // holds no receipt to resolve: that refusal is made without the lock, so it + // still writes nothing. + if _, err := os.Lstat(filepath.Join(repoRoot, IntentsRelDir)); errors.Is(err, fs.ErrNotExist) { + return ingestLocked(repoRoot, raw, rcp) + } + var res IngestVerdictResult + err := withIntentMintLock(repoRoot, func() error { + var err error + res, err = ingestLocked(repoRoot, raw, rcp) + return err + }) + return res, err +} + +// ingestLocked is IngestVerdictBytes's critical section, called under the +// intent store lock: it resolves rcp to its intent on the bytes read there and +// applies the verdict to those bytes. reingestVerdict and deadLetter are reached +// only from here, so they run under the same hold. +func ingestLocked(repoRoot string, raw []byte, rcp string) (IngestVerdictResult, error) { it, content, state, ok, err := findIntentByReceipt(repoRoot, rcp) if err != nil { return IngestVerdictResult{}, err @@ -678,6 +944,9 @@ func IngestVerdictBytes(repoRoot string, raw []byte) (IngestVerdictResult, error rollup := countVerdicts(v) block := ingestedBlock(rcp, v, rollup, free) + if err := checkReviewCitations(repoRoot, it, rcp, block); err != nil { + return IngestVerdictResult{}, err + } updated := upsertReviewBlock(content, rcp, block) if err := writeIntentFile(filepath.Join(repoRoot, it.Path), it.Path, updated); err != nil { return IngestVerdictResult{}, err @@ -702,7 +971,8 @@ func IngestVerdictBytes(repoRoot string, raw []byte) (IngestVerdictResult, error // block names its occasion and ingests again (the 2026-09-25 ruling in // .abcd/work/DECISIONS.md). A payload that does not validate is refused with // nothing written rather than dead-lettered: quarantine is for a receipt still -// owed a verdict, and a bad re-ingest must never replace a good one. +// owed a verdict, and a bad re-ingest must never replace a good one. It runs +// under the store lock ingestLocked holds. func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string) (IngestVerdictResult, error) { free, err := newVerdictProse(repoRoot) if err != nil { @@ -715,12 +985,15 @@ func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string } rollup := countVerdicts(v) block := ingestedBlock(rcp, v, rollup, free) - if existing, ok := reviewBlockText(content, rcp); ok && existing == block { + if existing, ok := reviewBlockText(content, rcp); ok && sameReviewBlock(existing, block, rcp) { return IngestVerdictResult{Status: "noop", ReceiptID: rcp, IntentID: it.ID}, nil } if err := checkIssuedPolicy(repoRoot, raw, it, rcp, content); err != nil { return IngestVerdictResult{}, err } + if err := checkReviewCitations(repoRoot, it, rcp, block); err != nil { + return IngestVerdictResult{}, err + } updated := upsertReviewBlock(content, rcp, block) if err := writeIntentFile(filepath.Join(repoRoot, it.Path), it.Path, updated); err != nil { return IngestVerdictResult{}, err @@ -766,13 +1039,27 @@ func checkIssuedPolicy(repoRoot string, raw []byte, it Intent, rcp, content stri } // Both values are sha256-shaped by the guard above, so quoting them back // cannot carry payload prose into the message. - return fmt.Errorf("intent: verdict %s carries policy hashes this receipt never issued; refusing to ingest.\n"+ + return issuedPolicyRefusal("verdict", rcp, got, want, "abcd intent audit "+it.ID) +} + +// issuedPolicyRefusal is the one wording of a refusal for policy hashes the +// host did not issue for receipt rcp, shared by the fidelity and consistency +// ingests. Its remedy is always the re-emit, reemit being the verb as it is +// spelled for that receipt: the likeliest writer of such a payload is an +// auditor that echoed a request an earlier binary wrote, so telling it to echo +// the Provenance block again names a remedy it has already followed +// (iss-2609262104070666). Both hash pairs must be sha256-shaped by the caller, +// so quoting them cannot carry payload prose into the message. +func issuedPolicyRefusal(payload, rcp string, got verdictPolicy, want auditPolicy, reemit string) error { + return fmt.Errorf("intent: %s %s carries policy hashes this receipt never issued; refusing to ingest.\n"+ " rubric_hash: got %s, issued %s\n"+ " prompt_hash: got %s, issued %s\n"+ "The host computes both and writes them into the request's Provenance block: rubric_hash is sha256 over the "+ "rubric the request states, prompt_hash is sha256 over the request's prompt body (everything above that block). "+ - "Re-emit with `abcd intent audit %s` and echo the two values it writes, rather than computing a hash yourself.", - rcp, got.RubricHash, want.RubricHash, got.PromptHash, want.PromptHash, it.ID) + "A request an earlier binary wrote states values this one no longer issues, so echoing it again cannot pass. "+ + "Re-emit with `%s`, run the pass again from the request it writes, and echo the two values that request states, "+ + "rather than computing a hash yourself.", + payload, rcp, got.RubricHash, want.RubricHash, got.PromptHash, want.PromptHash, reemit) } // readVerdictFile reads the untrusted verdict payload behind fsutil.ReadGuarded @@ -986,21 +1273,28 @@ func validateConditionDispositions(v verdict, intentContent string) error { // deadLetter quarantines a bad-but-resolvable verdict: it retains the raw payload // under the ephemeral reviews dir and replaces the parked marker with a -// DEAD_LETTER block recording all criteria INCONCLUSIVE. Never partial. +// DEAD_LETTER block recording all criteria INCONCLUSIVE. Never partial. It runs +// under the store lock ingestLocked holds, so its two writes — the retained +// payload and the record — land in one hold. func deadLetter(repoRoot string, it Intent, content, rcp string, raw []byte, reason string, free proseField) (IngestVerdictResult, error) { if !rcpIDRe.MatchString(rcp) { return IngestVerdictResult{}, fmt.Errorf("intent: receipt id %q is malformed; refusing to dead-letter", rcp) } dir := filepath.Join(repoRoot, reviewsRelDir) + dlRel := filepath.Join(reviewsRelDir, rcp+".deadletter.json") + untested := untestedDispositions(content) + block := deadLetterBlock(rcp, reason, dlRel, untested, free) + // The quarantine's reason quotes the payload, so it is held to the same gate + // as a verdict, before anything is retained or written. + if err := checkReviewCitations(repoRoot, it, rcp, block); err != nil { + return IngestVerdictResult{}, err + } if err := ensureRecordDir(repoRoot, reviewsRelDir); err != nil { return IngestVerdictResult{}, err } - dlRel := filepath.Join(reviewsRelDir, rcp+".deadletter.json") if err := fsutil.WriteFileAtomic(filepath.Join(dir, rcp+".deadletter.json"), raw, 0o644); err != nil { return IngestVerdictResult{}, fmt.Errorf("intent: retaining dead-letter payload %s: %w", dlRel, err) } - untested := untestedDispositions(content) - block := deadLetterBlock(rcp, reason, dlRel, untested, free) updated := upsertReviewBlock(content, rcp, block) if err := writeIntentFile(filepath.Join(repoRoot, it.Path), it.Path, updated); err != nil { return IngestVerdictResult{}, err @@ -1016,6 +1310,105 @@ func deadLetter(repoRoot string, it Intent, content, rcp string, raw []byte, rea }, nil } +// UnresolvedCitation is one record id a fragment cites that the repository's +// record gate refuses, at its 1-based line in the fragment. +type UnresolvedCitation struct { + Line int + ID string +} + +// proseCitationGate is record-lint's prose_citation_resolves asked of a +// fragment before it is written into the record at rel: the ids it cites that +// the gate would refuse there. It is lint.UnresolvedProseCitationsInRecord, +// registered by the front doors (SetProseCitationGate), because this package +// cannot import core/lint: lint's own tests import this package, and Go refuses +// the cycle. The same seam lint.SetIssueReader is, from the other side. +var proseCitationGate func(repoRoot, rel, text string) ([]UnresolvedCitation, error) + +// SetProseCitationGate registers the prose-citation gate every host-prose +// ingest asks before it writes: the verdict ingest here, and the consistency +// and reading ingests in the ledger through UnresolvedProseCitations. One +// registration serves them all, so a front door cannot arm one and miss +// another. Pass an adapter over lint.UnresolvedProseCitationsInRecord. +func SetProseCitationGate(fn func(repoRoot, rel, text string) ([]UnresolvedCitation, error)) { + proseCitationGate = fn +} + +// ErrNoProseCitationGate is UnresolvedProseCitations' answer when no front door +// registered the gate: the question cannot be answered, and a caller refuses +// rather than writes (fail closed). +var ErrNoProseCitationGate = errors.New("no prose-citation gate is registered") + +// UnresolvedProseCitations asks the registered gate which record ids text cites +// that the repository's record-lint would refuse in the record at rel — the +// same question the verdict ingest asks, for a writer outside this package +// that copies host-delegated prose into a lint-bound record +// (iss-2609261835118276). With no gate registered it returns +// ErrNoProseCitationGate. +func UnresolvedProseCitations(repoRoot, rel, text string) ([]UnresolvedCitation, error) { + if proseCitationGate == nil { + return nil, ErrNoProseCitationGate + } + return proseCitationGate(repoRoot, rel, text) +} + +// checkReviewCitations refuses a rendered review block that would cite a record +// id naming no record, in a repository whose record-lint gates prose citations +// in the intent store (iss-2609231036448320). The ingest validated the verdict +// against the rubric; this holds the block to the gate the record it lands in +// must pass, so a valid verdict can never produce an uncommittable record. +// +// It REFUSES rather than sanitises, and nothing is written. The verdict schema +// has no way to say an id is illustrative, and the cleaner every ingest routes +// prose through (termsafe) neutralises syntax, never a citation's meaning: a +// rewrite that dropped every id would erase the real citations an audit rests +// on, and one that dropped only the unresolvable ones would be a second +// sanitiser deciding what an auditor meant. The auditor re-words the prose to +// describe the record instead of citing an id that does not exist, and ingests +// again; the receipt stays in the state it was in. +// +// The gate is the repository's: a repository whose record-lint does not arm the +// rule over the intent store refuses nothing here. A front door that registered +// no gate is refused outright, since the check it owes cannot be made. +func checkReviewCitations(repoRoot string, it Intent, rcp, block string) error { + if proseCitationGate == nil { + return fmt.Errorf("intent: no prose-citation gate is registered, so the verdict for %s cannot be checked "+ + "against the record gate the intent record must pass; refusing to ingest (nothing written)", rcp) + } + rel := filepath.ToSlash(it.Path) + cites, err := proseCitationGate(repoRoot, rel, block) + if err != nil { + return fmt.Errorf("intent: the prose-citation check over the rendered review block: %w", err) + } + if len(cites) == 0 { + return nil + } + lines := strings.Split(block, "\n") + named := make([]string, 0, len(cites)) + for _, c := range cites { + at := "" + if c.Line >= 1 && c.Line <= len(lines) { + at = ": " + excerpt(lines[c.Line-1]) + } + named = append(named, fmt.Sprintf("%s (line %d of the rendered block%s)", c.ID, c.Line, at)) + } + return fmt.Errorf("intent: verdict %s cites a record id that names no record in this repository: %s; "+ + "record-lint's prose_citation_resolves refuses %s if it carries one, and a verdict has no way to mark an id "+ + "illustrative. Re-word the prose to describe the record rather than cite an id that does not exist, and "+ + "ingest again (nothing written)", rcp, strings.Join(named, "; "), rel) +} + +// excerpt shortens one rendered line for a refusal. The line is already cleaned +// for the record, so only its length needs bounding. +func excerpt(s string) string { + const limit = 120 + r := []rune(s) + if len(r) <= limit { + return s + } + return string(r[:limit]) + "…" +} + // occasionedStanding lists the condition-block dispositions the fold reports as // standing in content, ordered by condition identity so the report is // deterministic. @@ -1060,107 +1453,204 @@ func findIntentByReceipt(repoRoot, rcp string) (Intent, string, string, bool, er return Intent{}, "", "", false, nil } -// existingMarker returns the receipt id and state of the FIRST parked review -// marker in content, if any. Emit reuses this parked receipt rather than -// recomputing one (see emitAuditForIntent's receipt-shift note). -func existingMarker(content string) (string, string, bool) { - if m := markerRe.FindStringSubmatch(content); m != nil { - return m[2], m[1], true - } - return "", "", false +// reviewBlock is one review block as the record carries it: its marker's state +// and receipt, and the [start, end) lines it spans. +type reviewBlock struct { + state, receipt string + start, end int } -// markerState returns the state of the review marker for rcp, if present. -func markerState(content, rcp string) (string, bool) { - for _, m := range markerRe.FindAllStringSubmatch(content, -1) { - if m[2] == rcp { - return m[1], true +// readReviewBlocks is the ONE reader of an intent record's review blocks, in +// document order. Every question the audit asks of the record — which receipt +// it parked, what state a receipt is in, which lines a replacement rewrites — +// is answered from here, so the reader and the writer cannot disagree about +// where a block is. +// +// A marker counts only on a LIVE line of the live `## Audit Notes` section, as +// condition.AuditNotes reads it: a marker-shaped line in a fenced block, in an +// HTML comment span or under another heading is an example, not state, so a +// record quoting a marker cannot solicit, misroute or silence a verdict +// (iss-2609020529185438). termsafe's cleaner breaks the comment delimiters in +// every field the ingest writes; this reader is the other half, reading the +// record as a markdown reader parses it. +// +// A block's extent is reviewBlockEnd's. +func readReviewBlocks(content string) (lines []string, blocks []reviewBlock) { + lines, mask, start, end, ok := condition.AuditNotes(content) + if !ok { + return lines, nil + } + for i := start; i < end; i++ { + if mask[i] != 0 { + continue + } + m := markerRe.FindStringSubmatch(strings.TrimRight(lines[i], "\r")) + if m == nil { + continue } + blocks = append(blocks, reviewBlock{state: m[1], receipt: m[2], start: i, + end: reviewBlockEnd(lines, mask, i, end, m[1], m[2])}) } - return "", false + return lines, blocks } -// upsertReviewBlock replaces the existing review block for rcp with newBlock, or -// appends newBlock to the Audit Notes section (creating the section if absent). A -// review block runs from its marker line to the next block marker of EITHER -// grammar (condition.IsBlockMarker), the next heading, or end of file — so a -// condition block written after an OWED stub survives the stub's replacement -// rather than being swallowed as part of it (spc-2609020626046252). -func upsertReviewBlock(content, rcp, newBlock string) string { - lines := strings.Split(content, "\n") - if start, end, ok := reviewBlockRange(lines, rcp); ok { - // Keep the blank separator the old block ended with, so a block that - // follows it is not glued to the replacement. - sep := end - for sep > start+1 && strings.TrimSpace(lines[sep-1]) == "" { - sep-- +// reviewBlockEnd bounds the block whose marker is at line start, within a +// section body ending at sectionEnd: +// +// - A block closes on its own closing line (condition.ReviewEndRe naming its +// receipt), which every renderer writes; the block ends after it, so prose a +// human writes below the block is never part of it (iss-2609251451434656). +// - A block written before the closing line existed has none. An OWED stub +// has one known shape — the marker and its one sentence — and ends there, +// so a first ingest replaces the stub and nothing below it +// (iss-2609251451432601). +// - Any other block without one runs to the next live block marker of either +// grammar, closing line or heading, as it always has; a condition block +// written after it survives its replacement (spc-2609020626046252). A +// trailing run of link-reference definitions is not the block's: no +// renderer writes one, and a record parks them at the end of the section +// (iss-2608210737265820), so a replacement leaves them where they are. +func reviewBlockEnd(lines []string, mask []uint8, start, sectionEnd int, state, rcp string) int { + live := func(j int) (string, bool) { + if mask[j] != 0 { + return "", false } - out := make([]string, 0, len(lines)) - out = append(out, lines[:start]...) - out = append(out, strings.Split(newBlock, "\n")...) - out = append(out, lines[sep:]...) - return strings.Join(out, "\n") - } - return appendToAuditNotes(content, newBlock) -} - -// reviewBlockRange locates the review block for rcp in lines: from its marker -// line to the next block marker of either grammar, the next heading, or end of -// file. It is the one notion of a review block's extent, so the replacement and -// the idempotency comparison cannot disagree about where a block ends. -func reviewBlockRange(lines []string, rcp string) (start, end int, ok bool) { - for i, ln := range lines { - m := markerRe.FindStringSubmatch(strings.TrimRight(ln, "\r")) - if m == nil || m[2] != rcp { + return strings.TrimRight(lines[j], "\r"), true + } + for j := start + 1; j < sectionEnd; j++ { + t, ok := live(j) + if !ok { continue } - end = len(lines) - for j := i + 1; j < len(lines); j++ { - t := strings.TrimRight(lines[j], "\r") - if condition.IsBlockMarker(t) || mdrecord.IsHeading(t) { - end = j - break + if m := condition.ReviewEndRe.FindStringSubmatch(t); m != nil { + if m[1] == rcp { + return j + 1 } + break + } + if condition.IsBlockMarker(t) { + break + } + } + if state == "OWED" && start+1 < sectionEnd { + if t, ok := live(start + 1); ok && strings.HasPrefix(t, "Fidelity review OWED") { + return start + 2 + } + } + end := sectionEnd + for j := start + 1; j < sectionEnd; j++ { + if t, ok := live(j); ok && (condition.IsBlockMarker(t) || condition.ReviewEndRe.MatchString(t)) { + end = j + break + } + } + body := append([]string(nil), lines[start+1:end]...) + if refs := mdrecord.PeelTrailingLinkRefs(&body); len(refs) > 0 { + end = start + 1 + len(body) + } + return end +} + +// reviewBlockFor returns the block for rcp, if the record carries one. +func reviewBlockFor(content, rcp string) ([]string, reviewBlock, bool) { + lines, blocks := readReviewBlocks(content) + for _, b := range blocks { + if b.receipt == rcp { + return lines, b, true } - return i, end, true } - return 0, 0, false + return lines, reviewBlock{}, false +} + +// existingMarker returns the receipt id and state of the FIRST review marker in +// content, if any. Emit reuses this parked receipt rather than recomputing one +// (see emitAuditForIntent's receipt-shift note). +func existingMarker(content string) (string, string, bool) { + if _, blocks := readReviewBlocks(content); len(blocks) > 0 { + return blocks[0].receipt, blocks[0].state, true + } + return "", "", false +} + +// markerState returns the state of the review marker for rcp, if present. +func markerState(content, rcp string) (string, bool) { + _, b, ok := reviewBlockFor(content, rcp) + return b.state, ok +} + +// upsertReviewBlock replaces the existing review block for rcp with newBlock, or +// appends newBlock to the Audit Notes section (creating the section if absent). +// The block's extent is readReviewBlocks'. The record it returns ends in a +// newline, whatever the record it was handed ended in (iss-2609231011136579). +func upsertReviewBlock(content, rcp, newBlock string) string { + lines, b, ok := reviewBlockFor(content, rcp) + if !ok { + return appendToAuditNotes(content, newBlock) + } + // Keep the blank separator the old block ended with, so a block that + // follows it is not glued to the replacement. + sep := b.end + for sep > b.start+1 && strings.TrimSpace(lines[sep-1]) == "" { + sep-- + } + out := make([]string, 0, len(lines)) + out = append(out, lines[:b.start]...) + out = append(out, strings.Split(newBlock, "\n")...) + out = append(out, lines[sep:]...) + return withFinalNewline(strings.Join(out, "\n")) } // reviewBlockText is the review block for rcp as a renderer would have written // it: its lines with the trailing blank separator trimmed. func reviewBlockText(content, rcp string) (string, bool) { - lines := strings.Split(content, "\n") - start, end, ok := reviewBlockRange(lines, rcp) + lines, b, ok := reviewBlockFor(content, rcp) if !ok { return "", false } - return strings.TrimRight(strings.Join(lines[start:end], "\n"), "\r\n\t "), true + return strings.TrimRight(strings.Join(lines[b.start:b.end], "\n"), "\r\n\t "), true +} + +// sameReviewBlock reports whether the block on the record already says what +// rendered says. A block written before the closing line existed carries none, +// and says the same when it is rendered without it: an identical re-ingest over +// it is the noop it always was, not a rewrite that adds the line. +func sameReviewBlock(existing, rendered, rcp string) bool { + if existing == rendered { + return true + } + return !condition.ReviewEndRe.MatchString(lastLine(existing)) && + existing == strings.TrimSuffix(rendered, "\n"+condition.ReviewEndLine(rcp)) +} + +// lastLine is the final line of s. +func lastLine(s string) string { + if i := strings.LastIndex(s, "\n"); i >= 0 { + return s[i+1:] + } + return s +} + +// withFinalNewline ends s in a newline, as every record writer in the tree ends +// its file. +func withFinalNewline(s string) string { + if strings.HasSuffix(s, "\n") { + return s + } + return s + "\n" } // appendToAuditNotes appends a block to the `## Audit Notes` section, creating // the section at end of file if it is absent. func appendToAuditNotes(content, block string) string { - lines := strings.Split(content, "\n") - head := -1 - for i, ln := range lines { - if auditHeadingRe.MatchString(strings.TrimRight(ln, "\r")) { - head = i - break - } - } - if head < 0 { + // The section is the live one readReviewBlocks reads: a fenced or commented + // `## Audit Notes` is an example, and a block appended under it is a block + // the reader never finds again. + lines, _, bodyStart, end, ok := condition.AuditNotes(content) + if !ok { body := strings.TrimRight(content, "\n") return body + "\n\n## Audit Notes\n\n" + block + "\n" } - // Find the end of the Audit Notes section (next heading or EOF). - end := len(lines) - for j := head + 1; j < len(lines); j++ { - if mdrecord.IsHeading(strings.TrimRight(lines[j], "\r")) { - end = j - break - } - } + head := bodyStart - 1 // Copy the section out (never alias the backing array) and drop the template // placeholder line, so the first real review block replaces the "Empty" claim // rather than sitting beneath it. @@ -1200,7 +1690,7 @@ func appendToAuditNotes(content, block string) string { } rebuilt = append(rebuilt, "") rebuilt = append(rebuilt, lines[end:]...) - return strings.Join(rebuilt, "\n") + return withFinalNewline(strings.Join(rebuilt, "\n")) } // --------------------------------------------------------------------------- @@ -1208,7 +1698,8 @@ func appendToAuditNotes(content, block string) string { // --------------------------------------------------------------------------- func owedBlock(rcp string) string { - return fmt.Sprintf("\nFidelity review OWED (receipt %s).", rcp, rcp) + return fmt.Sprintf("\nFidelity review OWED (receipt %s).\n%s", + rcp, rcp, condition.ReviewEndLine(rcp)) } // deadLetterBlock renders the quarantine block. conds are the record's own scope @@ -1225,7 +1716,13 @@ func deadLetterBlock(rcp, reason, dlRel string, conds []verdictCondition, free p "Fidelity review DEAD_LETTER (receipt %s): %s. Raw payload retained at %s. "+ "All criteria recorded INCONCLUSIVE.\n", rcp, rcp, free(reason), dlRel) renderDispositions(&b, conds, free) - return strings.TrimRight(b.String(), "\n") + return closeReviewBlock(&b, rcp) +} + +// closeReviewBlock ends a rendered block on its closing line, the line +// readReviewBlocks bounds it by. +func closeReviewBlock(b *strings.Builder, rcp string) string { + return strings.TrimRight(b.String(), "\n") + "\n" + condition.ReviewEndLine(rcp) } // untestedDispositions is every identified scope condition the record carries, @@ -1282,7 +1779,7 @@ func ingestedBlock(rcp string, v verdict, rollup map[string]int, free proseField renderBucket(&b, "diverged", v.GapAudit.Diverged, free) renderBucket(&b, "missing", v.GapAudit.Missing, free) renderDispositions(&b, v.ScopeConditions, free) - return strings.TrimRight(b.String(), "\n") + return closeReviewBlock(&b, rcp) } // renderDispositions writes the scope-condition disposition block — the ONE diff --git a/internal/core/intent/audit_block_test.go b/internal/core/intent/audit_block_test.go new file mode 100644 index 000000000..a82c690a8 --- /dev/null +++ b/internal/core/intent/audit_block_test.go @@ -0,0 +1,370 @@ +package intent + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The review block's one reader and writer: where a block starts (a LIVE +// marker), where it stops (its closing marker, or the known shape of a block +// written before the closing marker existed), and how the record it is written +// into ends. + +// shippedWithAuditNotes is a shipped intent whose Audit Notes section body is +// notes, written verbatim: the caller decides how the file ends. +func shippedWithAuditNotes(notes string) string { + return "---\nid: itd-10\nslug: alpha\nspec_id: spc-1\nkind: standalone\nimpact: fix\n---\n" + + "# alpha\n\n## Scope Conditions\n\n" + NullityToken + + "\n\n## Acceptance Criteria\n\n- ok\n" + groundsSection + "\n## Audit Notes\n\n" + notes +} + +// fixtureWithNotes writes a shipped itd-10 (and its closed spc-1) whose Audit +// Notes are notes. +func fixtureWithNotes(t *testing.T, notes string) string { + t.Helper() + root := t.TempDir() + writeFile(t, root, shippedDir+"/itd-10-alpha.md", shippedWithAuditNotes(notes)) + writeFile(t, root, specsClosed+"/spc-1-alpha.md", specNaming("spc-1", "alpha", "itd-10")) + return root +} + +const blockRcp = "rcp-00000000abcd" + +func owedStub(rcp string) string { + return "\nFidelity review OWED (receipt " + rcp + ")." +} + +// TestIngestedRecordEndsInANewline: every write of a review block leaves the +// record ending in a newline, as every other record writer does +// (iss-2609231011136579) — including a record that reached the ingest without +// one, the shape the earlier writer left on the tree. +func TestIngestedRecordEndsInANewline(t *testing.T) { + t.Run("ship then ingest", func(t *testing.T) { + root := t.TempDir() + rcp := shipOne(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, validVerdict(rcp))); err != nil { + t.Fatal(err) + } + if s := shippedIntentBody(t, root); !strings.HasSuffix(s, "\n") { + t.Fatalf("the ingested record does not end in a newline:\n%q", s[max(0, len(s)-80):]) + } + }) + t.Run("stub at end of file with no newline", func(t *testing.T) { + root := fixtureWithNotes(t, owedStub(blockRcp)) + if _, err := IngestVerdict(root, writeVerdict(t, root, validVerdict(blockRcp))); err != nil { + t.Fatal(err) + } + if s := shippedIntentBody(t, root); !strings.HasSuffix(s, "\n") || strings.HasSuffix(s, "\n\n") { + t.Fatalf("want exactly one final newline:\n%q", s[max(0, len(s)-80):]) + } + }) + t.Run("dead letter", func(t *testing.T) { + root := fixtureWithNotes(t, owedStub(blockRcp)) + bad := strings.Replace(validVerdict(blockRcp), `"criterion_id": "ac-1"`, `"criterion_id": "ac-9"`, 1) + res, err := IngestVerdict(root, writeVerdict(t, root, bad)) + if err != nil || res.Status != "dead_letter" { + t.Fatalf("ingest = %+v %v, want dead_letter", res, err) + } + if s := shippedIntentBody(t, root); !strings.HasSuffix(s, "\n") { + t.Fatalf("the dead-lettered record does not end in a newline:\n%q", s[max(0, len(s)-80):]) + } + }) +} + +// TestFirstIngestKeepsLinkRefsBelowTheOwedStub: a stub parked above a trailing +// run of link-reference definitions (the shape appendToAuditNotes writes, per +// iss-2608210737265820) is replaced without taking the definitions with it +// (iss-2609251451432601). +func TestFirstIngestKeepsLinkRefsBelowTheOwedStub(t *testing.T) { + refs := "[iss-80]: https://example.com/issues/iss-80\n[spc-28]: https://example.com/specs/spc-28\n" + root := fixtureWithNotes(t, owedStub(blockRcp)+"\n\n"+refs) + if _, err := IngestVerdict(root, writeVerdict(t, root, validVerdict(blockRcp))); err != nil { + t.Fatal(err) + } + s := shippedIntentBody(t, root) + if !strings.HasSuffix(s, "\n\n"+refs) { + t.Fatalf("the trailing link references did not survive the first ingest, one blank line below the block:\n%s", s) + } + if !strings.Contains(s, "abcd-review: INGESTED receipt="+blockRcp) { + t.Fatalf("not ingested:\n%s", s) + } +} + +// TestReplacementKeepsLinkRefsBelowALegacyBlock: a block written before the +// closing marker existed runs to the next marker, heading or end of file, and a +// replacement of it keeps a trailing run of link-reference definitions, which +// the renderer never writes (iss-2609251451432601, "and any replacement"). +func TestReplacementKeepsLinkRefsBelowALegacyBlock(t *testing.T) { + refs := "[iss-80]: https://example.com/issues/iss-80\n" + legacy := "\nFidelity review — receipt " + blockRcp + " (verifier old v0).\n\nGap audit:\n- honoured: (none)" + content := shippedWithAuditNotes(legacy + "\n\n" + refs) + out := upsertReviewBlock(content, blockRcp, "\nNEW") + if !strings.HasSuffix(out, "NEW\n\n"+refs) { + t.Fatalf("the link reference did not survive the replacement:\n%s", out) + } + if strings.Contains(out, "verifier old v0") { + t.Fatalf("the legacy block was not replaced:\n%s", out) + } +} + +// TestReingestKeepsAHumanNoteBelowTheBlock: prose a human writes under an +// ingested block is not part of the block, so an identical re-ingest is the +// documented noop and a replacing one keeps the note (iss-2609251451434656). +func TestReingestKeepsAHumanNoteBelowTheBlock(t *testing.T) { + root := t.TempDir() + rcp := shipOne(t, root) + vp := writeVerdict(t, root, validVerdict(rcp)) + if _, err := IngestVerdict(root, vp); err != nil { + t.Fatal(err) + } + const note = "A note the product thinker wrote under the review." + path := filepath.Join(root, shippedDir, "itd-10-alpha.md") + withNote := shippedIntentBody(t, root) + "\n" + note + "\n" + if err := os.WriteFile(path, []byte(withNote), 0o644); err != nil { + t.Fatal(err) + } + + res, err := IngestVerdict(root, vp) + if err != nil { + t.Fatal(err) + } + if res.Status != "noop" || res.Replaced { + t.Fatalf("identical re-ingest = %+v, want noop", res) + } + if got := shippedIntentBody(t, root); got != withNote { + t.Fatalf("an identical re-ingest changed the record:\n%s", got) + } + + changed := strings.Replace(validVerdict(rcp), "the ship-move writes the OWED stub and request file", + "the ship-move writes the OWED stub, and the request file beside it", 1) + res, err = IngestVerdict(root, writeVerdict(t, root, changed)) + if err != nil { + t.Fatal(err) + } + if res.Status != "ingested" || !res.Replaced { + t.Fatalf("changed re-ingest = %+v, want ingested and replaced", res) + } + s := shippedIntentBody(t, root) + if !strings.Contains(s, "and the request file beside it") { + t.Fatalf("the replacement did not land:\n%s", s) + } + if !strings.HasSuffix(s, "\n\n"+note+"\n") { + t.Fatalf("the human's note did not survive the replacement, one blank line below the block:\n%s", s) + } +} + +// TestOnlyALiveMarkerCounts: the review marker is the ledger's own state, so +// only a marker a markdown reader would parse as one counts — a line of the +// Audit Notes, outside any fenced block or HTML comment span. A marker-shaped +// line in a fence, in a comment, or in another section is an example, not +// state (iss-2609020529185438). +func TestOnlyALiveMarkerCounts(t *testing.T) { + marker := "" + for name, body := range map[string]string{ + "backtick fence": "# a\n\n## Audit Notes\n\n```markdown\n" + marker + "\n```\n", + "tilde fence": "# a\n\n## Audit Notes\n\n~~~\n" + marker + "\n~~~\n", + "comment span": "# a\n\n## Audit Notes\n\n\n```\n\n" + marker + "\nFidelity review OWED.\n" + if state, ok := markerState(live, blockRcp); !ok || state != "OWED" { + t.Fatalf("the live marker below a fenced example must count: state=%q ok=%v", state, ok) + } + if rcp, _, ok := existingMarker(live); !ok || rcp != blockRcp { + t.Fatalf("existingMarker = %q, want the live %s, not the fenced example", rcp, blockRcp) + } +} + +// TestAFencedMarkerIsNotASolicitation is the end-to-end form: a record quoting +// a marker in a fenced example does not solicit a verdict for that receipt. +func TestAFencedMarkerIsNotASolicitation(t *testing.T) { + root := fixtureWithNotes(t, "```\n\n```\n") + before := shippedIntentBody(t, root) + if _, err := IngestVerdict(root, writeVerdictRaw(t, root, validVerdict(blockRcp))); err == nil || + !strings.Contains(err.Error(), "unsolicited") { + t.Fatalf("err = %v, want the receipt refused as unsolicited", err) + } + if shippedIntentBody(t, root) != before { + t.Fatal("a refused ingest changed the record") + } +} + +// armProseCitations writes a record-lint configuration arming +// prose_citation_resolves over the intent store, as this repository's does. +func armProseCitations(t *testing.T, root string) { + t.Helper() + writeFile(t, root, ".abcd/record-lint.json", `{ + "roots": [".abcd/development"], + "rules": { + "prose_citation_resolves": { + "enabled": true, + "severity": "blocker", + "record_stores": {"itd": ".abcd/development/intents", "spc": ".abcd/development/specs"} + } + } +} +`) +} + +// TestIngestRefusesAnUnresolvableCitation: the ingest writes a committed record +// that record-lint's prose_citation_resolves reads, so a verdict whose prose +// names a record id that resolves to nothing is refused before anything is +// written, naming the id — a valid verdict must never produce an uncommittable +// record (iss-2609231036448320). +func TestIngestRefusesAnUnresolvableCitation(t *testing.T) { + const dangling = "spc-2609999999999999" + cite := func(rcp, id string) string { + return strings.Replace(validVerdict(rcp), "the ship-move writes the OWED stub and request file", + "the fixture dangles its target to "+id+" and the check refuses it", 1) + } + + t.Run("armed: refused, nothing written", func(t *testing.T) { + root := fixtureWithNotes(t, owedStub(blockRcp)+"\n") + armProseCitations(t, root) + before := shippedIntentBody(t, root) + _, err := IngestVerdict(root, writeVerdict(t, root, cite(blockRcp, dangling))) + if err == nil || !strings.Contains(err.Error(), dangling) || !strings.Contains(err.Error(), "prose_citation_resolves") { + t.Fatalf("err = %v, want a refusal naming %s and the rule", err, dangling) + } + if shippedIntentBody(t, root) != before { + t.Fatal("a refused ingest changed the record") + } + }) + t.Run("armed: a resolving citation ingests", func(t *testing.T) { + root := fixtureWithNotes(t, owedStub(blockRcp)+"\n") + armProseCitations(t, root) + res, err := IngestVerdict(root, writeVerdict(t, root, cite(blockRcp, "spc-1"))) + if err != nil || res.Status != "ingested" { + t.Fatalf("ingest = %+v %v, want ingested", res, err) + } + }) + t.Run("unarmed: the repository does not gate prose citations", func(t *testing.T) { + root := fixtureWithNotes(t, owedStub(blockRcp)+"\n") + res, err := IngestVerdict(root, writeVerdict(t, root, cite(blockRcp, dangling))) + if err != nil || res.Status != "ingested" { + t.Fatalf("ingest = %+v %v, want ingested", res, err) + } + }) + t.Run("armed: a re-ingest is refused the same way", func(t *testing.T) { + root := fixtureWithNotes(t, owedStub(blockRcp)+"\n") + armProseCitations(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, validVerdict(blockRcp))); err != nil { + t.Fatal(err) + } + before := shippedIntentBody(t, root) + _, err := IngestVerdict(root, writeVerdict(t, root, cite(blockRcp, dangling))) + if err == nil || !strings.Contains(err.Error(), dangling) { + t.Fatalf("err = %v, want a refusal naming %s", err, dangling) + } + if shippedIntentBody(t, root) != before { + t.Fatal("a refused re-ingest changed the record") + } + }) +} + +// treeRoot is the repository this package is tested in. +func treeRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("the repository root is not where the test expects it: %v", err) + } + return root +} + +// rawMarkerRe is the byte pattern the review marker was read with before the +// live-marker reader: the migration guard below holds the reader to it on every +// record the tree carries. +var rawMarkerRe = regexp.MustCompile(`(?m)^\r?$`) + +// TestEveryTreeReviewBlockRoundTrips runs every intent record in this +// repository through the review block's reader and writer: the reader finds the +// marker the byte pattern found, and writing each block back over itself leaves +// the record byte-identical, so the bounded extent reads every block already on +// the tree exactly as the unbounded one did. +func TestEveryTreeReviewBlockRoundTrips(t *testing.T) { + root := treeRoot(t) + files, err := filepath.Glob(filepath.Join(root, ".abcd", "development", "intents", "*", "*.md")) + if err != nil { + t.Fatal(err) + } + blocks := 0 + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + content := string(data) + rel, _ := filepath.Rel(root, f) + raw := rawMarkerRe.FindAllStringSubmatch(content, -1) + if rcp, state, ok := existingMarker(content); ok != (len(raw) > 0) || (ok && (rcp != raw[0][2] || state != raw[0][1])) { + t.Errorf("%s: existingMarker = %s %s %v, the byte pattern read %v", rel, rcp, state, ok, raw) + } + for _, m := range raw { + blocks++ + if state, ok := markerState(content, m[2]); !ok || state != m[1] { + t.Errorf("%s: markerState(%s) = %s %v, want %s", rel, m[2], state, ok, m[1]) + } + text, ok := reviewBlockText(content, m[2]) + if !ok { + t.Errorf("%s: no review block read for %s", rel, m[2]) + continue + } + if got := upsertReviewBlock(content, m[2], text); got != content { + t.Errorf("%s: writing the %s block back over itself changed the record", rel, m[2]) + } + } + } + if blocks == 0 { + t.Fatal("the tree carries no review block; the guard read nothing") + } +} + +// TestAppendLandsInTheLiveAuditNotes: the writer appends into the section the +// reader reads, so a fenced `## Audit Notes` example above the real section +// never receives the block (iss-2609020529185438). +func TestAppendLandsInTheLiveAuditNotes(t *testing.T) { + content := "# a\n\n## Example\n\n```markdown\n## Audit Notes\n\nquoted\n```\n\n## Audit Notes\n\nlive\n" + out := upsertReviewBlock(content, blockRcp, owedStub(blockRcp)) + want := "# a\n\n## Example\n\n```markdown\n## Audit Notes\n\nquoted\n```\n\n## Audit Notes\n\nlive\n\n" + owedStub(blockRcp) + "\n" + if out != want { + t.Fatalf("the block did not land in the live section:\n got %q\nwant %q", out, want) + } + if state, ok := markerState(out, blockRcp); !ok || state != "OWED" { + t.Fatalf("the reader cannot find the block the writer wrote: %q %v", state, ok) + } +} + +// TestIngestRefusesWithNoGateRegistered: an ingest the prose-citation check +// cannot be made for writes nothing, rather than a record the gate never saw. +func TestIngestRefusesWithNoGateRegistered(t *testing.T) { + saved := proseCitationGate + SetProseCitationGate(nil) + t.Cleanup(func() { SetProseCitationGate(saved) }) + + root := fixtureWithNotes(t, owedStub(blockRcp)+"\n") + before := shippedIntentBody(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, validVerdict(blockRcp))); err == nil || + !strings.Contains(err.Error(), "no prose-citation gate is registered") { + t.Fatalf("err = %v, want the unregistered gate refused", err) + } + if shippedIntentBody(t, root) != before { + t.Fatal("a refused ingest changed the record") + } +} diff --git a/internal/core/intent/audit_forged_marker_test.go b/internal/core/intent/audit_forged_marker_test.go index 2f8cdb057..f0eb0d512 100644 --- a/internal/core/intent/audit_forged_marker_test.go +++ b/internal/core/intent/audit_forged_marker_test.go @@ -51,9 +51,9 @@ func verdictWithRationale(rcp, rationale string) string { // TestIngestVerdictCannotForgeAReviewMarkerFromACodeSpan is the end-to-end form of // the code-span exemption's one hole. The review marker -// `` is matched by markerRe, a bare -// unanchored regex over the record's bytes: it does not parse CommonMark, so -// backticks around a marker mean nothing to it. A cleaner that exempts code spans +// `` is matched by markerRe, which +// reads no inline code span, so backticks around a marker mean nothing to it. +// A cleaner that exempts code spans // from the HTML-comment delimiters therefore lets an untrusted rationale write a // WORKING marker into a committed intent record — one that claims a second // intent's outstanding receipt is already INGESTED, so the genuine verdict for it @@ -98,8 +98,8 @@ func TestIngestVerdictCannotForgeAReviewMarkerFromACodeSpan(t *testing.T) { // line of its own. An unanchored pattern found one anywhere in the record's // bytes — mid-sentence inside a rendered verdict field is exactly where an // untrusted payload would put it. This is defence in depth, not the primary -// guard: markerRe is still a byte pattern and not a grammar -// (iss-2609020529185438). +// guard; TestOnlyALiveMarkerCounts pins the other half of the reader, the +// fence-and-comment mask (iss-2609020529185438). func TestMarkerReCountsOnlyAWholeLine(t *testing.T) { const rcp = "rcp-0123456789ab" const marker = "" diff --git a/internal/core/intent/audit_request_test.go b/internal/core/intent/audit_request_test.go new file mode 100644 index 000000000..9584333e8 --- /dev/null +++ b/internal/core/intent/audit_request_test.go @@ -0,0 +1,246 @@ +package intent + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// audit_request_test.go — what the fidelity review request hands the auditor. +// The request is the auditor's whole +// brief: every set the ingest checks a verdict against has to be IN it, stated +// the way the ingest will read it, or a reviewer working from the request alone +// learns the set from a refusal. + +// readRequest returns the request file the emit wrote for rcp. +func readRequest(t *testing.T, root, rcp string) string { + t.Helper() + rb, err := os.ReadFile(filepath.Join(root, reviewsDir, rcp+".request.md")) + if err != nil { + t.Fatal(err) + } + return string(rb) +} + +// requestSection returns the body of one `## ` section of a request, up to the +// next `## ` heading, and the heading line itself. +func requestSection(t *testing.T, req, prefix string) (heading, body string) { + t.Helper() + _, after, ok := strings.Cut(req, "\n## "+prefix) + if !ok { + t.Fatalf("the request carries no `## %s` section:\n%s", prefix, req) + } + heading, body, _ = strings.Cut(after, "\n") + if i := strings.Index(body, "\n## "); i >= 0 { + body = body[:i] + } + return "## " + prefix + heading, body +} + +// firstCodeBlock is the first indented code block in a section body: the +// contiguous four-space lines up to the line that ends them, unindented. +func firstCodeBlock(body string) string { + var lines []string + for _, ln := range strings.Split(body, "\n") { + s, ok := strings.CutPrefix(ln, " ") + if !ok { + if len(lines) > 0 { + break + } + continue + } + lines = append(lines, s) + } + return strings.Join(lines, "\n") +} + +// TestAuditRequestListsTheScopeConditionIdentities is iss-2609181121301638: the +// verdict must dispose every cond-… identity the intent carries, exactly once, +// and the request used to name none of them — the auditor scraped HTML comments +// out of the record by hand, and a miscount quarantined the verdict. The +// request now lists each identity with its text, and prints the counts the +// ingest checks: the criteria's K and the conditions' total. +func TestAuditRequestListsTheScopeConditionIdentities(t *testing.T) { + root := t.TempDir() + rcp := shipWithConditions(t, root, + stampedCondition(condOne, "holds on POSIX"), + stampedCondition(condTwo, "holds below 10k records"), + ) + req := readRequest(t, root, rcp) + + heading, body := requestSection(t, req, "Scope Conditions") + if !strings.Contains(heading, "2 conditions") { + t.Errorf("the Scope Conditions heading does not state the count the ingest checks: %q", heading) + } + for _, want := range []string{ + "- " + condOne + " — holds on POSIX", + "- " + condTwo + " — holds below 10k records", + } { + if !strings.Contains(body, want) { + t.Errorf("the Scope Conditions block lacks %q:\n%s", want, body) + } + } + if strings.Contains(body, "") { + t.Fatalf("the ingest erased the condition block that landed in the window:\n%s", s) + } + if !strings.Contains(s, "abcd-review: INGESTED receipt="+rcp) { + t.Fatalf("the verdict was not written:\n%s", s) + } +} + +// iss-2609261935343851: the ingest takes the lock only where an intent store +// exists. Taking it creates the store, and a repository without one holds no +// receipt to resolve, so that refusal still writes nothing. +func TestIngestIntoARepositoryWithNoIntentStoreWritesNothing(t *testing.T) { + root := t.TempDir() + if _, err := IngestVerdictBytes(root, []byte(validVerdict("rcp-000000000000"))); err == nil { + t.Fatal("a verdict with no intent store to resolve it in must be refused") + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("the refusal wrote into the repository: %v", entries) + } +} + +// iss-2609261935407925: the review emit parks its OWED stub on the bytes it +// read under the lock, so a condition disposition landing in the window before +// it survives. +func TestReEmitKeepsAConditionDispositionLandedInTheWindow(t *testing.T) { + root, rcp := condFixture(t, "\\\"holds while the record is one repository\\\" "+condOne) + // A markerless shipped record: the emit parks a fresh stub, which is a write. + abs := filepath.Join(root, shippedDir, "itd-10-alpha.md") + unparked := strings.Replace(intentBody(t, root), owedBlock(rcp), "", 1) + if err := os.WriteFile(abs, []byte(unparked), 0o644); err != nil { + t.Fatal(err) + } + if reviewMarkers(t, unparked) != 0 { + t.Fatalf("the fixture must carry no review marker:\n%s", unparked) + } + fired := landAtLockEntry(t, func() { + if _, err := DispositionCondition(root, condReq(condition.Falsified)); err != nil { + t.Errorf("the disposition landing in the window must succeed: %v", err) + } + }) + + res, err := ReEmitAudit(root, "itd-10") + if !*fired { + t.Fatal("ReEmitAudit never took the store lock: the seam never fired") + } + if err != nil || res.Status != "owed" { + t.Fatalf("re-emit: %+v, %v", res, err) + } + s := intentBody(t, root) + if !strings.Contains(s, "") { + t.Fatalf("the emit erased the condition block that landed in the window:\n%s", s) + } + if !strings.Contains(s, "abcd-review: OWED receipt="+res.ReceiptID) { + t.Fatalf("the stub was not parked:\n%s", s) + } +} + +// iss-2609261935407995: two back-edges written to one intent — the one landing +// in the window is read under the lock, so both stand. +func TestAddRelatedIssueKeepsAnEdgeLandedInTheWindow(t *testing.T) { + root := t.TempDir() + rel := plannedDir + "/itd-10-alpha.md" + writeFile(t, root, rel, "---\nid: itd-10\nslug: alpha\nspec_id: null\nkind: standalone\n---\n# alpha\n") + fired := landAtLockEntry(t, func() { + if _, err := AddRelatedIssue(root, "itd-10", "rdi-16"); err != nil { + t.Errorf("the edge landing in the window must be written: %v", err) + } + }) + + it, err := AddRelatedIssue(root, "itd-10", "rdi-17") + if !*fired { + t.Fatal("AddRelatedIssue never took the store lock: the seam never fired") + } + if err != nil { + t.Fatal(err) + } + fields := frontmatter.Fields(strings.Split(readIntent(t, root, rel), "\n")) + if got := fields[RelatedIssuesKey].Value; got != "[rdi-16, rdi-17]" { + t.Fatalf("related_issues = %q, want both edges [rdi-16, rdi-17]", got) + } + if strings.Join(it.RelatedIssues, ",") != "rdi-16,rdi-17" { + t.Errorf("the result must report the list written: %v", it.RelatedIssues) + } +} + +// iss-2609261935407995, the link half: a hold landing in the window before a +// link survives the link's write. +func TestLinkKeepsAHoldLandedInTheWindow(t *testing.T) { + root := t.TempDir() + rel := plannedDir + "/itd-10-alpha.md" + writeFile(t, root, rel, "---\nid: itd-10\nslug: alpha\nspec_id: null\nkind: standalone\n---\n# alpha\n") + writeFile(t, root, specsOpen+"/spc-3-alpha.md", "---\nid: spc-3\nslug: alpha\nintent: itd-10\n---\n# alpha\n") + fired := landAtLockEntry(t, func() { + if _, err := Hold(root, "itd-10", "landed in the window"); err != nil { + t.Errorf("the hold landing in the window must succeed: %v", err) + } + }) + + if _, err := Link(root, "itd-10", "spc-3"); err != nil { + t.Fatal(err) + } + if !*fired { + t.Fatal("Link never took the store lock: the seam never fired") + } + fields := frontmatter.Fields(strings.Split(readIntent(t, root, rel), "\n")) + if fields[HeldKey].Value == "" || fields["spec_id"].Value != "spc-3" { + t.Fatalf("the link must keep the hold and write its spec_id: held=%q spec_id=%q", + fields[HeldKey].Value, fields["spec_id"].Value) + } +} diff --git a/internal/core/lifeboat/embark.go b/internal/core/lifeboat/embark.go index 050917cff..333750f65 100644 --- a/internal/core/lifeboat/embark.go +++ b/internal/core/lifeboat/embark.go @@ -27,6 +27,8 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/ahoy" + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/update" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -67,7 +69,9 @@ func EmbarkProbe(lifeboatDir, targetDir string) (EmbarkPlan, error) { // EmbarkFrom performs the write. It runs the same planner as EmbarkProbe; if the // plan carries ANY conflict it returns (result-with-Conflicts, ErrEmbarkConflicts) -// having written NOTHING. Otherwise it writes each ActionCreate file through +// having written NOTHING. It judges the plan again under the target's ledger +// and intent locks, and a conflict found then refuses the same way. Otherwise +// it writes each ActionCreate file through // os.Root containment + independent lexical validation + fsutil.WriteFileAtomic, // skips ActionUnchanged files, ensures the marker last, and returns the summary. func EmbarkFrom(lifeboatDir, targetDir string) (EmbarkResult, error) { @@ -92,10 +96,42 @@ func EmbarkFrom(lifeboatDir, targetDir string) (EmbarkResult, error) { return res, ErrEmbarkConflicts } - written, unchanged, bytesW, families, err := writeEmbark(pr.targetAbs, pr.planned) + if afterEmbarkPlan != nil { + afterEmbarkPlan() + } + // The write runs under the target's ledger lock, then its intent store's + // lock, then its spec store's — the one order every path holding more than + // one takes — and every planned write is judged again under them: a record + // created at a planned target between the classification above and this + // write — a capture, an intent or spec mint — refuses the whole write as a + // conflict instead of being replaced (iss-2609262143265180, + // iss-2609262218306589, iss-2609262218309668). + var ( + written, unchanged, bytesW int + families map[string]int + late []Conflict + ) + err = intent.WithLedgerThenMintLock(pr.targetAbs, embarkLedgerLock(pr.targetAbs, pr.planned), func() error { + if duringEmbarkWrite != nil { + duringEmbarkWrite() + } + var planned []PlannedEmbark + planned, late = rejudgeEmbark(pr.targetAbs, pr.planned) + if len(late) > 0 { + return nil + } + var werr error + written, unchanged, bytesW, families, werr = writeEmbark(pr.targetAbs, planned) + return werr + }) if err != nil { return EmbarkResult{}, fmt.Errorf("embark: %w", err) } + if len(late) > 0 { + res.Conflicts = late + res.Marker = embarkMarker(pr.targetAbs, true) + return res, ErrEmbarkConflicts + } res.Written = written res.Unchanged = unchanged res.BytesWritten = bytesW @@ -106,6 +142,42 @@ func EmbarkFrom(lifeboatDir, targetDir string) (EmbarkResult, error) { return res, nil } +// afterEmbarkPlan and duringEmbarkWrite are test seams, nil outside tests: +// the first is called between the plan and the locks, so a test can land a +// record in that window; the second with the locks held, before the +// re-judgement, so a test can prove a concurrent writer waits. +var afterEmbarkPlan, duringEmbarkWrite func() + +// embarkLedgerLock is the target ledger's lock when the plan creates an issue +// record there, and no lock otherwise: taking it creates the ledger, which an +// embark carrying no issue must not plant. +func embarkLedgerLock(targetAbs string, planned []PlannedEmbark) func(func() error) error { + for _, p := range planned { + if p.Family == "issues" && p.Action == ActionCreate { + return func(fn func() error) error { return capture.WithLedgerLock(targetAbs, fn) } + } + } + return func(fn func() error) error { return fn() } +} + +// rejudgeEmbark classifies every planned write again against the target as it +// is now, returning the writes to perform or the conflicts that refuse them. +func rejudgeEmbark(targetAbs string, planned []PlannedEmbark) ([]PlannedEmbark, []Conflict) { + var ( + out []PlannedEmbark + conflicts []Conflict + ) + for _, p := range planned { + pe, cf := classifyEmbark(targetAbs, p.LifeboatPath, p.TargetPath, p.Family, p.Content) + if cf != nil { + conflicts = append(conflicts, *cf) + continue + } + out = append(out, pe) + } + return out, conflicts +} + // VerifyManifest re-hashes every non-excluded file in the lifeboat and compares // the result to _provenance.json's manifest_sha256. It enforces the trust // boundary during the walk: it refuses a symlink anywhere in the tree, a path diff --git a/internal/core/lifeboat/embark_lock_test.go b/internal/core/lifeboat/embark_lock_test.go new file mode 100644 index 000000000..e3337730a --- /dev/null +++ b/internal/core/lifeboat/embark_lock_test.go @@ -0,0 +1,111 @@ +package lifeboat + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/intent" +) + +// landsWithin starts fn and reports whether it finished within d, and a +// channel its result arrives on. +func landsWithin(d time.Duration, fn func() error) (bool, chan error) { + done := make(chan error, 1) + go func() { done <- fn() }() + select { + case err := <-done: + done <- err + return true, done + case <-time.After(d): + return false, done + } +} + +// A record created at a planned target between embark's classification and +// its write is judged again under the locks and refuses the whole write, for +// the intent store (iss-2609262143265180) and the issue ledger +// (iss-2609262218306589) alike: it is never replaced without a conflict. +func TestEmbarkRejudgesARecordThatLandedAfterThePlan(t *testing.T) { + for _, rel := range []string{ + ".abcd/development/intents/drafts/itd-1-alpha.md", + ".abcd/work/issues/open/iss-1-open-thing.md", + } { + t.Run(filepath.Base(filepath.Dir(filepath.Dir(rel))), func(t *testing.T) { + source := embarkableSourceFixture(t) + dest := packSource(t, source) + target := t.TempDir() + // The store exists before the embark, as a target's usually does. + for _, dir := range []string{".abcd/development/intents/drafts", ".abcd/work/issues/open"} { + if err := os.MkdirAll(filepath.Join(target, dir), 0o755); err != nil { + t.Fatal(err) + } + } + const planted = "A record written in the window.\n" + afterEmbarkPlan = func() { mustWrite(t, filepath.Join(target, rel), []byte(planted)) } + t.Cleanup(func() { afterEmbarkPlan = nil }) + + res, err := EmbarkFrom(dest, target) + if !errors.Is(err, ErrEmbarkConflicts) { + t.Fatalf("want ErrEmbarkConflicts, got %v (written %d)", err, res.Written) + } + if res.Written != 0 || len(res.Conflicts) != 1 || res.Conflicts[0].Path != rel { + t.Errorf("the refusal must name the one record and write nothing: written=%d conflicts=%+v", res.Written, res.Conflicts) + } + if got, _ := os.ReadFile(filepath.Join(target, rel)); string(got) != planted { + t.Errorf("the record written in the window was replaced: %q", got) + } + if _, err := os.Stat(filepath.Join(target, ".abcd/development/decisions/adrs/0002-single-binary.md")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("a refused embark wrote another record (err %v)", err) + } + }) + } +} + +// Embark writes under the ledger lock and the intent store's lock: a ledger +// writer and an intent writer arriving while it writes wait for it. +func TestEmbarkWritesUnderTheLedgerAndIntentLocks(t *testing.T) { + source := embarkableSourceFixture(t) + dest := packSource(t, source) + target := t.TempDir() + if err := os.MkdirAll(filepath.Join(target, ".abcd/development/intents"), 0o755); err != nil { + t.Fatal(err) + } + var ledgerEarly, intentEarly bool + var ledgerW, intentW chan error + duringEmbarkWrite = func() { + ledgerEarly, ledgerW = landsWithin(300*time.Millisecond, func() error { + return capture.WithLedgerLock(target, func() error { return nil }) + }) + intentEarly, intentW = landsWithin(300*time.Millisecond, func() error { + return intent.WithMintLock(target, func() error { return nil }) + }) + } + t.Cleanup(func() { duringEmbarkWrite = nil }) + + res, err := EmbarkFrom(dest, target) + if err != nil { + t.Fatal(err) + } + if ledgerW == nil { + t.Fatal("embark never reached its write") + } + if err := <-ledgerW; err != nil { + t.Fatal(err) + } + if err := <-intentW; err != nil { + t.Fatal(err) + } + if ledgerEarly { + t.Error("a ledger writer landed while embark wrote: it does not hold the ledger lock") + } + if intentEarly { + t.Error("an intent writer landed while embark wrote: it does not hold the intent store's lock") + } + if res.Written == 0 { + t.Error("embark wrote nothing") + } +} diff --git a/internal/core/lint/lint_test.go b/internal/core/lint/lint_test.go index a53c17ede..81a2506dc 100644 --- a/internal/core/lint/lint_test.go +++ b/internal/core/lint/lint_test.go @@ -282,6 +282,17 @@ func TestDocsLintHarnessNameGate(t *testing.T) { for _, r := range []string{".abcd/README.md", "AGENTS.md", "CONTRIBUTING.md", "scripts/README.md"} { writeFile(t, root, r, "# t\n") } + // So must links_resolve's extra roots (iss-46), read from the config so a + // new one needs no edit here, and the persona rule needs its roster. + for _, r := range cfg.Rules["links_resolve"].ExtraRoots { + if !strings.HasSuffix(r, ".md") { + r += "/README.md" + } + writeFile(t, root, r, "# t\n") + } + if reg := cfg.Rules["persona_registry"].Registry; reg != "" { + writeFile(t, root, reg, `{"personas": [{"name": "Kira"}]}`+"\n") + } writeFile(t, root, "docs/named.md", "# t\n\nRun this in Claude Code.\n") writeFile(t, root, "docs/allowed.md", "# t\n\n Claude Code is named deliberately.\n") writeFile(t, root, "docs/clean.md", "# t\n\nUse the agent harness.\n") diff --git a/internal/core/lint/lintscope_test.go b/internal/core/lint/lintscope_test.go new file mode 100644 index 000000000..5f4221d96 --- /dev/null +++ b/internal/core/lint/lintscope_test.go @@ -0,0 +1,141 @@ +package lint_test + +import ( + "encoding/json" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// linkScopeExempt names every committed markdown path no links_resolve walk +// reads, each with the reason. A key ending in "/" names a tree. +var linkScopeExempt = map[string]string{ + // Fixture repositories the evals read as input: a planted link that does + // not resolve is part of what some of them test. + "evals/testdata/": "eval fixtures, whose content is test input", + // A block ahoy writes into another repository's CLAUDE.md; its links + // resolve there, not here. + "internal/core/ahoy/defaults/": "templates written into other repositories", + // Symlinked mirrors of AGENTS.md, which is read in their place. + "CLAUDE.md": "a symlinked mirror of AGENTS.md", + "GEMINI.md": "a symlinked mirror of AGENTS.md", +} + +// TestEveryCommittedMarkdownFileHasItsLinksChecked is the link half of iss-46's +// scope matrix: every committed markdown file sits under a tree some +// links_resolve walk reads — record-lint's roots or extra roots, or docs-lint's — +// or is named in linkScopeExempt with its reason. The roster is git's own list +// of committed files, so a markdown file added anywhere joins by existing, and +// a relative link in it that breaks is refused instead of shipping, which is +// what the root prose, the agent prompts and the plugin command pages did +// before the docs-lint walk reached them. +func TestEveryCommittedMarkdownFileHasItsLinksChecked(t *testing.T) { + root := filepath.Join("..", "..", "..") + + var scope []string + for _, rel := range []string{".abcd/record-lint.json", ".abcd/docs-lint.json"} { + var cfg struct { + Roots []string `json:"roots"` + Rules map[string]struct { + Enabled bool `json:"enabled"` + ExtraRoots []string `json:"extra_roots"` + } `json:"rules"` + } + if err := json.Unmarshal([]byte(readRepoFile(t, root, rel)), &cfg); err != nil { + t.Fatalf("decoding %s: %v", rel, err) + } + links, ok := cfg.Rules["links_resolve"] + if !ok || !links.Enabled { + t.Fatalf("%s does not enable links_resolve; this test reads the scope that rule walks", rel) + } + scope = append(scope, cfg.Roots...) + scope = append(scope, links.ExtraRoots...) + } + + cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "*.md") + cmd.Env = gittest.Env(t) + out, err := cmd.Output() + if err != nil { + t.Skipf("git ls-files unavailable in %s: %v", root, err) + } + files := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") + if len(files) < 100 { + t.Fatalf("git listed %d committed markdown files; the listing or the pathspec changed shape", len(files)) + } + + within := func(rel string, set []string) bool { + for _, s := range set { + s = strings.TrimSuffix(s, "/") + if rel == s || strings.HasPrefix(rel, s+"/") { + return true + } + } + return false + } + var exempt []string + for k := range linkScopeExempt { + exempt = append(exempt, k) + } + + used := map[string]bool{} + for _, rel := range files { + if rel == "" || within(rel, scope) { + continue + } + matched := false + for _, k := range exempt { + if within(rel, []string{k}) { + used[k], matched = true, true + } + } + if !matched { + t.Errorf("%s is committed markdown that no links_resolve walk reads.\n\n"+ + "Add its tree to docs-lint's links_resolve extra_roots (.abcd/docs-lint.json), or name "+ + "it in linkScopeExempt with the reason its links are not this repository's to check.", rel) + } + } + for k, why := range linkScopeExempt { + if !used[k] { + t.Errorf("linkScopeExempt names %s (%s), which matches no committed markdown file; "+ + "a stale exemption would pre-approve an unchecked file nobody has ruled on", k, why) + } + } +} + +// TestDocsLintArmsThePersonaRule is the persona half of iss-46: the +// persona_registry rule (a quote attributed to a persona outside the roster) +// ran over the design record alone, while docs/ is where the user-facing +// persona prose lives. docs-lint arms it against the same roster record-lint +// reads, at the same severity, so the two walks cannot disagree about who a +// persona may be. +func TestDocsLintArmsThePersonaRule(t *testing.T) { + root := filepath.Join("..", "..", "..") + type persona struct { + Enabled bool `json:"enabled"` + Severity string `json:"severity"` + Registry string `json:"registry"` + } + read := func(rel string) (persona, bool) { + var cfg struct { + Rules map[string]persona `json:"rules"` + } + if err := json.Unmarshal([]byte(readRepoFile(t, root, rel)), &cfg); err != nil { + t.Fatalf("decoding %s: %v", rel, err) + } + p, ok := cfg.Rules["persona_registry"] + return p, ok + } + record, ok := read(".abcd/record-lint.json") + if !ok || !record.Enabled { + t.Fatal(".abcd/record-lint.json does not arm persona_registry; this test holds docs-lint to it") + } + docs, ok := read(".abcd/docs-lint.json") + if !ok || docs != record { + t.Fatalf(".abcd/docs-lint.json arms persona_registry as %+v (present=%v), want record-lint's %+v: "+ + "docs/ carries persona prose, and a walk that never reads it passes a persona the roster "+ + "does not hold", docs, ok, record) + } +} diff --git a/internal/core/lint/pinnedtoolchain_test.go b/internal/core/lint/pinnedtoolchain_test.go new file mode 100644 index 000000000..797495532 --- /dev/null +++ b/internal/core/lint/pinnedtoolchain_test.go @@ -0,0 +1,228 @@ +package lint_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +// The declared-toolchain resolver, scripts/pinned-toolchain.sh, driven against a +// stub `go` so every branch runs without a network or a second toolchain on +// the machine. The stub answers the two queries the resolver makes of the go on +// PATH (`env GOVERSION`, `env GOROOT`); the fake GOROOT it reports holds a gofmt +// and a go whose `version` names the declared release. + +const resolverStubGo = `#!/bin/sh +case "$1 $2" in +"env GOVERSION") echo go1.27.1 ;; +"env GOROOT") + [ -n "$STUB_PROGRESS" ] && echo "$STUB_PROGRESS" >&2 + if [ -n "$STUB_FAIL" ]; then echo "$STUB_FAIL" >&2; exit 1; fi + echo "$STUB_GOROOT" + ;; +*) echo "stub go: unexpected arguments: $*" >&2; exit 3 ;; +esac +` + +type resolverRun struct { + stdout, stderr string + code int +} + +// runResolver runs the resolver with the stub go first on PATH. reported is the +// release the fake GOROOT's go claims to be. +func runResolver(t *testing.T, version, reported string, env ...string) (resolverRun, string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the resolver is a POSIX shell script; the gates run on macOS and Linux") + } + root := filepath.Join("..", "..", "..") + script, err := filepath.Abs(filepath.Join(root, "scripts", "pinned-toolchain.sh")) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + stubBin := filepath.Join(dir, "stub") + goroot := filepath.Join(dir, "goroot") + for _, d := range []string{stubBin, filepath.Join(goroot, "bin")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + writeExec(t, filepath.Join(stubBin, "go"), resolverStubGo) + writeExec(t, filepath.Join(goroot, "bin", "gofmt"), "#!/bin/sh\nexit 0\n") + writeExec(t, filepath.Join(goroot, "bin", "go"), "#!/bin/sh\necho \"go version "+reported+" stub/arch\"\n") + + cmd := exec.Command("bash", script, version) + cmd.Env = append([]string{ + "PATH=" + stubBin + string(os.PathListSeparator) + "/usr/bin" + string(os.PathListSeparator) + "/bin", + "STUB_GOROOT=" + goroot, + "HOME=" + dir, + }, env...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + code := 0 + if err := cmd.Run(); err != nil { + ee, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("running the resolver: %v", err) + } + code = ee.ExitCode() + } + return resolverRun{out.String(), errb.String(), code}, goroot +} + +func writeExec(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + t.Fatal(err) + } +} + +// TestPinnedToolchainResolverIgnoresDownloadProgress is iss-2609090951287096's +// detector. On the first run on a machine without the declared toolchain +// cached, `go env GOROOT` prints the download notice on stderr before it +// reports the root on stdout. The format gate used to capture both streams as +// the root, so the path became two lines, the executable test on it failed, +// and the gate refused — saying the fetch needed network, on the run where the +// fetch had just succeeded. +func TestPinnedToolchainResolverIgnoresDownloadProgress(t *testing.T) { + got, goroot := runResolver(t, "1.26.7", "go1.26.7", + "STUB_PROGRESS=go: downloading go1.26.7 (darwin/arm64)") + if got.code != 0 { + t.Fatalf("the resolver refused a fetch that succeeded and merely printed progress (exit %d).\n"+ + "stderr:\n%s", got.code, got.stderr) + } + if strings.TrimSuffix(got.stdout, "\n") != goroot { + t.Fatalf("the resolver printed %q, want exactly the GOROOT %q: stdout is the value a caller runs "+ + "binaries from, so anything else on it corrupts the path", got.stdout, goroot) + } +} + +// A fetch that FAILS refuses, exit 2, naming the declared release and the one +// on PATH — the skew — and carrying go's own error, and never prints a root a +// caller could run a fallback from. +func TestPinnedToolchainResolverRefusesAFailedFetchNamingTheSkew(t *testing.T) { + got, _ := runResolver(t, "1.26.7", "go1.26.7", + "STUB_FAIL=go: download go1.26.7: dial tcp: lookup proxy.golang.org: no such host") + if got.code != 2 { + t.Fatalf("a failed fetch exited %d, want the refusal's 2.\nstderr:\n%s", got.code, got.stderr) + } + if got.stdout != "" { + t.Errorf("a refusal printed %q on stdout; a caller would take it for a GOROOT", got.stdout) + } + for _, want := range []string{"REFUSING", "go1.26.7", "go1.27.1", "no such host"} { + if !strings.Contains(got.stderr, want) { + t.Errorf("the refusal does not name %q:\n%s", want, got.stderr) + } + } +} + +// A switch that silently did not happen — the resolved go reports another +// release — refuses rather than judging the tree with it. +func TestPinnedToolchainResolverRefusesAToolchainThatDidNotSwitch(t *testing.T) { + got, _ := runResolver(t, "1.26.7", "go1.27.1") + if got.code != 2 || !strings.Contains(got.stderr, "reports go1.27.1") { + t.Fatalf("a resolved toolchain reporting the wrong release exited %d, want 2 with the mismatch named.\n"+ + "stderr:\n%s", got.code, got.stderr) + } +} + +// go.mod without a go directive leaves the caller an empty version; that +// refuses before any go runs. +func TestPinnedToolchainResolverRefusesAnEmptyDeclaration(t *testing.T) { + got, _ := runResolver(t, "", "go1.26.7") + if got.code != 2 || !strings.Contains(got.stderr, "declares no") { + t.Fatalf("an empty declaration exited %d, want 2 naming the missing go line.\nstderr:\n%s", + got.code, got.stderr) + } +} + +// TestPreflightRunsTheDeclaredToolchain holds every Go step of `make preflight` +// to the toolchain go.mod declares (iss-2609261850045839). CI's setup-go +// installs that release and every Go step runs on it, while preflight ran the +// go on PATH — so on a newer machine a test asserting standard-library wording +// passed preflight and failed CI (pull request 728). Preflight exports +// GOTOOLCHAIN from the same go.mod read the format gate uses, and runs the +// format gate — whose resolver refuses, naming the skew, when the toolchain +// cannot be fetched — before any other gate, so nothing is judged on a +// toolchain that was never resolved. One resolver, not a second: the Makefile +// resolves a GOROOT nowhere but through the script. +func TestPreflightRunsTheDeclaredToolchain(t *testing.T) { + root := filepath.Join("..", "..", "..") + makefile := readRepoFile(t, root, "Makefile") + workflow := readRepoFile(t, root, ".github/workflows/ci.yml") + + const export = "preflight: export GOTOOLCHAIN := go$(GO_TOOLCHAIN_VERSION)" + if !strings.Contains(makefile, export) { + t.Errorf("the Makefile does not declare %q.\n\n"+ + "Without it preflight builds, vets and tests on the go on PATH while CI uses the "+ + "release go.mod declares, and a green preflight vouches for nothing CI's toolchain "+ + "would say differently.", export) + } + if n := strings.Count(makefile, "go env GOROOT"); n != 0 { + t.Errorf("the Makefile resolves a GOROOT itself (%d `go env GOROOT`); the resolver is "+ + "scripts/pinned-toolchain.sh alone, or the format gate and the Go steps can come to "+ + "disagree about which toolchain is declared", n) + } + + // The format gate CI runs, derived from CI's own step as the format test does. + step, ok := workflowStepBlock(workflow, formatStepName) + if !ok { + t.Fatalf(".github/workflows/ci.yml defines no %q step", formatStepName) + } + m := regexp.MustCompile(`\bmake ([a-z][a-z-]*)\b`).FindStringSubmatch(step) + if m == nil { + t.Fatalf("the %q step runs no `make `", formatStepName) + } + format := m[1] + recipe, ok := makeRecipe(makefile, format) + if !ok || !strings.Contains(recipe, "scripts/pinned-toolchain.sh") { + t.Errorf("the `%s:` recipe does not resolve its toolchain through scripts/pinned-toolchain.sh:\n\n%s", + format, recipe) + } + prereqs := preflightPrereqs(t, root) + if len(prereqs) < 2 || prereqs[1] != format { + t.Errorf("preflight's prerequisites are %v; the format gate %q must come second, straight "+ + "after the load check (a warning that judges nothing): it is CI's gate too, and its "+ + "resolver is what refuses, naming the skew, before any gate runs on a toolchain that "+ + "could not be fetched", prereqs, format) + } +} + +// TestPreflightArmsRecordLintAsCIDoes holds preflight's record-lint to the range +// CI's step arms it with (iss-2609021152026246). CI passes `-agent-diff +// ...HEAD`, which arms agent_contract's unbumped-edit check; preflight +// passed nothing, so a changed agent prompt that bumped no prompt_version +// passed three green preflights and was refused in the merge queue (pull +// request 606). +func TestPreflightArmsRecordLintAsCIDoes(t *testing.T) { + root := filepath.Join("..", "..", "..") + makefile := readRepoFile(t, root, "Makefile") + workflow := readRepoFile(t, root, ".github/workflows/ci.yml") + + ciStep, ok := workflowStepBlock(workflow, "Record-lint (drift gate)") + if !ok { + t.Fatal(".github/workflows/ci.yml defines no `Record-lint (drift gate)` step") + } + if !strings.Contains(ciStep, `-agent-diff "${BASE_SHA}...HEAD"`) { + t.Fatalf("CI's record-lint step no longer arms `-agent-diff \"${BASE_SHA}...HEAD\"`; "+ + "re-derive what preflight must match:\n\n%s", ciStep) + } + lintRecipe, ok := makeRecipe(makefile, "record-lint") + if !ok { + t.Fatal("the Makefile declares no `record-lint:` target") + } + if !strings.Contains(lintRecipe, "go run ./cmd/record-lint -agent-diff origin/main...HEAD") { + t.Errorf("the `record-lint:` recipe does not arm agent_contract over origin/main...HEAD, "+ + "the merge-base range CI's step passes as ${BASE_SHA}...HEAD:\n\n%s\n\n"+ + "Unarmed, the unbumped-prompt check is a no-op locally and fires first in the merge "+ + "queue.", lintRecipe) + } +} diff --git a/internal/core/lint/preflightgates_test.go b/internal/core/lint/preflightgates_test.go index 536128f90..90df50b67 100644 --- a/internal/core/lint/preflightgates_test.go +++ b/internal/core/lint/preflightgates_test.go @@ -454,7 +454,14 @@ func TestFormatGateResolvesThroughTheDeclaredToolchain(t *testing.T) { "a workflow calling a target that does not exist fails the job with a make error "+ "rather than a format report", target, target) } - if !strings.Contains(recipe, "GOTOOLCHAIN=go") { + // The resolution lives in scripts/pinned-toolchain.sh, the one resolver the + // format gate and preflight's Go steps share (iss-2609261850045839), so the + // pin is read from the script the recipe invokes as well as the recipe. + resolution := recipe + if strings.Contains(recipe, "scripts/pinned-toolchain.sh") { + resolution += "\n" + readRepoFile(t, root, "scripts/pinned-toolchain.sh") + } + if !strings.Contains(resolution, `GOTOOLCHAIN="go`) && !strings.Contains(resolution, "GOTOOLCHAIN=go") { t.Errorf("the `%s:` recipe does not resolve a pinned toolchain (no GOTOOLCHAIN=go...):\n\n%s\n\n"+ "gofmt must come from the toolchain go.mod declares, or the gate judges the tree "+ "by whatever version the caller happens to have", target, recipe) diff --git a/internal/core/lint/prosecitations.go b/internal/core/lint/prosecitations.go index 4892eb240..33e667280 100644 --- a/internal/core/lint/prosecitations.go +++ b/internal/core/lint/prosecitations.go @@ -95,6 +95,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "os" "path/filepath" "regexp" @@ -333,11 +334,33 @@ func proseCitationsInFile(repoRoot, abs string, resolver *recordid.Resolver, bas } rel := repoRel(repoRoot, abs) lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") - mask := fenceMask(lines) - skip := proseFrontmatterSkip(lines, recordBodyStart(lines)) var out []Finding + for _, c := range unresolvedProseCitations(lines, skip, resolver, baseline, used) { + out = append(out, Finding{ + File: rel, Line: c.Line, RuleID: ruleProseCitationResolves, Severity: severity, + Message: proseCitationMessage(c.ID), + }) + } + return out, nil +} + +// ProseCitation is one record id a line of prose cites that the rule refuses: +// it names no record, the baseline does not carry it, and its line carries no +// escape marker. Line is 1-based. +type ProseCitation struct { + Line int + ID string +} + +// unresolvedProseCitations is the rule's reading of a body's lines, shared by +// the gate and by a writer that asks before it writes, so the two cannot +// disagree about what the gate refuses. Fenced lines and the lines skip marks +// are not read. +func unresolvedProseCitations(lines []string, skip []bool, resolver *recordid.Resolver, baseline map[string]ProseBaselineEntry, used map[string]bool) []ProseCitation { + mask := fenceMask(lines) + var out []ProseCitation for i := 0; i < len(lines); i++ { if mask[i] || (i < len(skip) && skip[i]) { continue @@ -361,13 +384,72 @@ func proseCitationsInFile(repoRoot, abs string, resolver *recordid.Resolver, bas if escaped { continue } - out = append(out, Finding{ - File: rel, Line: i + 1, RuleID: ruleProseCitationResolves, Severity: severity, - Message: proseCitationMessage(id), - }) + out = append(out, ProseCitation{Line: i + 1, ID: id}) } } - return out, nil + return out +} + +// DefaultRecordLintConfigPath is the repository's record-lint configuration, +// repo-relative: the one a writer consults to learn what the gate will refuse. +const DefaultRecordLintConfigPath = ".abcd/record-lint.json" + +// UnresolvedProseCitationsInRecord is UnresolvedProseCitationsInText under the +// repository's own record-lint configuration. A repository with no +// configuration gates nothing, and the answer is empty; a configuration that +// cannot be read is an error, since the question cannot then be answered. +func UnresolvedProseCitationsInRecord(repoRoot, rel, text string) ([]ProseCitation, error) { + cfg, err := LoadConfig(filepath.Join(repoRoot, filepath.FromSlash(DefaultRecordLintConfigPath))) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading %s: %w", DefaultRecordLintConfigPath, err) + } + return UnresolvedProseCitationsInText(cfg, repoRoot, rel, text) +} + +// UnresolvedProseCitationsInText runs prose_citation_resolves over text a +// writer is about to put into the record at rel, BEFORE it is written: the one +// resolver, the committed baseline and the line escape marker, read exactly as +// the gate reads them. A writer composing a record from a host-delegated +// payload asks this first, because a payload the writer accepts must never +// produce a record the gate then refuses (iss-2609231036448320). A +// configuration that does not arm the rule, or whose record stores do not hold +// rel, gates nothing, and the answer is empty. text is a fragment of a record +// body, never its frontmatter. +func UnresolvedProseCitationsInText(cfg Config, repoRoot, rel, text string) ([]ProseCitation, error) { + rc, on := cfg.Rules[ruleProseCitationResolves] + if !on || !rc.Enabled || !underAnyStore(rel, rc.RecordStores) { + return nil, nil + } + resolver, err := recordid.NewResolver(repoRoot) + if err != nil { + return nil, &configError{ruleProseCitationResolves + ": " + err.Error()} + } + baselinePath := rc.Baseline + if baselinePath == "" { + baselinePath = DefaultProseBaselinePath + } + baseline, err := loadProseBaseline(repoRoot, baselinePath) + if err != nil { + return nil, err + } + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + return unresolvedProseCitations(lines, nil, resolver, baseline, map[string]bool{}), nil +} + +// underAnyStore reports whether the repo-relative path rel lies inside one of +// the configured record stores. +func underAnyStore(rel string, stores map[string]string) bool { + rel = filepath.ToSlash(filepath.Clean(rel)) + for _, dir := range stores { + dir = strings.TrimSuffix(filepath.ToSlash(filepath.Clean(dir)), "/") + if strings.HasPrefix(rel, dir+"/") { + return true + } + } + return false } // proseCitationMessage is the refusal, and it is where an author learns the diff --git a/internal/core/reading/gate_test.go b/internal/core/reading/gate_test.go new file mode 100644 index 000000000..eaeb7c827 --- /dev/null +++ b/internal/core/reading/gate_test.go @@ -0,0 +1,19 @@ +package reading + +import ( + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/lint" +) + +// init registers record-lint's prose-citation gate for this package's tests, +// as the front doors register it for every ingest they run. +func init() { + intent.SetProseCitationGate(func(repoRoot, rel, text string) ([]intent.UnresolvedCitation, error) { + cites, err := lint.UnresolvedProseCitationsInRecord(repoRoot, rel, text) + out := make([]intent.UnresolvedCitation, 0, len(cites)) + for _, c := range cites { + out = append(out, intent.UnresolvedCitation(c)) + } + return out, err + }) +} diff --git a/internal/core/reading/ingest.go b/internal/core/reading/ingest.go index aeaefaf15..fa1b9bcc9 100644 --- a/internal/core/reading/ingest.go +++ b/internal/core/reading/ingest.go @@ -602,15 +602,18 @@ func ingestUnderLock(root *os.Root, repoRoot string, req IngestRequest, res *Ing // The redactor for everything payload-derived that this invocation may now // commit, built ONCE and here: the identity is proven, so from this line on - // a refusal is recordable, and a recordable refusal is durable committed - // material. Constructing it earlier would make every payload that never - // reaches a recordable state pay for a scanner that probes the machine - // identity (iss-2609022002241168). + // a refusal is recordable, and a recorded refusal is durable committed + // material. Every refusal below is recorded through refuse() except the + // prose-citation refusal, which is returned unrecorded (see there). + // Constructing it earlier would make every payload that never reaches a + // recordable state pay for a scanner that probes the machine identity + // (iss-2609022002241168). free, degraded := newPayloadField(repoRoot) noteDegraded(res, degraded) // A definition that does not resolve refuses the run, and the refusal is - // RECORDED like every other one from this point on: the identity is proven + // RECORDED like every other one from this point on but the prose-citation + // refusal below: the identity is proven // above, so the run happened. The record states no regime, because the // regime is the definition's and this definition did not resolve — an empty // field is the honest value, and a substituted one would be the verb @@ -636,6 +639,34 @@ func ingestUnderLock(root *os.Root, repoRoot string, req IngestRequest, res *Ing return refuse(root, res, out, manifest, def, free, err) } + // The items are the host's words, bound for reading records that + // record-lint's prose_citation_resolves reads, so they are held to that gate + // here, before the sweep and the stage (iss-2609261835118276). An item + // citing a record id that names no record refuses the whole ingest + // UNRECORDED — the one refusal past the identity point that writes no + // refusal record, and no sweep runs — as the verdict ingest refuses: a + // recorded refusal would give the run an outcome, and refuseARerun would + // then turn away the same run re-worded, where the run left parked is + // ingested again once the prose describes the record rather than citing + // an id that does not exist. + // + // It still owes the rollback refuse() performs on every other refusal + // here: an earlier attempt at this run id that died between its ledger + // write and its commit marker left records the run never committed, and + // a refused run leaves no reading records. refuseARerun proved the id + // carries no outcome, so they are this run's own, and the rollback writes + // no outcome, so the re-worded run is still admitted. + if err := capture.CheckReadingCitations(capture.IngestReadingRequest{ + RepoRoot: repoRoot, Run: out.RunID, Items: items, + }); err != nil { + cause := fmt.Errorf("reading: run %s: %w", out.RunID, err) + if rbErr := rollbackThisRun(root, res, out.RunID); rbErr != nil { + return fmt.Errorf("%w (and the earlier attempt at run %s could not be rolled back: %v)", + cause, out.RunID, rbErr) + } + return cause + } + // The whole payload has validated: this is the first point at which the // committed tier may be deleted from. The sweep reports what it cleared // and what it rolled back, and whatever it did not reach stays pending — @@ -1026,7 +1057,9 @@ func runOutcome(root *os.Root, runID string) (string, error) { // refuse records a list-level refusal and returns it. It is the ONE writer of a // refusal record: every list-level refusal past the identity point routes // through here, and a refusal that returns bare instead is the defect -// iss-2608311518250688 names. +// iss-2608311518250688 names. The one deliberate exception is the +// prose-citation refusal, returned unrecorded so the run stays parked for its +// re-worded ingest; it still performs this function's rollback. // // The record is durable because the event is: a refused run is a run that // happened, and a rerun is a NEW run with a new run id, never an amendment. It diff --git a/internal/core/reading/ingest_citation_test.go b/internal/core/reading/ingest_citation_test.go new file mode 100644 index 000000000..ff422f46a --- /dev/null +++ b/internal/core/reading/ingest_citation_test.go @@ -0,0 +1,94 @@ +package reading + +import ( + "strings" + "testing" +) + +// TestAnUnresolvedCitationRefusesTheRunAndWritesNothing: an item is the host's +// words, bound for a reading record that record-lint's prose_citation_resolves +// reads, so an item citing a record id that names no record refuses the ingest +// naming the item and the id (iss-2609261835118276) — before the sweep, before +// the stage, and with no refusal record, so the run stays parked and the same +// run, re-worded, ingests. +func TestAnUnresolvedCitationRefusesTheRunAndWritesNothing(t *testing.T) { + const dangling = "iss-2609999999999999" + const orphan, orphanItem = "rdg-2608310000000031", "rdi-2608310000000032" + f := newIngestFixture(t, "detection") + f.write(".abcd/record-lint.json", []byte(`{ + "roots": [".abcd/work"], + "rules": { + "prose_citation_resolves": { + "enabled": true, + "severity": "blocker", + "record_stores": {"iss": ".abcd/work/issues", "rdi": ".abcd/work/issues/readings"} + } + } +} +`)) + rel, body := f.plantOrphan(orphan, orphanItem) + + doc := f.payload(1) + doc["items"].([]any)[0].(map[string]any)[PatternField] = "the pattern " + dangling + " names" + res, err := f.ingest(doc) + if err == nil || !strings.Contains(err.Error(), dangling) || !strings.Contains(err.Error(), "reading item 1's pattern") { + t.Fatalf("err = %v, want a refusal naming item 1's pattern and %s", err, dangling) + } + if res.RefusalPath != "" { + t.Errorf("the refusal was recorded at %s; it must write nothing", res.RefusalPath) + } + f.nothingDurable(f.runID) + if string(f.bytesAt(rel)) != string(body) || !f.exists(IngestStageDir+"/"+orphan) { + t.Error("a citation refusal swept another run's orphan") + } + + // The same run, re-worded, ingests: the refusal gave it no outcome. + f.mustIngest(f.payload(1)) +} + +// The citation refusal is returned unrecorded, but it owes the rollback every +// refusal past the identity point owes (refuse's rollbackThisRun): an earlier +// attempt at this run id that died between its ledger write and its commit +// marker left records the run never committed, and a refused run leaves no +// reading records. The rollback writes no outcome, so the same run re-worded +// still ingests. +func TestACitationRefusalRollsBackTheRunsOwnCrashedAttempt(t *testing.T) { + const dangling = "iss-2609999999999999" + f := newIngestFixture(t, "detection") + f.write(".abcd/record-lint.json", []byte(`{ + "roots": [".abcd/work"], + "rules": { + "prose_citation_resolves": { + "enabled": true, + "severity": "blocker", + "record_stores": {"iss": ".abcd/work/issues", "rdi": ".abcd/work/issues/readings"} + } + } +} +`)) + withFault(t, faultAfterLedger) + if _, err := f.ingest(f.payload(2)); err == nil { + t.Fatal("the injected fault did not stop the first attempt") + } + ingestFault = nil + if got := f.ledgerRecords(f.runID); len(got) != 2 { + t.Fatalf("the crashed attempt left %v in the ledger, want its 2 records", got) + } + + doc := f.payload(1) + doc["items"].([]any)[0].(map[string]any)[PatternField] = "the pattern " + dangling + " names" + res, err := f.ingest(doc) + if err == nil || !strings.Contains(err.Error(), dangling) { + t.Fatalf("err = %v, want a refusal naming %s", err, dangling) + } + if res.RefusalPath != "" { + t.Errorf("the refusal was recorded at %s; it must stay unrecorded", res.RefusalPath) + } + f.nothingDurableInTheLedger(f.runID) + if len(res.RolledBack) != 2 || f.exists(IngestStageDir+"/"+f.runID) { + t.Errorf("the refusal rolled back %v (stage standing: %v); it removes the earlier attempt's 2 "+ + "records and its stage and says so", res.RolledBack, f.exists(IngestStageDir+"/"+f.runID)) + } + + f.mustIngest(f.payload(1)) +} diff --git a/internal/core/relink/relink.go b/internal/core/relink/relink.go index 1a0ad475c..9c5acddd5 100644 --- a/internal/core/relink/relink.go +++ b/internal/core/relink/relink.go @@ -128,6 +128,16 @@ type Rewrite struct { // A move naming a path that is not a clean repo-relative slash path is refused // before anything is read. An error part-way through the walk returns the // rewrites already written alongside it, so a caller can report both. +// +// Every rewrite is a read-modify-write, and this package takes no lock: it +// cannot import the stores that own them (they import it). So the CALLER holds +// the ledger lock, then the intent store's lock, then the spec store's across +// the call, through intent.WithLedgerThenMintLock — intent's repointUnderLock +// for the verbs that move an intent or a spec, and capture's repointMovedIssue +// for an issue transition — or a ledger, intent or spec writer landing on a +// linking record between the read and the write is erased, and a spec close +// landing there leaves the spec in both status folders (iss-2609261254247117, +// iss-2609262143209970, iss-2609262218309668). func Repoint(repoRoot string, moves []Move) ([]Rewrite, error) { root, err := os.OpenRoot(repoRoot) if err != nil { diff --git a/internal/core/scribe/gate_test.go b/internal/core/scribe/gate_test.go new file mode 100644 index 000000000..9b9e0e8e0 --- /dev/null +++ b/internal/core/scribe/gate_test.go @@ -0,0 +1,19 @@ +package scribe + +import ( + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/lint" +) + +// init registers record-lint's prose-citation gate for this package's tests, +// as the front doors register it for every ingest they run. +func init() { + intent.SetProseCitationGate(func(repoRoot, rel, text string) ([]intent.UnresolvedCitation, error) { + cites, err := lint.UnresolvedProseCitationsInRecord(repoRoot, rel, text) + out := make([]intent.UnresolvedCitation, 0, len(cites)) + for _, c := range cites { + out = append(out, intent.UnresolvedCitation(c)) + } + return out, err + }) +} diff --git a/internal/core/spec/store.go b/internal/core/spec/store.go index 8d8a526f4..78180b069 100644 --- a/internal/core/spec/store.go +++ b/internal/core/spec/store.go @@ -4,7 +4,9 @@ import ( "errors" "fmt" "io" + "io/fs" "os" + "path" "path/filepath" "strings" "syscall" @@ -16,10 +18,17 @@ import ( "github.com/intentdriven/abcd/internal/fsutil" ) -// mintLockTimeout bounds how long Create waits for the spec-store mint lock. A -// var (not const) so a test can shorten it to exercise contention. +// mintLockTimeout bounds how long a spec writer waits for the spec store's +// lock (withStoreLock). A var (not const) so a test can shorten it to exercise +// contention. var mintLockTimeout = 5 * time.Second +// beforeStoreLock, when set, runs as withStoreLock is entered, before the store +// is opened for its lock. It is a test seam: a test removes the store there to +// stand in for a concurrent deletion landing between a writer's decision to +// lock and the lock itself. Production never sets it. +var beforeStoreLock func() + // specFamily is the spec store's id prefix, the family tag the mint splices // into every native spc id. const specFamily = "spc" @@ -184,14 +193,14 @@ func create(repoRoot, intentID string, intents []string, bundle, slug, productio if err != nil { return Spec{}, fmt.Errorf("spec: %w", err) } - // Mint and write under the exclusive mint lock: the presence check inside + // Mint and write under the store's exclusive lock: the presence check inside // mintSpecID and the write of spc-N-.md are one critical section, so // two concurrent plans in this checkout that draw the same id — the // same-second, same-suffix coincidence — cannot both write it. The filenames // differ by slug, so neither the atomic write nor a clobber guard would // notice on its own. var sp Spec - err = withMintLock(repoRoot, func() error { + err = withStoreLock(repoRoot, mintLockTimeout, createStore, func() error { store, err := Load(repoRoot) if err != nil { return err @@ -233,7 +242,7 @@ func create(repoRoot, intentID string, intents []string, bundle, slug, productio // ids and the entropy separates two minters in the same second. A candidate // already present is redrawn, never bumped: a bump would re-derive the next id // from the store's occupancy, a miniature maximum-plus-one (spc-33 ruling 2). -// Called under the mint lock so the check and the caller's write are atomic +// Called under the store's lock so the check and the caller's write are atomic // within the checkout. func mintSpecID(store Store) (string, error) { for attempt := 0; attempt < mintRetryBudget; attempt++ { @@ -248,38 +257,77 @@ func mintSpecID(store Store) (string, error) { return "", fmt.Errorf("spec: could not mint a free spc id after %d draws", mintRetryBudget) } -// withMintLock runs fn while holding an exclusive advisory lock over the spec -// store. It serializes the presence check and the write of one mint against -// concurrent abcd processes in the SAME checkout (two agent sessions, or a hook -// firing beside a manual command), which is the one clash — same second, same -// suffix, one directory — that time and entropy leave to the store to arbitrate -// (spc-33 ruling 2). It cannot see a sibling checkout and does not need to: the -// mint reads no maximum, so two checkouts never share the state a lock would -// have to protect. It flocks the specs/ directory file descriptor itself, so no -// lock artifact is left in the committed record tree. O_NOFOLLOW refuses a -// symlinked specs/. -func withMintLock(repoRoot string, fn func() error) error { +// ErrStoreLockBusy is the spec store's lock not granted within a writer's +// budget. The three-lock acquisition in the intent package retries on it, with +// every earlier lock released between attempts. +var ErrStoreLockBusy = errors.New("spec: could not acquire the spec store's lock") + +// withStoreLock runs fn while holding the spec store's one lock, an exclusive +// advisory flock on the specs/ directory itself, so no lock artifact is left in +// the committed record tree; O_NOFOLLOW refuses a symlinked specs/. It creates +// the store when absent, which only the mint may do. +// +// Every writer of a spec record takes it (iss-2609262218309668): the mint +// (Create and its siblings), Close, Discard, and — through WithStoreLock, from +// the intent package's three-lock acquisition — every link repoint and the +// lifeboat embark, the writers outside this package that rewrite or create a +// spec. Unlocked, a close renaming a spec open/ -> closed/ while a repoint that +// had read it at open/ wrote it back there left one record in both status +// folders, and an edit landing between a repoint's read and its write was +// erased. The mint's own clash — two plans in this checkout drawing the same +// id in the same second (spc-33 ruling 2) — is one more thing it arbitrates. +// It cannot see a sibling checkout and does not need to: the mint reads no +// maximum, so two checkouts never share the state it protects. +// +// Lock order: the issue ledger's lock, THEN the intent store's, THEN this one. +// It is the innermost of the three. Plan mints its spec inside the intent +// store's lock, and the three-lock acquisition takes it last; close, discard +// and a remainder's mint take it alone. This package imports neither the +// ledger's package nor the intent store's, so nothing run under this lock can +// request an earlier one, and it may never be taken the other way round. +// +// It is NOT reentrant — a second flock on another descriptor in the same +// process blocks until the budget runs out — so a caller holding it must not +// call a writer of this package, every one of which takes it. +// +// mode says what an absent store means. createStore plants it, which only the +// mint and the three-lock acquisition's path may do. storeMustExist refuses +// with errStoreAbsent instead, and the open that takes the lock is the check: +// a writer with nothing to act on in an absent store (Close, Discard) decides +// on the store the lock actually holds, so a store removed after an earlier +// look is never re-planted empty (iss-2609262342345159). +func withStoreLock(repoRoot string, timeout time.Duration, mode storeLockMode, fn func() error) error { + if beforeStoreLock != nil { + beforeStoreLock() + } specsDir := filepath.Join(repoRoot, SpecsRelDir) - if err := ensureDir(specsDir, SpecsRelDir); err != nil { - return err + if mode == createStore { + if err := ensureDir(specsDir, SpecsRelDir); err != nil { + return err + } + } else if di, err := os.Lstat(specsDir); err == nil && di.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("spec: %s is a symlink (refusing to follow)", SpecsRelDir) } fd, err := syscall.Open(specsDir, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + if mode == storeMustExist && errors.Is(err, syscall.ENOENT) { + return errStoreAbsent + } if err != nil { - return fmt.Errorf("spec: opening mint lock on %s: %w", SpecsRelDir, err) + return fmt.Errorf("spec: opening the store lock on %s: %w", SpecsRelDir, err) } defer syscall.Close(fd) - deadline := time.Now().Add(mintLockTimeout) + deadline := time.Now().Add(timeout) for { lockErr := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) if lockErr == nil { break } if lockErr != syscall.EWOULDBLOCK { - return fmt.Errorf("spec: acquiring mint lock: %w", lockErr) + return fmt.Errorf("spec: acquiring the store lock: %w", lockErr) } if time.Now().After(deadline) { - return fmt.Errorf("spec: could not acquire mint lock within %s", mintLockTimeout) + return fmt.Errorf("%w within %s", ErrStoreLockBusy, timeout) } time.Sleep(10 * time.Millisecond) } @@ -288,14 +336,81 @@ func withMintLock(repoRoot string, fn func() error) error { return fn() } +// storeLockMode says what withStoreLock does with an absent store. +type storeLockMode int + +const ( + // createStore plants an absent store and locks it. + createStore storeLockMode = iota + // storeMustExist refuses an absent store with errStoreAbsent and plants + // nothing. + storeMustExist +) + +// errStoreAbsent is withStoreLock's refusal, in storeMustExist mode, of a tree +// whose spec store is absent when the lock is taken; fn has not run. +var errStoreAbsent = errors.New("spec: the spec store is absent") + +// WithStoreLock runs fn holding the spec store's lock — the one every spec +// writer takes, not a second one — for a caller outside this package that +// writes a spec record. A caller that also writes ledger or intent records +// takes it through intent.WithLedgerThenMintLock, which takes the three in +// order and never holds an earlier lock while it waits for a later one. +// +// A tree with no spec store runs fn WITHOUT the lock: taking it creates the +// store, and a verb that writes no spec must not plant an empty one. With no +// store there is no spec record for fn to race. +func WithStoreLock(repoRoot string, fn func() error) error { + return WithStoreLockWithin(repoRoot, mintLockTimeout, fn) +} + +// WithStoreLockWithin is WithStoreLock with its own acquisition budget; a lock +// not granted within it is ErrStoreLockBusy, and fn has not run. +func WithStoreLockWithin(repoRoot string, timeout time.Duration, fn func() error) error { + if !storeExists(repoRoot) { + return fn() + } + return withStoreLock(repoRoot, timeout, createStore, fn) +} + +// storeExists reports whether the tree has a spec store. Only an absent one +// reads as false: any other Lstat failure is left for the lock's own open to +// report. +func storeExists(repoRoot string) bool { + _, err := os.Lstat(filepath.Join(repoRoot, SpecsRelDir)) + return !errors.Is(err, fs.ErrNotExist) +} + // Close moves a spec file open/ -> closed/ via os.Rename (atomic on one // filesystem) and returns the updated Spec. It fails closed if the spec is // missing or already closed. The linked intent is deliberately left untouched: -// moving it is a later reconcile concern that consumes Spec.Intent. +// moving it is a later reconcile concern that consumes Spec.Intent. The read +// and the rename are one critical section under the store's lock, so a writer +// holding it — a repoint between its read and its write — finishes before the +// spec moves. A tree with no spec store holds no spec to close, so the lock is +// taken in storeMustExist mode: the id is refused as not found and nothing is +// planted, even when the store is removed a moment before the lock. func Close(repoRoot, specID string) (Spec, error) { if !recordid.ValidSpecID(specID) { return Spec{}, fmt.Errorf("spec: id %q must match ^spc-[0-9]+$", specID) } + var sp Spec + err := withStoreLock(repoRoot, mintLockTimeout, storeMustExist, func() error { + var err error + sp, err = closeLocked(repoRoot, specID) + return err + }) + if errors.Is(err, errStoreAbsent) { + return Spec{}, fmt.Errorf("spec: %s not found", specID) + } + if err != nil { + return Spec{}, err + } + return sp, nil +} + +// closeLocked is Close's body, run under the store's lock. +func closeLocked(repoRoot, specID string) (Spec, error) { store, err := Load(repoRoot) if err != nil { return Spec{}, err @@ -313,12 +428,12 @@ func Close(repoRoot, specID string) (Spec, error) { return Spec{}, err } dstRel := filepath.Join(SpecsRelDir, StatusClosed, name) - // Best-effort clobber guard: os.Rename would silently overwrite the destination, - // so refuse when it already exists. This Lstat→Rename check is racy against a - // file appearing in the window — accepted under the trusted-worktree model (only - // the developer/agent mutates the store; there is no concurrent adversary), where - // the atomic same-filesystem rename is preferred over a non-atomic no-clobber - // link+remove that a crash could leave half-done. + // Clobber guard: os.Rename would silently overwrite the destination, so + // refuse when it already exists. Every abcd writer of the store holds the + // store's lock across this check and the rename; a hand edit does not, and + // under the trusted-worktree model (only the developer/agent mutates the + // store) the atomic same-filesystem rename is preferred over a non-atomic + // no-clobber link+remove that a crash could leave half-done. if _, err := os.Lstat(filepath.Join(closedDir, name)); err == nil { return Spec{}, fmt.Errorf("spec: refusing to overwrite existing %s", dstRel) } @@ -330,6 +445,31 @@ func Close(repoRoot, specID string) (Spec, error) { return sp, nil } +// Discard takes back a spec a refused operation minted a moment ago, under the +// store's lock, so the removal cannot interleave with another writer's read and +// write of the same file — a repoint writing it back would resurrect it. Only a +// spec in open/, named as the mint names one, is removed; any other path is +// refused before anything is touched. A spec already gone is not an error, and +// a tree with no spec store has none to remove: the lock is taken in +// storeMustExist mode, so nothing is planted there. +func Discard(repoRoot string, sp Spec) error { + rel := filepath.ToSlash(sp.Path) + name := path.Base(rel) + if rel != path.Join(filepath.ToSlash(SpecsRelDir), StatusOpen, name) || !specFileRe.MatchString(name) { + return fmt.Errorf("spec: refusing to discard %q, which is not a spec in %s", sp.Path, filepath.Join(SpecsRelDir, StatusOpen)) + } + err := withStoreLock(repoRoot, mintLockTimeout, storeMustExist, func() error { + if err := os.Remove(filepath.Join(repoRoot, filepath.FromSlash(rel))); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("spec: discarding %s: %w", sp.Path, err) + } + return nil + }) + if errors.Is(err, errStoreAbsent) { + return nil + } + return err +} + // readRepoFile reads a repo file behind the trust-boundary guards. It opens ONCE // with O_NOFOLLOW (refuse a symlinked leaf) and O_NONBLOCK (a FIFO/device leaf // returns immediately instead of blocking the open), then validates the SAME file diff --git a/internal/core/spec/storelock_test.go b/internal/core/spec/storelock_test.go new file mode 100644 index 000000000..307c7c077 --- /dev/null +++ b/internal/core/spec/storelock_test.go @@ -0,0 +1,245 @@ +package spec + +import ( + "errors" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// holdStoreLock takes the spec store's lock on another goroutine and returns +// once it is held, with the function that releases it and the channel the +// holder's result arrives on. +func holdStoreLock(t *testing.T, root string) (release func(), holder chan error) { + t.Helper() + held, rel := make(chan struct{}), make(chan struct{}) + holder = make(chan error, 1) + go func() { + holder <- WithStoreLock(root, func() error { + close(held) + <-rel + return nil + }) + }() + <-held + return func() { close(rel) }, holder +} + +// landsWithin starts fn and reports whether it finished within d, and the +// channel its result arrives on. +func landsWithin(d time.Duration, fn func() error) (bool, chan error) { + done := make(chan error, 1) + go func() { done <- fn() }() + select { + case err := <-done: + done <- err + return true, done + case <-time.After(d): + return false, done + } +} + +// Every writer of the spec store takes its one lock (iss-2609262218309668): +// with the lock held elsewhere, a close and a discard each wait for it rather +// than moving or removing a spec another writer is between reading and +// writing, and each completes once it is released. +func TestEverySpecWriterWaitsForTheStoreLock(t *testing.T) { + for _, tc := range []struct { + name string + write func(root string, sp Spec) error + gone string // the bucket the spec must have left + }{ + {"close", func(root string, sp Spec) error { _, err := Close(root, sp.ID); return err }, StatusOpen}, + {"discard", func(root string, sp Spec) error { return Discard(root, sp) }, StatusOpen}, + {"mint", func(root string, _ Spec) error { _, err := Create(root, "itd-8", "another", ""); return err }, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + sp, err := Create(root, "itd-9", "my-feature", "") + if err != nil { + t.Fatal(err) + } + release, holder := holdStoreLock(t, root) + landed, done := landsWithin(300*time.Millisecond, func() error { return tc.write(root, sp) }) + release() + if err := <-holder; err != nil { + t.Fatal(err) + } + if err := <-done; err != nil { + t.Fatalf("%s after the store lock was released: %v", tc.name, err) + } + if landed { + t.Errorf("%s finished while another holder had the spec store's lock: it wrote without taking it", tc.name) + } + if tc.gone != "" { + if _, err := os.Lstat(filepath.Join(root, SpecsRelDir, tc.gone, filepath.Base(sp.Path))); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s left the spec in %s/ (err %v)", tc.name, tc.gone, err) + } + } + }) + } +} + +// Discard takes back only a spec in open/ under the store's own directory: a +// path it did not mint is refused before anything is removed. +func TestDiscardRefusesAPathOutsideOpen(t *testing.T) { + root := t.TempDir() + sp, err := Create(root, "itd-9", "my-feature", "") + if err != nil { + t.Fatal(err) + } + for _, p := range []string{ + filepath.Join(SpecsRelDir, StatusClosed, filepath.Base(sp.Path)), + filepath.Join(SpecsRelDir, StatusOpen, "..", "..", "intents", "x.md"), + "README.md", + } { + bad := sp + bad.Path = p + if err := Discard(root, bad); err == nil { + t.Errorf("Discard must refuse %q", p) + } + } + if _, err := os.Lstat(filepath.Join(root, sp.Path)); err != nil { + t.Errorf("a refused discard removed the minted spec: %v", err) + } +} + +// WithStoreLock on a tree with no spec store runs fn without the lock and +// plants no store: taking the lock would create one, and with no store there is +// no spec record for fn to race. +func TestWithStoreLockPlantsNoStore(t *testing.T) { + root := t.TempDir() + ran := false + if err := WithStoreLock(root, func() error { ran = true; return nil }); err != nil || !ran { + t.Fatalf("fn must run: ran=%v err=%v", ran, err) + } + if _, err := os.Lstat(filepath.Join(root, SpecsRelDir)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("WithStoreLock planted a spec store (err %v)", err) + } +} + +// A holder past its budget is ErrStoreLockBusy, the sentinel the three-lock +// acquisition in the intent package retries on, and fn has not run. +func TestWithStoreLockWithinNamesItsBusyLock(t *testing.T) { + root := t.TempDir() + if _, err := Create(root, "itd-9", "my-feature", ""); err != nil { + t.Fatal(err) + } + release, holder := holdStoreLock(t, root) + ran := false + err := WithStoreLockWithin(root, 30*time.Millisecond, func() error { ran = true; return nil }) + release() + if herr := <-holder; herr != nil { + t.Fatal(herr) + } + if !errors.Is(err, ErrStoreLockBusy) || ran { + t.Errorf("a busy store lock must be ErrStoreLockBusy with fn not run: ran=%v err=%v", ran, err) + } +} + +// The spec store's lock is the innermost of the record-store locks (ledger, +// then intent, then spec). This package imports neither the ledger's nor the +// intent store's package, so nothing it runs under its own lock can request an +// earlier one: the order cannot be inverted from here. Were it to import one, +// the order would rest on every such call site instead of on the import graph. +func TestTheSpecStoreImportsNoEarlierLock(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + earlier := []string{"internal/core/capture", "internal/core/intent", "internal/core/lifeboat"} + fset := token.NewFileSet() + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".go") || strings.HasSuffix(e.Name(), "_test.go") { + continue + } + f, err := parser.ParseFile(fset, e.Name(), nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, imp := range f.Imports { + p, _ := strconv.Unquote(imp.Path.Value) + for _, bad := range earlier { + if strings.HasSuffix(p, bad) { + t.Errorf("%s imports %s, which holds an earlier lock in the order", e.Name(), p) + } + } + } + } +} + +// Close and Discard on a tree with no spec store have nothing to move or +// remove, so they must not create the store to lock it: Close refuses the +// id as not found and Discard succeeds, and neither plants +// .abcd/development/specs/ (iss-2609262342345159). +func TestAWriterOnATreeWithNoSpecStorePlantsNone(t *testing.T) { + t.Run("close", func(t *testing.T) { + root := t.TempDir() + if _, err := Close(root, "spc-1"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("Close with no store must refuse the id as not found, got %v", err) + } + if _, err := os.Lstat(filepath.Join(root, SpecsRelDir)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Close planted a spec store (err %v)", err) + } + }) + t.Run("discard", func(t *testing.T) { + root := t.TempDir() + sp := Spec{Path: filepath.Join(SpecsRelDir, StatusOpen, "spc-1-my-feature.md")} + if err := Discard(root, sp); err != nil { + t.Errorf("Discard with no store has nothing to remove and must succeed, got %v", err) + } + if _, err := os.Lstat(filepath.Join(root, SpecsRelDir)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Discard planted a spec store (err %v)", err) + } + }) +} + +// A store removed after Close or Discard decided to lock it, and before the +// lock is taken, must not be re-planted empty by the lock: Close refuses the +// id as not found and Discard succeeds, and neither leaves +// .abcd/development/specs/ behind (iss-2609262342345159, the review's +// remove-between-check-and-lock note). beforeStoreLock stands in for the +// concurrent deletion. +func TestAStoreRemovedBeforeTheLockIsNotReplanted(t *testing.T) { + removeStoreAtTheLock := func(t *testing.T, root string) { + t.Helper() + beforeStoreLock = func() { + if err := os.RemoveAll(filepath.Join(root, SpecsRelDir)); err != nil { + t.Fatalf("removing the store at the lock: %v", err) + } + } + t.Cleanup(func() { beforeStoreLock = nil }) + } + t.Run("close", func(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, SpecsRelDir, StatusOpen), 0o755); err != nil { + t.Fatal(err) + } + removeStoreAtTheLock(t, root) + if _, err := Close(root, "spc-1"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("Close on a store removed before the lock must refuse the id as not found, got %v", err) + } + if _, err := os.Lstat(filepath.Join(root, SpecsRelDir)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Close re-planted the removed spec store (err %v)", err) + } + }) + t.Run("discard", func(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, SpecsRelDir, StatusOpen), 0o755); err != nil { + t.Fatal(err) + } + removeStoreAtTheLock(t, root) + sp := Spec{Path: filepath.Join(SpecsRelDir, StatusOpen, "spc-1-my-feature.md")} + if err := Discard(root, sp); err != nil { + t.Errorf("Discard on a store removed before the lock has nothing to remove and must succeed, got %v", err) + } + if _, err := os.Lstat(filepath.Join(root, SpecsRelDir)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("Discard re-planted the removed spec store (err %v)", err) + } + }) +} diff --git a/internal/fsutil/flock.go b/internal/fsutil/flock.go index c6f2f78ef..08ead81c7 100644 --- a/internal/fsutil/flock.go +++ b/internal/fsutil/flock.go @@ -114,7 +114,7 @@ func openLockFd(lockPath string) (int, error) { // budget: a revalidation retry spends one deadline across more than one // acquisition, and the slice left for the last one is not what was asked for. func acquireFlock(fd int, deadline time.Time, timeout time.Duration) error { - backoff := 5 * time.Millisecond + backoff := lockPollStart for { err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB) if err == nil { @@ -131,12 +131,26 @@ func acquireFlock(fd int, deadline time.Time, timeout time.Duration) error { backoff = remaining } time.Sleep(backoff) - if backoff < 100*time.Millisecond { - backoff *= 2 - } + backoff = nextLockPoll(backoff) } } +// LockPollCeiling is the longest a waiter on a WithFileLock lock sleeps +// between two attempts: the poll starts at lockPollStart and doubles up to it, +// never past it. A caller that frees a lock for a window so that such a waiter +// is sure to find it free — intent's pair acquisition, resting between attempts +// — keeps that window longer than this, so it is derived from it rather than +// restated (iss-2609262257227538). +const LockPollCeiling = 100 * time.Millisecond + +// lockPollStart is the first interval of the poll. +const lockPollStart = 5 * time.Millisecond + +// nextLockPoll is the interval after b: doubled, and held at LockPollCeiling. +func nextLockPoll(b time.Duration) time.Duration { + return min(2*b, LockPollCeiling) +} + // WithFileLockIn is WithFileLock with the lock file resolved INSIDE root: rel is // a slash path relative to it, so an ancestor swapped for a symlink cannot carry // the lock out of the containment scope between a caller's checks and the open. diff --git a/internal/fsutil/flock_test.go b/internal/fsutil/flock_test.go index e8532c518..83d52a04f 100644 --- a/internal/fsutil/flock_test.go +++ b/internal/fsutil/flock_test.go @@ -133,3 +133,22 @@ func TestWithFileLockContentionNamesTheCallersTimeout(t *testing.T) { t.Fatalf("contention error %q does not name the caller's %s timeout", err, timeout) } } + +// The poll interval a lock waiter sleeps never exceeds LockPollCeiling and +// reaches it: the ceiling is what a caller freeing the lock for a window +// derives that window from, so a ceiling the loop can overshoot — the doubling +// once ran past it, 80ms to 160ms — breaks every guarantee built on it +// (iss-2609262257227538). +func TestTheLockPollNeverSleepsPastItsCeiling(t *testing.T) { + b, reached := lockPollStart, false + for i := 0; i < 64; i++ { + if b > LockPollCeiling { + t.Fatalf("the poll sleeps %s, past its ceiling of %s", b, LockPollCeiling) + } + reached = reached || b == LockPollCeiling + b = nextLockPoll(b) + } + if !reached { + t.Errorf("the poll never reaches its ceiling of %s", LockPollCeiling) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 0272b11c5..4854de1c5 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -2764,8 +2764,19 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { route = nil } return render(cmd.OutOrStdout(), *asJSON, withRequest(res, route), func(w io.Writer) { - fmt.Fprintf(w, "abcd intent audit — %s %s (receipt %s)\n request: %s\n", - res.IntentID, res.Status, res.ReceiptID, res.RequestPath) + fmt.Fprintf(w, "abcd intent audit — %s %s (receipt %s)\n", res.IntentID, res.Status, res.ReceiptID) + // The status is the receipt's state and the request line is the + // act: an owed receipt's request is rewritten on every re-emit, + // and a terminal one's is not written at all (iss-2609190337598356). + switch { + case !res.RequestWritten: + fmt.Fprintf(w, " no request written: the review is %s\n", + strings.ReplaceAll(strings.TrimPrefix(res.Status, "already_"), "_", "-")) + case res.Status == "already_owed": + fmt.Fprintf(w, " request rewritten: %s\n", res.RequestPath) + default: + fmt.Fprintf(w, " request: %s\n", res.RequestPath) + } renderRequestLine(w, route) }) }, @@ -2833,7 +2844,7 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { }) }, } - ingestCmd.Flags().StringVar(&verdictJSON, "verdict-json", "", "path to the intent-audit verdict JSON") + ingestCmd.Flags().StringVar(&verdictJSON, "verdict-json", "", "path to the intent-audit verdict JSON, in the shape the Verdict shape section of its review request states") ingestRoute = addRouteFlag(ingestCmd, auditAgent) auditCmd.AddCommand(ingestCmd) auditCmd.Flags().BoolVar(&issueDrift, "issue-drift", false, diff --git a/internal/surface/cli/intent_audit_conditions_test.go b/internal/surface/cli/intent_audit_conditions_test.go index 62f4891c2..738c77a74 100644 --- a/internal/surface/cli/intent_audit_conditions_test.go +++ b/internal/surface/cli/intent_audit_conditions_test.go @@ -193,3 +193,101 @@ func TestIntentAuditDeadLetterRendersTheUntestedSplit(t *testing.T) { t.Fatalf("the dead-letter render must report the untested split the JSON carries:\n%s", text) } } + +// TestIntentAuditIngestRefusesAnUnresolvableCitation is the front door's half of +// iss-2609231036448320: the CLI registers record-lint's prose-citation gate with +// the ingest, so a verdict whose prose cites a record that does not exist is +// refused, naming the id, with nothing written — in a repository whose +// record-lint arms the rule over the intent store. +func TestIntentAuditIngestRefusesAnUnresolvableCitation(t *testing.T) { + root, vp := conditionedRepo(t) + cfg := `{"roots": [".abcd/development"], "rules": {"prose_citation_resolves": {"enabled": true, "severity": "blocker", + "record_stores": {"itd": ".abcd/development/intents"}}}}` + "\n" + if err := os.WriteFile(filepath.Join(root, ".abcd", "record-lint.json"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(vp) + if err != nil { + t.Fatal(err) + } + const dangling = "spc-2609999999999999" + cites := writeVerdict(t, strings.Replace(string(raw), "the stub is parked", "the stub is parked, as "+dangling+" asked", 1)) + path := filepath.Join(root, ".abcd", "development", "intents", "shipped", "itd-10-alpha.md") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + out, err := runCLIErr(t, "intent", "audit", "ingest", "--verdict-json", cites) + if err == nil || !strings.Contains(err.Error()+string(out), dangling) { + t.Fatalf("ingest = %v\n%s\nwant a refusal naming %s", err, out, dangling) + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatalf("a refused ingest changed the record:\n%s", after) + } +} + +// TestIntentAuditReEmitNamesTheRequestItWrote is iss-2609190337598356 at the +// front door: a re-emit on an owed receipt says it rewrote the request, in both +// renders, and a re-emit on an ingested receipt names no request, because it +// wrote none. +func TestIntentAuditReEmitNamesTheRequestItWrote(t *testing.T) { + root, vp := conditionedRepo(t) + var again struct { + Status string `json:"status"` + RequestPath string `json:"request_path"` + RequestWritten bool `json:"request_written"` + } + if err := json.Unmarshal(runCLI(t, "intent", "audit", "itd-10", "--json"), &again); err != nil { + t.Fatal(err) + } + if again.Status != "already_owed" || !again.RequestWritten || again.RequestPath == "" { + t.Fatalf("an owed re-emit = %+v, want already_owed naming the request it wrote", again) + } + if text := string(runCLI(t, "intent", "audit", "itd-10")); !strings.Contains(text, "request rewritten: "+again.RequestPath) { + t.Fatalf("the owed re-emit's render does not say it rewrote the request:\n%s", text) + } + + runCLI(t, "intent", "audit", "ingest", "--verdict-json", vp) + text := string(runCLI(t, "intent", "audit", "itd-10")) + if !strings.Contains(text, "already_ingested") || !strings.Contains(text, "no request written: the review is ingested") || + strings.Contains(text, ".request.md") { + t.Fatalf("an ingested re-emit's render must name no request:\n%s", text) + } + _ = root +} + +// TestIntentAuditDeadLetterJSONRecordsNoVerdict is iss-2609190337545165 at the +// front door: a quarantined verdict's JSON says it recorded a quarantine, +// carries no acceptance rollup, and states the untested split the record holds. +func TestIntentAuditDeadLetterJSONRecordsNoVerdict(t *testing.T) { + _, vp := conditionedRepo(t) + body, err := os.ReadFile(vp) + if err != nil { + t.Fatal(err) + } + bad := strings.Replace(string(body), `"disposition": "narrowed"`, `"disposition": "not-a-disposition"`, 1) + if err := os.WriteFile(vp, []byte(bad), 0o644); err != nil { + t.Fatal(err) + } + var res map[string]any + if err := json.Unmarshal(runCLI(t, "intent", "audit", "ingest", "--verdict-json", vp, "--json"), &res); err != nil { + t.Fatal(err) + } + if res["status"] != "dead_letter" || res["recorded"] != "quarantine" || res["conditions_untested"] != float64(1) { + t.Fatalf("dead-letter JSON = %v, want a recorded quarantine stating 1 condition untested", res) + } + for _, k := range []string{"criteria", "met", "conditions", "untested"} { + if _, ok := res[k]; ok { + t.Errorf("dead-letter JSON carries the rollup member %q: %v", k, res) + } + } +} + +// TestIntentAuditIngestHelpNamesTheVerdictShape is iss-2609181121305984's other +// half: the ingest's help names where the shape it decodes is stated. +func TestIntentAuditIngestHelpNamesTheVerdictShape(t *testing.T) { + intentTestRepo(t) + if help := string(runCLI(t, "intent", "audit", "ingest", "--help")); !strings.Contains(help, "Verdict shape") { + t.Fatalf("the ingest help does not say where the verdict shape is stated:\n%s", help) + } +} diff --git a/internal/surface/cli/issuereader.go b/internal/surface/cli/issuereader.go index 866c218bd..ff88b8b2d 100644 --- a/internal/surface/cli/issuereader.go +++ b/internal/surface/cli/issuereader.go @@ -2,6 +2,7 @@ package cli import ( "github.com/intentdriven/abcd/internal/core/capture" + "github.com/intentdriven/abcd/internal/core/intent" "github.com/intentdriven/abcd/internal/core/lint" "github.com/intentdriven/abcd/internal/core/site" ) @@ -9,7 +10,24 @@ import ( // init registers the issue ledger's reader and the site renderer's body check // with the lint for every lint the CLI runs (`abcd lint docs`, `abcd lint`), so a config arming record_schema over an // issue store gets the reader-parity and body legs the record-lint gate runs. +// It registers record-lint's prose-citation gate the same way, once, for every +// ingest that copies host prose into a record: the intent audit's verdict +// ingest, and the consistency and reading ingests in the ledger, which ask it +// through intent.UnresolvedProseCitations. Each is held to the gate the record +// it writes must pass, and each refuses when no gate is registered. func init() { lint.SetIssueReader(capture.ReadRefusal) lint.SetRecordBodyCheck(site.CheckRecordBody) + intent.SetProseCitationGate(proseCitationGate) +} + +// proseCitationGate is lint.UnresolvedProseCitationsInRecord in the intent +// package's vocabulary. +func proseCitationGate(repoRoot, rel, text string) ([]intent.UnresolvedCitation, error) { + cites, err := lint.UnresolvedProseCitationsInRecord(repoRoot, rel, text) + out := make([]intent.UnresolvedCitation, 0, len(cites)) + for _, c := range cites { + out = append(out, intent.UnresolvedCitation(c)) + } + return out, err } diff --git a/internal/termsafe/prose.go b/internal/termsafe/prose.go index 214c627dc..bac6d2d9f 100644 --- a/internal/termsafe/prose.go +++ b/internal/termsafe/prose.go @@ -45,14 +45,15 @@ package termsafe // below — the tag rule defends a RENDER, and a renderer parses no HTML inside a // span, while the comment delimiters defend a GATE that does not read CommonMark // at all. The intent audit parks its review state as -// `` lines and finds them with a plain -// regex over the record's bytes, so backticks around a marker mean nothing to it: -// exempting spans let an untrusted verdict field write a WORKING marker into a -// committed intent record, claiming another intent's outstanding receipt was -// already INGESTED and turning that receipt's genuine review into a silent -// no-op. A gate that cannot see a code span cannot be given a code-span -// exemption. (That matcher is now line-anchored too — second defence, not a -// substitute for this one: it is still a byte pattern and not a grammar.) +// `` lines and finds them with a +// whole-line pattern over the lines mdrecord's fence-and-comment mask leaves +// live, which knows no inline code span, so backticks around a marker mean +// nothing to it: exempting spans let an untrusted verdict field write a WORKING +// marker into a committed intent record, claiming another intent's outstanding +// receipt was already INGESTED and turning that receipt's genuine review into a +// silent no-op. A gate that cannot see a code span cannot be given a code-span +// exemption. (That reader's line anchor and its mask are the second defence, +// not a substitute for this one.) // // THE INVARIANT THE EXEMPTION RESTS ON: a cleaned field is parsed as CommonMark // as the exact string it was cleaned as. The cleaner decides what is sheltered diff --git a/scripts/check-issue-resolution-cases.sh b/scripts/check-issue-resolution-cases.sh index f3b8b8f1a..96e6a8c7b 100755 --- a/scripts/check-issue-resolution-cases.sh +++ b/scripts/check-issue-resolution-cases.sh @@ -287,6 +287,59 @@ expect_refusal_naming "$d" "RS001 on a record terminal before divergence says to expect_refusal_not_naming "$d" "RS001 on a record terminal before divergence does not prescribe a rebase" \ "[Rr]ebase" -- commits main HEAD +# --- RS001 in the merge queue: a competitor's landing, not pre-divergence ----- +# +# iss-2609091433422134. The queue checks the would-be merge: its head is a merge +# of the entry's base with the branch, so the base is an ANCESTOR of the head. +# A competitor that resolved the same record while this entry waited left it +# terminal at the base, so it enters nothing across the range and the trailer is +# refused — the right verdict. The diagnosis used to probe head..base for the +# base-side commit that placed the record, and in the queue that walk is always +# empty, so every such refusal read as "already sat in resolved/ before this +# branch diverged": pre-divergence history that never happened, and an author +# told to drop a trailer that was correct when written. The refusal must name +# the competitor's landing, and a record genuinely terminal before the branch +# was cut must keep the message it has. +queue_merge() { + local d="$1" + git -C "$d" checkout -q -b queue main + git -C "$d" merge -q --no-ff --no-edit work +} +d="$(newrepo rs001-queue-collision)" +resolve_record "$d" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something + +Resolves: iss-999" +git -C "$d" checkout -q main +git -C "$d" mv "$ISS_DIR/open/iss-999-a-fixture.md" "$ISS_DIR/resolved/iss-999-a-fixture.md" +git -C "$d" add -A +git -C "$d" commit -qm "fix: a competing resolution that landed first" +queue_merge "$d" +expect_refusal_naming "$d" "RS001 in the queue names the competitor's landing" \ + "already sits in $ISS_DIR/resolved/ at main, placed there by .*competing resolution that landed first.*after this branch diverged.*while this change waited" -- commits main HEAD +expect_refusal_not_naming "$d" "RS001 in the queue does not call a competitor's landing pre-divergence history" \ + "before this branch diverged" -- commits main HEAD + +d="$(newrepo rs001-queue-terminal-before-divergence)" +git -C "$d" checkout -q main +resolve_record "$d" +git -C "$d" add -A +git -C "$d" commit -qm "chore: resolve a stale issue" +git -C "$d" checkout -q -B work main +echo "touched" >>"$d/README.md" +git -C "$d" add -A +git -C "$d" commit -qm "fix: something else + +Resolves: iss-999" +git -C "$d" checkout -q main +echo "unrelated" >"$d/unrelated.txt" +git -C "$d" add -A +git -C "$d" commit -qm "chore: unrelated base-side commit" +queue_merge "$d" +expect_refusal_naming "$d" "RS001 in the queue on a record terminal before divergence says to drop the trailer" \ + "already sat in $ISS_DIR/resolved/ before this branch diverged from main.*[Dd]rop the trailer" -- commits main HEAD + # A trailer naming a record the head tree does not hold at all, while the base # does: the branch predates the record (a cherry-pick from main onto a stale # branch produces exactly this). The rebase brings the record; the message must @@ -921,6 +974,24 @@ git -C "$d" checkout -q work expect_refusal_naming "$d" "RS005 on a stale branch names the base-side ship and a rebase" \ "itd-7 already sits in $INT_DIR/shipped/ at main .*squash of work.*[Rr]ebase onto main" -- commits main HEAD +# RS005's twin of the queue collision: a competitor shipped the intent while +# this entry waited, so the base is an ancestor of the head and holds it shipped. +d="$(newrepo_intents rs005-queue-collision)" +ship_intent "$d" 7 +git -C "$d" add -A +git -C "$d" commit -qm "feat: build the thing + +Delivers: itd-7" +git -C "$d" checkout -q main +ship_intent "$d" 7 +git -C "$d" add -A +git -C "$d" commit -qm "feat: a competing delivery that landed first" +queue_merge "$d" +expect_refusal_naming "$d" "RS005 in the queue names the competitor's landing" \ + "itd-7 already sits in $INT_DIR/shipped/ at main, placed there by .*competing delivery that landed first.*after this branch diverged.*while this change waited" -- commits main HEAD +expect_refusal_not_naming "$d" "RS005 in the queue does not call a competitor's landing pre-divergence history" \ + "before this branch diverged" -- commits main HEAD + d="$(newrepo_intents rs005-absent-here)" git -C "$d" checkout -q main intent_fixture "$d" planned 3 spc-3 diff --git a/scripts/check-issue-resolution.sh b/scripts/check-issue-resolution.sh index 8be0b15da..223215cb5 100755 --- a/scripts/check-issue-resolution.sh +++ b/scripts/check-issue-resolution.sh @@ -27,7 +27,10 @@ # The refusal names the shape it can prove (iss-2609012023256534): a # record already terminal at the base — the stale-branch shape, where a # rebase is the remedy and "resolve it" is not — is told apart from a -# record left open, one the head tree lacks, and an id with no record. +# record left open, one the head tree lacks, and an id with no record; +# and a record a competitor made terminal while a merge-queue entry +# waited is told apart from one terminal before the branch was cut +# (iss-2609091433422134). # # RS002 A resolved_by.commit sha ADDED in the range must name a commit that # exists and is reachable from the head being pushed. The --commit flag @@ -288,6 +291,23 @@ frontmatter_commit() { grep -oE '[0-9a-f]{7,64}' | head -1 || true } +# landed_while_waiting prints the base-side commit (" ") that put +# path where base holds it AFTER the branch carrying sha diverged from base, in +# the one shape the stale-branch probe cannot see: base an ANCESTOR of head. A +# merge-queue entry is that shape — its head is the would-be merge of the +# entry's base with the branch — so head..base is empty by construction, and a +# competitor that resolved (or shipped) the same record while the entry waited +# read as history from before the branch was cut (iss-2609091433422134). The +# walk that sees the landing starts at sha's own fork point. Prints nothing when +# base is not an ancestor of head, where the head..base probe already answers, +# or when the record already sat there at the fork point. +landed_while_waiting() { + local sha="$1" base="$2" head="$3" path="$4" fork + git merge-base --is-ancestor "$base" "$head" 2>/dev/null || return 0 + fork="$(git merge-base "$sha" "$base" 2>/dev/null)" || return 0 + git log -n1 --format='%h %s' "$fork".."$base" -- "$path" 2>/dev/null || true +} + # reachable reports whether sha names a real commit that ref can see. A sha that # does not resolve at all and one that resolves but is unreachable are distinct # faults, so they are reported separately rather than folded into "bad sha". @@ -433,9 +453,12 @@ check_delivery() { if [ "$base_bucket" = shipped ]; then # The stale-branch split RS001 draws, for the same reason: whether a rebase # is the remedy turns on WHEN the record reached shipped/. - local placer + local placer landed + landed="$(landed_while_waiting "$sha" "$base" "$head" "$base_path")" placer="$(git log -n1 --format='%h %s' "$head".."$base" -- "$base_path" || true)" - if [ -n "$placer" ]; then + if [ -n "$landed" ]; then + fail "$says $id already sits in $INTENTS_DIR/shipped/ at $base, placed there by $landed after this branch diverged from $base: another change delivered it while this change waited (a merge-queue collision), so it enters nothing in $base..$head. Rebase onto $base, reconcile this change with that one, and drop the trailer — the intent ships once, and $base already holds it shipped." + elif [ -n "$placer" ]; then fail "$says $id already sits in $INTENTS_DIR/shipped/ at $base (placed there on $base's side by $placer), and $head is $behind commit(s) behind $base: the delivery reached $base outside $base..$head, so this trailer describes work $base already holds. Rebase onto $base; if this commit survives the rebase, drop the trailer." else fail "$says $id already sat in $INTENTS_DIR/shipped/ before this branch diverged from $base: the trailer names an intent delivered before this commit. Drop the trailer." @@ -625,9 +648,12 @@ check_commits() { # names an issue resolved before this commit and nothing but # dropping it helps. The behind-count alone cannot tell them apart; # the record's base-side history can. - local placer + local placer landed + landed="$(landed_while_waiting "$sha" "$base" "$head" "$base_path")" placer="$(git log -n1 --format='%h %s' "$head".."$base" -- "$base_path" || true)" - if [ -n "$placer" ]; then + if [ -n "$landed" ]; then + fail "RS001 commit ${sha:0:12} declares 'Resolves: $id', but $id already sits in $ISSUES_DIR/$base_status/ at $base, placed there by $landed after this branch diverged from $base: another change resolved it while this change waited (a merge-queue collision), so it enters nothing in $base..$head. Rebase onto $base, reconcile this change with that one, and drop the trailer — the record is terminal once, and $base already holds it so." + elif [ -n "$placer" ]; then fail "RS001 commit ${sha:0:12} declares 'Resolves: $id', but $id already sits in $ISSUES_DIR/$base_status/ at $base (placed there on $base's side by $placer), and $head is $behind commit(s) behind $base: the resolution reached $base outside $base..$head, so this trailer describes work $base already holds. Rebase onto $base; if this commit survives the rebase, drop the trailer." else fail "RS001 commit ${sha:0:12} declares 'Resolves: $id', but $id already sat in $ISSUES_DIR/$base_status/ before this branch diverged from $base: the trailer names an issue that was resolved before this commit. Drop the trailer." diff --git a/scripts/pinned-toolchain.sh b/scripts/pinned-toolchain.sh new file mode 100755 index 000000000..63772f7bf --- /dev/null +++ b/scripts/pinned-toolchain.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# The one resolver for the Go toolchain go.mod declares (iss-2609081953452204, +# iss-2609261850045839). Prints the declared toolchain's GOROOT on stdout and +# nothing else; every diagnostic goes to stderr. +# +# scripts/pinned-toolchain.sh # e.g. 1.26.7, read from go.mod by the caller +# +# `GOTOOLCHAIN=go go env GOROOT` fetches and caches the declared +# toolchain if the machine lacks it, then reports where it landed. The format +# gate runs the gofmt under that root, and `make preflight`, which runs the +# format gate before any other gate, exports the same GOTOOLCHAIN to every Go +# step it makes, so the gofmt that judges the tree and the go that builds and +# tests it are the one toolchain CI's setup-go installs from go.mod. +# +# It REFUSES (exit 2) rather than falling back when the toolchain cannot be +# resolved (offline, or the fetch declined). A fallback would judge the tree +# with a toolchain CI does not use, which is the false green this resolver +# exists to remove, so the refusal names the skew instead +# (.abcd/development/principles/loud-staging.md). +set -euo pipefail + +version="${1:-}" +if [ -z "$version" ]; then + echo "pinned-toolchain: REFUSING — go.mod declares no \`go \` line, so there is no toolchain to resolve." >&2 + exit 2 +fi + +local_version="$(GOTOOLCHAIN=local go env GOVERSION 2>/dev/null || echo unknown)" + +# stdout ALONE is the root. The fetch prints its progress ("go: downloading +# go1.26.7 ...") and any error on stderr, which passes straight through to the +# caller's terminal: merged into the captured value, a first-run progress line +# made the root two lines, and the gate refused on the run where the fetch had +# just succeeded, blaming the network (iss-2609090951287096). +if ! goroot="$(GOTOOLCHAIN="go$version" go env GOROOT)"; then + echo "pinned-toolchain: REFUSING to judge this tree." >&2 + echo "pinned-toolchain: go.mod declares go$version; the go on PATH is $local_version." >&2 + echo "pinned-toolchain: the go$version toolchain could not be fetched (go's own error is above; the fetch needs network)." >&2 + echo "pinned-toolchain: NOT falling back to the go on PATH — a different toolchain judges this" >&2 + echo "pinned-toolchain: tree differently, so the fallback would pass what CI refuses." >&2 + exit 2 +fi +if [ ! -x "$goroot/bin/gofmt" ] || [ ! -x "$goroot/bin/go" ]; then + echo "pinned-toolchain: REFUSING — go$version resolved to '$goroot', which holds no bin/go and bin/gofmt." >&2 + echo "pinned-toolchain: go.mod declares go$version; the go on PATH is $local_version." >&2 + exit 2 +fi + +resolved="$("$goroot/bin/go" version 2>/dev/null | awk '{print $3}')" +if [ "$resolved" != "go$version" ]; then + echo "pinned-toolchain: REFUSING — go.mod declares go$version, but the resolved toolchain reports $resolved." >&2 + echo "pinned-toolchain: GOTOOLCHAIN did not switch, so the gate would run the wrong toolchain." >&2 + exit 2 +fi + +printf '%s\n' "$goroot"