diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index 12c1abff2..7f91a15ed 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 980000, - "measured_tokens_est": 964528, - "measured_bytes": 3713434, - "measured_at": "4c9c7be8995a0b53da068763104b7653b1b44b0e" + "tokens_est": 1000000, + "measured_tokens_est": 989958, + "measured_bytes": 3811341, + "measured_at": "eeff3b69722d67b1799bb1a450d7b61efdc1ea83" } }, "entailment": { @@ -133,9 +133,9 @@ ], "window": { "tokens_est": 340000, - "measured_tokens_est": 328679, - "measured_bytes": 1265417, - "measured_at": "4c9c7be8995a0b53da068763104b7653b1b44b0e" + "measured_tokens_est": 332485, + "measured_bytes": 1280070, + "measured_at": "eeff3b69722d67b1799bb1a450d7b61efdc1ea83" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 990000, - "measured_tokens_est": 973565, - "measured_bytes": 3748228, - "measured_at": "4c9c7be8995a0b53da068763104b7653b1b44b0e" + "tokens_est": 1010000, + "measured_tokens_est": 998994, + "measured_bytes": 3846129, + "measured_at": "eeff3b69722d67b1799bb1a450d7b61efdc1ea83" } } } diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 79e5dda1a..8c6203d11 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -57,6 +57,7 @@ judgement no verb makes. | `ready` | gate | shipped | | `audit` | audit | shipped | | `audit ingest` | audit | shipped | +| `condition` | — | shipped | ## 1. Intent IDs, kinds, and lifecycle @@ -280,7 +281,8 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | 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), but auto-running the reviewer off that queue is still deferred (no spec currently owns it; spc-6 (predecessor store) disowned auto-firing). | (stays) | | 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 into the shipped intent's `## Audit Notes` (or quarantines a bad payload). | (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 into the shipped intent's `## Audit Notes` (or quarantines a bad payload). 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`) | +| 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`) | | `/abcd:intent consistency []` | **Role 2 — cross-document fidelity.** Surfaces five judgement categories (terminology drift, premise contradictions, scope leakage, sequencing impossibilities, naming conflicts) across briefs + intents. **Bare** scans the whole corpus; **with ``** narrows to one intent's relationship with the rest. Findings land in `.abcd/.work.local/logs/audit/consistency-/report.{json,md}`. The judgement half + on-demand verb are the predecessor's spc-29 (a later phase); mechanical-half categories and pre-commit hook are deferred follow-ups. | (stays) | | `/abcd:intent shape []` | **Role 3 — kind classification.** Examines whether an intent's declared `kind` (the noun) still fits the corpus. Surfaces *suggested* reclassifications across three live types: `kind_change`, `bundle`, `supersession`. **Bare** scans the corpus; **with ``** checks one intent. Pairs with the reclassify step (action verb that commits a `shape` finding). On-demand only per spc-29 (predecessor store; a later phase); findings land in `.abcd/.work.local/logs/audit/shape-/report.{json,md}`. Concurrency via `flock(2)` on `.abcd/coordination/shape.lock` (see § 7). Scheduled / continuous invocation is a deferred follow-up. | (stays) | | The reclassify step, on one intent id | **A later phase — no reclassify sub-verb ships yet.** Late reclassification (e.g., a standalone intent realised to be a bundle-member; a draft realised to be a discipline; a shipped intent superseded by a later one). Records `reclassification_history` entry; moves the file between directories as the new kind dictates. Reclassifying to superseded, naming the successor handle, is the supersession path: the file moves to `superseded/`, frontmatter records `superseded_by: ` — the record that formally supersedes this intent, either an intent (`itd-M`) or an ADR (`adr-M`) when a decision redecided the question — AND `kind_at_supersession: ` so future readers know what shape the intent had when retired. | varies by destination kind | @@ -605,7 +607,7 @@ _Generated from the command tree; a drift test fails `go test` when this appendi ### `abcd intent` -Sub-verbs: `abcd intent audit`, `abcd intent hold`, `abcd intent link`, `abcd intent new`, `abcd intent plan`, `abcd intent ready`, `abcd intent unhold`. +Sub-verbs: `abcd intent audit`, `abcd intent condition`, `abcd intent hold`, `abcd intent link`, `abcd intent new`, `abcd intent plan`, `abcd intent ready`, `abcd intent unhold`. | Flag | Type | |---|---| @@ -630,6 +632,17 @@ Sub-verbs: none. |---|---| | `--verdict-json` | string | +### `abcd intent condition` + +Sub-verbs: none. + +| Flag | Type | +|---|---| +| `--disposition` | string | +| `--grounds` | string | +| `--narrowing` | string | +| `--occasioned-by` | string | + ### `abcd intent hold` Sub-verbs: none. 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 162f25640..9bf1e7605 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 @@ -66,9 +66,9 @@ A table that cannot say what a reading is about cannot distinguish four readings ## Scope Conditions -- Whether a scope should be expressible as a set of record families rather than one target is unknown until several readings have run. The grammar starts closed and narrow; widening it is cheaper than retracting it. `` -- The comparative position's object is the widening reading's pre-admission output, which is not a subset of the repository and has no channel today. A scope operand does not supply one, and that absence stays open — which is why this intent makes the position refuse rather than quietly serve it the wrong corpus. `` -- **The impact stays `fix`, and the reasoning is stated rather than left to be reconstructed.** A required operand where none existed is the textbook breaking change, and two things make it a fix here instead. The verb's output cannot be handed to any reader at any position, so there is no working invocation to break; and the comparative position was returning a corpus that is not its object, so its every past run was already wrong. Calling this `breaking` would assert that something usable stopped working, which would be the more misleading of the two labels. `` +- Whether a scope should be expressible as a set of record families rather than one target is unknown until several readings have run. The grammar starts closed and narrow; widening it is cheaper than retracting it. +- The comparative position's object is the widening reading's pre-admission output, which is not a subset of the repository and has no channel today. A scope operand does not supply one, and that absence stays open — which is why this intent makes the position refuse rather than quietly serve it the wrong corpus. +- **The impact stays `fix`, and the reasoning is stated rather than left to be reconstructed.** A required operand where none existed is the textbook breaking change, and two things make it a fix here instead. The verb's output cannot be handed to any reader at any position, so there is no working invocation to break; and the comparative position was returning a corpus that is not its object, so its every past run was already wrong. Calling this `breaking` would assert that something usable stopped working, which would be the more misleading of the two labels. ## Acceptance Criteria diff --git a/.abcd/development/intents/planned/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md b/.abcd/development/intents/shipped/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md similarity index 98% rename from .abcd/development/intents/planned/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md rename to .abcd/development/intents/shipped/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md index a6f0e2c20..1dfcfcbad 100644 --- a/.abcd/development/intents/planned/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md +++ b/.abcd/development/intents/shipped/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md @@ -72,7 +72,8 @@ None. ## Audit Notes -_Empty. Populated by intent-auditor when intent moves to shipped/._ + +Fidelity review OWED (receipt rcp-dd287c31bbb6). ## Grounds diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 77ac43b01..aacfe978d 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1294,6 +1294,40 @@ } ] }, + { + "path": "abcd intent condition", + "hidden": false, + "flags": [ + { + "name": "disposition", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "grounds", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "narrowing", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "occasioned-by", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + } + ] + }, { "path": "abcd intent hold", "hidden": false, diff --git a/.abcd/development/specs/open/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md b/.abcd/development/specs/closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md similarity index 87% rename from .abcd/development/specs/open/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md rename to .abcd/development/specs/closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md index bf21a345e..4c8bbdabc 100644 --- a/.abcd/development/specs/open/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md +++ b/.abcd/development/specs/closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md @@ -10,7 +10,7 @@ production_mode: dictated-and-formatted ## Summary spc-2609020626046252 delivers -[itd-2609020625405251](../../intents/planned/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md). +[itd-2609020625405251](../../intents/shipped/itd-2609020625405251-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md). `abcd intent condition --disposition --occasioned-by --grounds "" [--narrowing ""]` writes one scope-condition disposition against a shipped intent, keyed to the identity @@ -22,16 +22,17 @@ It is the second writer into the surface gave the fidelity verdict: The `## Audit Notes` section of the intent, one block per write. The verdict ingest keeps writing one undated block covering every condition; this verb writes one dated block covering one condition and -naming its occasion. A condition's standing disposition is the last block in -document order that names it, except that a later verdict block does not -override a reading-occasioned block unless its rationale names that block's -occasion, and the render says which block the standing came from. +naming its occasion. A condition's standing disposition 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, and the render +says which block the standing came from. The vocabulary the two writers share moves into a leaf package, `internal/core/condition`, so that one enum, one marker grammar and one block reader serve the verdict ingest, this verb, the readiness gate, and the record lint that the sibling spec -[spc-2609020626042471](spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md) +[spc-2609020626042471](../open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md) builds over the same dispositions. The reading-item locator moves into a second leaf, `internal/core/readingitem`, which the later Iteration 2 specs share. @@ -173,23 +174,37 @@ knows nothing of the condition blocks around it. returns every disposition in document order, each carrying its `ConditionID`, `Disposition`, `Rationale`, `Narrowing`, `Source` (`verdict rcp-…`, `condition rdi-…` or `condition itd-…`) and, for a condition block, its `Date` -and `Occasion`. `condition.Standing(content)` folds that list. For each -identity the **last entry in document order** stands, with one exception: An -entry from a verdict block does not replace a standing entry from a condition -block unless the verdict entry's rationale names that entry's occasion. A -re-audit writes a block covering every condition by construction and knows -nothing of the readings; without the exception it would erase, silently, the -one thing the join exists to keep, and an auditor who has weighed the reading -says so by naming it. Document order is the rule, not the date, because the -verdict block carries no date by design and both writers append, so position -in the section is the order of writing. +and `Occasion`. `condition.Standing(content)` folds that list by **source precedence**. +Within one source document order decides: the last condition-block entry for +an identity is its standing reading-occasioned judgement, and the last verdict +entry its standing audit. Between the sources, the condition-block entry stands +over every verdict entry, except that a verdict entry whose rationale names the +standing condition block's occasion replaces it, wherever either sits in the +section. A re-audit writes a block covering every condition by construction and +knows nothing of the readings; without the precedence it would erase, silently, +the one thing the join exists to keep, and an auditor who has weighed the +reading says so by naming it. + +> **Corrected 2026-09-25.** This section first folded by position — the last +> entry in document order stands — on the premise that both writers append, so +> position in the section is the order of writing. The premise is false for the +> verdict path: the ingest replaces the receipt's OWED stub in place, and the +> stub is parked at ship time, before any condition block exists, so a verdict +> ingested after a condition block sits above it and never overrode it. The +> fold above is the orchestrator's ruling on the lane review (recorded in +> `.abcd/work/DECISIONS.md` on 2026-09-25). The verdict ingest reports what it leaves standing. After `IngestVerdict` writes its block, `IngestVerdictResult` gains `ReadingOccasionedStanding`, the list of condition-block entries the fold still reports as standing after the write, each with its identity and occasion, and the CLI prints the list under the ingest's summary. An auditor who meant to override one names its occasion -in the rationale and ingests again. +in the rationale and ingests again for the same receipt: a payload that renders +differently from the ingested block replaces it in place and the result says +`replaced`, a payload that renders identically is a noop, and a payload that +does not validate is refused with nothing written rather than dead-lettered over +the verdict already on the record (corrected 2026-09-25; the re-ingest was a +noop whatever it carried, which left the override unreachable). The render lists each condition the intent carries as ` — (from )`, and `untested (no block)` for an @@ -203,7 +218,7 @@ readiness gate keeps reporting identities, not dispositions. `internal/core/readingitem`, as `Locate(issuesRoot, item string) (run, path string, err error)` and `Paths`, with sentinels `ErrUnknown` and `ErrDuplicate`; beside them the leaf carries `LocateDisposition` and -`ResolveOccasion(issuesRoot, id string, families ...Family)`, the one occasion +`ResolveOccasion(repoRoot, id string, families ...Family)`, the one occasion resolver the admission, reframe and condition verbs share, each naming the families it admits. `capture` keeps `findReadingItem` and `readingItemPaths` as thin wrappers over the leaf, mapping `ErrUnknown` and `ErrDuplicate` onto @@ -331,12 +346,16 @@ nothing of the item's dispatch. - ac-6: A planned intent is refused and the message names `planned`. `TestConditionRefusesUnshippedBucket`. - ac-7: After a verdict block and a condition block naming the same identity, - `ReadDispositions` returns both and `Standing` reports the later. The - inverse order is covered beside it: A verdict block written after a - condition block leaves the condition block standing and is reported, - unless its rationale names the occasion. + `ReadDispositions` returns both and `Standing` reports the condition block. + A verdict leaves the condition block standing and is reported, unless its + rationale names the occasion, wherever the verdict sits; that holds through + the writers, ship then condition then ingest, and ingest then condition then + re-ingest (corrected 2026-09-25). `TestStandingIsTheLatestBlock`, `TestVerdictDoesNotOverrideAReadingOccasionedBlock`, - `TestVerdictNamingTheOccasionOverrides`, `TestReviewBlockBoundaryStopsAtConditionMarker`. + `TestVerdictNamingTheOccasionOverrides`, `TestVerdictNamingTheOccasionOverridesWhereverItSits`, + `TestShipConditionIngestOverridesThroughTheWriters`, `TestReingestNamingTheOccasionOverrides`, + `TestReingestOfAnInvalidPayloadNeverReplacesAnIngestedVerdict`, + `TestReviewBlockBoundaryStopsAtConditionMarker`. - ac-8: An assembly over the fixture intent carries no block and the manifest asserts the exclusion. A regression guard, as stated above. `TestConditionBlockNeverReachesTheBundle`. diff --git a/.abcd/development/specs/open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md b/.abcd/development/specs/open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md index 3308fd6ab..ab05ff145 100644 --- a/.abcd/development/specs/open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md +++ b/.abcd/development/specs/open/spc-2609020626040342-an-admission-and-a-surprise-are-written-by-a-verb-and-the-or.md @@ -206,7 +206,7 @@ reasons for one act by any path. The refusal writes nothing and says so. `SurpriseRequest{RepoRoot, IssuesRoot, OccasionedBy, Text string}` and `SurpriseResult{ID, OccasionedBy, Path string; Redacted int; Degraded string}`. The occasion is resolved before anything is minted through -`readingitem.ResolveOccasion(issuesRoot, id, families)` with the families +`readingitem.ResolveOccasion(repoRoot, id, families)` with the families `rdi`, `adm` and `dsp`, the one occasion resolver spc-2609020626046252 introduces and spc-2609020626048705 also calls; an id outside those families, or one that resolves to nothing, refuses naming it. The text is redacted diff --git a/.abcd/development/specs/open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md b/.abcd/development/specs/open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md index 05913021e..01d3291e9 100644 --- a/.abcd/development/specs/open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md +++ b/.abcd/development/specs/open/spc-2609020626042471-a-principle-carries-typed-claims-its-reference-its-compariso.md @@ -39,7 +39,7 @@ No existing entry is renamed, typed or moved. This spec lands last of the Iteration 2 set. It consumes the condition reader the sibling spec -[spc-2609020626046252](spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md) +[spc-2609020626046252](../closed/spc-2609020626046252-a-scope-condition-is-dispositioned-from-a-reading-run-keyed.md) introduces, and it takes the include table, the exclusion floor and the eval tables as the earlier specs leave them. diff --git a/.abcd/development/specs/open/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md b/.abcd/development/specs/open/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md index c6ea4c910..8412f3d04 100644 --- a/.abcd/development/specs/open/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md +++ b/.abcd/development/specs/open/spc-2609020626048705-a-reframe-occasioned-by-a-reading-is-recorded-as-a-reframe-j.md @@ -176,7 +176,7 @@ what the gate would refuse. ### Resolving the occasion, and the one check on the join -The occasion is resolved through `readingitem.ResolveOccasion(issuesRoot, id, issueschema.ReframeOccasionFamilies...)` +The occasion is resolved through `readingitem.ResolveOccasion(repoRoot, id, issueschema.ReframeOccasionFamilies...)` in the `internal/core/readingitem` leaf, the one resolver the admission, reframe and condition verbs share; it refuses an id outside the families it is handed by shape before any path is built. An `rdi-N` resolves through diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 2f12964d3..f0feb20a2 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2531,6 +2531,7 @@ together (the script's header says why there is no escape hatch). - 2026-09-24 — Correction to the entry above on the 02:02Z ruling: the clause "a fork of an agent is still an agent" is not part of the product thinker's ruling. It is the run's own lane rule for autonomous run A, which counts a fork toward the ceiling because a fork is an agent alive. The ruling itself is the rest of that entry: no pauses, a ceiling of four sub-agents in any mix of roles, and rotation at about 60% of context unchanged (lane implementer, on review of the followup lane; the ledger is append-only, so the entry above stands as written). - 2026-09-24 — Release v0.10.0 is cut by autonomous run A, and the run's agenda line is: approve the publish step. Under ruling A2 of the product thinker's run A interview (2026-09-23 07:52Z, "approve the publish step": the product thinker authorises the run to approve the release environment itself once every gate is green), the run approves the `release` environment's deployment of v0.10.0 only after the merge queue, the verify job and every other gate on the tagged commit report green, and stops with a handover instead if any does not. The cut: v0.10.0, impact breaking, 43 records since v0.9.0 (nine shipped intents, thirty-four resolved or declined issues, four of them breaking), content commit 64ea8f62, the composer's payload accepted on the first ingest and recomposed once for two docs-currency findings. Both semantic gates ran at tier full: docs-currency-reviewer (Fable 5.1) with three findings, two fixed and one deferred because the load check's intent stays planned pending the product thinker's ruling on the stray definition (iss-2609231947544298); the brief-surface cross-check (40 pinned checkers, Opus 5.5, four at a time under the run's ceiling) with 154 findings, all deferred to their records: four user-facing ones captured as iss-2609240519413467, iss-2609240519418856, iss-2609240519471816 and iss-2609240519427388, one inside an appendix chapter captured as iss-2609240519422232, which records itd-147's ac-6 as not met, and the design-record drift to the systematic brief pass iss-2609091956001547. - 2026-09-24 — v0.10.0 is published. PR #693 merged as 1ac8b3a0, and auto-release run 35963282477 tagged it. The `release` environment was approved under ruling A2 once all 30 checks on the tagged commit had settled (25 success, 5 skipped by design). The release was published at 2026-09-24T06:33:10Z with four binaries, `checksums.txt`, the plugin archive `abcd-plugin-v0.10.0.zip` (its sha256 equals the marketplace pin, 1ab2acd1…) and the rendered site, which was deployed. Verified locally afterwards: the darwin-arm64 binary's checksum and its build attestation, and the binary reports v0.10.0. +- 2026-09-25 — A scope condition's standing is folded by source precedence, not by position, and a re-ingest for an INGESTED receipt whose payload renders differently replaces the ingested verdict (run A orchestrator, ruling on finding 1 of the lane review of itd-2609020625405251 / spc-2609020626046252, branch feat/scope-condition-verb). The spec's fold took the last disposition in document order, on the premise that both writers append; the verdict ingest does not, since it replaces the receipt's OWED stub in place and the stub is parked at ship time, so on every record the writers produce a verdict ingested after a condition block sits above it, and a same-receipt re-ingest was a noop whatever it carried. The documented override (a verdict whose rationale names the occasion) was therefore unreachable. The ruling: (1) a verdict entry replaces a condition entry iff its rationale names that condition's occasion, wherever either sits; condition blocks among themselves, and verdict blocks among themselves, keep document order; consequently a verdict that has named an occasion keeps overriding a later block written for that same occasion, and a researcher re-dispositioning after it cites a new occasion. (2) A re-ingest whose payload renders to the block already on the record stays a noop; one that renders differently replaces it in place after the first ingest's checks; one that does not validate is refused with nothing written, never dead-lettered over a verdict already ingested. No recorded decision makes the ingest write-once: itd-80's ac-5 ("ingest runs again with the same receipt_id … is a no-op: `## Audit Notes` is not duplicated") is an acceptance criterion whose stated property, one block per receipt and no duplication, the replacement keeps, and `TestIngestIdempotentNoOp` still holds. The closed spec carries a dated correction in place. Separately, the lane's commit bumping cold-reading-detection to 0.1.3 while editing itd-199's markers says readings are pinned to an earlier commit; this tree cannot show that pin, and needs none: no study in this repository is mid-flight, because the reading workstream lives in the research fork (the 2026-09-23 entry above on iss-2609021857343626). History is not rewritten. - 2026-09-25 — A push is gated before it opens its connection, by receipt (iss-2608290810036869, iss-2608210738378295; product thinker's ruling M16 of 2026-09-23, "check before connect"). The committed `.githooks/pre-push` no longer runs `make preflight`: git opens the connection before the hook, and a preflight inside it outlasted the transport's idle timeout, so a push reported success and moved nothing. `make preflight` ends by minting a receipt for HEAD under the checkout's local tier (`scripts/preflight-receipt.sh`), and only when the working tree matched HEAD (nothing staged, unstaged or untracked) both when the run began, read while the Makefile is parsed, and when it ended, with HEAD unmoved, so the gates read the tree CI checks out. The hook refuses a push whose commit is new to the remote and carries no receipt from any worktree of the repository; a commit the remote already holds (a tag on a merged commit) passes. Alternatives not taken: a push wrapper that runs the preflight and then `git push --no-verify` (it normalises `--no-verify`, and a plain `git push` would go ungated); keepalive settings on the transport (the hook would still hold the connection for ten minutes); a preflight on a clean export of HEAD (a second full tree and build per push, where refusing a divergent tree costs nothing). `git push --no-verify` skips the hook exactly as before, and CI stays the authority (lane hooks, autonomous run A). - 2026-09-25 — Reach of the push-receipt entry above, stated after review (iss-2608210738378295). The receipt's clean-tree test is `git status` read when the preflight begins and when it ends, and a tree `git status` cannot see is not vouched for. So `scripts/preflight-receipt.sh` also refuses to mint while any tracked file is flagged skip-worktree or assume-unchanged (`git ls-files -v` tags it `S` or in lower case), because either flag hides that file's edits from the status read; a sparse checkout sets skip-worktree and so never mints. What the receipt still cannot see, stated in the script's header: files git ignores, which are outside the commit yet can be read by a gate (a `go.work`, which `.gitignore` lists, changes every Go gate's module resolution); HEAD moved and moved back, or the tree changed and restored, between the two reads; and anything a gate reads from outside the checkout. The receipt stays a local convenience gate and CI the authority; closing those would mean running the gates on a clean export of HEAD, the alternative the entry above did not take (lane hooks fix round, autonomous run A). - 2026-09-25 — The layered configuration resolver has one home, `internal/core/layered`, and two file families; every later consumer reads through it rather than opening a file of its own (lane implementer, autonomous run A, on spc-2609180535002478 part 1). Precedence is flag, then repository, then machine, then bundled, and every value comes back with the layer and the origin that supplied it. The families are `layered.Config`, which is `.abcd/config.json` in the checkout the session resolved and `~/.abcd/config.json` on the machine, and `layered.OracleRouting`, which is `.abcd/config/oracle-routing.json` and `~/.abcd/oracle-routing.json`, the files itd-2609170822093401 names. Scalar keys go in `Config` under a namespace: `pace.*` (itd-2609201925079472), `oracle.review` (itd-6), `roles..runner` (itd-2609201916056194) and `match.threshold` (itd-2609212137116617). The per-agent routing table keeps its own file, as its intent names, and no new file family is added without an entry here. Loudness: an absent file is an absent layer; a present file that is malformed, symlinked, carries a key twice, trails content, declares the wrong `schema_version`, or (machine layer) is not the caller's own owner-only-writable file is an error naming it; a consumer claims its namespace and every key in it (`Stack.Claim`, with `*` for an open segment such as the role name), and an unknown key under a claimed namespace is refused. Because ahoy writes `oracle.backend` into the shared file, whoever claims `oracle` lists `backend` too. A winning value that does not decode or fails its check is refused, and nothing falls through to a lower layer or to the default. diff --git a/.abcd/work/issues/open/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md b/.abcd/work/issues/open/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md new file mode 100644 index 000000000..391139be1 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251451432601-intent-audit-ingest-the-first-ingest-and-any-replacement.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251451432601" +slug: "intent-audit-ingest-the-first-ingest-and-any-replacement" +severity: "minor" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/audit.go" +--- + +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. diff --git a/.abcd/work/issues/open/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md b/.abcd/work/issues/open/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md new file mode 100644 index 000000000..e6dff7759 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251451434656-intent-audit-ingest-reviewblockrange-treats-any-text-under.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251451434656" +slug: "intent-audit-ingest-reviewblockrange-treats-any-text-under" +severity: "minor" +category: "bug" +source: "impl-review" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/audit.go" +--- + +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. diff --git a/.abcd/work/issues/open/iss-2609251455354719-the-cold-reading-detection-position-s-declared-window-is-now.md b/.abcd/work/issues/open/iss-2609251455354719-the-cold-reading-detection-position-s-declared-window-is-now.md new file mode 100644 index 000000000..3e194cf1e --- /dev/null +++ b/.abcd/work/issues/open/iss-2609251455354719-the-cold-reading-detection-position-s-declared-window-is-now.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609251455354719" +slug: "the-cold-reading-detection-position-s-declared-window-is-now" +severity: "minor" +category: "observation" +source: "agent-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/config/reading-presets.json" +--- + +The cold-reading detection position's declared window is now 1,010,000 estimated tokens (widening 1,000,000) after the recalibration at 1a19fe55, which puts the assembled detection input past the million-token reader window the preset entries' own comment names as the bound. Each merged corpus lane grows it further, so the next recalibration will push widening past a million too. The documented alternative remedy is narrowing an entry's kinds (the comment measures widening without test at about 530,000), which changes a reading instrument and needs a ruling. Until then a reading at detection cannot be handed to a million-token reader whole. diff --git a/.abcd/work/issues/resolved/iss-2609250947113245-itd-199-s-three-scope-condition-identity-markers-are-wrapped.md b/.abcd/work/issues/resolved/iss-2609250947113245-itd-199-s-three-scope-condition-identity-markers-are-wrapped.md new file mode 100644 index 000000000..3148d8901 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609250947113245-itd-199-s-three-scope-condition-identity-markers-are-wrapped.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609250947113245" +slug: "itd-199-s-three-scope-condition-identity-markers-are-wrapped" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: ".abcd/development/intents/shipped/itd-199-a-reading-is-about-something-narrower-than-everything-its.md" +resolution: "unwrapped the three markers; the claim reader now reads all three identities and the condition verb's read form reports each with its verdict disposition" +impact: fix +resolved_by: + commit: "e5346b928eb5aabe60c9c5b476978df0598a7545" +--- + +itd-199's three scope-condition identity markers are wrapped in backticks in its shipped record, so the claim reader treats each as documentation of the marker grammar rather than an identity: ParseClaims reports zero conditions, the verdict dispositions keyed to cond-2608312031029678, cond-2608312031028702 and cond-2608312031020321 key to nothing the record carries, and abcd intent condition refuses to re-disposition cond-2608312031028702, the worked example its own spec names + +## Grounds + +- pursued: we expect itd-199 to carry three readable condition identities; a read of intent condition itd-199 reporting fewer than three would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609251052591014-readingitem-resolveoccasion-takes-an-issues-root-and-for-an.md b/.abcd/work/issues/resolved/iss-2609251052591014-readingitem-resolveoccasion-takes-an-issues-root-and-for-an.md new file mode 100644 index 000000000..a97ee2c9c --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609251052591014-readingitem-resolveoccasion-takes-an-issues-root-and-for-an.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609251052591014" +slug: "readingitem-resolveoccasion-takes-an-issues-root-and-for-an" +severity: "minor" +category: "tech-debt" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/readingitem/readingitem.go" +resolution: "ResolveOccasion takes the repository root and resolves an intent occasion by reading the intent store alone (recordid.LookupOne)." +impact: internal +resolved_by: + commit: "3606c9e9ff33a893b95fa6b39b7b8355a7e0bba2" +--- + +readingitem.ResolveOccasion takes an issues root and, for an itd-N occasion, recovers the repository root by suffix-stripping .abcd/work/issues off it, while its one caller (intent/condition.go) joins that suffix on only for it to be stripped; the lookup then builds a full recordid.NewResolver over every record family (intents, specs, issues, ADRs) to find one intent, so an unreadable spec store refuses an intent occasion. The leaf should take the repository root and look up the intent store alone. + +## Grounds + +- pursued: an intent occasion resolves from the repository root with no suffix arithmetic and with an unrelated family's store unreadable; TestResolveOccasionReadsOnlyTheIntentStore and TestLookupOneReadsOnlyTheIdsFamily would fail if either returned diff --git a/.abcd/work/issues/resolved/iss-2609251052592668-refusesymlinkeddir-is-defined-twice-with-identical-bodies-in.md b/.abcd/work/issues/resolved/iss-2609251052592668-refusesymlinkeddir-is-defined-twice-with-identical-bodies-in.md new file mode 100644 index 000000000..fa45aeb32 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609251052592668-refusesymlinkeddir-is-defined-twice-with-identical-bodies-in.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609251052592668" +slug: "refusesymlinkeddir-is-defined-twice-with-identical-bodies-in" +severity: "minor" +category: "tech-debt" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/reading.go" +resolution: "One symlink guard: readingitem.RefuseSymlinkedDir, which capture's call sites reach under capture's own sentinel." +impact: internal +resolved_by: + commit: "cba6b25afa923482425cadb5911d7e557494a833" +--- + +refuseSymlinkedDir is defined twice with identical bodies, in internal/core/capture/reading.go and internal/core/readingitem/readingitem.go, since the reading-item locator moved into the readingitem leaf; two copies of the symlink guard are how two walks come to disagree about what the ledger contains. One primitive, in the leaf, with capture's call sites routed through it. + +## Grounds + +- pursued: capture's refusal of a symlinked directory is the leaf's primitive and carries both sentinels with the message unchanged; TestSymlinkGuardIsTheLeafs would fail if a second copy returned diff --git a/.abcd/work/issues/resolved/iss-2609251053001508-the-issue-ledger-s-repo-relative-root-abcd-work-issues-is.md b/.abcd/work/issues/resolved/iss-2609251053001508-the-issue-ledger-s-repo-relative-root-abcd-work-issues-is.md new file mode 100644 index 000000000..e7cc1d4da --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609251053001508-the-issue-ledger-s-repo-relative-root-abcd-work-issues-is.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609251053001508" +slug: "the-issue-ledger-s-repo-relative-root-abcd-work-issues-is" +severity: "minor" +category: "tech-debt" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/recordid/resolve.go" +resolution: "recordid.IssuesRelDir is the one spelling of the ledger root; the other packages name it through the constant, and a test refuses a new literal." +impact: internal +resolved_by: + commit: "ee88b50d0e11ae3079e11505fe40c6104595ebbd" +--- + +The issue ledger's repo-relative root .abcd/work/issues is spelled as a string literal in seven packages (capture.LedgerRelPath, lint issuesDirOf, changelog issuesLedgerDir, readingitem ledgerRelDir, lifeboat nativeIssuesDir, intent issuesRelDir, recordid familyRoots), so a move of the ledger is seven edits and a missed one is a reader looking at an empty tree. One exported constant in the stdlib-only recordid leaf, referenced by the rest. + +## Grounds + +- pursued: no non-test source under internal/ spells the ledger root outside recordid; TestIssuesLedgerRootIsSpelledOnce would fail on a new literal diff --git a/.abcd/work/issues/resolved/iss-2609251053005833-appendtoauditnotes-leaves-two-blank-lines-under-the-audit.md b/.abcd/work/issues/resolved/iss-2609251053005833-appendtoauditnotes-leaves-two-blank-lines-under-the-audit.md new file mode 100644 index 000000000..7dffb31e3 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609251053005833-appendtoauditnotes-leaves-two-blank-lines-under-the-audit.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609251053005833" +slug: "appendtoauditnotes-leaves-two-blank-lines-under-the-audit" +severity: "nitpick" +category: "bug" +source: "review-followup" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/intent/audit.go" +resolution: "appendToAuditNotes trims the section's leading blank lines as it trims the trailing ones, so the section opens with one blank line." +impact: fix +resolved_by: + commit: "d12638d54ae484f8938558b93a9e4ac4e70f7baf" +--- + +appendToAuditNotes leaves two blank lines under the Audit Notes heading whenever the section already holds a block: it writes one blank after the heading and then copies the section back with its own leading blank line, trimming only the trailing ones. So the second write into a section (a condition block after the OWED stub, on every shipped record) opens it with a double blank line. + +## Grounds + +- pursued: a write into a section already holding a block leaves one blank line under the heading; TestAppendToAuditNotesOpensWithOneBlankLine would fail on a second diff --git a/agents/CHANGELOG.md b/agents/CHANGELOG.md index 154722194..573d6432f 100644 --- a/agents/CHANGELOG.md +++ b/agents/CHANGELOG.md @@ -12,6 +12,24 @@ 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-25 (itd-2609020625405251 — a detection item cites the condition it names) + +Iteration 2's condition disposition joins a researcher's mark on a scope +condition to the reading item that occasioned it, and the join runs through the +item's citation: where the constraint in play is a scope condition, the item +cites the condition's `cond-` identity in `constraint_in_play`. The condition +verb reads that citation and reports a mismatch with the condition it +dispositions; it never refuses on one, because the item is the reading's word +and the mark is the researcher's. + +### cold-reading-detection 0.1.3 + +PATCH: the item shape gains the one sentence above, and says the reading names +the condition without marking it. The body fields, the regime, the object's +source list and the blindness core are untouched, so the ingest validates a +detection body exactly as before; the other three definitions carry no such +field and do not move. Unmeasured, as before. + ## 2026-09-16 (iss-2609100505140261 — the provenance pair is echoed, never computed) The fidelity-review request now carries a `## Provenance (host-computed …)` diff --git a/agents/cold-reading-detection.md b/agents/cold-reading-detection.md index 59dd73657..7a24ba0b8 100644 --- a/agents/cold-reading-detection.md +++ b/agents/cold-reading-detection.md @@ -4,7 +4,7 @@ description: >- Cold reading at the detection position. Where is the shipped tree in tension with the claim record? Returns tensions, each with the constraint in play and why it is a tension, under the registrative supply regime. -prompt_version: 0.1.2 +prompt_version: 0.1.3 reads_untrusted_input: true capability_scope: task_classes: [cold_reading] @@ -119,7 +119,11 @@ key, and no operand an operator types at invocation sets it or overrides it. Three body fields: `tension`, `constraint_in_play` and `why_a_tension`. The constraint in play is quoted from the passed material rather than paraphrased, so -the researcher can check the tension against the same words you read. The pattern +the researcher can check the tension against the same words you read. Where the +constraint in play is a scope condition, cite the condition's identity in +`constraint_in_play` beside the quoted text: the `cond-` marker and its sixteen +digits, exactly as the passed material carries it. You name the condition; you +do not mark it, and no field of yours says whether it holds. The pattern you read under travels in the record's envelope, never in a body, and the enclosing envelope — the run, the manifest and the record identity — is the ingest verb's to compose rather than yours. diff --git a/commands/intent.md b/commands/intent.md index cdd7193a8..b51d3a006 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -1,7 +1,7 @@ --- name: intent description: Press-release intent lifecycle — status, quoted-text create, the implement-readiness gate, and the human planning interview that turns a draft into a planned, specced intent. -argument-hint: "[text] [--title \"\"] | ready <itd-N> [--grounds \"<pursued|deferred|declined>: <conjecture>\"] | plan <itd-N> [--impact <additive|breaking|fix>] | hold <itd-N> --reason \"<text>\" | unhold <itd-N> | link <itd-N> <spc-N> | audit [<itd-N>] | audit --issue-drift [--strict]" +argument-hint: "[text] [--title \"<title>\"] | ready <itd-N> [--grounds \"<pursued|deferred|declined>: <conjecture>\"] | plan <itd-N> [--impact <additive|breaking|fix>] | hold <itd-N> --reason \"<text>\" | unhold <itd-N> | link <itd-N> <spc-N> | audit [<itd-N>] | audit --issue-drift [--strict] | condition <itd-N> [<cond-id> --disposition <survived|narrowed|falsified|untested> --occasioned-by <rdi-N|itd-N> --grounds \"<why>\" [--narrowing \"<what now holds>\"]]" --- # `/abcd:intent` — intent lifecycle @@ -566,7 +566,11 @@ 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. +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. **Hand the auditor the whole request file.** `intent audit` writes it to the reported `request_path`, and its `## Provenance` block states the @@ -584,6 +588,50 @@ takes an empty block, a conditioned one a full one — so a partial or invented disposition quarantines the whole payload rather than applying half of it. Report the returned split alongside the acceptance rollup. +## Condition: disposition one scope condition from a reading or a delivery + +```bash +"${CLAUDE_PLUGIN_ROOT}/abcd" intent condition <itd-N> --json # read-only: each condition's standing +"${CLAUDE_PLUGIN_ROOT}/abcd" intent condition <itd-N> <cond-id> \ + --disposition <survived|narrowed|falsified|untested> \ + --occasioned-by <rdi-N|itd-N> --grounds "<why>" [--narrowing "<what now holds>"] --json +``` + +The verdict ingest above is one writer into a shipped intent's scope-condition +dispositions; this is the second. It records that a reading item, or a later +delivery, changed an assumption's standing, keyed to the `cond-…` identity the +condition carries rather than to its wording, and joined to what occasioned it. + +- **With the intent alone** it writes nothing. Report every condition's standing + disposition and the block it came from (`from verdict rcp-…` or + `from condition <occasion>, <date>`), or `untested (no block)`. `--json` + carries the whole history under `dispositions` and the fold under `standing`. +- **With a condition id** it appends one dated block to `## Audit Notes`: + the identity, the value, the occasion and the grounds, with the narrowing + under a `narrowed` value. The occasion is a reading item at any position, or + an intent in `shipped/` whose delivery changed the condition's standing. Ask + the researcher for the value and the grounds; the reading names the tension + and never marks the condition itself. + +A condition's standing is its latest reading-occasioned block where it has one, +and otherwise its latest verdict: a fidelity verdict leaves a reading-occasioned +block standing unless the verdict's rationale names that block's occasion, +wherever the two sit in `## Audit Notes`. The verdict ingest reports each block +it leaves standing (`still standing: …`); an auditor who meant to override one +names its occasion in the rationale and ingests again for the same receipt, +which replaces the ingested verdict (reported as `replaced`). + +Every refusal exits 2 with nothing written: an intent not in `shipped/` (the +refusal names its bucket), a condition id the intent does not carry or carries +twice, a value outside the four, grounds below the substance floor, `narrowed` +without `--narrowing` or `--narrowing` with any other value, an occasion that +does not resolve (an absent reading item, or an intent that is absent or not in +`shipped/`), and the intent named as its own occasion. The grounds and the +narrowing are redacted before the write; report a `redacted` count when there +is one. When the reading item's `constraint_in_play` cites a different +condition's identity, the result carries `occasion_citation` and the render a +`note:` line: report it as a question for the researcher, never as a refusal. + ## Issue drift: does every promote join read from both ends? ```bash diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index e280509a8..55e722b07 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1070,6 +1070,21 @@ Ingest an intent-audit verdict JSON into the shipped intent's Audit Notes --verdict-json string path to the intent-audit verdict JSON ``` +#### `abcd intent condition` + +Read a shipped intent's scope-condition standing, or disposition one condition from a reading item or a delivered intent + +**Usage:** `abcd intent condition <itd-N> [<cond-id> --disposition <survived|narrowed|falsified|untested> --occasioned-by <rdi-N|itd-N> --grounds "<why>" [--narrowing "<what now holds>"]] [flags]` + +**Flags:** + +``` + --disposition string the condition's disposition: survived|narrowed|falsified|untested + --grounds string why: held to the grounds substance floor, redacted before it is written + --narrowing string what now holds: required on narrowed and refused on every other value + --occasioned-by string what occasioned it: a reading item (rdi-N) or a shipped intent (itd-N) +``` + #### `abcd intent hold` Hold a draft or planned intent (writes `held: "<reason>"`; `intent plan` refuses it until `intent unhold`) diff --git a/internal/README.md b/internal/README.md index 03773d526..f4c3cf839 100644 --- a/internal/README.md +++ b/internal/README.md @@ -46,6 +46,21 @@ plugin surface, and a future MCP server share one engine. reader spelled twice is one the two can disagree about, which is how a bullet one writer appends becomes a bullet the other cannot find. It owns no heading's meaning: a caller supplies the pattern it is looking for. +- **`core/condition/`** — the scope-condition disposition vocabulary: the four + values, the `cond-…` identity marker, the two block grammars a disposition is + written under (the fidelity verdict's review marker and the condition verb's + dated block), and the one reader that folds them into a condition's standing + disposition. A leaf on the `core/grounds` precedent: two writers in + `core/intent` and the record lint in `core/lint` read one vocabulary, and + `core/intent`'s tests import `core/lint`, so a lint importing intent back is + an import cycle. It imports `core/mdrecord` for the one notion of a section, + and nothing else beyond the standard library. +- **`core/readingitem/`** — the reading ledger's locator: a reading item or a + disposition found by id across every run, symlink-refusing at each level, and + the one occasion resolver the verbs that name an occasion share, each naming + the families it admits. A leaf because `core/capture` imports `core/intent` + and both need it; `core/capture` keeps its historical locator names as thin + wrappers over it. - **`core/relink/`** — the one link-repoint primitive. A record's folder is its status, so every lifecycle transition is a rename, and a rename strands every relative link that named the file where it was. The verbs that move a record diff --git a/internal/core/capture/capture.go b/internal/core/capture/capture.go index 79be7e777..f420f2174 100644 --- a/internal/core/capture/capture.go +++ b/internal/core/capture/capture.go @@ -24,7 +24,7 @@ import ( ) // LedgerRelPath is the ledger root relative to the repo worktree. -const LedgerRelPath = ".abcd/work/issues" +const LedgerRelPath = recordid.IssuesRelDir // issFamily is the ledger's record family, the argument this package hands // recordid.SplitRecordFilename. Ledger filenames are split by that ONE shared diff --git a/internal/core/capture/reading.go b/internal/core/capture/reading.go index 6069f8b96..d09421052 100644 --- a/internal/core/capture/reading.go +++ b/internal/core/capture/reading.go @@ -21,6 +21,7 @@ package capture // content, and saying so is better than letting the header claim cover for it. import ( + "errors" "fmt" "os" "path/filepath" @@ -28,6 +29,7 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/readingitem" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/fsutil" ) @@ -709,68 +711,61 @@ func readingItemPosition(issuesRoot, item string) (string, error) { // findReadingItem locates a reading record by id across the run directories. // +// It is a thin wrapper over readingitem.Locate, the one locator core/capture and +// core/intent share (spc-2609020626046252); capture keeps the name, its +// sentinels and its messages until every caller has moved to the leaf. +// // The search needs no run argument: an id is unique to the LEDGER, not to the run // that minted it, so a caller dispositioning an item does not have to know which // run returned it. That uniqueness is enforced, not assumed — the mint probes the -// whole tree before it claims an id (mintUnusedItemID) — and the -// more-than-one-run arm below is what says so if it ever stops holding. +// whole tree before it claims an id (mintUnusedItemID) — and the leaf's +// more-than-one-run refusal is what says so if it ever stops holding. func findReadingItem(issuesRoot, item string) (string, error) { - matches, err := readingItemPaths(issuesRoot, item) + _, path, err := readingitem.Locate(issuesRoot, item) if err != nil { - return "", err - } - switch len(matches) { - case 0: - return "", fmt.Errorf("%w: %s is not a reading item this ledger holds", ErrUnknownIssueID, item) - case 1: - return matches[0], nil - default: - return "", fmt.Errorf("%w: %s is present in more than one run directory", ErrDuplicateIssueID, item) + return "", wrapLocatorErr(err) } + return path, nil } // readingItemPaths returns every file in the ledger that claims item, across all -// run directories. Zero matches means the id is free, which is what the mint -// asks; one is the ordinary case; more is a ledger fault findReadingItem names. -// An absent readings tree is no matches, not an error — a repository that has -// commissioned no reading is in a state, not a fault. +// run directories — readingitem.Paths under capture's sentinels. Zero matches +// means the id is free, which is what the mint asks; one is the ordinary case; +// more is a ledger fault findReadingItem names. func readingItemPaths(issuesRoot, item string) ([]string, error) { - if !recordid.ValidReadingItemID(item) { - return nil, fmt.Errorf("invalid %s-N identifier: %q", issueschema.ReadingItemFamily, item) - } - readingsRoot := filepath.Join(issuesRoot, issueschema.ReadingsDir) - if err := refuseSymlinkedDir(readingsRoot); err != nil { - return nil, err - } - runs, err := os.ReadDir(readingsRoot) + paths, err := readingitem.Paths(issuesRoot, item) if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err + return nil, wrapLocatorErr(err) } - var matches []string - for _, run := range runs { - if !recordid.ValidReadingRunID(run.Name()) { - continue - } - // Every run directory is checked, not only the ones a walk would descend - // into: a symlink IS a directory to ReadDir, and following one is how a - // read — or promote's stamp, which writes back to whatever this returns — - // leaves the tree that is supposed to contain it. - runDir := filepath.Join(readingsRoot, run.Name()) - if err := refuseSymlinkedDir(runDir); err != nil { - return nil, err - } - if !run.IsDir() { - continue - } - cand := filepath.Join(runDir, item+".md") - if fi, err := os.Lstat(cand); err == nil && fi.Mode().IsRegular() { - matches = append(matches, cand) + return paths, nil +} + +// locatorError is a leaf refusal carried under capture's own sentinel: it reads +// as capture's error, message included, and still satisfies errors.Is for the +// leaf's sentinel, so a caller on either side of the move reads it the same way. +type locatorError struct { + sentinel, cause error + msg string +} + +func (e *locatorError) Error() string { return e.msg } +func (e *locatorError) Unwrap() []error { return []error{e.sentinel, e.cause} } + +// wrapLocatorErr maps the leaf's sentinels onto capture's: ErrUnknown onto +// ErrUnknownIssueID, ErrDuplicate onto ErrDuplicateIssueID and ErrPathUnsafe +// onto ErrPathUnsafe, keeping the detail the leaf wrote after its sentinel. +func wrapLocatorErr(err error) error { + for _, m := range []struct{ leaf, ours error }{ + {readingitem.ErrUnknown, ErrUnknownIssueID}, + {readingitem.ErrDuplicate, ErrDuplicateIssueID}, + {readingitem.ErrPathUnsafe, ErrPathUnsafe}, + } { + if errors.Is(err, m.leaf) { + detail := strings.TrimPrefix(err.Error(), m.leaf.Error()) + return &locatorError{sentinel: m.ours, cause: err, msg: m.ours.Error() + detail} } } - return matches, nil + return err } // refuseSymlinkedDir is safeMkdirLeaf's guard without the mkdir: it refuses a @@ -779,19 +774,10 @@ func readingItemPaths(issuesRoot, item string) ([]string, error) { // read here is not read-only in consequence — promote stamps back into whatever // findReadingItem returns, so a symlinked readings root or run directory sent // that write outside the ledger. An absent path is not a fault: an unpopulated -// tree is a state. +// tree is a state. The judgement is the leaf's one primitive, +// readingitem.RefuseSymlinkedDir, carried under capture's ErrPathUnsafe. func refuseSymlinkedDir(dir string) error { - fi, err := os.Lstat(dir) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("%w: lstat failed for %s: %v", ErrPathUnsafe, dir, err) - } - if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { - return fmt.Errorf("%w: not a real directory: %s", ErrPathUnsafe, dir) - } - return nil + return wrapLocatorErr(readingitem.RefuseSymlinkedDir(dir)) } // standingDispositions lists the dispositions of one item that no sibling diff --git a/internal/core/capture/readingitem_wrap_test.go b/internal/core/capture/readingitem_wrap_test.go new file mode 100644 index 000000000..ee490e7a2 --- /dev/null +++ b/internal/core/capture/readingitem_wrap_test.go @@ -0,0 +1,100 @@ +package capture + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/readingitem" +) + +// TestCaptureSentinelsWrapLocator holds capture's locator as a thin wrapper over +// the readingitem leaf (spc-2609020626046252): every refusal is still capture's +// own sentinel with capture's own message, so its callers and mintUnusedItemID's +// probing read unchanged, and it is ALSO the leaf's, so a caller that has moved +// to the leaf's sentinels reads the same error through either door. +func TestCaptureSentinelsWrapLocator(t *testing.T) { + _, ir, item := readingFixture(t, "detection") + + path, err := findReadingItem(ir, item) + if err != nil { + t.Fatalf("findReadingItem: %v", err) + } + if _, leafPath, _ := readingitem.Locate(ir, item); leafPath != path { + t.Fatalf("the wrapper and the leaf disagree: %s vs %s", path, leafPath) + } + + const absent = "rdi-2608300000000077" + _, err = findReadingItem(ir, absent) + if !errors.Is(err, ErrUnknownIssueID) || !errors.Is(err, readingitem.ErrUnknown) { + t.Errorf("an absent item: err = %v, want both ErrUnknownIssueID and readingitem.ErrUnknown", err) + } + if err != nil && err.Error() != "unknown issue id: "+absent+" is not a reading item this ledger holds" { + t.Errorf("the message moved: %q", err.Error()) + } + + // A second run holding the same item is a ledger fault. + src, _ := os.ReadFile(path) + dup := filepath.Join(ir, issueschema.ReadingsDir, "rdg-2608300000000099", item+".md") + if err := os.MkdirAll(filepath.Dir(dup), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dup, src, 0o644); err != nil { + t.Fatal(err) + } + _, err = findReadingItem(ir, item) + if !errors.Is(err, ErrDuplicateIssueID) || !errors.Is(err, readingitem.ErrDuplicate) { + t.Errorf("a duplicated item: err = %v, want both duplicate sentinels", err) + } + if err := os.Remove(dup); err != nil { + t.Fatal(err) + } + + // A symlinked run directory is capture's ErrPathUnsafe as well as the leaf's. + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(ir, issueschema.ReadingsDir, "rdg-2608300000000098")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + _, err = findReadingItem(ir, item) + if !errors.Is(err, ErrPathUnsafe) || !errors.Is(err, readingitem.ErrPathUnsafe) { + t.Errorf("a symlinked run: err = %v, want both ErrPathUnsafe sentinels", err) + } + if _, err := readingItemPaths(ir, item); !errors.Is(err, ErrPathUnsafe) { + t.Errorf("readingItemPaths through a symlinked run: err = %v, want ErrPathUnsafe", err) + } +} + +// TestSymlinkGuardIsTheLeafs holds capture's directory guard to the leaf's one +// primitive: capture's refusal of a symlinked directory is its own +// ErrPathUnsafe, message unchanged, and also the leaf's, and it agrees with +// the leaf on every shape. +func TestSymlinkGuardIsTheLeafs(t *testing.T) { + root := t.TempDir() + real := filepath.Join(root, "real") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + file := filepath.Join(root, "file") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + for _, dir := range []string{real, filepath.Join(root, "absent"), file, link} { + ours, leaf := refuseSymlinkedDir(dir), readingitem.RefuseSymlinkedDir(dir) + if (ours == nil) != (leaf == nil) { + t.Errorf("%s: capture %v, leaf %v", dir, ours, leaf) + } + } + err := refuseSymlinkedDir(link) + if !errors.Is(err, ErrPathUnsafe) || !errors.Is(err, readingitem.ErrPathUnsafe) { + t.Errorf("a symlinked directory: err = %v, want both ErrPathUnsafe sentinels", err) + } + if err != nil && err.Error() != "path unsafe: not a real directory: "+link { + t.Errorf("the message moved: %q", err.Error()) + } +} diff --git a/internal/core/changelog/findings.go b/internal/core/changelog/findings.go index b9bb070c5..65ce453bd 100644 --- a/internal/core/changelog/findings.go +++ b/internal/core/changelog/findings.go @@ -8,16 +8,16 @@ import ( "github.com/intentdriven/abcd/internal/core/frontmatter" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/gitutil" ) -// issuesLedgerDir is the issue ledger's root, repo-relative. It is spelled ONCE -// in this package — issuesResolvedDir is derived from it — because two literals -// for one directory is how a gate ends up scanning a tree the writer no longer -// uses. core/capture holds the same constant for the ledger it writes; this -// package cannot import it (capture imports this one for the impact enum), so -// the second and last spelling lives here. -const issuesLedgerDir = ".abcd/work/issues" +// issuesLedgerDir is the issue ledger's root, repo-relative — recordid's one +// spelling, named here so issuesResolvedDir derives from it. Two literals for +// one directory is how a gate ends up scanning a tree the writer no longer +// uses; this package cannot import core/capture (capture imports this one for +// the impact enum), and the stdlib-only recordid leaf is below both. +const issuesLedgerDir = recordid.IssuesRelDir // FindingGuardStatus is the unfixed-findings guardrail's verdict on a cut. // diff --git a/internal/core/condition/condition.go b/internal/core/condition/condition.go new file mode 100644 index 000000000..90e20487f --- /dev/null +++ b/internal/core/condition/condition.go @@ -0,0 +1,213 @@ +// Package condition is the scope-condition disposition vocabulary as DATA: the +// four values a condition is dispositioned with, the identity marker a condition +// carries, the two block grammars a disposition is written under, and the one +// reader that folds those blocks into a condition's standing disposition. +// +// Two writers put dispositions into an intent's `## Audit Notes`: the fidelity +// verdict ingest (one block per receipt, covering every condition) and the +// condition verb (one dated block per write, covering one condition and naming +// what occasioned it). Both, and every reader of what they wrote — the verb's +// render, the verdict ingest's report of what it leaves standing, and the record +// lint the principles spec builds over the same dispositions — read one +// vocabulary from here, so no two of them can disagree about what a disposition +// is (spc-2609020626046252). +// +// It is a leaf for the reason core/grounds and core/issueschema are: core/lint +// must read dispositions, core/intent's tests import core/lint, and a lint that +// imported intent back would be an import cycle. It imports core/mdrecord for +// the one notion of where a section starts and stops, and otherwise only the +// standard library: no filesystem, no transport, no record store. +package condition + +import ( + "regexp" + "strings" + + "github.com/intentdriven/abcd/internal/core/mdrecord" +) + +// The four disposition values (spc-59). Narrowed is the one that requires a +// stated narrowing and the only one permitted to carry one; Untested is the +// word for the absence of a judgement. +const ( + Survived = "survived" + Narrowed = "narrowed" + Falsified = "falsified" + Untested = "untested" +) + +// Enum is the closed set, in the order a refusal names it. +var Enum = []string{Survived, Narrowed, Falsified, Untested} + +// Valid reports whether v is one of the four values. +func Valid(v string) bool { + for _, e := range Enum { + if v == e { + return true + } + } + return false +} + +var ( + // MarkerRe matches a condition's identity marker anywhere inside its bullet + // (spc-55). It is not line-anchored: an editor that rewraps a bullet moves the + // marker, and a positional read would orphan every disposition keyed on it. + MarkerRe = regexp.MustCompile(`<!-- cond: (cond-[0-9]{16}) -->`) + // MarkerIDRe is the identity alone, whole-string. + MarkerIDRe = regexp.MustCompile(`^cond-[0-9]{16}$`) + // ReviewMarkerRe is the verdict ingest's block marker: one line, whole-line, + // carrying the receipt state and id. + ReviewMarkerRe = regexp.MustCompile(`(?m)^<!-- abcd-review: (OWED|INGESTED|DEAD_LETTER) receipt=(rcp-[0-9a-f]+) -->\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. + BlockMarkerRe = regexp.MustCompile(`(?m)^<!-- abcd-condition: (cond-[0-9]{16}) occasion=((?:rdi|itd)-[0-9]+) -->\r?$`) +) + +var ( + auditHeadingRe = regexp.MustCompile(`^#{1,6}\s+Audit Notes\s*$`) + bulletRe = regexp.MustCompile(`^- (cond-[0-9]{16}) — ([a-z]+)(?:: (.*))?$`) + narrowingLineRe = regexp.MustCompile(`^ narrowing: (.*)$`) + blockDateRe = regexp.MustCompile(`^Condition disposition — ([0-9]{4}-[0-9]{2}-[0-9]{2}), occasioned by ((?:rdi|itd)-[0-9]+)\.$`) +) + +// dispositionsLabel opens the disposition list inside a verdict block; the +// bullets above it are per-criterion verdicts, not dispositions. +const dispositionsLabel = "Scope-condition dispositions:" + +// IsBlockMarker reports whether line opens a disposition-bearing block under +// either grammar. It is the boundary both writers end a block at, so a verdict +// block replaced in place never swallows a condition block that follows it. +func IsBlockMarker(line string) bool { + line = strings.TrimRight(line, "\r") + return ReviewMarkerRe.MatchString(line) || BlockMarkerRe.MatchString(line) +} + +// 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. +type Disposition struct { + ConditionID string `json:"condition_id"` + Disposition string `json:"disposition"` + Rationale string `json:"rationale,omitempty"` + Narrowing string `json:"narrowing,omitempty"` + // Source names the block: `verdict rcp-…` or `condition <occasion>`. + Source string `json:"source"` + Date string `json:"date,omitempty"` + Occasion string `json:"occasion,omitempty"` +} + +// 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. +func ReadDispositions(content string) []Disposition { + lines := strings.Split(content, "\n") + start, end, ok := mdrecord.SectionLineRange(lines, auditHeadingRe) + if !ok { + return nil + } + var ( + out []Disposition + source string // "" outside any disposition-bearing block + inList bool // verdict block: past the dispositions label + blockID string // condition block: the identity its marker names + occasion string + date string + lastIndex = -1 // the entry a narrowing line attaches to + ) + for _, raw := range lines[start:end] { + ln := strings.TrimRight(raw, "\r") + if m := ReviewMarkerRe.FindStringSubmatch(ln); m != nil { + source, inList, blockID, occasion, date, lastIndex = "verdict "+m[2], false, "", "", "", -1 + continue + } + if m := BlockMarkerRe.FindStringSubmatch(ln); m != nil { + source, inList, blockID, occasion, date, lastIndex = "condition "+m[2], true, m[1], m[2], "", -1 + continue + } + if source == "" { + continue + } + if blockID != "" && date == "" { + if m := blockDateRe.FindStringSubmatch(ln); m != nil && m[2] == occasion { + date = m[1] + continue + } + } + if blockID == "" && ln == dispositionsLabel { + inList = true + continue + } + if !inList { + continue + } + if m := bulletRe.FindStringSubmatch(ln); m != nil { + lastIndex = -1 + if !Valid(m[2]) || (blockID != "" && m[1] != blockID) { + continue + } + out = append(out, Disposition{ + ConditionID: m[1], Disposition: m[2], Rationale: m[3], + Source: source, Date: date, Occasion: occasion, + }) + lastIndex = len(out) - 1 + continue + } + if m := narrowingLineRe.FindStringSubmatch(ln); m != nil && lastIndex >= 0 { + out[lastIndex].Narrowing = m[1] + } + } + return out +} + +// Standing folds ReadDispositions into each identity's standing disposition. +// +// The fold is by source precedence, not by position. Among the entries of one +// source, document order decides: the last condition-block entry is the +// condition's standing reading-occasioned judgement, and the last verdict entry +// its standing audit. Between the sources, a condition-block entry stands over +// every verdict entry, except that a verdict entry whose rationale names the +// standing condition block's occasion replaces it, wherever either sits (the +// last such verdict entry in document order). A re-audit covers every condition +// by construction and knows nothing of the readings, so without that precedence +// it would erase, silently, the one thing the join exists to keep; an auditor +// who has weighed the reading says so by naming it. +// +// Position cannot decide between the sources because it is not the order of +// writing: the verdict ingest replaces the receipt's OWED stub in place, and the +// stub is parked at ship time, before any condition block, so a verdict +// ingested after a condition block sits above it (the 2026-09-25 ruling in +// .abcd/work/DECISIONS.md). +func Standing(content string) map[string]Disposition { + all := ReadDispositions(content) + lastCond := map[string]Disposition{} + lastVerdict := map[string]Disposition{} + for _, d := range all { + if d.Occasion != "" { + lastCond[d.ConditionID] = d + } else { + lastVerdict[d.ConditionID] = d + } + } + standing := map[string]Disposition{} + for id, d := range lastVerdict { + standing[id] = d + } + for id, c := range lastCond { + standing[id] = c + for _, d := range all { + if d.ConditionID == id && d.Occasion == "" && namesOccasion(d.Rationale, c.Occasion) { + standing[id] = d + } + } + } + return standing +} + +// namesOccasion reports whether text names occasion as a whole token, so +// rdi-12 is not named by a rationale citing rdi-123. +func namesOccasion(text, occasion string) bool { + return regexp.MustCompile(`(?:^|[^A-Za-z0-9-])` + regexp.QuoteMeta(occasion) + `(?:$|[^A-Za-z0-9-])`).MatchString(text) +} diff --git a/internal/core/condition/condition_test.go b/internal/core/condition/condition_test.go new file mode 100644 index 000000000..ef3919e93 --- /dev/null +++ b/internal/core/condition/condition_test.go @@ -0,0 +1,194 @@ +package condition + +import ( + "strings" + "testing" +) + +const ( + condA = "cond-2608311949582375" + condB = "cond-2608311949582376" +) + +// verdictBlock is the shape the verdict ingest renders: per-criterion bullets +// above the label, disposition bullets below it. +const verdictBlock = "<!-- abcd-review: INGESTED receipt=rcp-0123456789ab -->\n" + + "Fidelity review — receipt rcp-0123456789ab (verifier v 1).\n\n" + + "Per-criterion verdicts:\n" + + "- ac-1 — MET: holds\n" + + " evidence: a.go:1\n\n" + + "Scope-condition dispositions:\n" + + "- " + condA + " — survived: the tree still holds it\n" + + " evidence: a.go:2\n" + + "- " + condB + " — narrowed: only half holds\n" + + " narrowing: holds for one repository\n" + + " evidence: b.go:3\n" + +func conditionBlock(id, value, occasion, rationale, narrowing string) string { + s := "<!-- abcd-condition: " + id + " occasion=" + occasion + " -->\n" + + "Condition disposition — 2026-09-02, occasioned by " + occasion + ".\n" + + "- " + id + " — " + value + ": " + rationale + "\n" + if narrowing != "" { + s += " narrowing: " + narrowing + "\n" + } + return s +} + +func record(blocks ...string) string { + return "---\nid: itd-1\n---\n\n# An intent\n\n## Audit Notes\n\n" + strings.Join(blocks, "\n") + "\n## Later\n\n- " + condA + " — falsified: outside the section\n" +} + +func TestEnumIsTheFourValues(t *testing.T) { + if got := strings.Join(Enum, ","); got != "survived,narrowed,falsified,untested" { + t.Fatalf("Enum = %s", got) + } + for _, v := range Enum { + if !Valid(v) { + t.Errorf("Valid(%q) = false", v) + } + } + for _, v := range []string{"", "MET", "Survived", "narrow"} { + if Valid(v) { + t.Errorf("Valid(%q) = true", v) + } + } +} + +func TestReadDispositionsParsesVerdictBlock(t *testing.T) { + got := ReadDispositions(record(verdictBlock)) + if len(got) != 2 { + t.Fatalf("got %d dispositions, want 2 (criteria bullets and out-of-section bullets are not dispositions): %+v", len(got), got) + } + if got[0].ConditionID != condA || got[0].Disposition != Survived || got[0].Rationale != "the tree still holds it" || + got[0].Source != "verdict rcp-0123456789ab" || got[0].Occasion != "" || got[0].Date != "" { + t.Errorf("first = %+v", got[0]) + } + if got[1].Disposition != Narrowed || got[1].Narrowing != "holds for one repository" { + t.Errorf("second = %+v", got[1]) + } +} + +func TestReadDispositionsParsesConditionBlock(t *testing.T) { + got := ReadDispositions(record(conditionBlock(condA, Narrowed, "rdi-2609011200000001", "the reading named it", "one repository only"))) + if len(got) != 1 { + t.Fatalf("got %+v", got) + } + d := got[0] + if d.ConditionID != condA || d.Disposition != Narrowed || d.Rationale != "the reading named it" || + d.Narrowing != "one repository only" || d.Source != "condition rdi-2609011200000001" || + d.Date != "2026-09-02" || d.Occasion != "rdi-2609011200000001" { + t.Errorf("got %+v", d) + } +} + +func TestReadDispositionsSkipsABulletTheMarkerDoesNotName(t *testing.T) { + block := "<!-- abcd-condition: " + condA + " occasion=rdi-1 -->\n" + + "Condition disposition — 2026-09-02, occasioned by rdi-1.\n" + + "- " + condB + " — falsified: a hand edit keyed to another identity\n" + if got := ReadDispositions(record(block)); len(got) != 0 { + t.Errorf("a bullet the marker does not name was read: %+v", got) + } +} + +func TestStandingIsTheLatestBlock(t *testing.T) { + content := record(verdictBlock, conditionBlock(condA, Falsified, "rdi-7", "the detection named it", "")) + all := ReadDispositions(content) + if len(all) != 3 { + t.Fatalf("both blocks must stand in the history: %+v", all) + } + s := Standing(content) + if s[condA].Disposition != Falsified || s[condA].Source != "condition rdi-7" { + t.Errorf("standing for %s = %+v, want the later condition block", condA, s[condA]) + } + if s[condB].Disposition != Narrowed || s[condB].Source != "verdict rcp-0123456789ab" { + t.Errorf("standing for %s = %+v, want the verdict", condB, s[condB]) + } + // Two condition blocks: the later one stands. + content = record(conditionBlock(condA, Falsified, "rdi-7", "first", ""), conditionBlock(condA, Survived, "itd-9", "second", "")) + if got := Standing(content)[condA]; got.Occasion != "itd-9" { + t.Errorf("standing = %+v, want the later condition block", got) + } +} + +func TestVerdictDoesNotOverrideAReadingOccasionedBlock(t *testing.T) { + content := record(conditionBlock(condA, Falsified, "rdi-7", "the detection named it", ""), verdictBlock) + if got := Standing(content)[condA]; got.Occasion != "rdi-7" || got.Disposition != Falsified { + t.Errorf("a later verdict naming no occasion overrode the condition block: %+v", got) + } + // A rationale citing a longer id is not a citation of this one. + near := strings.Replace(verdictBlock, "the tree still holds it", "weighed rdi-70 and it holds", 1) + content = record(conditionBlock(condA, Falsified, "rdi-7", "the detection named it", ""), near) + if got := Standing(content)[condA]; got.Occasion != "rdi-7" { + t.Errorf("rdi-70 was read as naming rdi-7: %+v", got) + } +} + +func TestVerdictNamingTheOccasionOverrides(t *testing.T) { + named := strings.Replace(verdictBlock, "the tree still holds it", "weighed rdi-7 and the tree holds it", 1) + content := record(conditionBlock(condA, Falsified, "rdi-7", "the detection named it", ""), named) + got := Standing(content)[condA] + if got.Disposition != Survived || got.Occasion != "" { + t.Errorf("a verdict naming the occasion did not override: %+v", got) + } +} + +func TestIsBlockMarkerAcceptsBothGrammars(t *testing.T) { + for _, ln := range []string{ + "<!-- abcd-review: OWED receipt=rcp-0123456789ab -->", + "<!-- abcd-review: INGESTED receipt=rcp-0123456789ab -->\r", + "<!-- abcd-condition: " + condA + " occasion=rdi-1 -->", + } { + if !IsBlockMarker(ln) { + t.Errorf("IsBlockMarker(%q) = false", ln) + } + } + for _, ln := range []string{ + "", + "text <!-- abcd-condition: " + condA + " occasion=rdi-1 -->", + "<!-- abcd-condition: cond-1 occasion=rdi-1 -->", + "<!-- cond: " + condA + " -->", + } { + if IsBlockMarker(ln) { + t.Errorf("IsBlockMarker(%q) = true", ln) + } + } +} + +func TestBlockMarkerAdmitsBothOccasionForms(t *testing.T) { + for _, occ := range []string{"rdi-2609011200000001", "itd-199"} { + ln := "<!-- abcd-condition: " + condA + " occasion=" + occ + " -->" + if m := BlockMarkerRe.FindStringSubmatch(ln); m == nil || m[2] != occ { + t.Errorf("occasion %s not admitted: %v", occ, m) + } + } + for _, occ := range []string{"iss-1", "dsp-1", "spc-1", "rdi-", "rdi-1 x"} { + if BlockMarkerRe.MatchString("<!-- abcd-condition: " + condA + " occasion=" + occ + " -->") { + t.Errorf("occasion %q admitted", occ) + } + } +} + +// TestVerdictNamingTheOccasionOverridesWhereverItSits pins the fold as source +// precedence, not position: the verdict ingest replaces an OWED stub in place, +// so a verdict written after a condition block can sit above it. A verdict +// naming the occasion overrides from either side; one naming it does not reach +// a later condition block with another occasion. +func TestVerdictNamingTheOccasionOverridesWhereverItSits(t *testing.T) { + named := strings.Replace(verdictBlock, "the tree still holds it", "weighed rdi-7 and the tree holds it", 1) + content := record(named, conditionBlock(condA, Falsified, "rdi-7", "the detection named it", "")) + if got := Standing(content)[condA]; got.Disposition != Survived || got.Occasion != "" { + t.Errorf("a verdict above the condition block naming its occasion did not override: %+v", got) + } + // A verdict above, naming no occasion, still leaves the condition block standing. + content = record(verdictBlock, conditionBlock(condA, Falsified, "rdi-7", "the detection named it", "")) + if got := Standing(content)[condA]; got.Occasion != "rdi-7" { + t.Errorf("standing = %+v, want the condition block", got) + } + // Condition blocks among themselves keep document order: the later block's + // occasion is the one a verdict must name. + content = record(conditionBlock(condA, Falsified, "rdi-7", "first", ""), named, + conditionBlock(condA, Narrowed, "rdi-8", "second", "one repository")) + if got := Standing(content)[condA]; got.Occasion != "rdi-8" { + t.Errorf("standing = %+v, want the later condition block (the verdict names rdi-7, not rdi-8)", got) + } +} diff --git a/internal/core/intent/audit.go b/internal/core/intent/audit.go index f049e76ab..80657f339 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -12,6 +12,7 @@ import ( "strings" "syscall" + "github.com/intentdriven/abcd/internal/core/condition" "github.com/intentdriven/abcd/internal/core/mdrecord" "github.com/intentdriven/abcd/internal/core/recordid" "github.com/intentdriven/abcd/internal/core/spec" @@ -61,19 +62,25 @@ var verdictEnum = map[string]bool{ // dispositionUntested is the disposition vocabulary's word for the absence of a // judgement — the value the quarantine path records, and the only one exempt -// from the cited-evidence rule. -const dispositionUntested = "untested" +// from the cited-evidence rule. It is core/condition's value: the vocabulary is +// shared with the condition verb and every reader of both writers' blocks. +const dispositionUntested = condition.Untested // dispositionNarrowed is the one disposition that requires a stated narrowing — // and the only one permitted to carry one. -const dispositionNarrowed = "narrowed" +const dispositionNarrowed = condition.Narrowed -// dispositionEnum is the closed set of scope-condition dispositions (spc-59). -// It is deliberately disjoint from verdictEnum: a condition is not a criterion, -// and an acceptance verdict is not a judgement about an ex-ante assumption. -var dispositionEnum = map[string]bool{ - "survived": true, dispositionNarrowed: true, "falsified": true, dispositionUntested: true, -} +// dispositionEnum is the closed set of scope-condition dispositions (spc-59), +// as a set over core/condition's Enum. It is deliberately disjoint from +// verdictEnum: a condition is not a criterion, and an acceptance verdict is not +// a judgement about an ex-ante assumption. +var dispositionEnum = func() map[string]bool { + m := make(map[string]bool, len(condition.Enum)) + for _, v := range condition.Enum { + m[v] = true + } + return m +}() var ( // rcpIDRe constrains a receipt id so it can never build a path that escapes @@ -92,7 +99,10 @@ var ( // 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. - markerRe = regexp.MustCompile(`(?m)^<!-- abcd-review: (OWED|INGESTED|DEAD_LETTER) receipt=(rcp-[0-9a-f]+) -->\r?$`) + // + // The grammar is core/condition's ReviewMarkerRe, shared with the condition + // block's reader. + markerRe = condition.ReviewMarkerRe // auditPlaceholderRe matches an intent template's Audit Notes placeholder, // dropped when the first real review block lands so a populated audit carries no // stale "Empty" claim. It tolerates both delimiter styles the templates have @@ -207,6 +217,17 @@ type IngestVerdictResult struct { Untested int `json:"untested"` DeadLetterPath string `json:"dead_letter_path,omitempty"` Reason string `json:"reason,omitempty"` + // Replaced is set when the ingest replaced a verdict already ingested for + // the receipt: a re-ingest whose payload renders differently from the block + // on the record. A re-ingest that renders identically is a noop. + Replaced bool `json:"replaced,omitempty"` + // ReadingOccasionedStanding is every condition-block disposition the fold + // still reports as standing after this write: the verdict did not override + // it, because its rationale did not name the block's occasion + // (spc-2609020626046252). An auditor who meant to override one names its + // occasion in the rationale and ingests again for the same receipt: a + // payload that renders differently replaces the ingested block (Replaced). + ReadingOccasionedStanding []condition.Disposition `json:"reading_occasioned_standing,omitempty"` } // --------------------------------------------------------------------------- @@ -540,7 +561,10 @@ func auditProvenanceBlock(p auditPolicy) string { // // - malformed/oversize/unreadable payload with no resolvable receipt -> reject; // - receipt matching no parked marker (unsolicited) -> reject; -// - already INGESTED for this receipt -> no-op; +// - already INGESTED for this receipt -> no-op when the payload renders to +// the block on the record, a replacement in place when it renders +// differently, and a refusal with nothing written when it does not +// validate (see reingestVerdict); // - schema/semantic validation failure on a resolvable receipt -> DEAD_LETTER // (marker + INCONCLUSIVE criteria + retained raw payload), never partial; // - otherwise -> INGESTED (OWED stub replaced by the rendered verdict). @@ -576,7 +600,7 @@ func IngestVerdict(repoRoot, verdictPath string) (IngestVerdictResult, error) { return IngestVerdictResult{}, fmt.Errorf("intent: verdict receipt %s matches no parked review marker (unsolicited); refusing to ingest", rcp) } if state == "INGESTED" { - return IngestVerdictResult{Status: "noop", ReceiptID: rcp, IntentID: it.ID}, nil + return reingestVerdict(repoRoot, raw, it, rcp, content) } // The attestation chain must be the pair THIS receipt issued, not merely two @@ -622,7 +646,51 @@ func IngestVerdict(repoRoot, verdictPath string) (IngestVerdictResult, error) { NotMet: rollup["NOT_MET"], Inconclusive: rollup["INCONCLUSIVE"], Conditions: len(v.ScopeConditions), Survived: split["survived"], Narrowed: split[dispositionNarrowed], Falsified: split["falsified"], - Untested: split[dispositionUntested], + Untested: split[dispositionUntested], + ReadingOccasionedStanding: occasionedStanding(updated), + }, nil +} + +// reingestVerdict applies a verdict for a receipt already INGESTED. The receipt +// is the idempotency key, so a payload that renders to the block already on the +// record is a noop and writes nothing. A payload that renders differently +// replaces that block in place, after the same checks a first ingest makes: +// this is how an auditor who has since weighed a reading-occasioned condition +// 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. +func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string) (IngestVerdictResult, error) { + free, err := newVerdictProse(repoRoot) + if err != nil { + return IngestVerdictResult{}, err + } + v, verr := validateVerdict(raw, rcp, content) + if verr != nil { + return IngestVerdictResult{}, fmt.Errorf("intent: receipt %s is already INGESTED and this verdict does not validate: %s; "+ + "an ingested verdict is replaced only by a valid one (nothing written)", rcp, free(verr.Error())) + } + rollup := countVerdicts(v) + block := ingestedBlock(rcp, v, rollup, free) + if existing, ok := reviewBlockText(content, rcp); ok && existing == block { + return IngestVerdictResult{Status: "noop", ReceiptID: rcp, IntentID: it.ID}, nil + } + if err := checkIssuedPolicy(repoRoot, raw, it, rcp, content); 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 + } + split := countDispositions(v) + return IngestVerdictResult{ + Status: "ingested", Replaced: true, ReceiptID: rcp, IntentID: it.ID, Criteria: len(v.Criteria), + Met: rollup["MET"], MetWithConcern: rollup["MET_WITH_CONCERNS"], + NotMet: rollup["NOT_MET"], Inconclusive: rollup["INCONCLUSIVE"], + Conditions: len(v.ScopeConditions), Survived: split["survived"], + Narrowed: split[dispositionNarrowed], Falsified: split["falsified"], + Untested: split[dispositionUntested], + ReadingOccasionedStanding: occasionedStanding(updated), }, nil } @@ -901,9 +969,29 @@ func deadLetter(repoRoot string, it Intent, content, rcp string, raw []byte, rea Status: "dead_letter", ReceiptID: rcp, IntentID: it.ID, Conditions: len(untested), Untested: len(untested), DeadLetterPath: dlRel, Reason: reason, + ReadingOccasionedStanding: occasionedStanding(updated), }, nil } +// occasionedStanding lists the condition-block dispositions the fold reports as +// standing in content, ordered by condition identity so the report is +// deterministic. +func occasionedStanding(content string) []condition.Disposition { + standing := condition.Standing(content) + ids := make([]string, 0, len(standing)) + for id, d := range standing { + if d.Occasion != "" { + ids = append(ids, id) + } + } + sort.Strings(ids) + out := make([]condition.Disposition, 0, len(ids)) + for _, id := range ids { + out = append(out, standing[id]) + } + return out +} + // --------------------------------------------------------------------------- // Receipt resolution + Audit Notes surgery // --------------------------------------------------------------------------- @@ -951,34 +1039,60 @@ func markerState(content, rcp string) (string, bool) { // 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 marker, the next heading, or -// end of file. +// 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") - start := -1 + 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-- + } + 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 { - start = i - break + if m == nil || m[2] != rcp { + continue } - } - if start >= 0 { - end := len(lines) - for j := start + 1; j < len(lines); j++ { + end = len(lines) + for j := i + 1; j < len(lines); j++ { t := strings.TrimRight(lines[j], "\r") - if markerRe.MatchString(t) || mdrecord.IsHeading(t) { + if condition.IsBlockMarker(t) || mdrecord.IsHeading(t) { end = j break } } - out := make([]string, 0, len(lines)) - out = append(out, lines[:start]...) - out = append(out, strings.Split(newBlock, "\n")...) - out = append(out, lines[end:]...) - return strings.Join(out, "\n") + return i, end, true } - return appendToAuditNotes(content, newBlock) + return 0, 0, false +} + +// 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) + if !ok { + return "", false + } + return strings.TrimRight(strings.Join(lines[start:end], "\n"), "\r\n\t "), true } // appendToAuditNotes appends a block to the `## Audit Notes` section, creating @@ -1014,7 +1128,12 @@ func appendToAuditNotes(content, block string) string { } section = append(section, ln) } - // Drop trailing blank lines inside the section, then re-add one separator. + // Drop blank lines at both ends of the section, then re-add one separator on + // each side: the heading's blank line is written below, so a leading one kept + // here would open the section with two. + for len(section) > 0 && strings.TrimSpace(section[0]) == "" { + section = section[1:] + } for len(section) > 0 && strings.TrimSpace(section[len(section)-1]) == "" { section = section[:len(section)-1] } @@ -1142,14 +1261,7 @@ func renderDispositions(b *strings.Builder, conds []verdictCondition, free prose } b.WriteString("\nScope-condition dispositions:\n") for _, c := range conds { - fmt.Fprintf(b, "- %s — %s", oneLine(c.ConditionID), oneLine(c.Disposition)) - if r := free(c.Rationale); r != "" { - fmt.Fprintf(b, ": %s", r) - } - b.WriteString("\n") - if n := free(c.Narrowing); n != "" { - fmt.Fprintf(b, " narrowing: %s\n", n) - } + writeDispositionBullet(b, oneLine(c.ConditionID), oneLine(c.Disposition), free(c.Rationale), free(c.Narrowing)) for _, e := range c.Evidence { fmt.Fprintf(b, " evidence: %s\n", renderEvidence(e, free)) } diff --git a/internal/core/intent/audit_linkref_test.go b/internal/core/intent/audit_linkref_test.go index 6f6eb9331..f625904d3 100644 --- a/internal/core/intent/audit_linkref_test.go +++ b/internal/core/intent/audit_linkref_test.go @@ -63,3 +63,19 @@ func TestAppendToAuditNotesInsertsAboveTrailingLinkRefs(t *testing.T) { t.Fatalf("second link ref not preserved exactly once:\n%s", out) } } + +// TestAppendToAuditNotesOpensWithOneBlankLine: a write into a section that +// already holds a block leaves exactly one blank line under the heading and +// one between the blocks, as the first write into an empty section does. +func TestAppendToAuditNotesOpensWithOneBlankLine(t *testing.T) { + for _, tc := range []struct{ name, in, want string }{ + {"empty section", "# a\n\n## Audit Notes\n", "# a\n\n## Audit Notes\n\nBLOCK\n"}, + {"placeholder", "# a\n\n## Audit Notes\n\n_Empty until review._\n\n## Next\n", "# a\n\n## Audit Notes\n\nBLOCK\n\n## Next\n"}, + {"a block already there", "# a\n\n## Audit Notes\n\nFIRST\nline\n", "# a\n\n## Audit Notes\n\nFIRST\nline\n\nBLOCK\n"}, + {"CRLF blanks", "# a\r\n\r\n## Audit Notes\r\n\r\n\r\nFIRST\r\n", "# a\r\n\r\n## Audit Notes\r\n\nFIRST\r\n\nBLOCK\n"}, + } { + if got := appendToAuditNotes(tc.in, "BLOCK"); got != tc.want { + t.Errorf("%s:\n got %q\nwant %q", tc.name, got, tc.want) + } + } +} diff --git a/internal/core/intent/claims.go b/internal/core/intent/claims.go index 24532c331..c71919b63 100644 --- a/internal/core/intent/claims.go +++ b/internal/core/intent/claims.go @@ -19,6 +19,7 @@ import ( "regexp" "strings" + "github.com/intentdriven/abcd/internal/core/condition" "github.com/intentdriven/abcd/internal/core/mdrecord" "github.com/intentdriven/abcd/internal/core/recordid" ) @@ -65,7 +66,10 @@ var ( // of a line: an editor that rewraps an 80-column bullet moves the marker, and // a positional read would orphan every disposition keyed on it and then mint a // second identity for the same condition (iss-2608300235377731). - condMarkerRe = regexp.MustCompile(`<!-- cond: (cond-[0-9]{16}) -->`) + // + // The grammar is core/condition's MarkerRe, the one every disposition + // writer and reader keys on. + condMarkerRe = condition.MarkerRe // spaceRunRe collapses the whitespace left behind when a marker is excised // from the middle of a line, and by folding a wrapped bullet into one string. spaceRunRe = regexp.MustCompile(`\s+`) diff --git a/internal/core/intent/condition.go b/internal/core/intent/condition.go new file mode 100644 index 000000000..59b135c3d --- /dev/null +++ b/internal/core/intent/condition.go @@ -0,0 +1,309 @@ +package intent + +// condition.go — the second writer into the scope-condition disposition surface +// (spc-2609020626046252). The verdict ingest writes one block per receipt +// covering every condition; this writes one dated block covering ONE condition +// and naming what occasioned it — a reading item, or a delivered intent whose +// delivery changed the condition's standing. Both writers share the vocabulary, +// the bullet shape and the reader in core/condition, so neither can write a +// block the other's reader misreads. + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/intentdriven/abcd/internal/core/condition" + "github.com/intentdriven/abcd/internal/core/frontmatter" + "github.com/intentdriven/abcd/internal/core/grounds" + "github.com/intentdriven/abcd/internal/core/readingitem" + "github.com/intentdriven/abcd/internal/core/recordid" + "github.com/intentdriven/abcd/internal/fsutil" +) + +// maxOccasionBytes caps the reading item read for its citation; a reading +// record is a few short fields. +const maxOccasionBytes = 256 * 1024 + +// citedConditionRe finds a condition identity cited in free text. +var citedConditionRe = regexp.MustCompile(`\bcond-[0-9]{16}\b`) + +// ConditionRequest is one write of the condition verb. +type ConditionRequest struct { + IntentID string + ConditionID string + Disposition string + Narrowing string + OccasionedBy string + Grounds string + // Date is the block's date, YYYY-MM-DD; empty means today in UTC. + Date string +} + +// OccasionCitation reports a reading item whose constraint_in_play cites a +// condition identity other than the one dispositioned. It is a report, never a +// refusal: the item is the reading's word and the mark is the researcher's. +type OccasionCitation struct { + Occasion string `json:"occasion"` + Cited string `json:"cited"` + Dispositioned string `json:"dispositioned"` +} + +// StandingEntry is one condition's standing disposition and the block it came +// from. Source is empty, and Disposition `untested`, for a condition no block +// names. +type StandingEntry struct { + ConditionID string `json:"condition_id"` + Disposition string `json:"disposition"` + Source string `json:"source,omitempty"` + Occasion string `json:"occasion,omitempty"` + Date string `json:"date,omitempty"` +} + +// ConditionResult is the outcome of one write. +type ConditionResult struct { + IntentID string `json:"intent_id"` + ConditionID string `json:"condition_id"` + Disposition string `json:"disposition"` + Narrowing string `json:"narrowing,omitempty"` + OccasionedBy string `json:"occasioned_by"` + Grounds string `json:"grounds"` + Date string `json:"date"` + Path string `json:"path"` + Standing []StandingEntry `json:"standing"` + OccasionCitation *OccasionCitation `json:"occasion_citation,omitempty"` + Redacted int `json:"redacted,omitempty"` +} + +// ConditionStandingView is the read form: every disposition the record carries, +// in document order, and each condition's standing. +type ConditionStandingView struct { + IntentID string `json:"intent_id"` + Path string `json:"path"` + Dispositions []condition.Disposition `json:"dispositions"` + Standing []StandingEntry `json:"standing"` +} + +// ConditionStanding reads an intent's condition dispositions. It writes +// nothing and refuses no bucket: reading a record is not dispositioning it. +func ConditionStanding(repoRoot, intentID string) (ConditionStandingView, error) { + if !recordid.ValidIntentID(intentID) { + return ConditionStandingView{}, fmt.Errorf("intent: id %q must match ^itd-[0-9]+$", intentID) + } + it, content, err := loadIntentContent(repoRoot, intentID) + if err != nil { + return ConditionStandingView{}, err + } + all := condition.ReadDispositions(content) + if all == nil { + all = []condition.Disposition{} + } + return ConditionStandingView{ + IntentID: it.ID, Path: it.Path, + Dispositions: all, Standing: standingEntries(content), + }, nil +} + +// DispositionCondition writes one condition disposition against a shipped +// intent. Every refusal happens before anything is written, in the order the +// spec states: the intent's id, presence and bucket; the condition's identity, +// presence and uniqueness; the value; the grounds; the narrowing; the occasion. +func DispositionCondition(repoRoot string, req ConditionRequest) (ConditionResult, error) { + if !recordid.ValidIntentID(req.IntentID) { + return ConditionResult{}, fmt.Errorf("intent: id %q must match ^itd-[0-9]+$", req.IntentID) + } + it, content, err := loadIntentContent(repoRoot, req.IntentID) + if err != nil { + return ConditionResult{}, err + } + if it.Bucket != BucketShipped { + return ConditionResult{}, fmt.Errorf("intent: %s is in %s, not shipped; a condition is dispositioned against a delivered state, and only a shipped intent has one (nothing written)", it.ID, it.Bucket) + } + if !condition.MarkerIDRe.MatchString(req.ConditionID) { + return ConditionResult{}, fmt.Errorf("intent: condition id %q is not cond-<16 digits> (nothing written)", req.ConditionID) + } + conds := ParseClaims(content).Conditions + carried := false + for _, c := range conds { + if c.ID == req.ConditionID { + carried = true + } + } + if !carried { + return ConditionResult{}, fmt.Errorf("intent: %s does not carry the scope condition %s (nothing written)", it.ID, req.ConditionID) + } + for _, d := range DuplicateConditionIDs(conds) { + if d == req.ConditionID { + return ConditionResult{}, fmt.Errorf("intent: scope condition identity %s is carried by more than one condition, so a disposition cannot be keyed to either (nothing written)", d) + } + } + if !condition.Valid(req.Disposition) { + return ConditionResult{}, fmt.Errorf("intent: disposition %q is not one of %s (nothing written)", req.Disposition, strings.Join(condition.Enum, ", ")) + } + ground, redG, err := redactIntentText(repoRoot, req.Grounds) + if err != nil { + return ConditionResult{}, err + } + ground = grounds.Fold(ground) + if err := grounds.ValidateText(ground); err != nil { + return ConditionResult{}, fmt.Errorf("intent: --grounds: %w (nothing written)", err) + } + narrowing, redN, err := redactIntentText(repoRoot, req.Narrowing) + if err != nil { + return ConditionResult{}, err + } + narrowing = grounds.Fold(narrowing) + if req.Disposition == condition.Narrowed && narrowing == "" { + return ConditionResult{}, fmt.Errorf("intent: scope condition %s is narrowed but states no narrowing; say what now holds (nothing written)", req.ConditionID) + } + if req.Disposition != condition.Narrowed && narrowing != "" { + return ConditionResult{}, fmt.Errorf("intent: scope condition %s is %s but states a narrowing; only a narrowed condition carries one (nothing written)", req.ConditionID, req.Disposition) + } + if req.OccasionedBy == "" { + return ConditionResult{}, fmt.Errorf("intent: the occasion is required: a reading item (rdi-N) or a shipped intent (itd-N) (nothing written)") + } + occPath, err := readingitem.ResolveOccasion(repoRoot, req.OccasionedBy, + readingitem.FamilyItem, readingitem.FamilyIntent) + if err != nil { + return ConditionResult{}, fmt.Errorf("intent: occasion %q does not resolve: %v (nothing written)", req.OccasionedBy, err) + } + if recordid.SameID(req.OccasionedBy, it.ID) { + return ConditionResult{}, fmt.Errorf("intent: occasion %s is the intent itself; its own delivery is the verdict ingest's ground, not this verb's (nothing written)", req.OccasionedBy) + } + date := req.Date + if date == "" { + date = time.Now().UTC().Format(time.DateOnly) + } + if _, err := time.Parse(time.DateOnly, date); err != nil { + return ConditionResult{}, fmt.Errorf("intent: date %q is not YYYY-MM-DD (nothing written)", date) + } + var citation *OccasionCitation + if strings.HasPrefix(req.OccasionedBy, "rdi-") { + citation, err = occasionCitation(occPath, req.OccasionedBy, req.ConditionID) + if err != nil { + return ConditionResult{}, err + } + } + + block := conditionBlock(req.ConditionID, req.Disposition, ground, narrowing, req.OccasionedBy, date) + abs := filepath.Join(repoRoot, it.Path) + var updated string + if err := withIntentMintLock(repoRoot, func() error { + data, err := readRepoFile(abs, it.Path) + if err != nil { + return err + } + before := condition.ReadDispositions(string(data)) + updated = appendToAuditNotes(string(data), block) + after := condition.ReadDispositions(updated) + // Read back before the write: the block must parse as exactly one more + // disposition, the one asked for, or the record is not written. + if len(after) != len(before)+1 || after[len(after)-1].ConditionID != req.ConditionID || + after[len(after)-1].Occasion != req.OccasionedBy { + return fmt.Errorf("intent: the condition block for %s did not read back as written; nothing written", req.ConditionID) + } + return writeIntentFile(abs, it.Path, updated) + }); err != nil { + return ConditionResult{}, err + } + return ConditionResult{ + IntentID: it.ID, ConditionID: req.ConditionID, Disposition: req.Disposition, + Narrowing: narrowing, OccasionedBy: req.OccasionedBy, Grounds: ground, Date: date, + Path: it.Path, Standing: standingEntries(updated), OccasionCitation: citation, + Redacted: redG + redN, + }, nil +} + +// loadIntentContent resolves an intent by id and reads its bytes. +func loadIntentContent(repoRoot, intentID string) (Intent, string, error) { + corpus, err := Load(repoRoot) + if err != nil { + return Intent{}, "", err + } + it, ok := corpus.Lookup(intentID) + if !ok { + return Intent{}, "", fmt.Errorf("intent: %s not found in any bucket", intentID) + } + data, err := readRepoFile(filepath.Join(repoRoot, it.Path), it.Path) + if err != nil { + return Intent{}, "", err + } + return it, string(data), nil +} + +// conditionBlock renders the verb's block. The bullet is the one the verdict +// render writes (writeDispositionBullet), so one reader parses both; every +// field goes through oneLine, so no ground can forge either marker. +func conditionBlock(id, value, ground, narrowing, occasion, date string) string { + var b strings.Builder + fmt.Fprintf(&b, "<!-- abcd-condition: %s occasion=%s -->\n", id, occasion) + fmt.Fprintf(&b, "Condition disposition — %s, occasioned by %s.\n", date, occasion) + writeDispositionBullet(&b, oneLine(id), oneLine(value), oneLine(ground), oneLine(narrowing)) + return strings.TrimRight(b.String(), "\n") +} + +// writeDispositionBullet writes one disposition bullet from already-cleaned +// fields: `- <id> — <value>[: <rationale>]`, then an indented narrowing line +// where there is one. Both writers render through it. +func writeDispositionBullet(b *strings.Builder, id, value, rationale, narrowing string) { + fmt.Fprintf(b, "- %s — %s", id, value) + if rationale != "" { + fmt.Fprintf(b, ": %s", rationale) + } + b.WriteString("\n") + if narrowing != "" { + fmt.Fprintf(b, " narrowing: %s\n", narrowing) + } +} + +// standingEntries lists every condition the record carries, in its order, with +// its standing disposition; a condition no block names is untested. +func standingEntries(content string) []StandingEntry { + standing := condition.Standing(content) + out := []StandingEntry{} + seen := map[string]bool{} + for _, c := range ParseClaims(content).Conditions { + if c.ID == "" || seen[c.ID] { + continue + } + seen[c.ID] = true + e := StandingEntry{ConditionID: c.ID, Disposition: condition.Untested} + if d, ok := standing[c.ID]; ok { + e = StandingEntry{ConditionID: c.ID, Disposition: d.Disposition, Source: d.Source, Occasion: d.Occasion, Date: d.Date} + } + out = append(out, e) + } + return out +} + +// occasionCitation reads a reading item's constraint_in_play and reports when +// it cites condition identities of which none is the one dispositioned. An item +// citing nothing reports nothing. It is the one place the verb reads an item's +// body. +func occasionCitation(path, occasion, dispositioned string) (*OccasionCitation, error) { + data, err := fsutil.ReadGuarded(path, maxOccasionBytes) + if err != nil { + return nil, fmt.Errorf("intent: reading occasion %s: %w", occasion, err) + } + head, _ := frontmatter.Split(string(data)) + f, ok := frontmatter.Fields(strings.Split(head, "\n"))["constraint_in_play"] + if !ok { + return nil, nil + } + v, ok := frontmatter.ScalarString(f.Value) + if !ok { + return nil, nil + } + cited := citedConditionRe.FindAllString(v, -1) + for _, c := range cited { + if c == dispositioned { + return nil, nil + } + } + if len(cited) == 0 { + return nil, nil + } + return &OccasionCitation{Occasion: occasion, Cited: cited[0], Dispositioned: dispositioned}, nil +} diff --git a/internal/core/intent/condition_test.go b/internal/core/intent/condition_test.go new file mode 100644 index 000000000..a34354108 --- /dev/null +++ b/internal/core/intent/condition_test.go @@ -0,0 +1,461 @@ +package intent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/intentdriven/abcd/internal/core/condition" +) + +// condition_test.go — spc-2609020626046252: the second writer into the +// disposition surface. Every refusal presents the forbidden input and asserts +// the record is byte-identical afterwards. + +const ( + condItem = "rdi-2609011200000001" + condGround = "the detection pass found the tree reading from two repositories at once" + condDate = "2026-09-02" + condNarrowTo = "holds while one repository is read" +) + +// condFixture ships an intent carrying two stamped conditions and lays a +// reading item under the ledger whose constraint_in_play is cite. +func condFixture(t *testing.T, cite string) (root, rcp string) { + t.Helper() + root = t.TempDir() + rcp = shipWithConditions(t, root, + stampedCondition(condOne, "holds while the record is one repository"), + stampedCondition(condTwo, "holds below 10k records"), + ) + writeReadingItem(t, root, condItem, cite) + return root, rcp +} + +func writeReadingItem(t *testing.T, root, id, cite string) { + t.Helper() + writeFile(t, root, ".abcd/work/issues/readings/rdg-2609011200000009/"+id+".md", + "---\nschema_version: 1\nid: "+id+"\nrun: rdg-2609011200000009\nposition: detection\n"+ + "regime: registrative\ntension: two readings of one tree\n"+ + "constraint_in_play: \""+cite+"\"\nwhy_a_tension: both cannot hold\n---\n") +} + +func condReq(value string) ConditionRequest { + return ConditionRequest{ + IntentID: "itd-10", ConditionID: condOne, Disposition: value, + OccasionedBy: condItem, Grounds: condGround, Date: condDate, + } +} + +// refuses runs req and asserts a refusal naming want, with nothing written. +func refuses(t *testing.T, root string, req ConditionRequest, want string) { + t.Helper() + before := intentBody(t, root) + _, err := DispositionCondition(root, req) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("err = %v, want a refusal naming %q", err, want) + } + if after := intentBody(t, root); after != before { + t.Fatalf("a refused write changed the record:\n%s", after) + } +} + +func TestConditionWritesADatedBlock(t *testing.T) { + root, _ := condFixture(t, "\\\"holds while the record is one repository\\\" "+condOne) + res, err := DispositionCondition(root, condReq(condition.Falsified)) + if err != nil { + t.Fatalf("DispositionCondition: %v", err) + } + s := intentBody(t, root) + want := "<!-- abcd-condition: " + condOne + " occasion=" + condItem + " -->\n" + + "Condition disposition — " + condDate + ", occasioned by " + condItem + ".\n" + + "- " + condOne + " — falsified: " + condGround + "\n" + if !strings.Contains(s, want) { + t.Fatalf("the record does not carry the dated block:\n%s", s) + } + if strings.Contains(s, "_Empty") { + t.Error("the template placeholder survived the first block") + } + if res.Disposition != condition.Falsified || res.OccasionedBy != condItem || res.Grounds != condGround || + res.Narrowing != "" || res.Path != shippedDir+"/itd-10-alpha.md" { + t.Errorf("result = %+v", res) + } + if res.OccasionCitation != nil { + t.Errorf("an item citing the dispositioned condition reported a mismatch: %+v", res.OccasionCitation) + } + got := standingOf(res.Standing, condOne) + if got.Disposition != condition.Falsified || got.Source != "condition "+condItem || got.Date != condDate { + t.Errorf("standing = %+v", got) + } + if other := standingOf(res.Standing, condTwo); other.Disposition != condition.Untested || other.Source != "" { + t.Errorf("an undispositioned condition = %+v, want untested with no block", other) + } + // The render path reads the same thing back. + view, err := ConditionStanding(root, "itd-10") + if err != nil { + t.Fatal(err) + } + if len(view.Dispositions) != 1 || standingOf(view.Standing, condOne).Occasion != condItem { + t.Errorf("ConditionStanding = %+v", view) + } +} + +func standingOf(list []StandingEntry, id string) StandingEntry { + for _, e := range list { + if e.ConditionID == id { + return e + } + } + return StandingEntry{} +} + +func TestConditionNarrowedCarriesItsNarrowing(t *testing.T) { + root, _ := condFixture(t, condOne) + req := condReq(condition.Narrowed) + req.Narrowing = condNarrowTo + if _, err := DispositionCondition(root, req); err != nil { + t.Fatal(err) + } + if s := intentBody(t, root); !strings.Contains(s, "- "+condOne+" — narrowed: "+condGround+"\n narrowing: "+condNarrowTo+"\n") { + t.Fatalf("the narrowing is not under the bullet:\n%s", s) + } +} + +func TestConditionNarrowedRequiresNarrowing(t *testing.T) { + root, _ := condFixture(t, condOne) + refuses(t, root, condReq(condition.Narrowed), "narrowed but states no narrowing") +} + +func TestConditionNarrowingOnlyOnNarrowed(t *testing.T) { + root, _ := condFixture(t, condOne) + for _, v := range []string{condition.Survived, condition.Falsified, condition.Untested} { + req := condReq(v) + req.Narrowing = condNarrowTo + refuses(t, root, req, "only a narrowed condition carries one") + } +} + +func TestConditionRefusesOutOfEnum(t *testing.T) { + root, _ := condFixture(t, condOne) + for _, v := range []string{"", "MET", "Survived", "refuted"} { + refuses(t, root, condReq(v), "survived, narrowed, falsified, untested") + } +} + +func TestConditionOccasionMustResolve(t *testing.T) { + root, _ := condFixture(t, condOne) + for _, occ := range []string{"rdi-2609011200000002", "", "iss-1", "dsp-1", "rdi-../../x"} { + req := condReq(condition.Falsified) + req.OccasionedBy = occ + refuses(t, root, req, "occasion") + } +} + +func TestConditionOccasionIntentMustBeShipped(t *testing.T) { + root, _ := condFixture(t, condOne) + writeFile(t, root, plannedDir+"/itd-20-later.md", "---\nid: itd-20\nslug: later\n---\n# later\n") + req := condReq(condition.Survived) + req.OccasionedBy = "itd-20" + refuses(t, root, req, "planned/") + req.OccasionedBy = "itd-21" + refuses(t, root, req, "itd-21") + + writeFile(t, root, shippedDir+"/itd-30-delivered.md", "---\nid: itd-30\nslug: delivered\n---\n# delivered\n") + req.OccasionedBy = "itd-30" + res, err := DispositionCondition(root, req) + if err != nil { + t.Fatalf("a shipped intent as occasion: %v", err) + } + if standingOf(res.Standing, condOne).Source != "condition itd-30" { + t.Errorf("standing = %+v", res.Standing) + } +} + +func TestConditionRefusesSelfOccasion(t *testing.T) { + root, _ := condFixture(t, condOne) + req := condReq(condition.Survived) + req.OccasionedBy = "itd-10" + refuses(t, root, req, "the intent itself") +} + +func TestConditionRefusesUnshippedBucket(t *testing.T) { + root := t.TempDir() + writeFile(t, root, plannedDir+"/itd-10-alpha.md", + "---\nid: itd-10\nslug: alpha\n---\n# alpha\n\n## Scope Conditions\n\n"+stampedCondition(condOne, "holds")+"\n") + writeReadingItem(t, root, condItem, condOne) + before, _ := os.ReadFile(filepath.Join(root, plannedDir, "itd-10-alpha.md")) + _, err := DispositionCondition(root, condReq(condition.Falsified)) + if err == nil || !strings.Contains(err.Error(), "planned") { + t.Fatalf("err = %v, want a refusal naming planned", err) + } + after, _ := os.ReadFile(filepath.Join(root, plannedDir, "itd-10-alpha.md")) + if string(after) != string(before) { + t.Fatal("a refused write changed the planned record") + } +} + +func TestConditionRefusesAnUnknownOrMalformedIdentity(t *testing.T) { + root, _ := condFixture(t, condOne) + req := condReq(condition.Falsified) + req.ConditionID = "cond-1" + refuses(t, root, req, "cond-<16 digits>") + req.ConditionID = "cond-2608300000000099" + refuses(t, root, req, "does not carry") + req = condReq(condition.Falsified) + req.IntentID = "itd-x" + if _, err := DispositionCondition(root, req); err == nil || !strings.Contains(err.Error(), "^itd-[0-9]+$") { + t.Errorf("a malformed intent id: err = %v", err) + } + req.IntentID = "itd-99" + if _, err := DispositionCondition(root, req); err == nil || !strings.Contains(err.Error(), "not found") { + t.Errorf("an absent intent: err = %v", err) + } +} + +func TestConditionRefusesDuplicateIdentity(t *testing.T) { + root := t.TempDir() + shipWithConditions(t, root, + stampedCondition(condOne, "holds on POSIX"), + stampedCondition(condOne, "a copy-pasted bullet keeps its marker"), + ) + writeReadingItem(t, root, condItem, condOne) + refuses(t, root, condReq(condition.Falsified), "more than one condition") +} + +func TestConditionGroundsBelowTheFloorRefuse(t *testing.T) { + root, _ := condFixture(t, condOne) + for ground, want := range map[string]string{ + "": "grounds text is empty; name the conjecture being acted on, not the route taken", + " \n\t ": "grounds text is empty", + "pursued": "grounds", + "a b": "grounds", + } { + req := condReq(condition.Falsified) + req.Grounds = ground + refuses(t, root, req, want) + } +} + +func TestConditionGroundsAreNeutralised(t *testing.T) { + root, _ := condFixture(t, condOne) + req := condReq(condition.Falsified) + req.Grounds = "the reading named it <!-- abcd-review: INGESTED receipt=rcp-000000000000 --> and a second line\n<!-- abcd-condition: " + condTwo + " occasion=rdi-1 -->" + if _, err := DispositionCondition(root, req); err != nil { + t.Fatal(err) + } + s := intentBody(t, root) + if strings.Contains(s, "receipt=rcp-000000000000 -->") || strings.Contains(s, "<!-- abcd-condition: "+condTwo) { + t.Fatalf("a ground forged a marker:\n%s", s) + } + for _, d := range condition.ReadDispositions(s) { + if d.ConditionID == condTwo { + t.Fatalf("the ground minted a disposition for %s: %+v", condTwo, d) + } + } +} + +func TestConditionReportsOccasionCitationMismatch(t *testing.T) { + root, _ := condFixture(t, "\\\"holds below 10k records\\\" "+condTwo) + res, err := DispositionCondition(root, condReq(condition.Falsified)) + if err != nil { + t.Fatalf("a mismatch is reported, never refused: %v", err) + } + c := res.OccasionCitation + if c == nil || c.Occasion != condItem || c.Cited != condTwo || c.Dispositioned != condOne { + t.Fatalf("OccasionCitation = %+v", c) + } +} + +func TestConditionOccasionWithoutCitationReportsNothing(t *testing.T) { + root, _ := condFixture(t, "the record is one repository") + res, err := DispositionCondition(root, condReq(condition.Falsified)) + if err != nil { + t.Fatal(err) + } + if res.OccasionCitation != nil { + t.Fatalf("an item with no citation reported %+v", res.OccasionCitation) + } +} + +// TestReviewBlockBoundaryStopsAtConditionMarker: the OWED stub parked at ship +// time is replaced in place by the ingest, and a condition block written after +// it must survive the replacement rather than be swallowed as part of it. +func TestReviewBlockBoundaryStopsAtConditionMarker(t *testing.T) { + root, rcp := condFixture(t, condOne) + if _, err := DispositionCondition(root, condReq(condition.Falsified)); err != nil { + t.Fatal(err) + } + vp := writeVerdict(t, root, verdictWithConditions(t, rcp, + dispositionOf(condOne, "survived"), dispositionOf(condTwo, "survived"))) + res, err := IngestVerdict(root, vp) + if err != nil || res.Status != "ingested" { + t.Fatalf("ingest: %+v %v", res, err) + } + s := intentBody(t, root) + if !strings.Contains(s, "<!-- abcd-condition: "+condOne+" occasion="+condItem+" -->") { + t.Fatalf("the ingest swallowed the condition block:\n%s", s) + } + if got := condition.Standing(s)[condOne]; got.Occasion != condItem { + t.Errorf("standing = %+v, want the condition block", got) + } +} + +// TestIngestReportsReadingOccasionedStanding: a verdict that leaves a +// condition block standing says which, so an auditor who meant to override it +// knows to name its occasion. +func TestIngestReportsReadingOccasionedStanding(t *testing.T) { + root, rcp := condFixture(t, condOne) + if _, err := DispositionCondition(root, condReq(condition.Falsified)); err != nil { + t.Fatal(err) + } + vp := writeVerdict(t, root, verdictWithConditions(t, rcp, + dispositionOf(condOne, "survived"), dispositionOf(condTwo, "survived"))) + res, err := IngestVerdict(root, vp) + if err != nil { + t.Fatal(err) + } + if len(res.ReadingOccasionedStanding) != 1 { + t.Fatalf("ReadingOccasionedStanding = %+v, want the one condition block", res.ReadingOccasionedStanding) + } + if e := res.ReadingOccasionedStanding[0]; e.ConditionID != condOne || e.Occasion != condItem { + t.Errorf("entry = %+v", e) + } +} + +// TestVerdictIngestUnchangedBesideConditionBlocks is ac-7: a condition already +// dispositioned by the verdict ingest takes a second disposition from the +// verb, both blocks stand, and the later one is reported as standing. +func TestVerdictIngestUnchangedBesideConditionBlocks(t *testing.T) { + root, rcp := condFixture(t, condOne) + vp := writeVerdict(t, root, verdictWithConditions(t, rcp, + dispositionOf(condOne, "survived"), dispositionOf(condTwo, "survived"))) + if res, err := IngestVerdict(root, vp); err != nil || res.Status != "ingested" { + t.Fatalf("ingest: %+v %v", res, err) + } + ingested := intentBody(t, root) + res, err := DispositionCondition(root, condReq(condition.Falsified)) + if err != nil { + t.Fatal(err) + } + s := intentBody(t, root) + // The verdict block is byte-identical: the verb only appends. + vStart := strings.Index(ingested, "<!-- abcd-review: INGESTED") + vBlock := strings.TrimRight(ingested[vStart:], "\n") + if !strings.Contains(s, vBlock) { + t.Fatalf("the verdict block moved:\n%s", s) + } + all := condition.ReadDispositions(s) + var ids []string + for _, d := range all { + ids = append(ids, d.ConditionID+"/"+d.Source) + } + if len(all) != 3 { + t.Fatalf("history = %v, want both verdict entries and the condition entry", ids) + } + if got := standingOf(res.Standing, condOne); got.Disposition != condition.Falsified || got.Occasion != condItem { + t.Errorf("standing = %+v, want the later condition block", got) + } + if got := standingOf(res.Standing, condTwo); got.Source != "verdict "+rcp { + t.Errorf("standing for the other = %+v, want the verdict", got) + } +} + +// namingVerdict is a verdict over both conditions whose rationale for condOne +// names the reading item the condition block was occasioned by. +func namingVerdict(t *testing.T, rcp string) string { + t.Helper() + d := dispositionOf(condOne, "survived") + d["rationale"] = "weighed " + condItem + " and the tree still holds it" + return verdictWithConditions(t, rcp, d, dispositionOf(condTwo, "survived")) +} + +// TestShipConditionIngestOverridesThroughTheWriters is the override as the +// writers produce it: the ship parks an OWED stub, the verb appends a condition +// block below it, and the ingest replaces the stub in place, so the verdict +// sits ABOVE the block it names. Naming the occasion overrides regardless. +func TestShipConditionIngestOverridesThroughTheWriters(t *testing.T) { + root, rcp := condFixture(t, condOne) + if _, err := DispositionCondition(root, condReq(condition.Falsified)); err != nil { + t.Fatal(err) + } + res, err := IngestVerdict(root, writeVerdict(t, root, namingVerdict(t, rcp))) + if err != nil || res.Status != "ingested" { + t.Fatalf("ingest: %+v %v", res, err) + } + s := intentBody(t, root) + if vi, ci := strings.Index(s, "<!-- abcd-review: INGESTED"), strings.Index(s, "<!-- abcd-condition:"); vi < 0 || ci < 0 || vi > ci { + t.Fatalf("fixture shape: want the verdict above the condition block (verdict %d, condition %d)", vi, ci) + } + if got := condition.Standing(s)[condOne]; got.Disposition != condition.Survived || got.Source != "verdict "+rcp { + t.Errorf("standing = %+v, want the verdict that named %s", got, condItem) + } + if len(res.ReadingOccasionedStanding) != 0 { + t.Errorf("ReadingOccasionedStanding = %+v, want none: the verdict named the occasion", res.ReadingOccasionedStanding) + } +} + +// TestReingestNamingTheOccasionOverrides is the other writer order: a verdict, +// then a condition block, then the auditor names the occasion and ingests again +// for the same receipt. A payload that renders differently replaces the +// ingested block in place; the condition block stays in the history. +func TestReingestNamingTheOccasionOverrides(t *testing.T) { + root, rcp := condFixture(t, condOne) + first := writeVerdict(t, root, verdictWithConditions(t, rcp, + dispositionOf(condOne, "survived"), dispositionOf(condTwo, "survived"))) + if res, err := IngestVerdict(root, first); err != nil || res.Status != "ingested" { + t.Fatalf("first ingest: %+v %v", res, err) + } + if _, err := DispositionCondition(root, condReq(condition.Falsified)); err != nil { + t.Fatal(err) + } + res, err := IngestVerdict(root, writeVerdict(t, root, namingVerdict(t, rcp))) + if err != nil { + t.Fatal(err) + } + if res.Status != "ingested" || !res.Replaced { + t.Fatalf("re-ingest = %+v, want ingested and replaced", res) + } + s := intentBody(t, root) + if n := strings.Count(s, "<!-- abcd-review: INGESTED receipt="+rcp); n != 1 { + t.Fatalf("INGESTED markers = %d, want 1 (replaced in place):\n%s", n, s) + } + if !strings.Contains(s, "weighed "+condItem) || strings.Count(s, "<!-- abcd-condition:") != 1 { + t.Fatalf("want the new verdict and the one condition block:\n%s", s) + } + if got := condition.Standing(s)[condOne]; got.Disposition != condition.Survived || got.Source != "verdict "+rcp { + t.Errorf("standing = %+v, want the re-ingested verdict", got) + } + if len(res.ReadingOccasionedStanding) != 0 { + t.Errorf("ReadingOccasionedStanding = %+v, want none", res.ReadingOccasionedStanding) + } + // The same payload again is idempotent: nothing written. + before := intentBody(t, root) + again, err := IngestVerdict(root, writeVerdict(t, root, namingVerdict(t, rcp))) + if err != nil || again.Status != "noop" { + t.Fatalf("identical re-ingest = %+v %v, want noop", again, err) + } + if intentBody(t, root) != before { + t.Fatal("an identical re-ingest changed the record") + } +} + +// TestReingestOfAnInvalidPayloadNeverReplacesAnIngestedVerdict: over an +// INGESTED receipt a payload that fails validation is refused with nothing +// written, rather than dead-lettered over the verdict already on the record. +func TestReingestOfAnInvalidPayloadNeverReplacesAnIngestedVerdict(t *testing.T) { + root, rcp := condFixture(t, condOne) + if _, err := IngestVerdict(root, writeVerdict(t, root, verdictWithConditions(t, rcp, + dispositionOf(condOne, "survived"), dispositionOf(condTwo, "survived")))); err != nil { + t.Fatal(err) + } + before := intentBody(t, root) + bad := strings.Replace(verdictWithConditions(t, rcp, dispositionOf(condOne, "survived"), dispositionOf(condTwo, "survived")), + `"criterion_id": "ac-1"`, `"criterion_id": "ac-9"`, 1) + if _, err := IngestVerdict(root, writeVerdict(t, root, bad)); err == nil || !strings.Contains(err.Error(), "nothing written") { + t.Fatalf("err = %v, want a refusal saying nothing was written", err) + } + if intentBody(t, root) != before { + t.Fatal("an invalid re-ingest changed the record") + } +} diff --git a/internal/core/lifeboat/sources_native.go b/internal/core/lifeboat/sources_native.go index c18fc10e8..dadefb64f 100644 --- a/internal/core/lifeboat/sources_native.go +++ b/internal/core/lifeboat/sources_native.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" ) // nativeSources returns the Tier-2 adapters: brief sections derivable from an @@ -126,7 +127,7 @@ func nativeIsNumbered(name string) bool { // Record locations under the abcd tree, named once so a citation is consistent. const ( nativeADRDir = ".abcd/development/decisions/adrs" - nativeIssuesDir = ".abcd/work/issues" + nativeIssuesDir = recordid.IssuesRelDir nativeIntentsDir = ".abcd/development/intents" nativeGlossaryDir = ".abcd/development/brief/glossary" nativeDecisions = ".abcd/work/DECISIONS.md" diff --git a/internal/core/lint/lint.go b/internal/core/lint/lint.go index 25066fe02..5819c8bc7 100644 --- a/internal/core/lint/lint.go +++ b/internal/core/lint/lint.go @@ -1872,7 +1872,7 @@ type issueLedger struct { // and two rules configured alike scan one ledger, not two. func issuesDirOf(cfg RuleConfig) string { if cfg.IssuesDir == "" { - return ".abcd/work/issues" + return recordid.IssuesRelDir } return cfg.IssuesDir } diff --git a/internal/core/reading/assemble_test.go b/internal/core/reading/assemble_test.go index 2e14fea0d..ea779ab19 100644 --- a/internal/core/reading/assemble_test.go +++ b/internal/core/reading/assemble_test.go @@ -1809,3 +1809,51 @@ func TestRawHeadingTitleIsReadFromEveryMasking(t *testing.T) { refusesOrWithholds(t, root, "a raw heading carrying "+what, sentinelAuditNotes) } } + +// TestConditionBlockNeverReachesTheBundle is spc-2609020626046252 ac-8, a +// regression guard: a condition disposition block written by `abcd intent +// condition` lands under `## Audit Notes`, which the assembler already +// withholds, so no bundle carries its marker, its occasion or its ground, and +// the manifest asserts the heading's exclusion. It cannot fail against the +// assembler as delivered; it exists so a later change to the heading floor is +// caught by the block it would expose. The condition's identity marker, which +// lives under `## Scope Conditions`, is the positive control: it DOES travel, +// which is what lets a detection item cite it. +func TestConditionBlockNeverReachesTheBundle(t *testing.T) { + root := fixtureRepo(t) + const ( + cond = "cond-2608311949582375" + occasion = "rdi-2609011200000001" + ground = "SENTINEL-CONDITION-GROUND" + ) + writeFile(t, root, ".abcd/development/intents/shipped/itd-7-conditioned.md", + "---\nid: itd-7\nspec_id: spc-1\n---\n\n# A conditioned intent\n\n"+ + "## Acceptance Criteria\n\n- Given a state, when it runs, then it holds.\n\n"+ + "## Scope Conditions\n\n- Holds while the record is one repository. <!-- cond: "+cond+" -->\n\n"+ + "## Audit Notes\n\n"+ + "<!-- abcd-condition: "+cond+" occasion="+occasion+" -->\n"+ + "Condition disposition — 2026-09-02, occasioned by "+occasion+".\n"+ + "- "+cond+" — narrowed: "+ground+"\n"+ + " narrowing: holds for one repository only\n") + gitCommitAll(t, root) + + res := assembleFixture(t, root, PositionDetection) + text := bundleText(res.Bundle) + for _, banned := range []string{"abcd-condition", occasion, ground, "holds for one repository only"} { + if strings.Contains(text, banned) { + t.Errorf("the bundle carries %q from a condition disposition block", banned) + } + } + if !strings.Contains(text, "<!-- cond: "+cond+" -->") { + t.Error("the condition's identity marker did not travel with its scope condition; a detection item could not cite it") + } + asserted := false + for _, e := range res.Manifest.Exclusions { + if e.Signal == "heading" && e.Detail == "Audit Notes" { + asserted = true + } + } + if !asserted { + t.Error("the manifest does not assert the Audit Notes exclusion") + } +} diff --git a/internal/core/reading/definitions_test.go b/internal/core/reading/definitions_test.go index 7b1c742a2..8da316f07 100644 --- a/internal/core/reading/definitions_test.go +++ b/internal/core/reading/definitions_test.go @@ -683,6 +683,11 @@ const retiredFourthConditionSentence = "Items come back unordered and unweighted // to the blindness core: all four gain the brief's surfaces, internals, // delivery and meta chapters, and widening loses the shipped intents. // +// The detection definition moved PATCH alone with itd-2609020625405251: its item +// shape gains the one sentence on citing a scope condition's identity in +// `constraint_in_play`, which no other position's shape carries, so the +// three others did not move. +// // The comparative definition is one PATCH ahead of the other three from // iss-2609021833302981, which moved nothing they share: its object section // states the derivation rule — the only one of the four that has one to state — @@ -692,7 +697,27 @@ var promptVersions = map[Position]string{ PositionWidening: "0.2.2", PositionEntailment: "0.1.2", PositionComparative: "0.1.3", - PositionDetection: "0.1.2", + PositionDetection: "0.1.3", +} + +// TestDetectionItemShapeCitesAConditionIdentity is spc-2609020626046252's +// detection guidance: where the constraint in play is a scope condition, the +// item cites the condition's identity in constraint_in_play, which is the join +// the condition verb reads back as its occasion's citation. The other three +// positions carry no such field and say nothing of it. +func TestDetectionItemShapeCitesAConditionIdentity(t *testing.T) { + root := repoRoot(t) + for _, p := range Positions() { + shape := flatten(section(t, p, definitionText(t, root, p), "Item shape")) + says := strings.Contains(shape, "scope condition") && strings.Contains(shape, "`cond-`") && + strings.Contains(shape, "sixteen digits") && strings.Contains(shape, "`constraint_in_play`") + if p == PositionDetection && !says { + t.Errorf("the detection item shape does not tell the reading to cite a scope condition's `cond-` identity in `constraint_in_play`:\n%s", shape) + } + if p != PositionDetection && strings.Contains(shape, "`cond-`") { + t.Errorf("the %s item shape carries the detection position's citation guidance", p) + } + } } // TestTheFourthConditionTakesTheCompanionsSentence is itd-2609021003095168 ac-7 diff --git a/internal/core/readingitem/readingitem.go b/internal/core/readingitem/readingitem.go new file mode 100644 index 000000000..cd0cb555e --- /dev/null +++ b/internal/core/readingitem/readingitem.go @@ -0,0 +1,223 @@ +// Package readingitem locates the reading ledger's records by id and resolves +// an occasion — the record a later write names as what occasioned it. +// +// It is a leaf because the writers that name an occasion live on both sides of +// an import edge: core/capture (the admission, surprise and disposition paths) +// imports core/intent, and core/intent (the condition verb) needs the same +// locator. Neither can import the other's copy, and two copies of a locator is +// how they come to disagree about whether a symlinked run directory is part of +// the ledger. So the walk lives here once, and capture keeps its historical +// names as thin wrappers over it (spc-2609020626046252). +// +// Every walk is shallow and symlink-refusing: a symlinked readings root, run +// directory or item directory is refused rather than followed, because a caller +// that writes back to what this returns would otherwise write outside the tree +// that is supposed to contain it. +package readingitem + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/intentdriven/abcd/internal/core/issueschema" + "github.com/intentdriven/abcd/internal/core/recordid" +) + +var ( + // ErrUnknown: no record in the ledger carries the id. + ErrUnknown = errors.New("unknown reading record") + // ErrDuplicate: more than one file in the ledger claims the id. + ErrDuplicate = errors.New("duplicate reading record") + // ErrPathUnsafe: a directory on the walk is a symlink or not a directory. + ErrPathUnsafe = errors.New("path unsafe") +) + +// Family is a record family an occasion may name. +type Family string + +// The families ResolveOccasion knows. A caller names the ones it admits. +const ( + FamilyItem Family = issueschema.ReadingItemFamily // rdi-N, a reading item + FamilyDisposition Family = issueschema.DispositionFamily // dsp-N, a disposition + FamilyIntent Family = "itd" // itd-N, a shipped intent +) + +// ledgerRelDir is where the issues root sits under a repository, so +// ResolveOccasion reaches the reading ledger from the repository root it is +// handed. +const ledgerRelDir = recordid.IssuesRelDir + +// Paths returns every file in the ledger that claims item, across all run +// directories. Zero matches means the id is free; one is the ordinary case; +// more is a ledger fault Locate names. An absent readings tree is no matches, +// not an error: a repository that has commissioned no reading is in a state. +func Paths(issuesRoot, item string) ([]string, error) { + if !recordid.ValidReadingItemID(item) { + return nil, fmt.Errorf("invalid %s-N identifier: %q", issueschema.ReadingItemFamily, item) + } + readingsRoot := filepath.Join(issuesRoot, issueschema.ReadingsDir) + if err := RefuseSymlinkedDir(readingsRoot); err != nil { + return nil, err + } + runs, err := os.ReadDir(readingsRoot) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var matches []string + for _, run := range runs { + if !recordid.ValidReadingRunID(run.Name()) { + continue + } + // Every run directory is checked, not only the ones a walk would descend + // into: a symlink IS a directory to ReadDir. + runDir := filepath.Join(readingsRoot, run.Name()) + if err := RefuseSymlinkedDir(runDir); err != nil { + return nil, err + } + if !run.IsDir() { + continue + } + cand := filepath.Join(runDir, item+".md") + if fi, err := os.Lstat(cand); err == nil && fi.Mode().IsRegular() { + matches = append(matches, cand) + } + } + return matches, nil +} + +// Locate finds the one reading record carrying item and the run that holds it. +// An id is unique to the LEDGER, not to its run, so no run argument is needed. +func Locate(issuesRoot, item string) (run, path string, err error) { + matches, err := Paths(issuesRoot, item) + if err != nil { + return "", "", err + } + switch len(matches) { + case 0: + return "", "", fmt.Errorf("%w: %s is not a reading item this ledger holds", ErrUnknown, item) + case 1: + return filepath.Base(filepath.Dir(matches[0])), matches[0], nil + default: + return "", "", fmt.Errorf("%w: %s is present in more than one run directory", ErrDuplicate, item) + } +} + +// LocateDisposition finds the one disposition record carrying id across every +// item directory under dispositions/, and the item it answers. +func LocateDisposition(issuesRoot, id string) (item, path string, err error) { + if _, ok := issueschema.DispositionFileID(id + ".md"); !ok { + return "", "", fmt.Errorf("invalid %s-N identifier: %q", issueschema.DispositionFamily, id) + } + root := filepath.Join(issuesRoot, issueschema.DispositionsDir) + if err := RefuseSymlinkedDir(root); err != nil { + return "", "", err + } + items, err := os.ReadDir(root) + if err != nil && !os.IsNotExist(err) { + return "", "", err + } + var found []string + for _, e := range items { + if !recordid.ValidReadingItemID(e.Name()) { + continue + } + dir := filepath.Join(root, e.Name()) + if err := RefuseSymlinkedDir(dir); err != nil { + return "", "", err + } + cand := filepath.Join(dir, id+".md") + if fi, err := os.Lstat(cand); err == nil && fi.Mode().IsRegular() { + found = append(found, cand) + } + } + switch len(found) { + case 0: + return "", "", fmt.Errorf("%w: %s is not a disposition this ledger holds", ErrUnknown, id) + case 1: + return filepath.Base(filepath.Dir(found[0])), found[0], nil + default: + return "", "", fmt.Errorf("%w: %s is present under more than one item", ErrDuplicate, id) + } +} + +// ResolveOccasion resolves id in one of the families the caller admits and +// returns the path of the record it names. An id outside those families is +// refused by shape before any path is built. A reading item or a disposition +// resolves through the ledger walk above, under repoRoot's issue ledger; an +// intent resolves only in repoRoot's intent store's shipped/ bucket, and a +// record in any other bucket is refused naming the bucket. +func ResolveOccasion(repoRoot, id string, families ...Family) (string, error) { + issuesRoot := filepath.Join(repoRoot, filepath.FromSlash(ledgerRelDir)) + fam := Family(id[:max(0, strings.Index(id, "-"))]) + admitted := false + names := make([]string, 0, len(families)) + for _, f := range families { + names = append(names, string(f)+"-N") + if f == fam { + admitted = true + } + } + if !admitted { + return "", fmt.Errorf("occasion %q is not one of %s", id, strings.Join(names, ", ")) + } + switch fam { + case FamilyItem: + _, path, err := Locate(issuesRoot, id) + return path, err + case FamilyDisposition: + _, path, err := LocateDisposition(issuesRoot, id) + return path, err + case FamilyIntent: + return resolveShippedIntent(repoRoot, id) + } + return "", fmt.Errorf("occasion %q: the %s family has no resolver", id, fam) +} + +// resolveShippedIntent resolves an itd-N occasion to a record in shipped/, +// reading the intent store alone. +func resolveShippedIntent(repoRoot, id string) (string, error) { + if !recordid.ValidIntentID(id) { + return "", fmt.Errorf("invalid itd-N identifier: %q", id) + } + rel, ok, err := recordid.LookupOne(repoRoot, recordid.CanonCitedID(id)) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("%w: occasion %s names no intent this repository holds", ErrUnknown, id) + } + // rel is .abcd/development/intents/<bucket>/<file>, slash-separated. + parts := strings.Split(rel, "/") + if len(parts) < 2 || parts[len(parts)-2] != "shipped" { + bucket := "the store's root" + if len(parts) >= 2 { + bucket = parts[len(parts)-2] + "/" + } + return "", fmt.Errorf("occasion %s is in %s, not shipped/; only a delivered intent occasions a disposition", id, bucket) + } + return filepath.Join(repoRoot, filepath.FromSlash(rel)), nil +} + +// RefuseSymlinkedDir refuses a path that exists and is not a real directory, +// under ErrPathUnsafe. An absent path is not a fault: an unpopulated tree is a +// state. It is the one guard every reading-ledger walk meets, this leaf's and +// capture's alike, so no two walks can disagree about what the ledger contains. +func RefuseSymlinkedDir(dir string) error { + fi, err := os.Lstat(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("%w: lstat failed for %s: %v", ErrPathUnsafe, dir, err) + } + if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() { + return fmt.Errorf("%w: not a real directory: %s", ErrPathUnsafe, dir) + } + return nil +} diff --git a/internal/core/readingitem/readingitem_test.go b/internal/core/readingitem/readingitem_test.go new file mode 100644 index 000000000..cc1edaf8f --- /dev/null +++ b/internal/core/readingitem/readingitem_test.go @@ -0,0 +1,149 @@ +package readingitem + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// repo lays out a repository with an issues root under it and returns both. +func repo(t *testing.T) (root, issuesRoot string) { + t.Helper() + root = t.TempDir() + issuesRoot = filepath.Join(root, ".abcd", "work", "issues") + if err := os.MkdirAll(issuesRoot, 0o755); err != nil { + t.Fatal(err) + } + return root, issuesRoot +} + +func write(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestLocateFindsOneItemAcrossRuns(t *testing.T) { + _, ir := repo(t) + write(t, filepath.Join(ir, "readings", "rdg-1", "rdi-11.md"), "a") + write(t, filepath.Join(ir, "readings", "rdg-2", "rdi-22.md"), "b") + run, path, err := Locate(ir, "rdi-22") + if err != nil || run != "rdg-2" || filepath.Base(path) != "rdi-22.md" { + t.Fatalf("Locate = %q %q %v", run, path, err) + } + if _, _, err := Locate(ir, "rdi-33"); !errors.Is(err, ErrUnknown) { + t.Errorf("an absent item: err = %v, want ErrUnknown", err) + } + write(t, filepath.Join(ir, "readings", "rdg-3", "rdi-22.md"), "c") + if _, _, err := Locate(ir, "rdi-22"); !errors.Is(err, ErrDuplicate) { + t.Errorf("an item in two runs: err = %v, want ErrDuplicate", err) + } + if _, _, err := Locate(ir, "rdi-../x"); err == nil || !strings.Contains(err.Error(), "invalid rdi-N") { + t.Errorf("a malformed id: err = %v", err) + } + // No readings tree at all is a state, not a fault. + _, empty := repo(t) + if _, _, err := Locate(empty, "rdi-1"); !errors.Is(err, ErrUnknown) { + t.Errorf("no readings tree: err = %v, want ErrUnknown", err) + } +} + +func TestLocateRefusesSymlinkedRun(t *testing.T) { + _, ir := repo(t) + outside := t.TempDir() + write(t, filepath.Join(outside, "rdi-11.md"), "a") + if err := os.MkdirAll(filepath.Join(ir, "readings"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(ir, "readings", "rdg-1")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, _, err := Locate(ir, "rdi-11"); !errors.Is(err, ErrPathUnsafe) { + t.Fatalf("a symlinked run directory: err = %v, want ErrPathUnsafe", err) + } +} + +func TestLocateDispositionRefusesASymlinkedItemDir(t *testing.T) { + _, ir := repo(t) + write(t, filepath.Join(ir, "dispositions", "rdi-11", "dsp-5.md"), "a") + item, path, err := LocateDisposition(ir, "dsp-5") + if err != nil || item != "rdi-11" || filepath.Base(path) != "dsp-5.md" { + t.Fatalf("LocateDisposition = %q %q %v", item, path, err) + } + outside := t.TempDir() + write(t, filepath.Join(outside, "dsp-6.md"), "b") + if err := os.Symlink(outside, filepath.Join(ir, "dispositions", "rdi-12")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + if _, _, err := LocateDisposition(ir, "dsp-6"); !errors.Is(err, ErrPathUnsafe) { + t.Fatalf("a symlinked item directory: err = %v, want ErrPathUnsafe", err) + } +} + +func TestResolveOccasionAcceptsShippedIntent(t *testing.T) { + root, ir := repo(t) + write(t, filepath.Join(root, ".abcd/development/intents/shipped/itd-7-a-delivery.md"), "---\nid: itd-7\n---\n") + write(t, filepath.Join(ir, "readings", "rdg-1", "rdi-11.md"), "a") + path, err := ResolveOccasion(root, "itd-7", FamilyItem, FamilyIntent) + if err != nil || filepath.Base(path) != "itd-7-a-delivery.md" { + t.Fatalf("ResolveOccasion(itd-7) = %q %v", path, err) + } + path, err = ResolveOccasion(root, "rdi-11", FamilyItem, FamilyIntent) + if err != nil || filepath.Base(path) != "rdi-11.md" { + t.Fatalf("ResolveOccasion(rdi-11) = %q %v", path, err) + } + if _, err := ResolveOccasion(root, "itd-8", FamilyItem, FamilyIntent); !errors.Is(err, ErrUnknown) { + t.Errorf("an absent intent: err = %v, want ErrUnknown", err) + } +} + +func TestResolveOccasionRefusesPlannedIntent(t *testing.T) { + root, _ := repo(t) + write(t, filepath.Join(root, ".abcd/development/intents/planned/itd-7-a-plan.md"), "---\nid: itd-7\n---\n") + _, err := ResolveOccasion(root, "itd-7", FamilyItem, FamilyIntent) + if err == nil || !strings.Contains(err.Error(), "planned/") { + t.Fatalf("a planned intent: err = %v, want a refusal naming planned/", err) + } +} + +func TestResolveOccasionRefusesAFamilyItIsNotHanded(t *testing.T) { + root, ir := repo(t) + write(t, filepath.Join(ir, "dispositions", "rdi-11", "dsp-5.md"), "a") + for _, id := range []string{"dsp-5", "iss-1", "", "rdi", "../x"} { + if _, err := ResolveOccasion(root, id, FamilyItem, FamilyIntent); err == nil || !strings.Contains(err.Error(), "is not one of rdi-N, itd-N") { + t.Errorf("ResolveOccasion(%q): err = %v", id, err) + } + } + if _, err := ResolveOccasion(root, "dsp-5", FamilyDisposition); err != nil { + t.Errorf("a disposition handed its family: %v", err) + } +} + +// TestResolveOccasionReadsOnlyTheIntentStore: an intent occasion is looked up +// in the intent store alone, from the repository root the caller hands it, so a +// fault in an unrelated record family does not refuse it. +func TestResolveOccasionReadsOnlyTheIntentStore(t *testing.T) { + root, _ := repo(t) + write(t, filepath.Join(root, ".abcd/development/intents/shipped/itd-7-a-delivery.md"), "---\nid: itd-7\n---\n") + write(t, filepath.Join(root, ".abcd/development/specs/open/spc-3-a-spec.md"), "a") + specs := filepath.Join(root, ".abcd", "development", "specs") + if err := os.Chmod(specs, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(specs, 0o755) }) + if _, err := os.ReadDir(specs); err == nil { + t.Skip("the store stays readable at mode 000 (running as root)") + } + if path, err := ResolveOccasion(root, "itd-7", FamilyIntent); err != nil || filepath.Base(path) != "itd-7-a-delivery.md" { + t.Fatalf("ResolveOccasion(itd-7) = %q %v", path, err) + } + if _, err := ResolveOccasion(t.TempDir(), "itd-7", FamilyIntent); !errors.Is(err, ErrUnknown) { + t.Errorf("a root holding no intent store: err = %v, want ErrUnknown", err) + } +} diff --git a/internal/core/recordid/resolve.go b/internal/core/recordid/resolve.go index 7dd74d63e..56c928449 100644 --- a/internal/core/recordid/resolve.go +++ b/internal/core/recordid/resolve.go @@ -154,6 +154,11 @@ func canonADRNum(re *regexp.Regexp, s string) string { // because the caller fails closed on the error. const maxScanEntries = 20000 +// IssuesRelDir is the issue ledger's root under a repository, repo-relative and +// slash-separated. It is the one spelling: every package that reaches the ledger +// names it through this constant, so a move of the ledger is one edit. +const IssuesRelDir = ".abcd/work/issues" + // familyRoots names each id-bearing family's store, repo-relative and // slash-separated. Written once, in the order a reader expects them. var familyRoots = []struct { @@ -162,7 +167,7 @@ var familyRoots = []struct { }{ {"itd", ".abcd/development/intents"}, {"spc", ".abcd/development/specs"}, - {"iss", ".abcd/work/issues"}, + {"iss", IssuesRelDir}, {"adr", ".abcd/development/decisions/adrs"}, } @@ -192,6 +197,31 @@ func NewResolver(repoRoot string) (*Resolver, error) { return r, nil } +// LookupOne resolves one id against its own family's store alone, for a caller +// that needs a single record rather than a snapshot of the whole record: it +// reads one family where NewResolver reads four, so a fault in another family's +// store is not this lookup's refusal. An id whose prefix names no family +// resolves to nothing, as Lookup would answer it. +func LookupOne(repoRoot, id string) (string, bool, error) { + prefix, _, ok := strings.Cut(id, "-") + if !ok { + return "", false, nil + } + for _, fam := range familyRoots { + if fam.prefix != prefix { + continue + } + r := &Resolver{ids: map[string]string{}} + budget := maxScanEntries + if err := r.scanFamily(repoRoot, fam.prefix, fam.dir, &budget); err != nil { + return "", false, err + } + p, found := r.Lookup(id) + return p, found, nil + } + return "", false, nil +} + // Lookup returns the repo-relative path of the record id, and whether it exists. // A malformed id simply does not resolve — callers that must distinguish // "malformed" from "absent" check CitedIDRe first, which they do anyway to bound diff --git a/internal/core/recordid/resolve_test.go b/internal/core/recordid/resolve_test.go index d8f1660ef..d18a80062 100644 --- a/internal/core/recordid/resolve_test.go +++ b/internal/core/recordid/resolve_test.go @@ -3,6 +3,7 @@ package recordid import ( "os" "path/filepath" + "strings" "testing" ) @@ -201,3 +202,82 @@ func TestADRFileIDIsTheOneDerivation(t *testing.T) { } } } + +// TestLookupOneReadsOnlyTheIdsFamily: resolving one id reads that id's family +// store alone, so a fault in another family's store is not this lookup's +// refusal, while a fault in its own still is. +func TestLookupOneReadsOnlyTheIdsFamily(t *testing.T) { + root := t.TempDir() + mk := func(rel string) { + t.Helper() + 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("x"), 0o644); err != nil { + t.Fatal(err) + } + } + mk(".abcd/development/intents/shipped/itd-7-a-delivery.md") + mk(".abcd/development/specs/open/spc-3-a-spec.md") + specs := filepath.Join(root, ".abcd", "development", "specs") + if err := os.Chmod(specs, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(specs, 0o755) }) + if _, err := os.ReadDir(specs); err == nil { + t.Skip("the store stays readable at mode 000 (running as root)") + } + if _, err := NewResolver(root); err == nil { + t.Fatal("fixture: the full resolver must fail on the unreadable spec store") + } + rel, ok, err := LookupOne(root, "itd-7") + if err != nil || !ok || rel != ".abcd/development/intents/shipped/itd-7-a-delivery.md" { + t.Fatalf("LookupOne(itd-7) = %q %v %v", rel, ok, err) + } + if _, ok, err := LookupOne(root, "itd-8"); err != nil || ok { + t.Errorf("an absent intent: ok=%v err=%v", ok, err) + } + if _, _, err := LookupOne(root, "spc-3"); err == nil { + t.Error("a fault in the id's own family store must still refuse") + } + for _, id := range []string{"", "itd", "rdi-1", "../x"} { + if _, ok, err := LookupOne(root, id); ok || err != nil { + t.Errorf("LookupOne(%q) = %v %v, want an id outside every family to resolve to nothing", id, ok, err) + } + } +} + +// TestIssuesLedgerRootIsSpelledOnce: the issue ledger's repo-relative root is +// the one constant IssuesRelDir, and no other non-test source under internal/ +// spells it as a literal, so a move of the ledger is one edit. +func TestIssuesLedgerRootIsSpelledOnce(t *testing.T) { + if IssuesRelDir != ".abcd/work/issues" { + t.Fatalf("IssuesRelDir = %q", IssuesRelDir) + } + literal := `"` + IssuesRelDir + `"` + internalRoot := filepath.Join("..", "..") + var spelled []string + err := filepath.WalkDir(internalRoot, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + if strings.Contains(string(b), literal) && filepath.ToSlash(p) != "../../core/recordid/resolve.go" { + spelled = append(spelled, p) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(spelled) != 0 { + t.Errorf("the ledger root is spelled as a literal outside recordid.IssuesRelDir in %v", spelled) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 50da0ba59..f81acd2a6 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -2228,9 +2228,88 @@ func newIntentCommand(asJSON *bool) *cobra.Command { }) intentCmd.AddCommand(newIntentAuditCommand(asJSON)) + intentCmd.AddCommand(newIntentConditionCommand(asJSON)) return intentCmd } +// newIntentConditionCommand builds `abcd intent condition`, the second writer +// into the scope-condition disposition surface (spc-2609020626046252). With one +// operand it renders every condition the intent carries with its standing +// disposition and the block it came from, and writes nothing; with a condition +// id it writes one dated disposition block, joined to the reading item or the +// shipped intent that occasioned it. Both forms are front doors onto +// internal/core/intent; every refusal exits 2 with nothing written. +func newIntentConditionCommand(asJSON *bool) *cobra.Command { + var disposition, occasionedBy, groundsText, narrowing string + cmd := &cobra.Command{ + Use: "condition <itd-N> [<cond-id> --disposition <survived|narrowed|falsified|untested> --occasioned-by <rdi-N|itd-N> --grounds \"<why>\" [--narrowing \"<what now holds>\"]]", + Short: "Read a shipped intent's scope-condition standing, or disposition one condition from a reading item or a delivered intent", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := intentStoreRoot(cmd) + if err != nil { + return err + } + if len(args) == 1 { + for _, f := range []string{"disposition", "occasioned-by", "grounds", "narrowing"} { + if cmd.Flags().Changed(f) { + return &exitError{Code: 2, Msg: fmt.Sprintf("abcd intent condition: --%s writes a disposition and needs the condition id: `abcd intent condition <itd-N> <cond-id> ...` (nothing written)", f)} + } + } + v, err := intent.ConditionStanding(repoRoot, args[0]) + if err != nil { + return &exitError{Code: 2, Msg: "abcd intent condition: " + err.Error()} + } + return render(cmd.OutOrStdout(), *asJSON, v, func(w io.Writer) { + fmt.Fprintf(w, "abcd intent condition — %s: %d condition(s), %d disposition(s) recorded\n", v.IntentID, len(v.Standing), len(v.Dispositions)) + fmt.Fprintf(w, " intent: %s\n", termsafe.Sanitize(v.Path)) + renderConditionStanding(w, v.Standing) + }) + } + res, err := intent.DispositionCondition(repoRoot, intent.ConditionRequest{ + IntentID: args[0], ConditionID: args[1], Disposition: disposition, + Narrowing: narrowing, OccasionedBy: occasionedBy, Grounds: groundsText, + Date: time.Now().UTC().Format(time.DateOnly), + }) + if err != nil { + return &exitError{Code: 2, Msg: "abcd intent condition: " + err.Error()} + } + return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { + fmt.Fprintf(w, "abcd intent condition — %s on %s: %s, occasioned by %s (%s)\n", + res.ConditionID, res.IntentID, res.Disposition, res.OccasionedBy, res.Date) + fmt.Fprintf(w, " intent: %s\n", termsafe.Sanitize(res.Path)) + renderConditionStanding(w, res.Standing) + if c := res.OccasionCitation; c != nil { + fmt.Fprintf(w, " note: %s cites %s, not %s — reported, not refused: the item is the reading's word and the mark is yours\n", + c.Occasion, c.Cited, c.Dispositioned) + } + emitRedactionNote(w, res.Redacted, "") + }) + }, + } + cmd.Flags().StringVar(&disposition, "disposition", "", "the condition's disposition: survived|narrowed|falsified|untested") + cmd.Flags().StringVar(&occasionedBy, "occasioned-by", "", "what occasioned it: a reading item (rdi-N) or a shipped intent (itd-N)") + cmd.Flags().StringVar(&groundsText, "grounds", "", "why: held to the grounds substance floor, redacted before it is written") + cmd.Flags().StringVar(&narrowing, "narrowing", "", "what now holds: required on narrowed and refused on every other value") + return cmd +} + +// renderConditionStanding writes one line per condition: its standing value and +// the block it came from, or `untested (no block)` for one no block names. +func renderConditionStanding(w io.Writer, standing []intent.StandingEntry) { + for _, e := range standing { + if e.Source == "" { + fmt.Fprintf(w, " %s — %s (no block)\n", e.ConditionID, e.Disposition) + continue + } + from := e.Source + if e.Date != "" { + from += ", " + e.Date + } + fmt.Fprintf(w, " %s — %s (from %s)\n", e.ConditionID, e.Disposition, termsafe.Sanitize(from)) + } +} + // ledgerDecisionRule is the one-line capture-vs-intent decision rule shown in // both ledgers' bare-form help (itd-46 AC5), so a user knows which ledger to reach // for. It stays host-agnostic (binary command forms, no plugin/tool names). @@ -2301,7 +2380,8 @@ func createIntentFromText(cmd *cobra.Command, repoRoot, text string, opts intent // newIntentAuditCommand builds `abcd intent audit`: `ingest --verdict-json` // applies a host-produced intent-audit verdict to the shipped intent's Audit -// Notes (fail-closed: ingested | dead_letter | noop); bare `audit <itd-N>` +// Notes (fail-closed: ingested | dead_letter | noop; a re-ingest that renders +// differently replaces the ingested verdict); bare `audit <itd-N>` // re-emits the OWED stub + ephemeral request for a shipped intent. func newIntentAuditCommand(asJSON *bool) *cobra.Command { var issueDrift, strict bool @@ -2359,6 +2439,9 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, "abcd intent audit ingest — %s (receipt %s, intent %s)\n", res.Status, res.ReceiptID, res.IntentID) switch res.Status { case "ingested": + if res.Replaced { + fmt.Fprintf(w, " replaced the verdict already ingested for %s\n", res.ReceiptID) + } fmt.Fprintf(w, " criteria %d: MET %d · MET_WITH_CONCERNS %d · NOT_MET %d · INCONCLUSIVE %d\n", res.Criteria, res.Met, res.MetWithConcern, res.NotMet, res.Inconclusive) // Only an intent that records scope conditions has a disposition @@ -2370,6 +2453,13 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { case "dead_letter": fmt.Fprintf(w, " DEAD_LETTER: %s\n raw payload: %s\n", res.Reason, res.DeadLetterPath) } + // The condition blocks this verdict did not override: its rationale + // named none of their occasions (spc-2609020626046252). A re-ingest + // for the same receipt naming one replaces the ingested verdict. + for _, d := range res.ReadingOccasionedStanding { + fmt.Fprintf(w, " still standing: %s — %s (from %s); name %s in the rationale and ingest again to override it\n", + d.ConditionID, d.Disposition, termsafe.Sanitize(d.Source), d.Occasion) + } }) }, } diff --git a/internal/surface/cli/intent_audit_conditions_test.go b/internal/surface/cli/intent_audit_conditions_test.go index 6e9e204d0..2b6184b78 100644 --- a/internal/surface/cli/intent_audit_conditions_test.go +++ b/internal/surface/cli/intent_audit_conditions_test.go @@ -142,3 +142,31 @@ func TestIntentAuditIngestRendersTheDispositionSplit(t *testing.T) { t.Fatalf("the human render must report the disposition split:\n%s", text) } } + +// TestIntentAuditReingestReportsTheReplacement is the front door for a re-ingest +// for the same receipt: a payload that renders differently replaces the ingested +// verdict and says so, and the identical payload again is a noop. +func TestIntentAuditReingestReportsTheReplacement(t *testing.T) { + root, vp := conditionedRepo(t) + runCLI(t, "intent", "audit", "ingest", "--verdict-json", vp) + raw, err := os.ReadFile(vp) + if err != nil { + t.Fatal(err) + } + changed := writeVerdict(t, strings.Replace(string(raw), "bounded tighter than the design assumed", + "bounded tighter than the design assumed, weighed again", 1)) + text := string(runCLI(t, "intent", "audit", "ingest", "--verdict-json", changed)) + if !strings.Contains(text, "— ingested") || !strings.Contains(text, "replaced the verdict already ingested") { + t.Fatalf("re-ingest render does not report the replacement:\n%s", text) + } + body, err := os.ReadFile(filepath.Join(root, ".abcd", "development", "intents", "shipped", "itd-10-alpha.md")) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(body), "abcd-review: INGESTED") != 1 || !strings.Contains(string(body), "weighed again") { + t.Fatalf("the record must carry the one replaced verdict:\n%s", body) + } + if text := string(runCLI(t, "intent", "audit", "ingest", "--verdict-json", changed)); !strings.Contains(text, "— noop") { + t.Fatalf("an identical re-ingest must be a noop:\n%s", text) + } +} diff --git a/internal/surface/cli/intent_condition_cli_test.go b/internal/surface/cli/intent_condition_cli_test.go new file mode 100644 index 000000000..2147fc853 --- /dev/null +++ b/internal/surface/cli/intent_condition_cli_test.go @@ -0,0 +1,103 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// intent_condition_cli_test.go — spc-2609020626046252's wiring proof: the +// condition verb is reachable through the front door in both forms, the read +// form writes nothing, and the write form's refusals exit 2 with nothing +// written. + +const cliConditionItem = "rdi-2609011200000001" + +// conditionVerbRepo stages the shipped, conditioned intent and one reading item +// whose constraint_in_play cites cite. +func conditionVerbRepo(t *testing.T, cite string) (root, rel string) { + t.Helper() + root = intentTestRepo(t) + rel = ".abcd/development/intents/shipped/itd-10-alpha.md" + writeRepoFile(t, root, rel, conditionedIntent) + writeRepoFile(t, root, ".abcd/work/issues/readings/rdg-2609011200000009/"+cliConditionItem+".md", + "---\nschema_version: 1\nid: "+cliConditionItem+"\nrun: rdg-2609011200000009\nposition: detection\n"+ + "regime: registrative\ntension: two readings\nconstraint_in_play: \""+cite+"\"\nwhy_a_tension: both cannot hold\n---\n") + return root, rel +} + +func TestIntentConditionReadFormWritesNothing(t *testing.T) { + root, rel := conditionVerbRepo(t, auditConditionID) + before, _ := os.ReadFile(filepath.Join(root, rel)) + out := string(runCLI(t, "intent", "condition", "itd-10")) + if !strings.Contains(out, auditConditionID+" — untested (no block)") { + t.Fatalf("read form render:\n%s", out) + } + if after, _ := os.ReadFile(filepath.Join(root, rel)); string(after) != string(before) { + t.Fatal("the read form wrote the record") + } + // A write flag on the read form is refused rather than ignored. + if _, err := runCLIErr(t, "intent", "condition", "itd-10", "--disposition", "falsified"); err == nil || exitCodeOf(err) != 2 { + t.Fatalf("a write flag without a condition id: exit = %d (%v)", exitCodeOf(err), err) + } +} + +func TestIntentConditionJSONCarriesStanding(t *testing.T) { + root, rel := conditionVerbRepo(t, "holds elsewhere cond-2608300000000001") + before, _ := os.ReadFile(filepath.Join(root, rel)) + + // A refusal exits 2 and writes nothing. + if _, err := runCLIErr(t, "intent", "condition", "itd-10", auditConditionID, + "--disposition", "narrowed", "--occasioned-by", cliConditionItem, + "--grounds", "the detection pass named the index bound"); err == nil || + !strings.Contains(err.Error(), "states no narrowing") || exitCodeOf(err) != 2 { + t.Fatalf("narrowed without --narrowing: exit = %d (%v)", exitCodeOf(err), err) + } + if after, _ := os.ReadFile(filepath.Join(root, rel)); string(after) != string(before) { + t.Fatal("a refused write changed the record") + } + + text := string(runCLI(t, "intent", "condition", "itd-10", auditConditionID, + "--disposition", "narrowed", "--narrowing", "holds below 2k records", + "--occasioned-by", cliConditionItem, "--grounds", "the detection pass named the index bound")) + for _, want := range []string{ + "abcd intent condition — " + auditConditionID + " on itd-10: narrowed, occasioned by " + cliConditionItem, + auditConditionID + " — narrowed (from condition " + cliConditionItem, + "cites cond-2608300000000001, not " + auditConditionID, + } { + if !strings.Contains(text, want) { + t.Errorf("write render lacks %q:\n%s", want, text) + } + } + + var view struct { + IntentID string `json:"intent_id"` + Dispositions []struct { + ConditionID string `json:"condition_id"` + Disposition string `json:"disposition"` + Narrowing string `json:"narrowing"` + Occasion string `json:"occasion"` + Date string `json:"date"` + } `json:"dispositions"` + Standing []struct { + ConditionID string `json:"condition_id"` + Disposition string `json:"disposition"` + Source string `json:"source"` + Occasion string `json:"occasion"` + } `json:"standing"` + } + out := runCLI(t, "intent", "condition", "itd-10", "--json") + if err := json.Unmarshal(out, &view); err != nil { + t.Fatalf("--json: %v\n%s", err, out) + } + if len(view.Dispositions) != 1 || view.Dispositions[0].Narrowing != "holds below 2k records" || + view.Dispositions[0].Occasion != cliConditionItem || view.Dispositions[0].Date == "" { + t.Fatalf("dispositions = %+v", view.Dispositions) + } + if len(view.Standing) != 1 || view.Standing[0].Disposition != "narrowed" || + view.Standing[0].Source != "condition "+cliConditionItem { + t.Fatalf("standing = %+v", view.Standing) + } +}