From 3d86610400b6e71839f6c2fad70f268fd130e65b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:35:49 +0100 Subject: [PATCH 01/66] chore(record): end the 39 ingested intents in a newline The verdict ingest wrote 39 shipped intents back with no final newline (the replace-in-place path dropped the separator), so each ends on the last byte of its review block. Every other record writer ends its file with a newline. Each file gains exactly that one byte; no line changes. Refs: iss-2609231011136579 Assisted-by: Claude:claude-opus-5-5 --- ...ice-reads-one-page-and-knows-exactly-where-abcd-stands-in.md | 2 +- ...ry-citation-abcd-publishes-is-provably-alive-and-honestly.md | 2 +- ...r-repo-says-the-same-thing-about-itself-everywhere-becaus.md | 2 +- ...cd-teaches-repo-agents-the-shell-commands-they-must-never.md | 2 +- ...d-gates-a-new-idea-before-it-becomes-a-record-entry-resea.md | 2 +- ...d-mints-collision-proof-record-ids-across-parallel-agents.md | 2 +- ...d-update-completes-a-chosen-update-in-one-verb-it-fetches.md | 2 +- .../shipped/itd-132-hook-binary-to-persistent-data-dir.md | 2 +- ...d-has-a-face-a-block-pixel-duckling-is-the-mascot-the-off.md | 2 +- ...nt-worktrees-commit-without-the-private-name-guard-abcd-w.md | 2 +- ...e-agent-prompts-read-attacker-influenceable-input-without.md | 2 +- ...cd-s-remote-config-apply-verb-should-enable-github-native.md | 2 +- .../itd-154-s4-gate-failed-bootstrap-provisioned-no-binary.md | 2 +- .../itd-155-scanner-adjacency-galloping-probe-structural-fix.md | 2 +- ...-guard-tokenizer-does-not-perform-brace-expansion-so-a-fl.md | 2 +- .../itd-157-by-links-layout-publishes-overlapping-positions.md | 2 +- .../shipped/itd-158-provenance-lacks-pass-b-exemption-marker.md | 2 +- ...-dangling-supersedes-and-spec-targets-nothing-checks-them.md | 2 +- ...ord-lint-cannot-see-a-decision-shaped-document-filed-outs.md | 2 +- .../shipped/itd-162-adoption-templates-outside-record.md | 2 +- ...-reasoning-behind-what-was-pursued-no-longer-evaporates-a.md | 2 +- ...r-cold-reading-definitions-one-blindness-core-each-positi.md | 2 +- ...-ingest-verb-validates-every-cold-reading-output-includin.md | 2 +- ...-read-block-eval-falsifies-the-firewall-planted-warm-cont.md | 2 +- ...nesia-is-a-repository-property-proven-by-an-eval-the-same.md | 2 +- ...t-the-widening-reading-proposes-is-admitted-or-declined-o.md | 2 +- ...n-assembly-reports-what-it-would-cost-before-a-reading-is.md | 2 +- ...a-reading-is-about-something-narrower-than-everything-its.md | 2 +- .abcd/development/intents/shipped/itd-4-issue-capture.md | 2 +- .../development/intents/shipped/itd-40-folder-classification.md | 2 +- .../shipped/itd-46-abcd-intent-quoted-text-create-symmetric.md | 2 +- .../intents/shipped/itd-67-installable-versioned-plugin.md | 2 +- .abcd/development/intents/shipped/itd-73-derived-versioning.md | 2 +- .../intents/shipped/itd-80-intent-lifecycle-automation.md | 2 +- .../intents/shipped/itd-88-lifeboat-coverage-experiment.md | 2 +- .../intents/shipped/itd-89-start-the-transcript-clock.md | 2 +- ...intent-that-is-not-planned-cannot-be-implemented-abcd-gai.md | 2 +- ...embark-grounds-a-lifeboat-s-open-questions-on-a-repo-s-to.md | 2 +- ...embark-reads-a-repo-s-naming-and-internals-conventions-in.md | 2 +- 39 files changed, 39 insertions(+), 39 deletions(-) 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) From 55964951091ab03bd9c07167ab6a26f0cb7df7c7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:36:08 +0100 Subject: [PATCH 02/66] fix(intent): one live, bounded reader and writer for the review block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's review block is read and written in one place, readReviewBlocks, and every question the ingest asks of a record goes through it. - Liveness: a marker counts only on a live line of the live Audit Notes section, through condition.AuditNotes over mdrecord.Mask (the one fence-and-comment rule). A marker-shaped line in a fence, a comment span or another section is an example, not state, so it can neither solicit a verdict nor be reused as the parked receipt. appendToAuditNotes finds its section the same way, so the writer never appends under a fenced example the reader cannot see, and condition.ReadDispositions reads live lines only. - Extent: every renderer closes its block on a `` line, and the block ends there, so prose written below a block survives a replacement and an identical re-ingest stays a noop. A block written before the closing line existed is read by its known extent: an OWED stub is its marker and one sentence, and any other block keeps the rule it always had (next marker, heading or end of section) less a trailing run of link-reference definitions, which no renderer writes. An identical re-ingest over such a block is still a noop, so no tree record is rewritten to gain the line. - Final newline: the record every write returns ends in a newline. - Citations: a rendered block (verdict, re-ingest or dead letter) whose prose cites a record id that names no record is REFUSED, naming the id and its line, with nothing written, where the repository's record-lint arms prose_citation_resolves over the intent store. Refusal over sanitising: the verdict schema cannot mark an id illustrative, and termsafe neutralises syntax, never a citation's meaning, so a rewrite would be a second sanitiser guessing what the auditor meant. The check is the gate's own reading (lint.UnresolvedProseCitationsInRecord, which shares the per-line loop with the rule), registered by the front doors through intent.SetProseCitationGate because lint's tests import intent; an unregistered gate refuses the ingest. The brief's intent surface row and commands/intent.md state the closing line, the liveness rule and the citation refusal. Watched red on a scratch archive of d850f06f: TestIngestedRecordEndsIn ANewline (no-newline stub, dead letter), TestFirstIngestKeepsLinkRefs BelowTheOwedStub, TestReplacementKeepsLinkRefsBelowALegacyBlock, TestReingestKeepsAHumanNoteBelowTheBlock, TestOnlyALiveMarkerCounts, TestAFencedMarkerIsNotASolicitation, TestIngestRefusesAnUnresolvable Citation, TestAppendLandsInTheLiveAuditNotes, TestReadDispositionsReadsOnlyLiveBlocks and the CLI's TestIntentAuditIngestRefusesAnUnresolvableCitation. TestEveryTreeReviewBlockRoundTrips holds every intent record in the tree to the byte pattern's reading and to a byte-identical write-back. Refs: iss-2609231011136579, iss-2609231036448320, iss-2609251451432601 Refs: iss-2609251451434656, iss-2609020529185438 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/05-intent.md | 2 +- commands/intent.md | 9 +- internal/core/condition/condition.go | 43 +- internal/core/condition/condition_test.go | 36 ++ internal/core/intent/audit.go | 380 +++++++++++++----- internal/core/intent/audit_block_test.go | 370 +++++++++++++++++ .../core/intent/audit_forged_marker_test.go | 10 +- internal/core/intent/gate_test.go | 16 + internal/core/intent/owed.go | 17 +- internal/core/lint/prosecitations.go | 96 ++++- .../cli/intent_audit_conditions_test.go | 32 ++ internal/surface/cli/issuereader.go | 16 + internal/termsafe/prose.go | 17 +- 13 files changed, 921 insertions(+), 123 deletions(-) create mode 100644 internal/core/intent/audit_block_test.go create mode 100644 internal/core/intent/gate_test.go diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index aa7ad64a7..6841be9cd 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -311,7 +311,7 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | 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`). 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) | diff --git a/commands/intent.md b/commands/intent.md index 966ab9d95..3d6d365fd 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -682,7 +682,14 @@ or `noop`) and, for `dead_letter`, the reason. 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 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. @@ -595,6 +598,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) @@ -678,6 +683,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 @@ -715,12 +723,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 @@ -992,15 +1003,20 @@ func deadLetter(repoRoot string, it Intent, content, rcp string, raw []byte, rea 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 +1032,84 @@ 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 verdict ingest +// asks before it writes. Pass an adapter over lint.UnresolvedProseCitationsInRecord. +func SetProseCitationGate(fn func(repoRoot, rel, text string) ([]UnresolvedCitation, error)) { + proseCitationGate = fn +} + +// 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 +1154,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 +1391,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 +1399,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 +1417,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 +1480,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/gate_test.go b/internal/core/intent/gate_test.go new file mode 100644 index 000000000..a46618afd --- /dev/null +++ b/internal/core/intent/gate_test.go @@ -0,0 +1,16 @@ +package intent + +import "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() { + SetProseCitationGate(func(repoRoot, rel, text string) ([]UnresolvedCitation, error) { + cites, err := lint.UnresolvedProseCitationsInRecord(repoRoot, rel, text) + out := make([]UnresolvedCitation, 0, len(cites)) + for _, c := range cites { + out = append(out, UnresolvedCitation(c)) + } + return out, err + }) +} diff --git a/internal/core/intent/owed.go b/internal/core/intent/owed.go index 10c8f76b2..78168bd25 100644 --- a/internal/core/intent/owed.go +++ b/internal/core/intent/owed.go @@ -126,14 +126,21 @@ func ReviewOf(repoRoot string, it Intent) (ReviewEntry, error) { // retained at . ...". The reason is cut at the LAST retention clause, // because the reason is free text and the path after it is ours. A block in any // other shape yields the empty reason rather than a guess. +// +// The block is readReviewBlocks' block for rcp, so a marker quoted in a fenced +// example is never the one the reason is read from. func deadLetterReason(content, rcp string) string { - loc := markerRe.FindStringIndex(content) - if loc == nil { + lines, b, ok := reviewBlockFor(content, rcp) + if !ok { return "" } - rest := strings.TrimLeft(content[loc[1]:], "\r\n") - line, _, _ := strings.Cut(rest, "\n") - line = strings.TrimRight(line, "\r") + line := "" + for _, ln := range lines[b.start+1 : b.end] { + if ln = strings.TrimRight(ln, "\r"); ln != "" { + line = ln + break + } + } prefix := "Fidelity review DEAD_LETTER (receipt " + rcp + "): " if !strings.HasPrefix(line, prefix) { return "" 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/surface/cli/intent_audit_conditions_test.go b/internal/surface/cli/intent_audit_conditions_test.go index 62f4891c2..0dfa2b0b7 100644 --- a/internal/surface/cli/intent_audit_conditions_test.go +++ b/internal/surface/cli/intent_audit_conditions_test.go @@ -193,3 +193,35 @@ 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) + } +} diff --git a/internal/surface/cli/issuereader.go b/internal/surface/cli/issuereader.go index 866c218bd..32f0e6bc1 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,22 @@ 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 with the intent audit's ingest +// the same way, so every verdict the CLI ingests is held to the gate the record +// it writes must pass. 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 From aadb97202600a51c97e64faf385ed44dd73e42a1 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:36:16 +0100 Subject: [PATCH 03/66] =?UTF-8?q?chore:=20capture=20iss-2609261835118276?= =?UTF-8?q?=20=E2=80=94=20two=20ingests=20skip=20the=20prose-citation=20ga?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found sweeping the verdict ingest's siblings: the consistency ingest and the reading ingest write host-delegated prose into stores prose_citation_resolves reads, with no check before the write. Left for its own lane: capture cannot import core/lint, so the fix is the same front-door seam, and each writer decides whether it refuses the payload or the one finding. Refs: iss-2609261835118276 Assisted-by: Claude:claude-opus-5-5 --- ...nsistency-ingest-and-the-reading-ingest-copy.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261835118276-the-consistency-ingest-and-the-reading-ingest-copy.md diff --git a/.abcd/work/issues/open/iss-2609261835118276-the-consistency-ingest-and-the-reading-ingest-copy.md b/.abcd/work/issues/open/iss-2609261835118276-the-consistency-ingest-and-the-reading-ingest-copy.md new file mode 100644 index 000000000..6c1b2d71a --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261835118276-the-consistency-ingest-and-the-reading-ingest-copy.md @@ -0,0 +1,14 @@ +--- +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" +--- + +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. From d5f0d0eacb5987eff162f63841669aac1310a90f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:36:39 +0100 Subject: [PATCH 04/66] chore: resolve the five review-block records the ingest fix closes The fix in 55964951 gives the intent audit one live, bounded reader and writer for the review block: records end in a newline, a verdict citing an id that names no record is refused before any write, the block's extent is bounded so trailing link references and prose below it survive, and only a marker on a live line of Audit Notes is state. Resolves: iss-2609231011136579 Resolves: iss-2609231036448320 Resolves: iss-2609251451432601 Resolves: iss-2609251451434656 Resolves: iss-2609020529185438 Assisted-by: Claude:claude-opus-5-5 --- ...-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md | 8 ++++++++ ...nt-audit-ingest-writes-the-shipped-intent-back-with.md | 8 ++++++++ ...intent-audit-ingest-copies-the-verifier-s-rationale.md | 8 ++++++++ ...t-audit-ingest-the-first-ingest-and-any-replacement.md | 8 ++++++++ ...audit-ingest-reviewblockrange-treats-any-text-under.md | 8 ++++++++ 5 files changed, 40 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609020529185438-markerre-is-a-byte-pattern-not-a-grammar-the-intent-audit-s.md (58%) rename .abcd/work/issues/{open => resolved}/iss-2609231011136579-abcd-intent-audit-ingest-writes-the-shipped-intent-back-with.md (67%) rename .abcd/work/issues/{open => resolved}/iss-2609231036448320-abcd-intent-audit-ingest-copies-the-verifier-s-rationale.md (60%) rename .abcd/work/issues/{open => resolved}/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md (52%) rename .abcd/work/issues/{open => resolved}/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md (52%) 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-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 From 50fee5e1479e26deffc810ed8c7e08545791a41d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:50:06 +0100 Subject: [PATCH 05/66] =?UTF-8?q?chore:=20capture=20iss-2609261850045839?= =?UTF-8?q?=20=E2=80=94=20preflight=20runs=20its=20Go=20steps=20on=20the?= =?UTF-8?q?=20PATH=20toolchain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: iss-2609261850045839 Assisted-by: Claude:claude-opus-5-5 --- ...s-on-the-path-toolchain-not-the-declared-one.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md diff --git a/.abcd/work/issues/open/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md b/.abcd/work/issues/open/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md new file mode 100644 index 000000000..103e1f1eb --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md @@ -0,0 +1,14 @@ +--- +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" +--- + +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. From 128e3b4ae6aae25239146492abdda1e58ed2fd8d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:05:56 +0100 Subject: [PATCH 06/66] fix: resolve the declared toolchain from stdout alone The format gate captured `GOTOOLCHAIN=go go env GOROOT` with stderr merged into stdout, so on a machine without the declared toolchain cached the fetch's "go: downloading ..." line became the first line of the captured root. The executable test on that two-line path failed and the gate refused, blaming the network, on the run where the fetch had just succeeded. The resolution moves out of the Makefile into scripts/pinned-toolchain.sh, which prints the GOROOT on stdout and nothing else and lets go's own progress and errors pass through on stderr. It refuses (exit 2) and names the skew for a fetch that failed, and separately for a root that holds no go or gofmt, or a go that reports another release. `make fmt-check` and `make fmt` run the gofmt under the root it prints. The resolver's branches are driven against a stub go in TestPinnedToolchainResolver*; the progress case failed on the extracted merged-stream capture before the streams were separated. Refs: iss-2609090951287096 Assisted-by: Claude:claude-opus-5-5 --- Makefile | 30 +---- internal/core/lint/pinnedtoolchain_test.go | 144 +++++++++++++++++++++ internal/core/lint/preflightgates_test.go | 9 +- scripts/pinned-toolchain.sh | 54 ++++++++ 4 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 internal/core/lint/pinnedtoolchain_test.go create mode 100755 scripts/pinned-toolchain.sh diff --git a/Makefile b/Makefile index 57705e8bb..60fbb3624 100644 --- a/Makefile +++ b/Makefile @@ -76,10 +76,11 @@ 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. # # 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 +90,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 .)"; \ diff --git a/internal/core/lint/pinnedtoolchain_test.go b/internal/core/lint/pinnedtoolchain_test.go new file mode 100644 index 000000000..a5168f53d --- /dev/null +++ b/internal/core/lint/pinnedtoolchain_test.go @@ -0,0 +1,144 @@ +package lint_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "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) + } +} 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/scripts/pinned-toolchain.sh b/scripts/pinned-toolchain.sh new file mode 100755 index 000000000..55504113a --- /dev/null +++ b/scripts/pinned-toolchain.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# The one resolver for the Go toolchain go.mod declares (iss-2609081953452204). +# 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, which is the one 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" From 42f06f5a424689a20fdff8beb80693b2491966f7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:06:14 +0100 Subject: [PATCH 07/66] =?UTF-8?q?chore:=20resolve=20iss-2609090951287096?= =?UTF-8?q?=20=E2=80=94=20the=20toolchain=20resolver=20reads=20stdout=20al?= =?UTF-8?q?one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609090951287096 Assisted-by: Claude:claude-opus-5-5 --- ...-gofmt-merges-toolchain-download-output-into-goroot.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609090951287096-pinned-gofmt-merges-toolchain-download-output-into-goroot.md (72%) 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 From 67a2adf31c461209503deaea3b29e10a6bd9b73f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:06:31 +0100 Subject: [PATCH 08/66] fix: run preflight's Go steps on the toolchain go.mod declares make preflight built, vetted and tested on the go on PATH (go1.27.1 on the machine that found it) while CI builds and tests with the release go.mod declares (go 1.26.7). On 2026-09-26 pull request 728 passed preflight and failed CI on a test whose assertion depended on go 1.27's encoding/json error wording. preflight now exports GOTOOLCHAIN=go$(GO_TOOLCHAIN_VERSION), read from go.mod by the same line the format gate uses, to every Go step it makes: its build, vet, test and race lines and each go run and go test of its prerequisites. The go on PATH switches to the declared release and puts its bin first on PATH for what it runs, so a go a test execs is the declared one too. fmt-check, CI's format gate, joins preflight second, straight after the load check: its resolver, scripts/pinned-toolchain.sh, is the one resolver, and it refuses, naming the skew, before any gate runs on a toolchain that cannot be fetched. That also closes iss-46's "gofmt missing from make preflight" parity gap: preflight runs every gate CI's check job runs. Gates whose behaviour changes: preflight (pinned toolchain for every Go step; fmt-check as a prerequisite). AGENTS.md, CONTRIBUTING.md, the install guide, the pre-push hook header and the Makefile comment say so. TestPreflightRunsTheDeclaredToolchain failed on the missing export and on fmt-check's absence from the prerequisite list before this change. Refs: iss-2609261850045839, iss-46 Assisted-by: Claude:claude-opus-5-5 --- .githooks/pre-push | 22 ++++----- AGENTS.md | 31 ++++++++----- CONTRIBUTING.md | 12 +++-- Makefile | 28 +++++++++--- docs/how-to/install.md | 8 ++-- internal/core/lint/pinnedtoolchain_test.go | 53 ++++++++++++++++++++++ scripts/pinned-toolchain.sh | 12 +++-- 7 files changed, 122 insertions(+), 44 deletions(-) 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..457c9d06c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,13 +49,15 @@ 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 diff --git a/Makefile b/Makefile index 60fbb3624..eb90354fc 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 \ @@ -80,7 +81,8 @@ evals-cold-reading: # 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. +# 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 @@ -264,14 +266,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 @@ -288,7 +291,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 ./... @@ -319,6 +322,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/docs/how-to/install.md b/docs/how-to/install.md index a59afb482..65f51ca26 100644 --- a/docs/how-to/install.md +++ b/docs/how-to/install.md @@ -295,10 +295,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/internal/core/lint/pinnedtoolchain_test.go b/internal/core/lint/pinnedtoolchain_test.go index a5168f53d..00d9b7ea5 100644 --- a/internal/core/lint/pinnedtoolchain_test.go +++ b/internal/core/lint/pinnedtoolchain_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -142,3 +143,55 @@ func TestPinnedToolchainResolverRefusesAnEmptyDeclaration(t *testing.T) { 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) + } +} diff --git a/scripts/pinned-toolchain.sh b/scripts/pinned-toolchain.sh index 55504113a..63772f7bf 100755 --- a/scripts/pinned-toolchain.sh +++ b/scripts/pinned-toolchain.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash -# The one resolver for the Go toolchain go.mod declares (iss-2609081953452204). -# Prints the declared toolchain's GOROOT on stdout and nothing else; every -# diagnostic goes to stderr. +# 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, which is the one CI's setup-go installs -# from go.mod. +# 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 From d0881cd6e9efcb22b6714b4a8204a483575c5ee4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:06:52 +0100 Subject: [PATCH 09/66] =?UTF-8?q?chore:=20resolve=20iss-2609261850045839?= =?UTF-8?q?=20=E2=80=94=20preflight=20runs=20the=20declared=20toolchain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261850045839 Assisted-by: Claude:claude-opus-5-5 --- ...go-steps-on-the-path-toolchain-not-the-declared-one.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md (67%) diff --git a/.abcd/work/issues/open/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 similarity index 67% rename from .abcd/work/issues/open/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md rename to .abcd/work/issues/resolved/iss-2609261850045839-preflight-runs-go-steps-on-the-path-toolchain-not-the-declared-one.md index 103e1f1eb..db12faa99 100644 --- a/.abcd/work/issues/open/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 @@ -9,6 +9,14 @@ 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 From 525dd9ee1ef838862a579604ce416db2ef3bfcf6 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:07:22 +0100 Subject: [PATCH 10/66] fix: arm preflight's record-lint over the branch's own range make preflight ran record-lint without -agent-diff, so agent_contract's unbumped-edit check (a changed agent prompt bumps its prompt_version and adds its agents/CHANGELOG.md entry) was a no-op locally and armed only in CI, which passes -agent-diff "${BASE_SHA}...HEAD". Pull request 606 passed three green preflights and was refused in the merge queue for exactly that check. The record-lint target now passes -agent-diff origin/main...HEAD, the merge-base range CI's step passes from its base, and needs origin/main as lint-issues and lint-decisions already do. Gate whose behaviour changes: record-lint, standalone and under preflight. In a scratch clone with an agent prompt edited and its version unbumped, the old recipe printed no blocker and the new one refuses with agent_contract. TestPreflightArmsRecordLintAsCIDoes failed on the unarmed recipe before this change. Refs: iss-2609021152026246 Assisted-by: Claude:claude-opus-5-5 --- Makefile | 10 ++++++- internal/core/lint/pinnedtoolchain_test.go | 31 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index eb90354fc..6dbf7c473 100644 --- a/Makefile +++ b/Makefile @@ -145,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 diff --git a/internal/core/lint/pinnedtoolchain_test.go b/internal/core/lint/pinnedtoolchain_test.go index 00d9b7ea5..797495532 100644 --- a/internal/core/lint/pinnedtoolchain_test.go +++ b/internal/core/lint/pinnedtoolchain_test.go @@ -195,3 +195,34 @@ func TestPreflightRunsTheDeclaredToolchain(t *testing.T) { "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) + } +} From 56ab0975d7bc5eb89ca9f5f8c63895e832adc578 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:07:24 +0100 Subject: [PATCH 11/66] =?UTF-8?q?chore:=20resolve=20iss-2609021152026246?= =?UTF-8?q?=20=E2=80=94=20preflight=20arms=20record-lint=20as=20CI=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609021152026246 Assisted-by: Claude:claude-opus-5-5 --- ...ed-so-the-agent-contract-changelog-check-is-ci-only.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609021152026246-preflight-runs-record-lint-unarmed-so-the-agent-contract-changelog-check-is-ci-only.md (67%) 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 From e4fabe3ff1b5576d072a58cc9d983721238db7cb Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:07:35 +0100 Subject: [PATCH 12/66] fix: widen docs-lint to every committed markdown file and the persona rule Three of iss-46's lint scope holes, each mechanical: - links_resolve read .abcd/development and .abcd/work (record-lint) and docs/ and README.md (docs-lint), and nothing else: 59 committed markdown files, the root prose, the agent prompts, the plugin command pages and the READMEs, had relative links no gate checked. docs-lint's links_resolve now walks them through extra_roots. They carry no broken link today; a planted one in AGENTS.md is refused. TestEveryCommittedMarkdownFileHasItsLinksChecked derives its roster from git ls-files, so a markdown file added anywhere joins by existing or is named in its exemption list with the reason (eval fixtures, the templates ahoy writes into other repositories, the symlinked mirrors). It failed with 59 files against the old config. - persona_registry ran over the design record alone. docs-lint now arms it against the same roster and severity (TestDocsLintArmsThePersonaRule, red against the old config); docs/ carries no unregistered persona today, and a planted one is refused. The docs-lint seed withholds it: a prepared repository has no persona roster to read. - CONTRIBUTING.md documented how to activate the committed hooks but not that the pre-commit name guard depends on the per-machine banlist, and warns and lets the commit through without one. Gate whose behaviour changes: docs-lint (wider link scope, persona rule), in preflight and CI alike. Refs: iss-46 Assisted-by: Claude:claude-opus-5-5 --- .abcd/docs-lint.json | 23 +++- CONTRIBUTING.md | 6 +- Makefile | 8 +- internal/core/ahoy/docslint_seed_test.go | 4 + internal/core/lint/lintscope_test.go | 141 +++++++++++++++++++++++ 5 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 internal/core/lint/lintscope_test.go 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/CONTRIBUTING.md b/CONTRIBUTING.md index 457c9d06c..1a46d4ec4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,8 +60,10 @@ inbound = outbound statement is the whole of it. 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 6dbf7c473..42e0bcd63 100644 --- a/Makefile +++ b/Makefile @@ -227,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 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/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) + } +} From ff9d94ae13e1cd7e0363ccd7a55ff6114c544498 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:07:57 +0100 Subject: [PATCH 13/66] chore: defer iss-46's ratchet remainder past v0.11.0 iss-46 named five lint scope holes. Four are closed in this branch: the link check walks every committed markdown file, docs-lint arms the persona rule, preflight runs fmt-check and an armed record-lint on CI's toolchain, and CONTRIBUTING.md documents the hooks' banlist dependency. The fifth, a warn-baseline ratchet in record-lint with a scope matrix beyond links, is a design that needs a ruling (which warn rules freeze, where the baseline lives, how it only shrinks), so the record stays open with deferred_after: v0.11.0 and that reason. Refs: iss-46 Assisted-by: Claude:claude-opus-5-5 --- .abcd/work/issues/open/iss-46-lint-scope-holes.md | 2 ++ 1 file changed, 2 insertions(+) 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 From 17bf7424c23cc84e11f81bd56ada7a368acbf60e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:08:10 +0100 Subject: [PATCH 14/66] fix: name a merge-queue collision in RS001 and RS005 refusals When a competitor lands a record's resolution while a merge-queue entry waits, the record is terminal at the entry's base and enters nothing across the range, so RS001 refuses the entry's trailer, rightly. The diagnosis probed head..base for the base-side commit that placed the record, and in the queue the base is an ancestor of the head, so that walk is always empty and the only message selectable was "already sat in resolved/ before this branch diverged ... Drop the trailer": history that never happened, and a remedy aimed at a trailer that was right when it was written. A new probe, landed_while_waiting, answers in exactly that shape: when the base is an ancestor of the head it walks from the declaring commit's own fork point to the base, and a base-side commit that placed the record there is named as the competitor's landing, with rebase-and-reconcile as the remedy. A record terminal before the branch was cut keeps the message it has, in the queue and out of it. RS005 drew the same stale-branch split for the intent store and had the same blind spot; it takes the same probe. Gate whose behaviour changes: check-issue-resolution.sh commits (RS001, RS005 refusal text only; every verdict is unchanged). The queue cases in check-issue-resolution-cases.sh failed against the old probe. Refs: iss-2609091433422134 Assisted-by: Claude:claude-opus-5-5 --- scripts/check-issue-resolution-cases.sh | 71 +++++++++++++++++++++++++ scripts/check-issue-resolution.sh | 36 +++++++++++-- 2 files changed, 102 insertions(+), 5 deletions(-) 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." From e214165b502a22244c3ba0b27fddce4bb18ae897 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:08:12 +0100 Subject: [PATCH 15/66] =?UTF-8?q?chore:=20resolve=20iss-2609091433422134?= =?UTF-8?q?=20=E2=80=94=20RS001=20names=20a=20queue=20collision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609091433422134 Assisted-by: Claude:claude-opus-5-5 --- ...ributes-a-queue-collision-to-pre-divergence-history.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609091433422134-rs001-misattributes-a-queue-collision-to-pre-divergence-history.md (71%) 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 From d7ec7fa50e72f9d28ab48ef83949d46450c8a28b Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:11:07 +0100 Subject: [PATCH 16/66] test: give the harness-name fixture docs-lint's extra roots and roster TestDocsLintHarnessNameGate loads the real .abcd/docs-lint.json into a temporary repository, and a configured tree that does not resolve is a load error. Widening links_resolve's extra_roots and arming persona_registry in docs-lint made the fixture fail to load for want of those trees and the persona roster. It now creates each extra root and a roster from the config it loads, so a new root needs no edit here. Refs: iss-46 Assisted-by: Claude:claude-opus-5-5 --- internal/core/lint/lint_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) 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") From bd94e4b03b504c48abbbfe9a621434e579646e8d Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:21:57 +0100 Subject: [PATCH 17/66] test: provision docs-lint's extra roots in the banlist fixtures Two banlist tests lint a fixture repository with the real docs-lint config, and the config's new links_resolve extra roots and persona roster did not exist there, so the load refused. A helper creates every tree the config reads beyond its roots and name_roots, read from the config itself. Refs: iss-46 Assisted-by: Claude:claude-opus-5-5 --- internal/core/banlist/public_test.go | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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") + } +} From 710c1de1982bf6bcfe14ab4dd99bbf55be58a46f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:35:49 +0100 Subject: [PATCH 18/66] chore: capture three intent writers outside the mint lock The verdict ingest (the review-drainA1 flag), the fidelity-review emit and the related-issue/link frontmatter writes each rewrite an intent record as a read-modify-write outside withIntentMintLock. Refs: iss-2609261935343851 Refs: iss-2609261935407925 Refs: iss-2609261935407995 Assisted-by: Claude:claude-opus-5-5 --- ...51-intent-audit-ingest-outside-the-mint-lock.md | 14 ++++++++++++++ ...5-fidelity-review-emit-outside-the-mint-lock.md | 14 ++++++++++++++ ...-issue-and-link-writes-outside-the-mint-lock.md | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md create mode 100644 .abcd/work/issues/open/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md create mode 100644 .abcd/work/issues/open/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md diff --git a/.abcd/work/issues/open/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md b/.abcd/work/issues/open/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md new file mode 100644 index 000000000..a8ccb6767 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md @@ -0,0 +1,14 @@ +--- +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" +--- + +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). diff --git a/.abcd/work/issues/open/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md b/.abcd/work/issues/open/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md new file mode 100644 index 000000000..10fb2aa9b --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md @@ -0,0 +1,14 @@ +--- +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" +--- + +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. diff --git a/.abcd/work/issues/open/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md b/.abcd/work/issues/open/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md new file mode 100644 index 000000000..27516b462 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md @@ -0,0 +1,14 @@ +--- +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" +--- + +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. From 96fe3813ccabb0ce662493a2ae13594b684108ee Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:38:26 +0100 Subject: [PATCH 19/66] fix(intent): hold the store lock across the whole verdict ingest IngestVerdictBytes resolved the receipt, judged the record and wrote it outside withIntentMintLock, so two ingests on one intent, or an ingest beside a condition disposition, each wrote the bytes they read and the later erased the earlier with both exiting 0. The receipt resolution, every check on the record and the write(s) now run in one hold (ingestLocked); reingestVerdict and deadLetter are reached only from there, so the dead-letter's two writes land in the same hold. A repository with no intent store is refused without the lock, which would otherwise create the store in a refusal. The tests land a whole verb in the window through the lock seam rather than on the wall clock: a verdict ingested first makes the second a reported replacement; a malformed verdict never dead-letters over a verdict that landed; a condition disposition survives the ingest. Refs: iss-2609261935343851 Assisted-by: Claude:claude-opus-5-5 --- internal/core/intent/audit.go | 38 +++++- internal/core/intent/writer_lock_test.go | 153 +++++++++++++++++++++++ 2 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 internal/core/intent/writer_lock_test.go diff --git a/internal/core/intent/audit.go b/internal/core/intent/audit.go index 4cd737a3b..430352c1e 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" + "os" "path/filepath" "regexp" "sort" @@ -640,6 +642,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 @@ -710,7 +741,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 { @@ -997,7 +1029,9 @@ 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) diff --git a/internal/core/intent/writer_lock_test.go b/internal/core/intent/writer_lock_test.go new file mode 100644 index 000000000..9218efab9 --- /dev/null +++ b/internal/core/intent/writer_lock_test.go @@ -0,0 +1,153 @@ +package intent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/condition" +) + +// writer_lock_test.go — every writer of an intent record judges and writes the +// bytes it read under the store lock, so a write landing between its early +// reads and its write is seen, not erased. Each test lands a whole verb in that +// window through the lock seam (landAtLockEntry) rather than racing two +// goroutines on the wall clock (iss-2608301301041887): a writer that never takes +// the lock never reaches the seam, which is the finding in its original shape. + +// verdictBytes is the payload writeVerdict would hand the ingest for payload: +// the host-issued policy hashes substituted in, read back as bytes. +func verdictBytes(t *testing.T, root, payload string) []byte { + t.Helper() + b, err := os.ReadFile(writeVerdict(t, root, payload)) + if err != nil { + t.Fatal(err) + } + return b +} + +// reviewMarkers counts the live review markers the record carries. +func reviewMarkers(t *testing.T, content string) int { + t.Helper() + _, blocks := readReviewBlocks(content) + return len(blocks) +} + +const ( + rationaleFirst = "the ship-move writes the OWED stub and request file" + rationaleSecond = "the second auditor read the ship-move and its request file" +) + +// iss-2609261935343851: two ingests of different verdicts on one receipt. The +// one landing in the window is seen under the lock: the second replaces it in +// place and says so (Replaced), rather than reading the stale OWED stub and +// reporting a fresh ingest over a verdict it erased. Exactly one block stands. +func TestIngestSeesAVerdictLandedInTheWindow(t *testing.T) { + root := t.TempDir() + rcp := shipOne(t, root) + first := verdictBytes(t, root, validVerdict(rcp)) + second := verdictBytes(t, root, strings.Replace(validVerdict(rcp), rationaleFirst, rationaleSecond, 1)) + fired := landAtLockEntry(t, func() { + res, err := IngestVerdictBytes(root, first) + if err != nil || res.Status != "ingested" || res.Replaced { + t.Errorf("the ingest landing in the window must be a first ingest: %+v, %v", res, err) + } + }) + + res, err := IngestVerdictBytes(root, second) + if !*fired { + t.Fatal("IngestVerdictBytes never took the store lock: the seam never fired") + } + if err != nil { + t.Fatalf("the second ingest must replace the first under the lock: %v", err) + } + if res.Status != "ingested" || !res.Replaced { + t.Errorf("the second ingest must report the verdict it replaced: %+v", res) + } + s := intentBody(t, root) + if n := reviewMarkers(t, s); n != 1 { + t.Fatalf("one receipt, one review block: found %d\n%s", n, s) + } + if !strings.Contains(s, rationaleSecond) || strings.Contains(s, rationaleFirst) { + t.Fatalf("exactly the second verdict must stand:\n%s", s) + } +} + +// iss-2609261935343851, the dead-letter half: a malformed verdict whose ingest +// began while the receipt was OWED must not quarantine over a valid verdict +// that landed in the window. Under the lock it reads the receipt INGESTED, and a +// bad re-ingest is refused with nothing written — neither file of the +// dead-letter's two. +func TestADeadLetterNeverOverwritesAVerdictLandedInTheWindow(t *testing.T) { + root := t.TempDir() + rcp := shipOne(t, root) + good := verdictBytes(t, root, validVerdict(rcp)) + bad := verdictBytes(t, root, strings.Replace(validVerdict(rcp), `"verdict": "MET"`, `"verdict": "MAYBE"`, 1)) + var landed string + fired := landAtLockEntry(t, func() { + if _, err := IngestVerdictBytes(root, good); err != nil { + t.Errorf("the ingest landing in the window must succeed: %v", err) + } + landed = intentBody(t, root) + }) + + _, err := IngestVerdictBytes(root, bad) + if !*fired { + t.Fatal("IngestVerdictBytes never took the store lock: the seam never fired") + } + if err == nil { + t.Fatal("a malformed verdict over an INGESTED receipt must be refused") + } + if s := intentBody(t, root); s != landed { + t.Fatalf("the refused ingest changed the record the landed verdict wrote:\n%s", s) + } + if _, statErr := os.Stat(filepath.Join(root, reviewsDir, rcp+".deadletter.json")); !os.IsNotExist(statErr) { + t.Fatalf("no payload may be retained when nothing is quarantined: %v", statErr) + } +} + +// iss-2609261935343851, across writers: a condition disposition landing in the +// window before an ingest survives the ingest's write. +func TestIngestKeepsAConditionDispositionLandedInTheWindow(t *testing.T) { + root, rcp := condFixture(t, "\\\"holds while the record is one repository\\\" "+condOne) + payload := verdictBytes(t, root, verdictWithConditions(t, rcp, + dispositionOf(condOne, condition.Survived), dispositionOf(condTwo, condition.Survived))) + 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 := IngestVerdictBytes(root, payload) + if !*fired { + t.Fatal("IngestVerdictBytes never took the store lock: the seam never fired") + } + if err != nil || res.Status != "ingested" { + t.Fatalf("ingest: %+v, %v", res, err) + } + s := intentBody(t, root) + if !strings.Contains(s, "") { + 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) + } +} From 23ccce4141213e2ecc376c78cc25705f2b7eb03f Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:38:38 +0100 Subject: [PATCH 20/66] =?UTF-8?q?chore:=20resolve=20iss-2609261935343851?= =?UTF-8?q?=20=E2=80=94=20the=20verdict=20ingest=20holds=20the=20store=20l?= =?UTF-8?q?ock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261935343851 Assisted-by: Claude:claude-opus-5-5 --- ...935343851-intent-audit-ingest-outside-the-mint-lock.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md (65%) diff --git a/.abcd/work/issues/open/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 similarity index 65% rename from .abcd/work/issues/open/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md rename to .abcd/work/issues/resolved/iss-2609261935343851-intent-audit-ingest-outside-the-mint-lock.md index a8ccb6767..ce7e14d31 100644 --- a/.abcd/work/issues/open/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 @@ -9,6 +9,14 @@ 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 From a27d06e087bec06e47c0570e8d7ce652a7d4c49c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:39:18 +0100 Subject: [PATCH 21/66] fix(intent): park the fidelity-review stub under the store lock emitAuditWith, reached from the spec-close ship move, the bundle close, `intent audit` and the audit drain, read a shipped intent, parked the OWED stub and wrote the record back outside withIntentMintLock, so a condition disposition or verdict ingest landing between its read and its write was erased. The read, the marker judgement and the writes now run in one hold (emitLocked); every caller reaches it after any hold of its own is released, so the lock is never taken twice. Refs: iss-2609261935407925 Assisted-by: Claude:claude-opus-5-5 --- internal/core/intent/audit.go | 17 +++++++++++ internal/core/intent/writer_lock_test.go | 36 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/internal/core/intent/audit.go b/internal/core/intent/audit.go index 430352c1e..86274efb8 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -276,6 +276,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 { diff --git a/internal/core/intent/writer_lock_test.go b/internal/core/intent/writer_lock_test.go index 9218efab9..1d9712fb7 100644 --- a/internal/core/intent/writer_lock_test.go +++ b/internal/core/intent/writer_lock_test.go @@ -151,3 +151,39 @@ func TestIngestIntoARepositoryWithNoIntentStoreWritesNothing(t *testing.T) { 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) + } +} From 8ef9a60109facf2c2e6fe6713ec02b14989b7725 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:39:25 +0100 Subject: [PATCH 22/66] =?UTF-8?q?chore:=20resolve=20iss-2609261935407925?= =?UTF-8?q?=20=E2=80=94=20the=20review=20emit=20holds=20the=20store=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261935407925 Assisted-by: Claude:claude-opus-5-5 --- ...35407925-fidelity-review-emit-outside-the-mint-lock.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md (65%) diff --git a/.abcd/work/issues/open/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 similarity index 65% rename from .abcd/work/issues/open/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md rename to .abcd/work/issues/resolved/iss-2609261935407925-fidelity-review-emit-outside-the-mint-lock.md index 10fb2aa9b..d809bd419 100644 --- a/.abcd/work/issues/open/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 @@ -9,6 +9,14 @@ 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 From 44ec7c60bb0bf8b8bb5e4f378a36b95ce5fed862 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:40:43 +0100 Subject: [PATCH 23/66] fix(intent): write related-issue edges and spec links under the store lock AddRelatedIssue (the intent half of `capture promote --intent`, any bucket) and Link (`intent link`) rewrote the intent's frontmatter as a read-modify-write outside withIntentMintLock, so a hold, a disposition, an ingest or a second edge landing between the read and the write was erased. Both now read and write in one hold, and AddRelatedIssue judges the existing list on the bytes read there rather than on the corpus, so two edges written to one intent both stand. Refs: iss-2609261935407995 Assisted-by: Claude:claude-opus-5-5 --- internal/core/intent/lifecycle.go | 76 +++++++++++++++--------- internal/core/intent/writer_lock_test.go | 55 +++++++++++++++++ 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/internal/core/intent/lifecycle.go b/internal/core/intent/lifecycle.go index 63348bfd1..3963a3fab 100644 --- a/internal/core/intent/lifecycle.go +++ b/internal/core/intent/lifecycle.go @@ -623,15 +623,20 @@ func Link(repoRoot, intentID, specID string) (LinkResult, error) { rel := it.Path abs := filepath.Join(repoRoot, rel) - data, err := readRepoFile(abs, rel) - if err != nil { - return LinkResult{}, err - } - updated, err := setFrontmatterFields(string(data), map[string]string{"spec_id": specID}) - if err != nil { - return LinkResult{}, err - } - if err := writeIntentFile(abs, rel, updated); err != nil { + // The read and the write are ONE critical section under the store's + // advisory lock (iss-2609261935407995): a hold or any other write landing + // between an unlocked read and this write would be erased. + if err := withIntentMintLock(repoRoot, func() error { + data, err := readRepoFile(abs, rel) + if err != nil { + return err + } + updated, err := setFrontmatterFields(string(data), map[string]string{"spec_id": specID}) + if err != nil { + return err + } + return writeIntentFile(abs, rel, updated) + }); err != nil { return LinkResult{}, err } @@ -685,28 +690,43 @@ func AddRelatedIssue(repoRoot, intentID, source string) (Intent, error) { } rel := it.Path abs := filepath.Join(repoRoot, rel) - data, err := readRepoFile(abs, rel) - if err != nil { - return Intent{}, err - } - if _, retired := frontmatter.Fields(strings.Split(string(data), "\n"))[RetiredRelatedIssuesKey]; retired { - return Intent{}, fmt.Errorf("%w: %s carries `%s`, renamed to `%s`; run `abcd capture migrate --apply` first, nothing written", - ErrRetiredField, intentID, RetiredRelatedIssuesKey, RelatedIssuesKey) - } - for _, have := range it.RelatedIssues { - if have == source { - return it, nil // already joined; the write would change no byte + // The read, the list judged on it and the write are ONE critical section + // under the store's advisory lock (iss-2609261935407995): the list is read + // from the bytes held there, not from the corpus, so an edge or any other + // write landing before this one is kept rather than overwritten. + if err := withIntentMintLock(repoRoot, func() error { + data, err := readRepoFile(abs, rel) + if err != nil { + return err } - } - list := append(append([]string{}, it.RelatedIssues...), source) - updated, err := setFrontmatterFields(string(data), map[string]string{RelatedIssuesKey: "[" + strings.Join(list, ", ") + "]"}) - if err != nil { - return Intent{}, err - } - if err := writeIntentFile(abs, rel, updated); err != nil { + fields := frontmatter.Fields(strings.Split(string(data), "\n")) + if _, retired := fields[RetiredRelatedIssuesKey]; retired { + return fmt.Errorf("%w: %s carries `%s`, renamed to `%s`; run `abcd capture migrate --apply` first, nothing written", + ErrRetiredField, intentID, RetiredRelatedIssuesKey, RelatedIssuesKey) + } + var have []string + if f, ok := fields[RelatedIssuesKey]; ok && !frontmatter.IsNull(f.Value) { + have = frontmatter.StringList(f.Value) + } + it.RelatedIssues = have + for _, h := range have { + if h == source { + return nil // already joined; the write would change no byte + } + } + list := append(append([]string{}, have...), source) + updated, err := setFrontmatterFields(string(data), map[string]string{RelatedIssuesKey: "[" + strings.Join(list, ", ") + "]"}) + if err != nil { + return err + } + if err := writeIntentFile(abs, rel, updated); err != nil { + return err + } + it.RelatedIssues = list + return nil + }); err != nil { return Intent{}, err } - it.RelatedIssues = list return it, nil } diff --git a/internal/core/intent/writer_lock_test.go b/internal/core/intent/writer_lock_test.go index 1d9712fb7..c61e5e0e4 100644 --- a/internal/core/intent/writer_lock_test.go +++ b/internal/core/intent/writer_lock_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/intentdriven/abcd/internal/core/condition" + "github.com/intentdriven/abcd/internal/core/frontmatter" ) // writer_lock_test.go — every writer of an intent record judges and writes the @@ -187,3 +188,57 @@ func TestReEmitKeepsAConditionDispositionLandedInTheWindow(t *testing.T) { 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) + } +} From bb14bbe99e49b368452ac7fa9caf26c454c58b3c Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:40:46 +0100 Subject: [PATCH 24/66] =?UTF-8?q?chore:=20resolve=20iss-2609261935407995?= =?UTF-8?q?=20=E2=80=94=20edge=20and=20link=20writes=20hold=20the=20store?= =?UTF-8?q?=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609261935407995 Assisted-by: Claude:claude-opus-5-5 --- ...related-issue-and-link-writes-outside-the-mint-lock.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md (67%) diff --git a/.abcd/work/issues/open/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 similarity index 67% rename from .abcd/work/issues/open/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md rename to .abcd/work/issues/resolved/iss-2609261935407995-related-issue-and-link-writes-outside-the-mint-lock.md index 27516b462..21fe299a1 100644 --- a/.abcd/work/issues/open/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 @@ -9,6 +9,14 @@ 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 From 1b5a171e3e8070e8d67a2d603dfe762f3004a293 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:41:09 +0100 Subject: [PATCH 25/66] chore: capture capture migrate writing intents outside the mint lock Left open by the fix2-drainA1 sweep: the fix needs an exported seam for another package to take the intent store lock, the gap relink.Repoint already records. Refs: iss-2609261941039204 Refs: iss-2609261254247117 Assisted-by: Claude:claude-opus-5-5 --- ...migrate-writes-intents-outside-the-mint-lock.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609261941039204-capture-migrate-writes-intents-outside-the-mint-lock.md diff --git a/.abcd/work/issues/open/iss-2609261941039204-capture-migrate-writes-intents-outside-the-mint-lock.md b/.abcd/work/issues/open/iss-2609261941039204-capture-migrate-writes-intents-outside-the-mint-lock.md new file mode 100644 index 000000000..4261eb616 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609261941039204-capture-migrate-writes-intents-outside-the-mint-lock.md @@ -0,0 +1,14 @@ +--- +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" +--- + +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. From b31905ae1459f683c46819e612b2f7d2bbbabad7 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:11:13 +0100 Subject: [PATCH 26/66] chore: capture two fidelity-request remainders The consistency request carries no findings shape, the Role 2 sibling of the fidelity request's missing verdict shape; and the fidelity request's delivered line still leaves the diff range to the host, the third addendum of the scope-condition record, which needs a design call on attestation. Refs: iss-2609262011046013 Refs: iss-2609262011091645 Refs: iss-2609181121301638 Refs: iss-2609181121305984 Assisted-by: Claude:claude-opus-5-5 --- ...ency-review-request-carries-no-findings-json.md | 14 ++++++++++++++ ...review-request-s-delivered-line-still-leaves.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609262011046013-the-consistency-review-request-carries-no-findings-json.md create mode 100644 .abcd/work/issues/open/iss-2609262011091645-the-fidelity-review-request-s-delivered-line-still-leaves.md diff --git a/.abcd/work/issues/open/iss-2609262011046013-the-consistency-review-request-carries-no-findings-json.md b/.abcd/work/issues/open/iss-2609262011046013-the-consistency-review-request-carries-no-findings-json.md new file mode 100644 index 000000000..b76f6b559 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609262011046013-the-consistency-review-request-carries-no-findings-json.md @@ -0,0 +1,14 @@ +--- +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" +--- + +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. 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. From 15a138c651e1ced5030e36f3f6fe3a81249d02b2 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:13:31 +0100 Subject: [PATCH 27/66] fix(intent): the audit results say what the emit and the ingest did The verdict ingest returns one struct for every outcome, so a dead-lettered verdict's JSON carried zero-valued criteria counters beside a conditions count, which a lane took for a rollup. Its JSON now states what the ingest recorded (`recorded`: verdict, quarantine or nothing) and carries the acceptance rollup and the disposition split only beside a recorded verdict, where a zero is a count. A quarantine keeps the one split it did record, every scope condition untested, as `conditions_untested`, so the agreement with the record that iss-2608300927241768 asked for still holds. The Go fields are unchanged, so the text render and every in-process reader are untouched. A re-emit on an OWED receipt rewrote the request and reported only already_owed, which read as nothing happened. The emit result now names the act beside the state (`request_written`), and `request_path` is the request this emit wrote: a terminal receipt, whose emit writes nothing, names none rather than a path to a request it did not write (the same honesty rule from the other side). The text render says `request rewritten:` on an owed re-emit and `no request written` on a terminal one. Refs: iss-2609190337545165 Refs: iss-2609190337598356 Refs: iss-2608300927241768 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/05-intent.md | 4 +- commands/intent.md | 16 +- internal/core/intent/audit.go | 75 ++++++++- internal/core/intent/audit_result_test.go | 153 ++++++++++++++++++ internal/surface/cli/cli.go | 15 +- .../cli/intent_audit_conditions_test.go | 57 +++++++ 6 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 internal/core/intent/audit_result_test.go diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 6841be9cd..888defa1c 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -307,11 +307,11 @@ 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`. 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. 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`) | +| 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) | diff --git a/commands/intent.md b/commands/intent.md index 3d6d365fd..a908e3a40 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -678,7 +678,13 @@ 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: @@ -721,8 +727,12 @@ 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`. 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 diff --git a/internal/core/intent/audit.go b/internal/core/intent/audit.go index 86274efb8..4491180a5 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -198,11 +198,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. @@ -235,6 +242,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) // --------------------------------------------------------------------------- @@ -308,7 +371,6 @@ func emitLocked(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmitRes // 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" @@ -322,6 +384,8 @@ func emitLocked(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmitRes 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 } @@ -344,6 +408,7 @@ func emitLocked(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmitRes } res.Status = "owed" res.RequestPath = filepath.Join(reviewsRelDir, rcp+".request.md") + res.RequestWritten = true return res, nil } diff --git a/internal/core/intent/audit_result_test.go b/internal/core/intent/audit_result_test.go new file mode 100644 index 000000000..870360421 --- /dev/null +++ b/internal/core/intent/audit_result_test.go @@ -0,0 +1,153 @@ +package intent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// audit_result_test.go — what the emit and the ingest results say they did. +// A result names the receipt's state and the act separately, because the two +// differ, and a reader who takes one for the other acts on something that did +// not happen. + +// TestIngestResultJSONCarriesARollupOnlyForARecordedVerdict is +// iss-2609190337545165: one result struct serves every outcome, and a +// quarantined verdict's JSON carried zero-valued criteria counters beside a +// conditions count read from the intent — a rollup that reads as a result. The +// JSON now says what the ingest recorded (`recorded`), carries the rollup only +// when that is a verdict, and states a quarantine's untested split under its +// own name. +func TestIngestResultJSONCarriesARollupOnlyForARecordedVerdict(t *testing.T) { + rollup := []string{"criteria", "met", "met_with_concerns", "not_met", "inconclusive", + "conditions", "survived", "narrowed", "falsified", "untested"} + decode := func(t *testing.T, res IngestVerdictResult) map[string]any { + t.Helper() + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + return m + } + + root := t.TempDir() + rcp := shipWithConditions(t, root, + stampedCondition(condOne, "holds on POSIX"), + stampedCondition(condTwo, "holds below 10k records"), + ) + bad := verdictWithConditions(t, rcp, dispositionOf(condOne, "MET"), dispositionOf(condTwo, "survived")) + dl, err := IngestVerdict(root, writeVerdict(t, root, bad)) + if err != nil || dl.Status != "dead_letter" { + t.Fatalf("setup: want a dead_letter, got %+v, %v", dl, err) + } + m := decode(t, dl) + for _, k := range rollup { + if _, ok := m[k]; ok { + t.Errorf("the dead_letter JSON carries the rollup member %q:\n%v", k, m) + } + } + if m["recorded"] != "quarantine" { + t.Errorf("the dead_letter JSON says recorded = %v, want quarantine", m["recorded"]) + } + if m["conditions_untested"] != float64(2) { + t.Errorf("the dead_letter JSON must state the 2 conditions its quarantine recorded untested: %v", m) + } + if m["dead_letter_path"] == nil || m["reason"] == nil { + t.Errorf("the dead_letter JSON must still say where the payload went and why: %v", m) + } + + root = t.TempDir() + rcp = shipOne(t, root) + vp := writeVerdict(t, root, validVerdict(rcp)) + ok, err := IngestVerdict(root, vp) + if err != nil || ok.Status != "ingested" { + t.Fatalf("setup: want ingested, got %+v, %v", ok, err) + } + m = decode(t, ok) + for _, k := range rollup { + if _, ok := m[k]; !ok { + t.Errorf("the ingested JSON lacks the rollup member %q (a zero count is a count):\n%v", k, m) + } + } + if m["recorded"] != "verdict" { + t.Errorf("the ingested JSON says recorded = %v, want verdict", m["recorded"]) + } + if _, ok := m["conditions_untested"]; ok { + t.Errorf("the ingested JSON carries the quarantine's member: %v", m) + } + + noop, err := IngestVerdict(root, vp) + if err != nil || noop.Status != "noop" { + t.Fatalf("setup: want noop, got %+v, %v", noop, err) + } + m = decode(t, noop) + for _, k := range rollup { + if _, ok := m[k]; ok { + t.Errorf("the noop JSON carries the rollup member %q:\n%v", k, m) + } + } + if m["recorded"] != "nothing" { + t.Errorf("the noop JSON says recorded = %v, want nothing", m["recorded"]) + } +} + +// TestReEmitOfAnOwedReceiptSaysItRewroteTheRequest is iss-2609190337598356: a +// re-emit on an OWED receipt rewrites the request and reported only +// already_owed, which a caller read as "nothing happened". The result now says +// it wrote the request, and a terminal receipt — whose emit writes nothing — +// names no request path at all rather than one it did not write. +func TestReEmitOfAnOwedReceiptSaysItRewroteTheRequest(t *testing.T) { + root := t.TempDir() + rcp := shipOne(t, root) + reqPath := filepath.Join(root, reviewsDir, rcp+".request.md") + if err := os.Remove(reqPath); err != nil { + t.Fatal(err) + } + + res, err := ReEmitAudit(root, "itd-10") + if err != nil { + t.Fatal(err) + } + if res.Status != "already_owed" || !auditEmitJSON(t, res)["request_written"].(bool) || res.RequestPath == "" { + t.Fatalf("an OWED re-emit = %+v, want already_owed with the request it wrote", res) + } + if _, err := os.Stat(reqPath); err != nil { + t.Fatalf("the re-emit reported a request it did not write: %v", err) + } + + if _, err := IngestVerdict(root, writeVerdict(t, root, validVerdict(rcp))); err != nil { + t.Fatal(err) + } + if err := os.Remove(reqPath); err != nil { + t.Fatal(err) + } + done, err := ReEmitAudit(root, "itd-10") + if err != nil { + t.Fatal(err) + } + if m := auditEmitJSON(t, done); done.Status != "already_ingested" || m["request_written"] != false || m["request_path"] != nil { + t.Fatalf("a terminal re-emit = %+v, want already_ingested naming no request", done) + } + if _, err := os.Stat(reqPath); err == nil { + t.Fatal("a terminal re-emit wrote a request") + } +} + +// auditEmitJSON is an emit result as its --json reader sees it. +func auditEmitJSON(t *testing.T, res AuditEmitResult) map[string]any { + t.Helper() + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + m := map[string]any{"request_written": false} + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + return m +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 97bb1ffb3..d8ea103c7 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -2732,8 +2732,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) }) }, diff --git a/internal/surface/cli/intent_audit_conditions_test.go b/internal/surface/cli/intent_audit_conditions_test.go index 0dfa2b0b7..326661272 100644 --- a/internal/surface/cli/intent_audit_conditions_test.go +++ b/internal/surface/cli/intent_audit_conditions_test.go @@ -225,3 +225,60 @@ func TestIntentAuditIngestRefusesAnUnresolvableCitation(t *testing.T) { 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) + } + } +} From d3a4bd667760f980f44e549c1a2dbafe122021fd Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:14:27 +0100 Subject: [PATCH 28/66] fix(intent): the fidelity request states the conditions, the count and the shape The request listed the acceptance criteria as "numbered ac-1..ac-K" without printing K, and never named the scope-condition identities the verdict must dispose: they reached the auditor only as HTML comments in the record, and a miscount quarantined a verdict. The request now prints K (the bullet count the ingest judges against) and carries a Scope Conditions block listing every condition under its cond- identity with its text, read through ParseClaims, the reader the ingest's coverage check uses; a conditionless intent is told its list is empty, and an unstamped condition is listed as one. It also carried no verdict shape, which lived only in the bundled agent definition, so a reviewer working from the request learned it from refusals. A Verdict shape section now states it, rendered by reflection from the verdict struct the ingest decodes with DisallowUnknownFields: the schema itself, never a second copy, so a field added to the struct moves the shape and the prompt_hash with it. The ingest's --verdict-json help names the section. The dry-run validator the second record's addendum offers as an alternative remedy is not added: the record names either remedy as sufficient. Both blocks sit in the hashed prompt body, which stays a pure function of the receipt and the record, so the ingest recomputes prompt_hash as before. The auditor definition (0.4.1) names both blocks. The record's third addendum, the delivered range the host still supplies, is captured apart because it needs an attestation design call. Refs: iss-2609181121301638 Refs: iss-2609181121305984 Refs: iss-2609262011091645 Assisted-by: Claude:claude-opus-5-5 --- .../brief/04-surfaces/05-intent.md | 2 +- agents/CHANGELOG.md | 15 ++ agents/intent-auditor.md | 8 +- commands/intent.md | 16 +- docs/reference/cli/commands.md | 2 +- internal/core/intent/audit.go | 145 +++++++++++++- internal/core/intent/audit_request_test.go | 180 ++++++++++++++++++ internal/surface/cli/cli.go | 2 +- .../cli/intent_audit_conditions_test.go | 9 + 9 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 internal/core/intent/audit_request_test.go diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 888defa1c..66b4b1c25 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -307,7 +307,7 @@ 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`. 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) | +| 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) | diff --git a/agents/CHANGELOG.md b/agents/CHANGELOG.md index bce873684..6d9d9da15 100644 --- a/agents/CHANGELOG.md +++ b/agents/CHANGELOG.md @@ -12,6 +12,21 @@ 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 — the request states the conditions and the shape) + +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, so the auditor no longer scrapes identities out of the +record or learns the shape from refusals. + +### intent-auditor 0.4.1 + +PATCH: 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 rubric, the verdict's shape and every ingest rule are untouched, so +a verdict 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..17db641d2 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.4.1 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", diff --git a/commands/intent.md b/commands/intent.md index a908e3a40..eca53e8a7 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -727,12 +727,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`. 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 +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 diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 83aae21f2..172a72498 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1538,7 +1538,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/intent/audit.go b/internal/core/intent/audit.go index 4491180a5..efe96a3d2 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -9,6 +9,7 @@ import ( "io/fs" "os" "path/filepath" + "reflect" "regexp" "sort" "strings" @@ -501,20 +502,158 @@ 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. +func fillShape(v reflect.Value, name string, spec shapeSpec) { + 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) // --------------------------------------------------------------------------- diff --git a/internal/core/intent/audit_request_test.go b/internal/core/intent/audit_request_test.go new file mode 100644 index 000000000..9905dd63f --- /dev/null +++ b/internal/core/intent/audit_request_test.go @@ -0,0 +1,180 @@ +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, "