From ab0d72356f2ed29bce946855b7b9267dec0f41d5 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 29 Jul 2026 16:51:37 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Story=205.4:=20the=20engine=20gets=20a=20re?= =?UTF-8?q?turn=20type=20=E2=80=94=20and=20still=20no=20algebra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `identity/cascade.rs` gains five types and no behaviour: `Verdict` (D13's five, spelled as architecture.md:964 spells them), `RuleVerdict { rule, verdict, evidence }`, `RulesetVersion`, `Conclusion` and `Decision`. Nothing combines a verdict set, no rule produces a `Verdict`, nothing produces a `Decision` — the combining function is story 5.4b's, inserted at this story's contexting. `Decision` is a struct and `Conclusion` an enum so that `ruleset_version` rests on one declaration rather than three variants agreeing (D14: without it, improving the engine is "a silent data migration — the worst kind"). Measured: removing the field gives five E0560 plus one E0609. 267 → 271 tests. Four mutations run. M3 is the one worth naming: deleting a `Verdict` variant gives `error[E0599]` at three sites and NEVER the `E0308` this story's first draft predicted — the validation agent compiled the case and refuted the prediction before dev, and the measurement agrees with the correction. M4 turned up an unpredicted side effect, recorded rather than smoothed: dropping `evidence` orphans the `ObsId` import, which the CI form of clippy makes a hard error. Two of the four reds are compiler-carried, so this does NOT close deferred-work.md:1045-1053 — owner stays 5.5. Six doc locations changed, five because this story falsified them, including `trap.rs`'s `RuleId` doc: it predicted Epic 5 would close the enum, and the corpus writes seven rule names of which five are `l2-*`, Epic 6's. Nothing under fixtures/ moved. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 +- .../5-4-decision-and-ruleset-version.md | 967 ++++++++++++++++++ .../implementation-artifacts/deferred-work.md | 128 ++- .../sprint-status.yaml | 144 ++- _bmad-output/planning-artifacts/epics.md | 44 +- crates/opencmdb-core/src/identity/cascade.rs | 512 +++++++++- crates/opencmdb-core/src/identity/mod.rs | 6 +- crates/opencmdb-core/src/lib.rs | 4 +- crates/opencmdb-core/src/score.rs | 21 +- crates/opencmdb-core/src/trap.rs | 9 +- docs/project-context.md | 10 +- 11 files changed, 1805 insertions(+), 42 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md diff --git a/CLAUDE.md b/CLAUDE.md index f717cf8..c38dd71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project status -`opencmdb` is a self-hosted, single-binary **Rust** network reconciliation engine (IPAM + light app CMDB + topology) for home-lab/SMB. Core thesis: continuously compare the **observed** state (auto-discovered) against the **declared** state (documented); the gap is the product. **Planning is COMPLETE** (product brief, PRD, UX spec, architecture — all in `_bmad-output/planning-artifacts/`, decision register D1–D66). **As of 2026-07-22 the code RUNS**: `v0.1.1` is tagged and published to Docker Hub as `gcorbaz/opencmdb`, and it scans a CIDR and shows a real observed-vs-declared gap on one page. **Epics 1–4 of 23 are done** (the walking skeleton shipped as v0.1). **Epic 4 closed on 2026-07-25**: the fixture corpus, the metrics harness and the trap corpus — all written BEFORE the identity engine — are committed and locked (25 artefacts, 24 traps across nine families, plus the wire-format spec). Its story 4.19 was SPLIT at closure: 4.19a shipped, **4.19b (the mutation generator) moved to Epic 11** — recorded in `epic-4-correct-course-2026-07-25.md` and GitHub issue #34, not silently. **Epic 5 — the identity engine — is IN PROGRESS since 2026-07-27** (15 stories). Its **three inherited debt stories come first and are all `done`** — 5.1, 5.2 and 5.2b, PRs #41, #44 and #46, all merged 2026-07-28. The engine proper starts at **5.3, `done`** — PR #48, merged 2026-07-29 — which ships the identity engine's own abstention vocabulary (`IdentityAbstentionCause`, in the new `crates/opencmdb-core/src/identity/`) and **no engine**: no cascade, no rule, no `Decision`, no join. Next is 5.4. _(This sentence named only 5.1 until 2026-07-29 — stale for a day, found by story 5.3's code review, not by the two stories that caused the drift.)_ Live status is `_bmad-output/implementation-artifacts/sprint-status.yaml`; grounding is `docs/project-context.md`. +`opencmdb` is a self-hosted, single-binary **Rust** network reconciliation engine (IPAM + light app CMDB + topology) for home-lab/SMB. Core thesis: continuously compare the **observed** state (auto-discovered) against the **declared** state (documented); the gap is the product. **Planning is COMPLETE** (product brief, PRD, UX spec, architecture — all in `_bmad-output/planning-artifacts/`, decision register D1–D66). **As of 2026-07-22 the code RUNS**: `v0.1.1` is tagged and published to Docker Hub as `gcorbaz/opencmdb`, and it scans a CIDR and shows a real observed-vs-declared gap on one page. **Epics 1–4 of 23 are done** (the walking skeleton shipped as v0.1). **Epic 4 closed on 2026-07-25**: the fixture corpus, the metrics harness and the trap corpus — all written BEFORE the identity engine — are committed and locked (25 artefacts, 24 traps across nine families, plus the wire-format spec). Its story 4.19 was SPLIT at closure: 4.19a shipped, **4.19b (the mutation generator) moved to Epic 11** — recorded in `epic-4-correct-course-2026-07-25.md` and GitHub issue #34, not silently. **Epic 5 — the identity engine — is IN PROGRESS since 2026-07-27** (**16 stories**: 5.4b was INSERTED 2026-07-29 at story 5.4's contexting, splitting the verdict algebra — a function that must be TOTAL over a table D13 leaves one input class short — and its anti-float `xtask` gate out of 5.4). Its **three inherited debt stories come first and are all `done`** — 5.1, 5.2 and 5.2b, PRs #41, #44 and #46, all merged 2026-07-28. The engine proper starts at **5.3, `done`** — PR #48, merged 2026-07-29 — which ships the identity engine's own abstention vocabulary (`IdentityAbstentionCause`, in the new `crates/opencmdb-core/src/identity/`) and **no engine**: no cascade, no rule, no `Decision`, no join. **5.4 follows and is in `review`**: it adds the engine's RETURN type in the same file — `Verdict` (D13's five verdicts), `RuleVerdict { rule, verdict, evidence }`, `RulesetVersion`, `Conclusion` and `Decision` — and still **no algebra**: nothing combines a verdict set, no rule produces a verdict, nothing produces a decision. Next is 5.4b. _(This sentence named only 5.1 until 2026-07-29 — stale for a day, found by story 5.3's code review, not by the two stories that caused the drift.)_ Live status is `_bmad-output/implementation-artifacts/sprint-status.yaml`; grounding is `docs/project-context.md`. ### Build / lint / test commands (the stack is chosen and building) diff --git a/_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md b/_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md new file mode 100644 index 0000000..75c92ba --- /dev/null +++ b/_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md @@ -0,0 +1,967 @@ +# Story 5.4: `Decision` — the engine's return type, and its ruleset version + +Status: review + + + +## Story + +As the identity engine, +I want a return type carrying the verdict vector, the conclusion, its evidence and the ruleset +version, +so that the explanation is free (D13) and improving the engine is not a silent data migration (D14). + +**This story writes TYPES and their tests. It writes no algebra.** There is no `decide()`, no +implementation of D13's six-row table, no rule, no join, no producer. Story **5.4b** — inserted at +this story's contexting, 2026-07-29, with Guy — owns the table and the anti-float gate; 5.5 owns the +join; 5.6 the blocker. The build order, quoted as epics.md:1317 groups it: *"the three debt stories +(5.1, 5.2, 5.2b) -> the engine's vocabulary (5.3, 5.4) -> the verdict algebra (5.4b) -> the pure join +(5.5) -> the blocker (5.6) -> wiring it to the corpus (5.7, 5.8) -> persistence (5.9, 5.10) -> the +invariants (5.11, 5.12, 5.13) -> the operator-visible surface (5.14)"* — **5.4b is its own step +there, not a tail of the vocabulary.** + +**Why the split, recorded so it is not re-litigated:** story 5.3's own Dev Notes attribute +`fn decide(verdicts) -> Decision` to "5.4". At contexting the work was measured as two deliverables — +a vocabulary of five types, and a **total** function over a table whose input space has a class +D13 does not cover (see *The finding* below). Guy took the split on 2026-07-29, on the 5.2b idiom +(a letter suffix so 5.5–5.14 keep their numbers). Splitting the table out is what lets its own +totality be the subject of a story rather than a subsection of one. + +**Nothing under `fixtures/` moves.** No artefact bytes, no `MANIFEST.toml`, no re-hash. This story +does not read the corpus at all. If any step appears to require re-authoring a committed artefact, +**STOP** — that is a finding, reported rather than absorbed. The procedure is in Dev Notes. + +### The finding this story records but does not fix + +Enumerating D13's table [architecture.md:967-974] over the PRESENCE of each verdict yields seven +input classes, and the table's six rows cover six of them. **The class `≥1 Opposes`, with no +`Decisive`, no `Supports` and no `Disqualifying`, is covered by no row**: it is not *"only `Neutral` +/ nothing"*, it is not *"`Supports` AND `Opposes`"*, and every other row requires a `Decisive` or a +`Supports`. Measured at contexting by enumeration, not by reading. + +**Guy's call, 2026-07-29: that class conclues `Abstained { AbsenceOfProof }`** — nothing argues FOR +the merge, so there is no merge to refuse, and D13 deliberately reserves the refusal-that-names-a-rule +for `Disqualifying`. **The arbitration belongs to story 5.4b**, which writes the function that has to +be total. It is recorded here because this story is where it was found, and because a reader of +`Verdict`'s doc must not conclude that the five variants and the six rows are a complete +specification. This story's docs may **name** the gap; they must not pre-implement the answer. + +## Acceptance Criteria + +1. **AC1 — `Verdict` is D13's enumeration, spelled as the architecture spells it.** + **Given** D13's *"each yields an enumerated verdict; verdicts combine by an **algebra, not a + sum**"* and its literal `enum Verdict { Decisive, Supports, Neutral, Opposes, Disqualifying }` + [architecture.md:960-965] + **when** the type is defined + **then** it carries **exactly those five variants, under those five names**, in + `crates/opencmdb-core/src/identity/cascade.rs` — the file the architecture's source tree names for + the verdict algebra [architecture.md:3369]. + + Binding specifics, so they are not re-litigated at review: + - **Name `Verdict`, not `RuleVerdict`, not `IdentityVerdict`.** The architecture writes `Verdict`; + deviating from a locked document's own spelling needs a reason and there is none. It is **not** + `error[E0252]` against `score::TrapVerdict` (different names — measured: `grep -rn "\bVerdict\b" + crates xtask --include=*.rs` returns **two prose lines only**, `cascade.rs:6` and `score.rs:238`; + no ITEM anywhere is named `Verdict`, and `lib.rs`'s flat block re-exports none, so the new name + cannot collide. A separate grep for `TrapVerdict` finds the existing type). The two names are one + letter apart in meaning and four in spelling, so **each type's doc names the other** (AC5's + vocabulary table). + - **Derives:** `Debug, Clone, Copy, PartialEq, Eq`. **No `PartialOrd`/`Ord`** — D20 is explicit + that strength, if it ever returns, returns *"as an ORDINAL"* `Opposes(Weak) | Opposes(Strong)` + under a four-condition ADR [architecture.md:1374-1394]; deriving `Ord` today would let a + comparison of two verdicts compile, which is the "magnitude" D13 refuses. Record the reason in + the doc — a reviewer will read the missing derive as an omission otherwise. + - **No `Serialize`/`Deserialize`** on any type this story defines. Nothing persists a decision: the + identity link table does not exist (story 5.9 creates it). The precedent is `ScoredRecord`'s own + doc — *"deriving a wire format for a domain type with no consumer is a finding this project has + already recorded once"* (`score.rs:310-311`) — and story 5.3 applied it to + `IdentityAbstentionCause`. + - **No `#[non_exhaustive]`**, for the reason 5.3 recorded and registered + (`deferred-work.md:1005-1010`): `opencmdb-bin` is a different crate, so the attribute would force + a `_` arm on every downstream `match` and destroy the `error[E0004]` that makes a new variant + break its consumers. **`Verdict` is the type where that matters most** — 5.4b's table matches on + it, and a sixth verdict must stop the build there. + - **`pub fn all() -> [Self; 5]`** — the variant witness, in the exact idiom story 5.3 established + and with the **corrected** doc its code review produced (**Task 2**). + +2. **AC2 — The `(rule, verdict, evidence)` triple exists as a type, and evidence is `Vec`.** + **Given** D13's *"the list of `(rule, verdict, evidence)` IS the explanation"* [architecture.md:977-978] + and D19's *"a rule that fires without leaving its `rule_id` in the database is a rule we cannot + debug in production"* [architecture.md:1309-1310] — ⚠️ **quote the architecture, not the register**: + the wording *"a rule that fires must leave its `rule_id` and its evidence behind"* is + `deferred-work.md:309-310`'s paraphrase of D19, and story 4.7a is its author + **when** the element is defined + **then** `RuleVerdict { rule: RuleId, verdict: Verdict, evidence: Vec }` carries all three, + with a `///` on the struct **and on each field**. + + - **`evidence: Vec` is the MINIMUM that is not invented, and the doc says so.** `ObsId` is + the corpus's stable name for an observation, chosen in stories 4.1/4.2 *because* a line number + *"would silently shift under the truth"*. The architecture **mentions evidence on sixteen lines** + (measured: `grep -c "evidence" architecture.md` → 16); **five of them concern the identity link's + evidence** — `:978`, `:1015`, `:1032`, `:1309`, `:3378` — and **none of the five gives it a + shape** (the last is a source-tree line for a `gap/evidence.rs` that does not exist on disk; + `gap/` holds only `mod.rs`). A richer payload — the fact values, the candidate pair, a rendered sentence — has **no producer** + until 5.5 and would be invented here. Registered with owner 5.5. + - **Nothing enforces non-empty evidence, and that is registered, not fixed.** A `Neutral` verdict + legitimately has nothing to show, so the rule is not "evidence is never empty" but "a verdict + that ARGUES leaves evidence" — a validation with no producer to red it. The precedent is + `ScoredRecord`: all fields `pub`, no constructor, the gap registered + (`deferred-work.md:224-233`). Same shape here: `pub` fields, no constructor, a register entry + with owner **5.5**. + - **`RuleId` is NOT closed into an enum by this story**, although `trap.rs:33-35`'s doc says Epic 5 + closes it. Measured on the committed corpus: `grep -rhoP 'rule\s*=\s*"[^"]+"' fixtures/ | sort -u` + returns **seven** distinct rule names — `l1-distinct-mac`, `l1-exact-mac`, `l2-different-hostname`, + `l2-different-switch`, `l2-hostname-agrees`, `l2-uplink-agrees`, `l2-virtual-mac-prefix`. **Five of + the seven are `l2-*`, which Epic 6 owns.** Closing the enum here would either enumerate five rules + nobody has designed or make five sha256-locked trap files unparseable. The deferral is registered + with owner **Epic 6** — and because that measurement **falsifies `trap.rs:33-35`'s own doc** + (*"Epic 5 names them. It closes into an enum when it does"*), that doc sentence is corrected in + this story under AC5's "a doc comment must be TRUE" charter. **Doc-only: `:38`'s declaration is + byte-unchanged.** + +3. **AC3 — `Decision` carries the conclusion, the verdict vector and the ruleset version, and the + version is mandatory by construction.** + **Given** D14's *"`ruleset_version` is mandatory: without it, improving the engine is **a silent + data migration — the worst kind**"* [architecture.md:1044-1045] + **when** a decision exists + **then** `Decision { conclusion: Conclusion, verdict_vector: Vec, ruleset_version: + RulesetVersion }` — a struct, so the version is carried **once** and cannot be forgotten on one + variant of an enum. + + - **`RulesetVersion(pub u32)`** — a newtype, `Debug, Clone, Copy, PartialEq, Eq`. **No `Ord`**: the + first consumer that ORDERS two versions is persistence (D20: *"existing links are not recomputed + (they carry the version they were decided under)"*), which is story 5.9. Registered. This is the + same "no derive without a consumer" argument 5.3 applied, applied consistently rather than + bent because a version *feels* ordered. + - **No `const CURRENT_RULESET_VERSION`, and no default value.** There is no ruleset: no rule + exists. A constant `1` would be a value asserting that the rules it versions exist. The version + arrives as a **parameter** at construction (5.4b's `decide` signature takes one; this story only + requires that the field be non-optional and undefaulted). Registered with owner **5.5**, the + first story with rules to version. + - **No `#[derive(Default)]` on `Decision`, and no `Default` impl.** That is the mechanism behind + "mandatory": there is no way to obtain a `Decision` without naming a version. AC6's M1 measures + it. + - **`verdict_vector: Vec` — the field name is D18's word.** *"The harness records, for + every case, the COMPLETE VERDICT VECTOR, not just the outcome… the anti-drift is not discipline, + it is a data requirement"* [architecture.md:1397-1399]. + - **No value is refused, including `RulesetVersion(0)`.** D14's *"mandatory"* is about PRESENCE, not + meaning. Meaning attaches the day a ruleset exists to be versioned; validating a number against + nothing would be the same invention AC2 refuses for evidence. Registered with owner **5.5**. + - ⚠️ **A `Decision` whose `Conclusion` names a rule ABSENT from its own `verdict_vector` is + representable, and so is a `Match` with an empty vector — nothing refuses either.** Stated here + because it is the objection a reviewer leads with, given that this story quotes *"the list of + `(rule, verdict, evidence)` IS the explanation"* as its own justification: a `Decision { conclusion: + Match { rule }, verdict_vector: vec![], .. }` compiles and means *"merged, with no explanation"*. + It is **not fixed here, and the reason is a producer, not a preference**: the conclusion and the + vector are first built together by 5.4b's `decide`, which is the only place a test could red. + The shape is `ScoredRecord`'s precedent exactly — `pub` fields, no constructor, the gap + registered (`deferred-work.md:224-233`). **Owner: story 5.4b**, and AC7 carries the entry. + - **No type this story defines carries a float or a magnitude**, which is where epics.md's Story 5.4 + AC2 (*"no float crosses a decision boundary; any ranking value is an INTEGER in milli-units"*, + epics.md:1435-1437) lands at the type level: `RulesetVersion(u32)` is an **identifier, not a + weight**, and its doc says so; `Verdict` is enumerated and derives no `Ord`; no ranking value is + invented because Epic 5's L1 is a deterministic lookup with nothing to rank. **The GATE that + holds this mechanically is story 5.4b's** — measured at contexting: zero `f32`/`f64` in the whole + workspace today, so the rule is currently true by accident. + +4. **AC4 — `Conclusion` is D13's three-way decision, and it mirrors `Outcome`'s shape.** + **Given** D13's three-way decision — *"`match` / `no-match` / **`ambiguous` -> abstain**"* + [architecture.md:931-932] and `score::Outcome`'s `Merged { rule } | Refused { rule } | Abstained + { cause }` (`score.rs:68-76`) + **when** the type is defined + **then** `Conclusion { Match { rule: RuleId }, NoMatch { rule: RuleId }, Abstained { cause: + IdentityAbstentionCause } }` — **a decision names a rule, an abstention names a cause and no + rule** — and `Decision::rule() -> Option<&RuleId>` returns `Some` on exactly the two decision + variants, `None` on the abstention, **by construction rather than by a runtime guard**, exactly as + `Outcome::rule()` does (`score.rs:86-91`). + + - **The mirror is held by a TEST, not by prose** (AC6): for each of the three conclusions and its + counterpart outcome, `Decision::rule().is_some()` equals `Outcome::rule().is_some()`. That is the + executable form of the epic's claim *"the same shape `Outcome` mirrors, so `run_trap`'s existing + assertion needs no runtime guard"*. + - **`Conclusion::NoMatch` names a rule, always** — which is precisely why D13's `NoMatch` row + splits two ways onto this type. The `any Disqualifying` half has a rule to name and lands on + `NoMatch`; the `only Neutral / nothing` half has none and lands on `Abstained { AbsenceOfProof }` + (story 5.3's variant, traced to architecture.md:974). **This story creates the fork at the type + level; 5.4b decides which side an input falls on.** The register entries that have been waiting + for this since story 4.6a (`deferred-work.md:249-256`, `:328-332`) are annotated accordingly in + AC7 — annotated, **not struck**. + - **NO `From for Outcome`, and no bridge in either direction.** `Outcome` is the trap + harness's record of an answer; `Decision` is the engine's return. Mapping one onto the other is + story **5.7**'s (the trap runner consuming a real engine), and it is a decision about the gate, + not a convenience. The precedent is 5.3's refusal of a `From` between the two abstention + vocabularies. Registered. + - **NO `Decision::cause()` accessor.** `Outcome` has none either, and nothing consumes one until + 5.14 groups abstentions by cause. `rule()` exists because `run_trap` justifies it; `cause()` has + no such justification today. Registered with owner 5.14. *(Adding it would be the same + no-consumer act AC1 refuses for serde — pre-empted here because a reviewer will ask why one + accessor exists and not the other.)* + - **`rule()` lives on `Decision`, NOT on `Conclusion`, and there is no `Conclusion::rule()`.** The + mirror is therefore structural rather than symmetric — `Outcome::rule()` sits on an enum, this + one on the struct that wraps one — and that is deliberate: `run_trap`'s consumer holds a + decision, not a conclusion, so an accessor on the inner enum would have no caller. Same + no-consumer argument, applied consistently. Registered with owner **5.7**. + +5. **AC5 — Four names now mean four different things, and one page says which.** + **Given** `Verdict` (what ONE rule says), `Conclusion` (what the cascade concluded), `Outcome` + (what the trap harness records as an answer, `score.rs:69`) and `TrapVerdict` (what the RUNNER + says about a trap, `score.rs:205`) — all four in `opencmdb-core`, all four re-exported flat at the + crate root + **when** a reader meets them + **then** `cascade.rs`'s module doc carries a table naming all four, one line each, saying whose + judgement it is and which story owns it. `score.rs`'s `TrapVerdict` doc gains one sentence + pointing at `Verdict` by full path, and vice versa. + + **And** **SIX** existing doc locations change and are re-read against the final tree — *"a doc + comment must be TRUE"*, the rule this project has caught itself breaking three times. **FIVE of the + six are FALSIFIED by this story and are rewritten** (`cascade.rs:1-11`, `identity/mod.rs:8-9`, + `score.rs:46-47`, `score.rs:276-277`, `trap.rs:33-35`); **the sixth (`score.rs:197-203`) is not + false** — it gains the reciprocal cross-reference this AC requires. The count is stated once, here, + and Task 5 lists exactly six bullets against it — *"a count in a doc is a claim"* is this story's + own inherited lesson #3, and the first place it applies is this sentence: + - **`score.rs:46-47`** — *"**Not named `Decision`.** The architecture reserves that name for the + engine's real return type **and never lists its fields**; taking it here would squat a type Epic + 5 has to define."* After this story `Decision` exists and its fields are listed. The weaker true + sentence: `Outcome` is the harness's record, `Decision` (full path) is the engine's return, and + nothing converts between them yet. **Doc-only edit; `Outcome`'s declaration does not move.** + - **`score.rs:276-277`** — `VerdictVectorEntry`'s *"The vector's element is `(rule, verdict, + evidence)` and **none of the three exists**: rules arrive in Epic 5."* After this story the + element type exists (`RuleVerdict`) and has no PRODUCER. ⚠️ **The rest of that doc block stays + true and must survive** (`:277-284`): `VerdictVectorEntry` remains **uninhabited** (the word is at + `:277`) and `ScoredRecord::verdict_vector` remains **provably empty**. See Task 4 — replacing it + is out of scope and would falsify three other places. + - **`trap.rs:33-35`** — `RuleId`'s *"Epic 5 names them. It closes into an enum when it does."* + AC2's measurement refutes it: five of the corpus's seven rule names are `l2-*`, which Epic 6 + owns. **Doc-only; `:38`'s declaration is byte-unchanged.** + - **`identity/mod.rs:8-9`** and **`cascade.rs:1-11`** both say story 5.4 writes *"the algebra"*. + After this story they must say what is true: this file holds the vocabulary **and the return + type**; the algebra that combines verdicts is **5.4b**'s. One sentence naming the immediate next + owner — *"a forward inventory is exactly the inventory-with-no-guard 5.2's review caught"* + (5.3, Task 2). + +6. **AC6 — Every claim about BEHAVIOUR or SHAPE is pinned by a test, and every new guard is proven to + red.** + ⚠️ **"Every claim" is scoped deliberately, because the unscoped version is unsatisfiable.** Six of + this story's binding claims are carried by an **ABSENT derive or an absent item** — no serde on + five types, no `Ord` on `Verdict`, no `Ord` on `RulesetVersion`, no `#[non_exhaustive]`, no + `From for Outcome`, no `Decision::cause()`. **None of them is testable in Rust without a + compile-fail harness this project does not have**, and writing a vacuous `let _ = …` to look + compliant would be worse than saying so. Each is instead carried by a doc sentence naming its owner + **and** by an AC7 register entry, and the Completion Notes state that split explicitly rather than + letting a blanket stand. This is the over-claim class 5.3's review recorded one level down + (*"two of four tests are carried by the compiler, not by their assertions"*, `deferred-work.md:1045-1053`). + + Prove-to-red is the house rule (story 1.3). The four mutations of **Task 7** are **run**, and each + records **every** file, line and failing test — or the exact compiler error code and site — that + went red, then names which of those reds is the one the NEW guard contributes. A compile error + counts as a red, but it must be **run and quoted, never predicted**. + + **A mutation with several reds is expected and is not a defect.** A record naming one red where + four fired is the under-reporting this project's reviews keep catching, and it is the same defect + as an over-claim (5.2b's lesson, inherited by 5.3). + + **And** the test-placement convention this file has been owed since 5.3's review + (`deferred-work.md:1054-1059`, *"Owner: whoever next adds a test to `cascade.rs` — decide the + convention once, in one place"*) is **decided and written down once**, in `cascade.rs`'s test + module doc: *a test lives with the item whose CLAIM it pins; the items it merely READS are + dependencies, imported and not owned.* The convention is stated with **the two cases it decides**, + because a convention that does not resolve the case that opened the register entry closes nothing: + - The **mirror test** (AC4) pins a claim about **`Decision`'s shape** — that it mirrors `Outcome`'s. + `Outcome` is the dependency it reads. It belongs in `cascade.rs`. + - **`an_abstention_names_no_rule_whatever_its_cause`** (5.3, `cascade.rs`) pins a claim about **the + abstention VOCABULARY's relationship to a rule** — *"an abstention has no rule to name, for EVERY + cause"*. The subject is `IdentityAbstentionCause`; `Outcome::rule()` is the mechanism it reads to + express it. It therefore stays where it is, **by the convention rather than by fiat**. If the dev + disagrees with that reading after writing it out, **say so and leave the register entry open** — + an entry closed by assertion is worse than an entry still open. + +7. **AC7 — The register is closed by appending, never rewriting.** + **Given** `deferred-work.md`'s entries that name this story or its types + **when** the story ends + **then**: + - `## Deferred from: code review of story-5.3`'s **AC7-drafting** entry (`:1073-1076`, *"Owner: + whoever writes 5.4's AC7 — require the PR and stop"*) is marked **`✅ CLOSED by story 5.4`** in + the file's own `✅ **CLOSED by story X.** ~~struck~~` idiom. **This story's own AC8 is that + closure**: it requires the branch, the PR and green CI, and **stops there**. + - The two `NoMatch` entries — `:249-256` and `:328-332` — keep their existing `↺ PARTLY closed` + annotations and gain **one appended line each**: the type-level fork now exists + (`Conclusion::NoMatch { rule }` vs `Abstained { cause }`); the MAPPING still has no producer. + They are **not struck** — striking would claim a behaviour that exists nowhere, the over-claim + this project's reviews keep catching. + ⚠️ **The owner move is mechanical, and Task 8's "never rewrite a bullet" binds it.** The string + `Owner stays **stories 5.4/5.5**` does **not** live in those two entries — it lives in the 5.3 + ANNOTATION bullets above them (`:240-248` and `:319-327`). So: **strike it in place** + (`~~Owner stays **stories 5.4/5.5**~~`) and append `↺ **Owner UPDATED 2026-07-29 by story + 5.4: 5.4b/5.5** — 5.4 built the fork, 5.4b decides which side an input falls on.` Nothing else in + either bullet is rewritten. + - `## Deferred from: story-4.7a`'s **firing-rule/evidence contract** (`:309-318`, Owner: Epic 5) is + annotated `↺ PARTLY closed by story 5.4`: the type that CARRIES `(rule, verdict, evidence)` now + exists (`RuleVerdict`), and nothing produces one. **Owner moves to story 5.5** — *"a test must red + if it does not"* needs a firing rule. Not struck. + ⚠️ **Placement, measured — the file has BOTH idioms and only one thing is settled.** `:241-248` + and `:319-327` sit **above** their target and say *"the entry BELOW"*; `:490` sits **below** its + target and says *"the entry above"*. What 5.3's review actually settled is that an annotation + **must NAME its target** (`:540-542` of that story's findings), not which side it sits on. Place + this one immediately **above** `:309`, right after the `## Deferred from: story-4.7a` header, + naming *"the entry BELOW (the firing-rule contract)"* — the idiom of its nearest neighbours. + **Then re-read `:319-327`**: it is 5.3's own annotation and its text refers to the entries above + and below it. If the insertion makes either reference stop resolving, amend that phrase **in the + same edit** and say so in the Completion Notes. A commit that falsifies a sentence it did not + touch is inherited lesson #1. + - A new `## Deferred from: story-5.4` section is **appended at the END of the file**, after + `## Deferred from: code review of story-5.3` (`:1017`) — the file is **chronological, not + topical**. It carries **fifteen** items. **Thirteen name a STORY as owner; TWO are explicitly + ownerless and say so** — and any prose about this section, here or in `sprint-status.yaml`, + states that split rather than claiming fifteen named owners. *(5.3 shipped exactly this + over-claim and its review wrote it up.)* + **Owned by a story:** the evidence shape (5.5) · non-empty evidence unenforced (5.5) · a + `Conclusion` naming a rule absent from its own `verdict_vector`, and `Match` with an empty vector + (**5.4b**) · no serde on five types (5.9) · no `Ord` on `RulesetVersion` (5.9) · `RulesetVersion(0)` + unrefused (5.5) · no `const` ruleset version (5.5) · `RuleId` not closed into an enum, with the + seven measured corpus strings (Epic 6) · no `From for Outcome` (5.7) · no + `Decision::cause()` and no `Conclusion::rule()` (5.14 / 5.7) · `VerdictVectorEntry` vs + `RuleVerdict`, two types for one triple (5.7) · the D13 table gap of *The finding* above (5.4b) · + **`Verdict::all()` inherits the measured lazy-repair residue of `IdentityAbstentionCause::all()`** + (`:1025-1044`, owner **5.14**) — folded into that existing entry rather than duplicated. + **Explicitly ownerless, each naming the CONDITION that would produce an owner:** no `Ord` on + `Verdict` — `Owner: whoever writes D20's ADR` · the flat re-export idiom, now **five names + heavier**, aggravating `:1067-1072` — `Owner: whoever revisits the crate's re-export policy` + (that entry's own wording). The register's own idiom allows a condition in place of a name; what + it does not allow is calling one a name. + - **Two `Owner: Epic 5` entries are deliberately NOT touched**, and saying so turns silence into a + scope statement: `:285-290` (lattice monotonicity — needs an engine producing verdicts across + capability subsets, story 5.13) and `:336-344` (`RuleId` whitespace/case normalization in + `run_trap` — bites when a PRODUCER emits a `RuleId`, story 5.5). A one-line note in the + Completion Notes is enough; do not edit those entries. + +8. **AC8 — The gate is green, the docs are current, and the flow stops at the PR.** + Tasks 8–10 are acceptance criteria, not housekeeping, so an AC-by-AC auditor can see them: + `cargo fmt --all` clean · `cargo clippy --workspace --locked --all-targets -- -D warnings` clean · + **`cargo clippy --workspace --locked -- -D warnings` clean** (the CI form, without + `--all-targets`) · `cargo test --workspace --locked` green with the three per-crate counts + **re-measured on the final tree** · `cargo xtask ci` green (`ℹ views-hash STALE`, exit 0, is + correct and **must not be regenerated in a story**) · `git status` under `fixtures/` empty · + **the docs, with their scope stated rather than left conditional**: `sprint-status.yaml` and + `docs/project-context.md` updated, `CLAUDE.md`'s Epic 5 sentence updated — **not conditional**: + both `docs/project-context.md:45` and `CLAUDE.md:7` were corrected at this story's contexting when + 5.4b's insertion moved Epic 5 from 15 to 16 stories, so what the dev owes is the story's OWN + effect on them, not that count · `epics.md` **verified only** — 5.4b is already present, the counts + already read sixteen, and an edit there is a finding rather than a task · + **branch → PR → green CI. The story ends at status `review` and the PR open.** The + merge is a separate act and it is what makes a story `done` in this project (5.1, 5.2, 5.2b, 5.3); + the `code-review` workflow's own default of setting `done` at the end of the review is WRONG here. + +## Tasks / Subtasks + +- [x] **Task 1 — Read before writing** (AC1–AC5) + - [x] `crates/opencmdb-core/src/identity/cascade.rs` **in full** (162 lines) — the module doc, the + `IdentityAbstentionCause` doc block, `all()`'s **corrected** doc and its inline comment, and + the two tests. The `all()` idiom you will copy for `Verdict` is there, and so is the honest + statement of its limit; copy **both**, not just the code. + - [x] `crates/opencmdb-core/src/identity/mod.rs` (21 lines) — every sentence in it is about to + change tense. + - [x] `crates/opencmdb-core/src/score.rs:38-92` (`Outcome`, its doc, `rule()`), `:197-250` + (`TrapVerdict` and `run_trap` — the shape AC4 mirrors and the assertion it protects), + `:252-286` (`SourceState` and `VerdictVectorEntry`, the uninhabited-placeholder precedent), + `:288-338` (`ScoredRecord` — the `pub`-fields-no-constructor precedent AC2 cites). + - [x] `crates/opencmdb-core/src/trap.rs:31-38` (`RuleId` and its "closes into an enum" doc at + `:33-35`) and `:55-109` (`Expectation`, `column()`, `rule()`, to the close of the `impl`). + Also `:413-415` — the private test helper `fn obs(n: u128) -> ObsId`, which is the crate's + only idiom for minting an `ObsId` in a test (Task 6 needs it). + - [x] `crates/opencmdb-core/src/observation/mod.rs:22-52` — the `uuid_newtype!` macro and `ObsId`. + Note the derives at `:27`: `Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, + Serialize, Deserialize`. `Vec` is therefore `PartialEq` without any work. + - [x] `architecture.md` **D13** (`:929-1011`) and **D14** (`:1013-1049`) — start from the Decision + Index near the top of the file (F56), not from a grep. Read **D20** (`:1348-1399`) too: it is + where the missing `Ord` on `Verdict` and the milli-units clause come from. + - [x] `deferred-work.md:249-256`, `:309-318`, `:328-332`, `:1025-1044`, `:1054-1059`, `:1067-1076`. + +- [x] **Task 2 — `Verdict`** (AC1) + - [x] Five variants, D13's spelling, a `///` on the enum **and on each variant**. Each variant doc + says what a rule MEANS by it — and `Disqualifying`'s cites its two committed instances from + D13 itself: the IANA VRRP/HSRP prefixes and the U/L bit — *"**Both are** `Disqualifying` as + grouping anchors, known at ingestion."* [architecture.md:1002]. That is the one variant whose + meaning is already fixed by a decision rather than by a future rule. + - [x] The enum doc states the **algebra clause** and attributes it: *"verdicts combine by an + algebra, never a sum"* is D13's contract, **and story 5.4b implements it** — never phrased as + a description of this file's behaviour. The doc may **name** the uncovered input class of + *The finding*; it must not state what that class concludes. That answer is 5.4b's AC. + - [x] `#[derive(Debug, Clone, Copy, PartialEq, Eq)]` plus a one-line comment naming what is absent + and why, with owners: serde (5.9), `Ord` (D20's ADR — *"if strength returns, it returns as an + ORDINAL"*), `Display` (5.14), `#[non_exhaustive]` (never — it would destroy the `error[E0004]` + 5.4b's table depends on). + - [x] **`pub fn all() -> [Self; 5]` — the exact idiom from `cascade.rs:104-119`, with the corrected + doc.** Ship BOTH guards (the literal + the exhaustive witness match) and the `[Self; 5]` + return type. ⚠️ **Do not copy the pre-review wording**: the two errors are **alternatives + along one repair path, never simultaneous** (measured under 5.3's M1b/M1c), and repairing only + the `error[E0004]` with a bare arm leaves `all()` returning the old list **while the suite + stays green** (measured: 90 passed). The doc says the guarantee AND its limit, and the inline + comment names the wrong repair. Copy `cascade.rs:84-119` as the model — the doc begins at + `:84`, not at the `# What the witness below guarantees` heading — adapting the counts. + +- [x] **Task 3 — `RuleVerdict`** (AC2) + - [x] `pub struct RuleVerdict { pub rule: RuleId, pub verdict: Verdict, pub evidence: Vec }`, + `#[derive(Debug, Clone, PartialEq, Eq)]` — **no `Copy`** (`RuleId` is a `String` newtype, + `Vec` is not `Copy`). + - [x] `///` on the struct and **on each of the three fields**. The `evidence` field's doc carries + AC2's two sentences: what it is (the observations the rule read), and that a richer payload is + deferred to 5.5 with no producer today. + - [x] The struct doc names the relationship to `` [`crate::score::VerdictVectorEntry`] `` **by full + path**: that placeholder is uninhabited and stays so; this is the engine-side element with no + producer; **story 5.7 owns the unification**, when the harness first records a real run. + ⚠️ **An import added only to shorten a doc link is an unused import** under the CI form of + clippy — 5.3 measured this. Full paths, no `use`. + - [x] `use crate::trap::RuleId;` and `use crate::observation::ObsId;` — both are real code uses, so + both are live imports. **`trap.rs` is not edited.** + +- [x] **Task 4 — `RulesetVersion`, `Conclusion`, `Decision`** (AC3, AC4) + - [x] `pub struct RulesetVersion(pub u32);` — `Debug, Clone, Copy, PartialEq, Eq`, no `Ord`, no + serde, no constant, no `Default`. Its doc quotes D14's *"a silent data migration — the worst + kind"* and D20's *"any reintroduction increments `ruleset_version`; existing links are not + recomputed"* [architecture.md:1392-1394], and says the value has no producer until 5.5. + - [x] `pub enum Conclusion { Match { rule: RuleId }, NoMatch { rule: RuleId }, Abstained { cause: + IdentityAbstentionCause } }` — `Debug, Clone, PartialEq, Eq`. Variant docs cite D13's + three-way decision; `NoMatch`'s doc carries AC4's fork sentence and attributes the choice of + side to **5.4b**. + - [x] `pub struct Decision { pub conclusion: Conclusion, pub verdict_vector: Vec, pub + ruleset_version: RulesetVersion }` — `Debug, Clone, PartialEq, Eq`. **No `Default`, no + constructor** (the `ScoredRecord` precedent: `pub` fields, and the validation gap registered + rather than invented). + - [x] `pub fn rule(&self) -> Option<&RuleId>` on `Decision`, matching exhaustively with **no `_` + arm**, doc'd as the mirror of `` [`crate::score::Outcome::rule`] ``. + - [x] ⚠️ **`score::VerdictVectorEntry` is NOT replaced, and `ScoredRecord` is NOT touched.** + Replacing the uninhabited placeholder with `RuleVerdict` would falsify four things at once, + with **no producer** to justify any of them: `VerdictVectorEntry`'s *"uninhabited"* doc + (the word is at `score.rs:277`; the declaration at `:285-286`), + `ScoredRecord::verdict_vector`'s *"Always empty… and provably so"* (`:335-337`), + `comparable_fields`' *"no producer until an engine; empty on both sides"* (`:453`), and the + register entry at `deferred-work.md:210-215`. **Owner: story 5.7.** If the work looks + tempting, that is the story to put it in. + - [x] `lib.rs`: extend the existing `pub use identity::cascade::…;` line (`:40`) to + `pub use identity::cascade::{Conclusion, Decision, IdentityAbstentionCause, RuleVerdict, + RulesetVersion, Verdict};` — alphabetical inside the braces, following the `gap` and `score` + re-export idiom (`gap` at `:39`, `score` at `:46-49`). **This aggravates a registered + asymmetry** (the flat + re-export has no consumer, `deferred-work.md:1067-1072`); it is the crate's idiom and + deviating for one module would be the inconsistency. Register the new cost (five names), do + not deviate. + +- [x] **Task 5 — The six doc locations** (AC5) — **all six are doc-only**; no declaration moves. Five + are false after this story and are rewritten; the sixth gains a cross-reference. + - [x] `cascade.rs`'s module doc — rewrite. It currently says *"Story 5.4 writes that algebra and the + `Verdict` enum it combines; this file holds neither."* After this story it holds `Verdict` and + not the algebra. Add AC5's four-name table (`Verdict` / `Conclusion` / `Outcome` / + `TrapVerdict`), one line each, full paths for the two that live in `score`. + - [x] `identity/mod.rs`'s module doc — rewrite `:8-9`. One sentence naming 5.4b as the algebra's + owner; **not** a four-story inventory. + - [x] `score.rs:46-47` — rewrite the *"Not named `Decision`"* paragraph. `Decision` exists now. + - [x] `score.rs:276-277` — rewrite the one sentence about the element not existing. **Everything + else in that doc block stays** (`:277-284`), including *"uninhabited"* and *"provably empty"*, + which remain true. + - [x] `trap.rs:33-35` — **one sentence, doc-only.** It predicts that Epic 5 closes `RuleId` into an + enum; AC2's measurement refutes it (five of the corpus's seven rule names are `l2-*`, Epic 6's). + The weaker true sentence: it closes when every rule the corpus names is designed, and five of + them belong to Epic 6. **`:38`'s declaration is byte-unchanged**, and the two pins at + `:569-579` / `:584-591` are not touched. + - [x] `score.rs:197-203` (`TrapVerdict`'s doc) — one sentence pointing at + `` [`crate::identity::cascade::Verdict`] `` and saying whose judgement each is. Reciprocal in + `Verdict`'s own doc. + - [x] ⚠️ **Re-read every rewritten sentence against the FINAL tree after the last edit.** 5.1 cited + a `grep` its own diff broke; 5.2 replaced a false sentence with another one the same commit + falsified. This is the inherited lesson, and this story touches **six** doc locations. + +- [x] **Task 6 — The tests** (AC1–AC4, AC6) — inline, in `cascade.rs`'s trailing `#[cfg(test)] mod + tests` (D56b, one per file). All of them live in `cascade.rs`: every claim below is a claim + about a type this file defines. + - [x] **Write the convention down first** (AC6), in the test module's own doc comment: *a test lives + with the item whose CLAIM it pins, importing other modules as dependencies.* One paragraph, + naming the mirror test as the case it decides and `an_abstention_names_no_rule_whatever_its_cause` + as the case it retroactively covers. This closes `deferred-work.md:1054-1059`. + - [x] **The five verdicts of D13.** `Verdict::all()` contains exactly the five, asserted as a SET. + Do **not** assert the length — on `[Self; 5]` that is a tautology the return type guarantees; + say so in a comment so a later reader does not "fix" the omission. ⚠️ And state, in the test's + doc, what it does NOT catch: a sixth variant leaves the five in `all()` and this test green — + what stops that is the witness's `error[E0004]`, whose doc says exactly how far it goes. + - [x] **A decision names a rule; an abstention does not.** `Decision::rule()` is `Some` for `Match` + and `NoMatch`, `None` for `Abstained` — over all of `IdentityAbstentionCause::all()`, not one + hand-picked cause. + - [x] **The mirror (AC4).** For the three conclusion/outcome counterparts — + `(Match, Merged)`, `(NoMatch, Refused)`, `(Abstained, Abstained)` — + `Decision::rule().is_some() == Outcome::rule().is_some()`. The assertion message names the + pair. This is the executable form of *"the same shape `Outcome` mirrors, so `run_trap`'s + existing assertion needs no runtime guard"*. + - [x] **The verdict vector is carried verbatim.** A `Decision` built with three `RuleVerdict`s — + distinct rules, three different `Verdict` variants, distinct non-empty `evidence` — returns + them in order, each field intact. This is the test that reds when a field is dropped (M4). + ⚠️ **Minting an `ObsId`: use the crate's own idiom, `ObsId::from_uuid(Uuid::from_u128(n))` + with distinct `n`** — the shape `trap.rs:413-415` already uses. The test module therefore + gains `use uuid::Uuid;`. **`Uuid::new_v4()` does NOT compile here**: `opencmdb-core` builds + `uuid` with `features = ["v7", "serde"]`, so reaching for it yields `error[E0599]`. And + `Uuid::nil()` three times would make the three evidence lists identical — the test would pass + while proving nothing, which is the failure this project bans outright. + - [x] Assertion messages name the variant AND the claim. *"expected Some"* is not actionable in a CI + log with four verdict-ish types in scope. + +- [x] **Task 7 — Prove to red** (AC6). Run each, quote the observed failure, restore, re-run green. + **These are predictions to check against, not a licence to skip running**: if the observed set + differs, the DIFFERENCE is the finding and it goes in the Completion Notes. + - [x] **M1 — delete `ruleset_version` from `Decision`.** Predicted: `error[E0560]`/`E0063` at every + construction site in the test module. Proves AC3's "mandatory by construction". Quote the + first error and the site count. + - [x] **M2 — make `Decision::rule()` return `None` for `NoMatch`.** A **behavioural** red, not a + compile error: the rule test and the mirror test both fail. Name which is new. This is the one + mutation that does not lean on the type system, and it is the one that proves the mirror is an + assertion rather than a shape. + - [x] **M3 — delete `Verdict::Disqualifying`.** Predicted: **`error[E0599]`** at `all()`'s literal, + at its witness match arm, and at every test that spells the variant. ⚠️ **NOT `error[E0308]`** — + this was measured at contexting by compiling the exact shape: deleting a variant aborts on the + unknown name long before the array length is checked, so `E0308` never fires. `E0308` belongs + to the OTHER repair path (shortening the literal while `[Self; 5]` stands), which is exactly + what AC1 and Task 2 say: the two errors are **alternatives along one repair path, never + simultaneous**. Quote the first error and the site count. + - [x] **M4 — delete `evidence` from `RuleVerdict`.** Predicted: compile errors at the vector test's + construction sites. Proves the triple is a triple and not a pair. + - [x] ⚠️ **Two of these four reds are carried by the COMPILER, not by an assertion, and the record + must say so.** M3's red is a spelling error (`E0599`) and M4's is a construction-site error — + both would fire identically if the test body were `assert_eq!(1, 1)`. That is the class + `deferred-work.md:1045-1053` records. **M1 and M2 are the two that are not**: M1 proves the + field is unforgettable, and M2 is the only mutation that reds an ASSERTION rather than a name. + The Completion Notes name which tests remain compiler-carried and **why no behavioural + mutation exists for them** — a `pub` field has no code to break — and that register entry's + owner stays **5.5**, the first story with a producer. + - [x] ⚠️ **Restore after every mutation and verify with `git status` before the next one.** Local + flakiness (issue #38) and a forgotten revert look identical. + +- [x] **Task 8 — The register** (AC7) — append-and-strike, never rewrite a bullet. + - [x] Close `:1073-1076` (AC7 drafting) with `✅ **CLOSED by story 5.4**`, naming what this story's + AC8 says instead: branch → PR → green CI, stop. + - [x] Append one line to each `NoMatch` entry (`:249-256`, `:328-332`). The owner move is made in + the 5.3 ANNOTATION bullets that carry the string (`:240-248`, `:319-327`): strike + `Owner stays **stories 5.4/5.5**` in place and append the updated owner. Nothing is rewritten. + - [x] Annotate `:309-318` (firing-rule/evidence) `↺ PARTLY closed by story 5.4`, owner → **5.5**, + placed **above** its target (the idiom of `:241` and `:319`) and **naming it explicitly**. + Then re-read `:319-327` and confirm its own above/below references still resolve. Not struck. + - [x] Open `## Deferred from: story-5.4` at the **END** of the file with AC7's **fifteen** items — + thirteen owned by a story, two explicitly ownerless and saying so. The file is chronological. + - [x] Note in the Completion Notes (not in the file) the two `Owner: Epic 5` entries left untouched + on purpose — `:285-290`, `:336-344`. + +- [x] **Task 9 — The full local gate, run WHOLE** (AC8; mirrors CI — Epic 3's retrospective recorded + four CI-only failures from skipping exactly this) + - [x] `cargo fmt --all` · `cargo clippy --workspace --locked --all-targets -- -D warnings` · + `cargo test --workspace --locked` · `cargo xtask ci`. **`--locked` everywhere.** + - [x] ⚠️ **Also run clippy the way CI runs it — `cargo clippy --workspace --locked -- -D warnings`, + WITHOUT `--all-targets`.** That compiles the lib without `cfg(test)`, and it is the ONLY + invocation that catches an import kept alive solely by a test module or a doc link. Both must + be green. + - [x] Report the test count as three numbers (bin + core + xtask). **Baseline measured 2026-07-29 at + `505379e`: 135 + 90 + 42 = 267, zero failures.** Only `core` should move. + - [x] `git status` under `fixtures/` **empty**; `MANIFEST.toml` untouched. + - [x] `cargo xtask ci` reporting `ℹ views-hash STALE` and exiting 0 is **expected and correct** + (GitHub issue #50). **Do NOT regenerate `architecture-views.md`** — milestone task, never a + story task. + - [x] File-size headroom, so the gate's message is not a surprise: `cascade.rs` is **162 lines total + with the first `#[cfg(test)]` at `:122` → 121 code lines**, ceiling 2000. `score.rs` is 1329 + total, first `#[cfg(test)]` at `:587` → **586 code lines**. Both far under. + +- [x] **Task 10 — Docs current before push** (AC8; project rule) + - [x] `sprint-status.yaml` — the `5-4-…` entry and the narrative comment block. + - [x] `docs/project-context.md` — the test count and the Epic 5 line. + - [x] `CLAUDE.md` — the Epic 5 sentence. **Not conditional**: this story changes what "the engine + proper" has shipped, so the sentence naming 5.3 as the last engine story becomes stale on + merge. + - [x] `_bmad-output/planning-artifacts/epics.md` — **verify only, do not edit.** Story 5.4b is + present, `:22` and `:1313` both read sixteen, and `:1317`'s build order names 5.4b as its own + step — all corrected at this story's contexting (2026-07-29). **An edit here is a finding, not + a task**; a missing insertion is likewise a finding, not something to improvise. + - [x] No manual, README or gh-pages change is expected: this story ships nothing a user can see. If + that turns out to be wrong, the doc moves in the same push. + +- [x] **Task 11 — Branch → PR → green CI** (AC8). Never straight to `master` (`enforce_admins` is + false; honouring it is on the author). Branch `story-5.4-decision-and-ruleset-version`. **The + story ends at status `review` with the PR open and CI green.** The merge is a separate act. + +## Dev Notes + +### What was measured, before the story was written + +All on the committed tree at `505379e` (2026-07-29), so the dev re-derives nothing and a surprise +reads as a **finding**: + +- **`cargo test --workspace --locked` → 135 (bin) + 90 (core) + 42 (xtask) = 267, zero failures** + (plus one ignored doc-test target reporting 0). That is the baseline this story moves, and only the + `core` number should change. +- **Zero `f32` and zero `f64` in the entire Rust workspace.** `grep -rn "\bf32\b\|\bf64\b" crates + xtask --include=*.rs | wc -l` → **0**. D13's *"if the output is a float, B has won in disguise"* is + therefore currently true by accident rather than by a gate — **story 5.4b adds the gate**, and this + story must not introduce the first float. +- **The corpus writes SEVEN distinct rule names**, five of them `l2-*` (AC2). This is the measurement + that decides `RuleId` stays a `String` newtype here. +- **`Conclusion`, `RulesetVersion`, `RuleVerdict` and `Evidence` occur ZERO times** in `crates/` and + `xtask/`. **`Decision` occurs TWICE, both in prose** — `score.rs:46` (the *"Not named `Decision`"* + doc AC5 rewrites) and `fixtures.rs:1662` (*"The case Decision 2 is ARGUED from"*) — **never as an + item**. `Verdict`, matched on a word boundary, returns two prose lines only (`cascade.rs:6`, + `score.rs:238`). So no name this story takes is contested, and none is `error[E0252]` against + `lib.rs`'s flat re-export block. +- **`ObsId` derives `Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, + Deserialize`** (`observation/mod.rs:27`, via `uuid_newtype!`). `Vec` is `PartialEq` for free; + nothing needs to be added to make `RuleVerdict` comparable. +- **`identity/cascade.rs` is 162 lines, first `#[cfg(test)]` at `:122`** → 121 code lines. The five + types, their docs and their tests fit with an order of magnitude to spare. +- **`Outcome::Abstained` has NINE construction sites, every one in a test module** — `cascade.rs:156`, + `score.rs:644`/`:741`/`:762`/`:805`, `trap_gate.rs:450`/`:508`/`:652`/`:658`, all past their file's + first `#[cfg(test)]`. ⚠️ **The figure SIX is 5.3's PRE-merge measurement**; three sites were added by + 5.3's own test loops and 5.3's completion record already corrected it + (`sprint-status.yaml:452-453`). Re-introducing a corrected number is the same defect as inventing + one, and this bullet exists because this story's first draft did exactly that. Production code + constructs no outcome and no decision; this story adds no producer either. + +### The one thing that would make this story fail review + +**Writing `decide()`.** The table is in front of you at architecture.md:967-974, the types you just +wrote are exactly its input and output, and it looks like twenty minutes. It is story **5.4b**'s, and +writing it here would: + +1. put a function whose input space has a **class D13 does not cover** into a story whose ACs never + arbitrate it — the arbitration was taken separately, with Guy, precisely so it would be visible; +2. give the table's totality no story of its own, so *"the six rows are implemented"* would be + reviewed as a subsection instead of as the subject; +3. and repeat, one story later, the exact mistake story 5.3's Dev Notes warned about when they + refused to write `decide()` for the same reason. + +**The deliverable is five types, their documentation, and the tests that pin their claims.** That is +the whole story. + +### Why `Decision` is a struct and `Conclusion` an enum + +The natural first draft is `enum Decision { Match { rule, evidence, ruleset_version }, … }`, and it +is wrong for one measurable reason: `ruleset_version` would be repeated on all three variants, so +"mandatory" would rest on three declarations agreeing rather than on one. D14's whole point is that +the version cannot be forgotten. A struct carries it once, and a variant added to `Conclusion` later +cannot forget it. + +The same argument does **not** apply to `Outcome` (`score.rs:69`), which carries no common field — +which is why the two types are shaped differently while `Conclusion` mirrors `Outcome`'s three-way +algebra exactly. AC4's mirror test asserts the part that must agree (rule-or-cause) and is silent +about the part that must not (the envelope). + +### The four names, and why none of them can be dropped + +| type | whose judgement | how many | owner | +|---|---|---|---| +| `identity::cascade::Verdict` | ONE rule about ONE candidate pair | 5 | this story | +| `identity::cascade::Conclusion` | the CASCADE, over the verdict set | 3 | this story | +| `score::Outcome` | what the trap harness RECORDS as an answer | 3 | story 4.6a | +| `score::TrapVerdict` | the RUNNER, about one trap | 3 | story 4.7a | + +`Outcome` and `Conclusion` look redundant and are not: `Outcome` is what a harness writes down about +*any* answer, including a hand-authored one in a test; `Conclusion` is what the engine concluded, and +it travels with its verdict vector and its ruleset version. The day they meet is story **5.7**, and +the mapping is that story's decision, not a `From` impl written here. + +### The evidence question, stated plainly because a reviewer will ask + +The architecture names the identity link's evidence on five lines and shapes it on none of them (AC2 +carries the measurement and the full sixteen-line grep count). `Vec` is the smallest +thing that is **not invented**: it names the observations a rule read, using the identity the corpus +already froze. It is enough to make a firing rule debuggable — *"a rule that fires without leaving its +`rule_id` is undebuggable in production"* — and it is not enough to render an operator-facing +explanation. That richer shape has no producer until 5.5 and no consumer until 5.14; inventing it now +would be the inversion of the order Epic 4 was built to enforce. + +**Do not add a `reason: String`.** An explanation assembled from `(rule, evidence)` is derived data; +a stored sentence is a second source of truth for it, and D47's *"an error there is domain data, not a +string"* is the same argument one layer up. + +### What this touches, and what it must not break + +- **`crates/opencmdb-core/src/identity/cascade.rs`** (UPDATE) — five new types, **four** new tests + (Task 6 lists exactly four), the module doc rewritten. *Must be preserved:* `IdentityAbstentionCause`'s two variants and their docs, + `all()`'s corrected doc and its inline comment, and both existing tests **unchanged**. +- **`crates/opencmdb-core/src/identity/mod.rs`** (UPDATE, doc only) — `:8-9`. +- **`crates/opencmdb-core/src/lib.rs`** (UPDATE) — one re-export line widened. +- **`crates/opencmdb-core/src/score.rs`** (UPDATE, **doc only** — three blocks: `:46-47` and + `:276-277` rewritten, `:197-203` gains a cross-reference). *Must be preserved:* the exhaustive 3×3 with no `_` arm; `run_trap`'s positive gate + (`!= Score::Pass`); `Outcome::rule()` returning `None` by construction; `comparable_fields`' + `..`-free destructure; **`VerdictVectorEntry` uninhabited** and `ScoredRecord::verdict_vector` + provably empty. +- **`crates/opencmdb-core/src/trap.rs`** (UPDATE, **doc only** — `:33-35`). `RuleId` stays a `String` + newtype; only the sentence predicting that Epic 5 closes it into an enum is corrected, because AC2's + own measurement refutes it. `:38`'s declaration and the two pins at `:569-579` / `:584-591` are + byte-unchanged. +- **`crates/opencmdb-core/src/lib.rs:8-11`'s module doc** — re-read and **left standing**, recorded so + the omission reads as a judgement rather than an oversight: five types with no producer and no + algebra still *"assert nothing about identity yet"*. If the dev disagrees after writing the types, + the correction belongs in this story, not in the next one. +- **`crates/opencmdb-core/src/gap/mod.rs`**, **`crates/opencmdb-bin/`** (all of it, including + `page.rs`, `trap_gate.rs`, `fixtures.rs`, `locales/app.yml`), **`xtask/`** — **NOT touched.** No + gate changes here; the anti-float gate is 5.4b's. +- **Under `fixtures/`: NOTHING.** +- **`deferred-work.md`** (UPDATE, append-only) · **`sprint-status.yaml`**, **`docs/project-context.md`**, + **`epics.md`**, **`CLAUDE.md`** (UPDATE). + +### What STOP means, procedurally + +If a step appears to require re-authoring a committed artefact or re-hashing `MANIFEST.toml`: **stop, +do not edit the artefact.** Record what was attempted, what the tree says, and the exact command that +shows the conflict; report it as a finding in the Completion Notes, **open a GitHub issue on +`guycorbaz/opencmdb`** (CLAUDE.md: issues are the single source of truth for work items outside the +story flow — a finding that lives only in a story file is not tracked), and raise it with Guy. + +### Inherited from stories 5.1, 5.2, 5.2b and 5.3 — read before writing a doc comment + +1. **A check that its own commit falsifies is worse than no check.** 5.1 cited a `grep` its own diff + broke; 5.2 replaced a false doc sentence with another the same commit falsified; 5.3 shipped a + doc claiming a mechanism its own review measured as weaker. This story rewrites **five** doc + blocks — re-read every one against the final tree after the last edit. +2. **An inventory in a doc comment has no guard behind it.** Say what THIS type is and what THIS test + proves; let the register count what is open. One sentence naming 5.4b is a pointer; a list of four + stories is an inventory. +3. **A count in a doc is a claim.** If this story writes "five types", "seven rule names", "three + conclusions" or "121 code lines", it **re-counts them on the final tree**. The numbers in Dev Notes + are the `505379e` baseline, not a post-condition. +4. **A red set is a count too.** Task 7's predictions are measurements taken before the change; the + record must be what was OBSERVED. Reporting one red where four fired understates the guard exactly + as an over-claim overstates it. +5. **Two of 5.3's four tests were carried by the compiler, not by their assertions** + (`deferred-work.md:1045-1053`). ⚠️ **This story does NOT close that entry, and must not claim to.** + **M2 is the only mutation here that reds an ASSERTION** rather than a name — M3's red is a spelling + error and M4's a construction-site error, both of which would fire on a test body of + `assert_eq!(1, 1)`. So the honest statement is *"one of the four mutations is behavioural"*, not + *"the residue is answered"*. Where a test has no possible behavioural mutation — a `pub` field has + no code to break — **say that in the record**; it is a real red and a weak test, and both facts + belong there. The entry's owner stays **5.5**. +6. **Name the test behind every claim.** The temptation here is *"the identity engine now has a return + type"*. What will hold is: *"`Decision` carries a conclusion, a verdict vector and a mandatory + ruleset version; `Decision::rule()` mirrors `Outcome::rule()` and a test says so; nothing produces + a `Decision` and no code combines a verdict."* + +### House rules that bind this story + +- **Prove-to-red is not optional** (story 1.3). Task 7 names four mutations. +- **Document every public item** — struct, enum, **field**, **variant**, function — in idiomatic + rustdoc prose (never `@param`/`@return`). This story adds two structs with six fields between them, + two enums with eight variants, one newtype and two functions: **every one carries a `///`**. + **A doc comment must be TRUE**; prefer the weaker true sentence. `opencmdb-core` does not yet carry + `#![deny(missing_docs)]` (`lib.rs:14-18` says why); that is not a licence to skip one. +- **DRY, with deliberate redundancy protected.** `Verdict::all()`'s literal + witness match is the + protected kind: the exhaustive `match` is what makes a sixth variant a compile error. Do not + collapse it. +- **File size:** ≤ 2000 CODE lines, tests excluded, counted to the first `#[cfg(test)]`. +- **Dependency frontier (D47):** `opencmdb-core` must not gain `anyhow`, `axum`, `sqlx` or `askama`. + Nothing this story needs is outside `std` plus the crate's existing types. + +### Testing standards + +Tests live inline in a trailing `#[cfg(test)] mod tests` (D56b, one per file). Test names are +sentences that say what they prove — `a_decision_names_a_rule_and_an_abstention_does_not` reads at a +CI log; `test_rule` does not. Assertion messages name the variant AND the claim, which matters more +here than in 5.3: four verdict-ish types are in scope. The identity engine's unit tests are pure — +*"the engine is a pure function: a `FixtureConnector` and nothing else — no database"* +[architecture.md:3302], and *"the engine never touches the clock (D19)"* [architecture.md:3364]. +Nothing in this story needs either, so neither claim is load-bearing here; they are quoted rather +than paraphrased because a paraphrase that widened them ("no I/O") would be a claim with no source. +⚠️ **Local flakiness is a known unexplained condition (GitHub issue #38)** — a failure that does not +reproduce is reported, never smoothed, and the "Synology Drive" explanation for it was **refuted by +measurement**. No test in this story reads a database, so `DATABASE_URL` is irrelevant to it. + +### Project Structure Notes + +`identity/` under `opencmdb-core/src/` is the architecture's own layout [architecture.md:3365-3373]. +That tree also names `index.rs` (`:3367`), `blocking.rs` (`:3368`), `field_decision/` (`:3370-3372`) +and `migration.rs` (`:3373`) — **none of which this story creates**; `blocking.rs` is story 5.6's. +Everything this story writes goes into the **existing `cascade.rs`**, which the tree describes as +*"the verdict algebra. No float decides. (D13)"* (`:3369`) — so the five types land in the file the +architecture already names for them, and **no new module is created**. `mod.rs` (`:3366`) is +described as holding `IdentityError`; there is still no fallible operation, so it keeps the module +doc and its `pub mod cascade;` and says so. D54's point stands and is unchanged by this story: the +FOLDER is not the frontier — visibility is — and `identity/` earns its existence when +`pub(in crate::identity)` starts meaning something, which nothing here does. + +### Git intelligence + +Last five commits: `505379e` (issue-#50 bookkeeping, PR #51) · `d4151d1` (5.3 bookkeeping, PR #49) · +`62f9c83` (**story 5.3**, PR #48) · `12be2fc` (5.2b bookkeeping, #47) · `fe6a19e` (**story 5.2b**, #46). + +Story 5.3 is the immediate predecessor and the ONLY Epic 5 commit that touched `opencmdb-core`'s +engine surface — it created `identity/`, retyped `Outcome::Abstained` and edited `score.rs`, +`trap.rs` and `gap/mod.rs`. **So this story inherits a tree its predecessor just reshaped**, which is +why every `score.rs` and `cascade.rs` line number in this file was re-measured at `505379e` rather +than carried over from 5.3's own story file — and why one number carried over anyway and was caught +(the SIX/NINE construction sites). Every one of those commits went branch → PR → green CI → squash +merge; hold the same, and stop at the PR (AC8). + +### Toolchain + +No new dependency, no version to research. `uuid` is already a dependency of `opencmdb-core` with +`features = ["v7", "serde"]` — Task 6 depends on that fact. Rust 1.96+, edition 2024, `Cargo.lock` +committed, every build `--locked`. **Never invent a version** — pin from the real `Cargo.lock` if one +is ever needed. + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 5.4] — epics.md:1423-1443. **Of its four + acceptance criteria, this story implements three**: the `(rule, verdict, evidence)` triple, the + no-float/milli-units clause at the TYPE level (its gate is 5.4b's), and `ruleset_version`. The + fourth — *"combining verdicts is an algebra, never a sum"* — was **SPLIT to story 5.4b** at this + story's contexting and epics.md:1443 carries the annotation. Build order at `:1317`. +- [Source: _bmad-output/planning-artifacts/architecture.md#D13] — `:929-1011`: the `Verdict` enum + (`:964`), the six-row table (`:967-974`), *"explanation is free"* (`:977-978`), the refusal of + `rule -> confidence: f64` (`:956-958`), the milli-units corollary (`:988-993`), `Disqualifying` as + a structural fact (`:995-1002`). +- [Source: _bmad-output/planning-artifacts/architecture.md#D14] — `:1013-1049`: *"`ruleset_version` is + mandatory… a silent data migration — the worst kind"* (`:1044-1045`). +- [Source: _bmad-output/planning-artifacts/architecture.md#D20] — `:1348-1399`: strength returns as an + ORDINAL, never a weight (`:1374-1376`); the four-condition ADR (`:1378-1394`); the complete verdict + vector as a **data requirement** (`:1396-1399`). +- [Source: _bmad-output/planning-artifacts/architecture.md#D19] — `:1309`: *"a rule that fires must + leave its `rule_id` and its evidence behind"*. +- [Source: crates/opencmdb-core/src/score.rs] — `Outcome` (`:38-92`), `TrapVerdict` / `run_trap` + (`:197-250`), the uninhabited placeholders (`:252-286`), `ScoredRecord` (`:288-338`). +- [Source: crates/opencmdb-core/src/identity/cascade.rs] — `IdentityAbstentionCause` and its doc + (`:13-81`); the `all()` idiom this story copies, doc included (`:84-119`). +- [Source: _bmad-output/implementation-artifacts/deferred-work.md] — `:249-256`, `:309-318`, + `:328-332`, `:1025-1044`, `:1054-1059`, `:1067-1076`. +- [Source: docs/project-context.md] — the grounding rules for this repository. + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 5 (1M context) — `claude-opus-5[1m]`. + +### Debug Log References + +No debug session was needed: the implementation compiled and the four new tests passed on the first +run. The four mutations of Task 7 were each run against a byte-copy of the file (`md5` verified +identical on restore, `cc91cf8dcd11880e0691f177c7f4e104` before and after every one), and +`git status fixtures/` was empty at every step. + +### Completion Notes List + +**In the weaker true sentence:** `identity/cascade.rs` now holds five types — `Verdict` (D13's five +variants and its spelling), `RuleVerdict { rule, verdict, evidence }`, `RulesetVersion(u32)`, +`Conclusion { Match | NoMatch | Abstained }` and `Decision { conclusion, verdict_vector, +ruleset_version }` — plus `Verdict::all()` and `Decision::rule()`. Four tests pin their claims. +**Nothing combines a verdict set, no rule produces a `Verdict`, and nothing produces a `Decision`.** +No byte moved under `fixtures/` (`git status fixtures/` and `MANIFEST.toml` both verified empty). + +**Counts re-measured on the FINAL tree** (inherited lesson #3 — a count in a doc is a claim): + +- **267 → 271 tests: 135 bin + 94 core + 42 xtask**, zero failures. Only `core` moved, by the four + new tests, as predicted. +- `cascade.rs`: **660 lines total, first `#[cfg(test)]` at `:445` → 444 CODE lines**, ceiling 2000. + The `file-size` gate reports 22 files under the ceiling, largest 884. +- `cargo doc --workspace --no-deps` → **3 warnings, the same three that were pre-existing** (`ing`, + `comparable_fields`, `ScoredRecord`). None of the many new full-path intra-doc links is broken. +- ⚠️ **`Decision` no longer occurs twice.** The story's Dev Notes measured 2 prose occurrences at + `505379e`; the workspace now has **26**, because the type exists. The baseline was a pre-condition, + not a post-condition — recorded because this is precisely the number 5.3's review caught being + carried past its own commit. + +**Both forms of clippy are green** — `--all-targets` and the CI form without it, which is the only +one that catches an import kept alive solely by a test module or a `///` link. `cargo xtask ci`: all +five gates green, `ℹ views-hash STALE` exit 0 (expected, GitHub issue #50, **not regenerated**). + +**FOUR mutations run, and one prediction was corrected BEFORE dev rather than after:** + +- **M1** (delete `ruleset_version` from `Decision`) → **6 errors**: five `error[E0560]` + (`cascade.rs:528`, `:541`, and three more construction sites) plus one `error[E0609]` at `:653:22`, + the assertion that READS the field. This is AC3's "mandatory by construction" measured: there is no + way to obtain a `Decision` without naming a version. +- **M2** (`Decision::rule()` returns `None` for `NoMatch`) → **2 failing tests, both assertion + panics, not compile errors**: `a_decision_names_a_rule_and_an_abstention_does_not` (`:546`) and + `the_conclusion_mirrors_the_outcomes_rule_shape` (`:603`). **Both are new, and this is the only one + of the four that reds an assertion rather than a name.** +- **M3** (delete `Verdict::Disqualifying`) → **`error[E0599]` at three sites** (`all()`'s literal + `:110`, its witness match `:124`, the test at `:502`) — the lib alone fails with two, the lib-test + target adds the third. ⚠️ **`error[E0308]` NEVER fires**, exactly as the story predicts after the + validation agent compiled the case: deleting a variant aborts on the unknown name long before the + array length is checked. `E0308` belongs to the other repair path. *(The story's first draft + predicted `E0308` here and was corrected at validation — the prediction was wrong on paper and the + measurement agrees with the correction, not with the draft.)* +- **M4** (delete `evidence` from `RuleVerdict`) → **4 errors**: three `error[E0560]` at the vector + test's construction sites (`:610`, `:615`, `:620`) and one `error[E0609]` at `:641:40`. **Plus an + observed side effect the story did not predict and which is recorded rather than smoothed:** a + `warning: unused import: crate::observation::ObsId` at `:30:5` — under the CI form of clippy that + warning is a hard error, so the `evidence` field is what keeps that import live in the lib build. + +⚠️ **Two of the four reds are carried by the COMPILER, not by an assertion, and the record says so** +rather than claiming the residue closed. M3's red is a spelling error and M4's a construction-site +error — both would fire identically on a test body of `assert_eq!(1, 1)`. M1 and M2 are the two that +are not. `deferred-work.md:1045-1053`'s owner **stays 5.5**; this story does not close it, and +`the_verdict_vector_carries_the_whole_triple_in_order` has **no possible behavioural mutation** — +a `pub` field has no code to break. + +**Six doc locations changed, five of them because this story falsified them** — `cascade.rs`'s module +doc, `identity/mod.rs:8-9`, `score.rs:46-47` (*"Not named `Decision`"* — the name is now taken), +`score.rs`'s `VerdictVectorEntry` element sentence (the triple has a type; the *"uninhabited"* and +*"provably empty"* claims survive untouched, as AC5 required), and `trap.rs`'s `RuleId` doc, whose +*"Epic 5 names them"* prediction AC2's measurement refutes. The sixth, `TrapVerdict`'s doc, was not +false and gained the reciprocal cross-reference. **Every one was re-read against the final tree after +the last edit.** + +**The register.** `deferred-work.md:1073-1076` (*"Owner: whoever writes 5.4's AC7 — require the PR and +stop"*) is **CLOSED by append-and-strike**, this story being the owner it names. Three entries are +annotated `↺` and **none is struck**: the two `NoMatch` bullets, whose owner moves to **5.4b/5.5** +(the owner string lives in 5.3's ANNOTATION bullets, so it was struck in place there and the update +appended — nothing was rewritten), and the firing-rule/evidence contract, whose owner moves to +**5.5**. ⚠️ The new annotation was placed **above** its target, the idiom of its two nearest +neighbours, and **`:319-327` was re-read afterwards**: 5.3's own *"the entry BELOW"* and *"the +firing-rule/evidence entry above it"* both still resolve — the section reads 5.4's annotation → +firing-rule entry → 5.3's annotation → `NoMatch` entry. No amendment was needed. + +A new `## Deferred from: story-5.4` section is appended at the END (the file is chronological) with +**fifteen items — thirteen naming a story, two naming the condition that would produce an owner** +(`whoever writes D20's ADR`, `whoever revisits the crate's re-export policy`). The section's own +preamble states that split rather than claiming fifteen named owners. + +**Two `Owner: Epic 5` entries were deliberately NOT touched**, and saying so turns silence into a +scope statement: `:285-290` (lattice monotonicity — needs an engine producing verdicts across +capability subsets, story 5.13) and `:336-344` (`RuleId` whitespace/case normalization in `run_trap` +— it bites when a PRODUCER emits a `RuleId`, story 5.5). + +**Scope held.** No `decide()`, no table, no `xtask` gate (5.4b) · no rule, no join, no producer (5.5) +· no `From for Outcome`, `VerdictVectorEntry` untouched and still uninhabited (5.7) · no +serde, no persistence (5.9) · no `Display`, no `cause()`, no grouping (5.14) · `RuleId` not closed +into an enum (Epic 6) · `page.rs`, `locales/app.yml`, `fixtures.rs`, `trap_gate.rs`, `gap/mod.rs`, +`xtask/` and everything under `fixtures/` untouched. + +### File List + +**Modified — code:** + +- `crates/opencmdb-core/src/identity/cascade.rs` — five new types, `Verdict::all()`, + `Decision::rule()`, four new tests, the test module's placement convention, module doc rewritten. +- `crates/opencmdb-core/src/identity/mod.rs` — module doc (`:8-9`), doc only. +- `crates/opencmdb-core/src/lib.rs` — the `identity::cascade` re-export widened to six names. +- `crates/opencmdb-core/src/score.rs` — three doc blocks (`Outcome`'s *"Not named `Decision`"*, + `TrapVerdict`'s cross-reference, `VerdictVectorEntry`'s element sentence). **Doc only.** +- `crates/opencmdb-core/src/trap.rs` — `RuleId`'s doc. **Doc only**; the declaration is + byte-unchanged. + +**Modified — documents:** + +- `_bmad-output/implementation-artifacts/deferred-work.md` — one closure, three `↺` annotations, one + new section of fifteen items. +- `_bmad-output/implementation-artifacts/sprint-status.yaml` +- `_bmad-output/planning-artifacts/epics.md` — story 5.4b inserted, Epic 5 counts to sixteen, the + float AC annotated as split *(done at contexting, before dev)*. +- `docs/project-context.md`, `CLAUDE.md` + +**Added:** + +- `_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md` (this file) + +**Untouched, verified:** everything under `fixtures/` (including `MANIFEST.toml`), `Cargo.lock`, +`crates/opencmdb-bin/` in its entirety, `xtask/`, `crates/opencmdb-core/src/gap/mod.rs`. + +## Change Log + +| Date | Change | +|---|---| +| 2026-07-29 | Story contexted; story 5.4b inserted into `epics.md` with Guy; Epic 5 → 16 stories. | +| 2026-07-29 | Validated by two fresh-context agents (fact-check + gap-hunt); 11 HIGH + 16 MEDIUM/LOW applied, including a mutation prediction the gap-hunt agent compiled and refuted. | +| 2026-07-29 | Implemented: five types, four tests, six doc locations, four mutations run. 267 → 271 tests. Status → `review`. | diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 0785d1b..d443f48 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -243,9 +243,13 @@ not, and one guarantee changed shape. Stated against the existing bullets withou deliberately NOT struck.** 5.3 created the cause that absence-of-proof will carry (`IdentityAbstentionCause::AbsenceOfProof`, traced to architecture.md:974's row) and wrote its variant doc around this very entry. **The MAPPING still - has no producer**: nothing decides what the cascade returns, because there is no cascade. Owner - stays **stories 5.4/5.5** — the `Decision` type and the L1 join. Striking this would claim a + has no producer**: nothing decides what the cascade returns, because there is no cascade. ~~Owner + stays **stories 5.4/5.5** — the `Decision` type and the L1 join.~~ Striking this would claim a behaviour that exists nowhere. + ↺ **Owner UPDATED 2026-07-29 by story 5.4: stories 5.4b/5.5.** The `Decision` type now exists + (`identity/cascade.rs`) and its `Conclusion` carries both halves of `NoMatch` as distinct variants + — a refusal that names a rule, and an abstention that cannot. **The mapping still has no + producer.** Story 5.4b writes the function that chooses. Still not struck. - **The cascade's `NoMatch` maps two ways onto `Outcome`, and only half of that is recorded.** `architecture.md:967-974` makes `NoMatch` cover BOTH an active opposition (`any Disqualifying`) and a mere absence of proof (`only Neutral / nothing`). `Outcome::Refused` requires a rule to name, so @@ -306,6 +310,18 @@ not, and one guarantee changed shape. Stated against the existing bullets withou ## Deferred from: story-4.7a (2026-07-23) +- ↺ **PARTLY closed by story 5.4 — this annotation belongs to the entry BELOW** (*"The firing-rule + contract (AC6) is RECORDED, not built"*), **and is deliberately NOT struck.** The TYPE that carries + the `(rule, verdict, evidence)` triple now exists: `identity::cascade::RuleVerdict`, with + `evidence: Vec` — the smallest shape that is not invented, since the architecture requires + evidence in four places and shapes it in none. **Nothing produces one**: no rule speaks, so no + verdict vector is ever built, and *"a test must red if it does not"* still has nothing to red. + `score::VerdictVectorEntry` therefore stays uninhabited and `ScoredRecord::verdict_vector` stays + provably empty — story 5.7 owns that unification. ⚠️ Nor does anything enforce that a verdict which + ARGUES leaves non-empty evidence: `RuleVerdict`'s fields are `pub` with no constructor + (`ScoredRecord`'s precedent). **Owner moves from Epic 5 to story 5.5**, the first story with a + firing rule. + - **The firing-rule contract (AC6) is RECORDED, not built.** D19/D46b: *"a rule that fires must leave its `rule_id` and its evidence behind — a rule that fires without leaving its `rule_id` is undebuggable in production."* There is no rule and no producer in Epic 4, so 4.7a's `run_trap` @@ -323,8 +339,13 @@ not, and one guarantee changed shape. Stated against the existing bullets withou absence of proof has a name to abstain with (`IdentityAbstentionCause::AbsenceOfProof`) that is not a refusal, so the failure mode this entry's sibling warns about — mapping `NoMatch → Refused` uniformly and failing every honest `must-abstain` trap — now has a type-level alternative. The - QUESTION itself is untouched: no engine decides which half of `NoMatch` it is in. Owner stays - **stories 5.4/5.5**. + QUESTION itself is untouched: no engine decides which half of `NoMatch` it is in. ~~Owner stays + **stories 5.4/5.5**~~ + ↺ **Owner UPDATED 2026-07-29 by story 5.4: stories 5.4b/5.5.** 5.4 built the FORK at the type + level — `Conclusion::NoMatch { rule }` for the `any Disqualifying` half, `Conclusion::Abstained + { cause: AbsenceOfProof }` for the half with no rule to name — and `Conclusion::NoMatch`'s own doc + carries the argument. **Which side an input falls on is still decided by nothing**: story 5.4b + writes the combining function. Still not struck. - **The `NoMatch → Refused` vs `Abstained` question is Epic 5's, not scored here.** `run_trap` scores answers; it does not decide what an engine that finds no merging rule should return. Whether "no rule matched" is a `Refused` (a decision, names an opposing rule) or an `Abstained` (no decision, @@ -1070,13 +1091,108 @@ PATCHED in the story, not deferred; what follows is the residue._ same argument, not applied. PRE-EXISTING idiom: `lib.rs` re-exports every module's public surface flat, and deviating for one type would be the inconsistency. Recorded so the asymmetry reads as inherited rather than chosen. Owner: whoever revisits the crate's re-export policy. -- **AC7's drafting requires a squash merge inside a workflow that must end at `review`.** It reads +- ✅ **CLOSED by story 5.4**, which is the owner this bullet names. Its AC8 requires *"branch → PR → + green CI. The story ends at status `review` and the PR open"* and says in the same clause that the + merge is a separate act — so the two halves no longer contradict. The wording is available to + 5.4b–5.14 to inherit. + ~~**AC7's drafting requires a squash merge inside a workflow that must end at `review`.** It reads *"branch → PR → green CI → squash merge, ending at status `review`, never `done`"* — the two halves cannot both hold, because the merge is what makes a story `done` in this project. Every Epic 5 - story inherits the wording. Owner: whoever writes 5.4's AC7 — require the PR and stop. + story inherits the wording. Owner: whoever writes 5.4's AC7 — require the PR and stop.~~ - **CI runs no `cargo doc`, so broken intra-doc links are gated by nothing.** `cargo clippy` does not check them, and story 5.3 added many full-path `[`crate::...`]` links precisely to avoid unused imports. The tree is clean (verified twice, three pre-existing warnings, none in new code) — but by measurement on a developer's machine, not by a mechanism. Owner: whoever next touches `.github/workflows/ci.yml`; a `cargo doc --workspace --no-deps` with `-D rustdoc::broken_intra_doc_links` would need the three pre-existing warnings fixed first. + +## Deferred from: story-5.4 (2026-07-29) + +_The story wrote five TYPES and their tests, and no algebra. Everything below is open because it +needs a producer, a consumer, or a decision no code yet forces — not because it was skipped. +**Fifteen items: thirteen name a story as owner, two name the CONDITION that would produce one.** The +register's idiom allows a condition in place of a name; what it does not allow is calling one a name._ + +- **`RuleVerdict::evidence` is `Vec`, the smallest shape that is not invented.** The + architecture requires a firing rule to leave evidence — *"a rule that fires without leaving its + `rule_id` in the database is a rule we cannot debug in production"* [architecture.md:1309-1310] — + and **shapes it on none of the five lines that mention the identity link's evidence** (`:978`, + `:1015`, `:1032`, `:1309`, `:3378`; the last names a `gap/evidence.rs` that does not exist on + disk). A richer payload — the fact values, the candidate pair, a rendered sentence — is a design + with no producer. **Owner: story 5.5**, the first story with a rule that fires. +- **Nothing enforces that a verdict which ARGUES leaves non-empty evidence.** `RuleVerdict`'s fields + are `pub` with no constructor (`ScoredRecord`'s precedent, `:224-233`). A `Neutral` verdict + legitimately has nothing to show, so the rule is not "evidence is never empty" — it needs a + producer to state and to red. **Owner: story 5.5.** +- **A `Decision` whose `Conclusion` names a rule ABSENT from its own `verdict_vector` is + representable, and so is a `Conclusion::Match` with an EMPTY vector.** That is *"merged, with no + explanation"* — exactly what D13's *"the list of `(rule, verdict, evidence)` IS the explanation"* + exists to prevent. Not fixed here because the conclusion and the vector are first built together by + the combining function, which is the only place a test could red. **Owner: story 5.4b.** + *(Raised by the gap-hunt validation agent, 2026-07-29; no AC, doc or register entry had covered it.)* +- **None of the five new types derives `Serialize`/`Deserialize`.** Nothing persists a decision: the + identity link table does not exist. Deriving a wire format for a domain type with no consumer is a + finding this project has already recorded once (`ScoredRecord`, 4.6a). **Owner: story 5.9**, which + persists the interface and the identity link, if it persists a decision at all. +- **`RulesetVersion` derives no `PartialOrd`/`Ord`.** The first consumer that ORDERS two versions is + persistence — D20's *"existing links are not recomputed (they carry the version they were decided + under)"* is a claim about which version a row carries, not a comparison anything performs. + **Owner: story 5.9.** Recorded because "a version feels ordered" is exactly the argument that would + bend the no-derive-without-a-consumer rule 5.3 set. +- **`RulesetVersion(0)` is constructible and means nothing; no value is refused.** D14's "mandatory" + is about PRESENCE. Meaning attaches the day a ruleset exists to be versioned; validating a number + against nothing would be the same invention this story refuses for evidence. **Owner: story 5.5.** +- **There is no `CURRENT_RULESET_VERSION` constant and no `Default` on `Decision`.** There is no + ruleset: no rule exists, so a constant would assert that the rules it versions are there. The + absent `Default` is deliberate and load-bearing — it is what makes the version unforgettable + (measured: removing the field gives five `error[E0560]` plus one `error[E0609]`). **Owner: story + 5.5**, the first story with rules to version. +- **`RuleId` is NOT closed into an enum, although `trap.rs`'s doc predicted Epic 5 would close it.** + Measured on the committed corpus: `grep -rhoP 'rule\s*=\s*"[^"]+"' fixtures/ | sort -u` returns + **seven** names — `l1-distinct-mac`, `l1-exact-mac`, `l2-different-hostname`, `l2-different-switch`, + `l2-hostname-agrees`, `l2-uplink-agrees`, `l2-virtual-mac-prefix` — and **five are `l2-*`**. + Closing it would enumerate five rules nobody has designed or make five sha256-locked trap files + unparseable. The doc sentence was corrected in this story rather than left standing. + **Owner: Epic 6**, which designs the `l2-*` half. +- **No `From for Outcome` in either direction.** `Outcome` is the harness's record of an + answer; `Decision` is the engine's return. Mapping one onto the other is a decision about the + release gate, not a convenience — the same refusal, for the same reason, that kept the two + abstention vocabularies unbridged in 5.3. **Owner: story 5.7**, the trap runner consuming a real + engine. +- **No `Decision::cause()` and no `Conclusion::rule()`.** `Outcome` has no `cause()` either, and + nothing groups abstentions by cause until 5.14. `rule()` exists on `Decision` because a consumer + holds a decision; an accessor on the inner enum would have no caller. **Owner: story 5.14** for + `cause()`, **story 5.7** for `Conclusion::rule()` if a consumer ever holds a bare conclusion. +- **`score::VerdictVectorEntry` and `identity::cascade::RuleVerdict` are two types for one triple.** + The first is the harness-side placeholder, deliberately **uninhabited** so + `ScoredRecord::verdict_vector` is provably empty; the second is the engine-side element, with no + producer. Replacing the placeholder now would falsify four places at once (`score.rs`'s + "uninhabited" doc, `ScoredRecord::verdict_vector`'s "always empty… provably so", + `comparable_fields`' "empty on both sides", and `:210-215` of this file) with nothing to justify + it. **Owner: story 5.7**, when the harness first records a run a real engine produced. +- **`Verdict::all()` inherits the measured lazy-repair residue of `IdentityAbstentionCause::all()`.** + Same idiom, same limit: the witness stops the build on a new variant (`error[E0004]`) but does not + force it into the list, and the two errors are alternatives along one repair path, never + simultaneous — **re-measured on this enum by story 5.4's M3: deleting `Disqualifying` gives + `error[E0599]` at three sites and NEVER `error[E0308]`.** Folded into the existing entry at + `:1025-1044` rather than duplicated. **Owner: story 5.14.** +- **D13's six-row table does not cover every input, and story 5.4 only NAMES the gap.** Enumerated + over the PRESENCE of each verdict, the table leaves exactly one class unanswered: at least one + `Opposes`, with no `Decisive`, no `Supports` and no `Disqualifying`. It is not *"only `Neutral` / + nothing"* and not *"`Supports` AND `Opposes`"*. **Guy's arbitration, 2026-07-29: it concludes + `Abstained { AbsenceOfProof }`** — nothing argues FOR the merge, so there is no merge to refuse, and + D13 reserves the refusal-that-names-a-rule for `Disqualifying`. Independently re-derived and + confirmed by both validation agents. **Owner: story 5.4b**, which writes the function that must be + total; the correction to D13 itself belongs to a milestone edit of `architecture.md`, never to a + story. +- **`Verdict` derives no `PartialOrd`/`Ord`, and that is D20's business, not a story's.** D20: + *"if strength returns, it returns as an ORDINAL, not a weight: `Opposes(Weak) | Opposes(Strong)`"* + [architecture.md:1374-1376], under four conjoint conditions demonstrated **before any code** + [architecture.md:1378-1394]. An orderable `Verdict` would let magnitude compile today, which is the + move that ADR gates. **Owner: whoever writes D20's ADR** — there is no story, and inventing one + would be the reintroduction the ADR exists to refuse. +- **`lib.rs`'s flat re-export block grew by five names with no consumer**, aggravating the entry at + `:1067-1072` by a measured amount: `Conclusion`, `Decision`, `RuleVerdict`, `RulesetVersion` and + `Verdict` are all re-exported at the crate root and nothing imports them from there. Following the + crate's idiom was chosen over deviating for one module, and the cost is recorded rather than + hidden. **Owner: whoever revisits the crate's re-export policy** (that entry's own wording). diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 41ffb63..1a0989a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -50,7 +50,7 @@ # 'optional' and will not be back-filled. generated: 2026-07-21 -last_updated: 2026-07-29 # Story 5.3 DONE — PR #48 merged as 62f9c83; NEXT = create-story 5.4 +last_updated: 2026-07-29 # Story 5.4 IMPLEMENTED -> review; PR open. NEXT = code-review 5.4 project: opencmdb project_key: NOKEY tracking_system: file-system @@ -151,10 +151,10 @@ development_status: # ── Epic 5: Identité d'interface fiable (v0.3) ─────────────────────────── # Decomposed 2026-07-26 into 14 stories (epics.md); story 5.2b was INSERTED the same day, - # bringing it to 15. Build order is the numbering: the THREE inherited debt stories first - # (this epic bumps the corpus), then the engine's vocabulary, the pure join, the blocker, - # the corpus wiring, persistence, the invariants, and last the operator-visible abstention - # surface. + # bringing it to 15, and story 5.4b on 2026-07-29, bringing it to 16. Build order is the + # numbering: the THREE inherited debt stories first (this epic bumps the corpus), then the + # engine's vocabulary, the verdict algebra, the pure join, the blocker, the corpus wiring, + # persistence, the invariants, and last the operator-visible abstention surface. # ⚠️ NFR4 CANNOT go green here — D18 gates at the DEVICE level (Epic 6). Of the 24 committed # traps, 13 name an l1-* rule, 8 an l2-* rule, 3 a cause. Story 5.8 keeps the 8 unanswerable # ones IN the denominator so a green gate can never mean "we did not ask". @@ -506,7 +506,139 @@ development_status: # NEXT = `create-story` 5.4 (`Decision` and `ruleset_version`), then its MANDATORY validation pass # by two fresh-context agents before dev-story. 5-3-engine-abstention-cause: done - 5-4-decision-and-ruleset-version: backlog + # + # 5.4 CONTEXTED 2026-07-29 — the engine's return type, and NO algebra: no `decide`, no rule, no + # join, no producer. It writes FIVE types in the existing `identity/cascade.rs` — `Verdict` (D13's + # five, spelled as architecture.md:964 spells them), `RuleVerdict { rule, verdict, evidence }`, + # `RulesetVersion`, `Conclusion` (Match | NoMatch | Abstained, mirroring `score::Outcome`'s shape) + # and `Decision { conclusion, verdict_vector, ruleset_version }` — plus five tests and five + # rewritten doc blocks. + # SIX measurements taken against master (505379e) BEFORE the story was written, so the dev + # re-derives nothing and a surprise reads as a FINDING: + # · `cargo test --workspace --locked` → 135 bin + 90 core + 42 xtask = 267, zero failures; + # · ZERO `f32` and ZERO `f64` in the whole Rust workspace — so D13's "if the output is a float, + # B has won in disguise" is true by ACCIDENT today, held by no gate. That measurement is what + # justifies story 5.4b's gate; + # · the committed corpus writes SEVEN distinct rule names and FIVE of them are `l2-*` (Epic 6's). + # That is why `RuleId` does NOT close into an enum here, although `trap.rs:33-35` says Epic 5 + # closes it: doing so would enumerate five undesigned rules or unparse five locked trap files. + # That doc sentence is therefore corrected by this story (doc-only; `:38` byte-unchanged); + # · `Conclusion`, `RulesetVersion`, `RuleVerdict` occur ZERO times in crates/ and xtask/; + # `Decision` occurs TWICE, both in PROSE (`score.rs:46`, `fixtures.rs:1662`), never as an item; + # `Verdict` on a word boundary returns two prose lines only. No name is contested, none is + # E0252 against lib.rs's flat re-export block; + # · `ObsId` already derives Ord/Eq/Copy (uuid_newtype!), so `Vec` is PartialEq for free; + # · `cascade.rs` is 162 lines with the first `#[cfg(test)]` at :122 → 121 CODE lines, ceiling + # 2000. Ample. + # DECISIONS taken at contexting, so they are not re-litigated at review: `Decision` is a STRUCT and + # `Conclusion` an ENUM (a version repeated on three variants would rest on three declarations + # agreeing, which is exactly what D14 says must not be forgettable); `evidence: Vec` is the + # smallest non-invented shape (the architecture mentions evidence on SIXTEEN lines, five of them + # about the identity link's evidence, and SHAPES it on none); NO serde, NO `Ord` anywhere (D20: strength returns as an ORDINAL under a four-condition + # ADR, so an orderable `Verdict` would let magnitude compile), NO `Default` on `Decision` (that IS + # the "mandatory" mechanism), NO `const CURRENT_RULESET_VERSION` (there is no ruleset), NO + # `From for Outcome` (story 5.7's decision about the gate), NO `Decision::cause()` (no + # consumer until 5.14), and `score::VerdictVectorEntry` is NOT replaced — doing so would falsify + # four places at once with no producer to justify any of them (owner 5.7). + # ⚠️ A FINDING was measured at contexting and is recorded rather than absorbed: enumerating D13's + # table over the PRESENCE of each verdict yields SEVEN input classes and its six rows cover SIX. + # `>=1 Opposes` with no `Decisive`, no `Supports` and no `Disqualifying` is covered by NO row. + # Guy's arbitration 2026-07-29: it concludes `Abstained { AbsenceOfProof }`. The arbitration + # belongs to 5.4b (the function that must be total); 5.4 may NAME the gap and must not answer it. + # Story 5.4 also CLOSES the register entry that named it by name: "Owner: whoever writes 5.4's AC7 + # — require the PR and stop" (deferred-work.md:1073-1076). Its AC8 ends at the PR; the merge is a + # separate act. + # VALIDATED 2026-07-29 by two fresh-context agents (fact-check + gap-hunt). Both verified against + # the tree, and every finding they raised was checked before being applied. 6 HIGH + 8 MEDIUM from + # the gap-hunt, 5 HIGH + 8 MEDIUM/LOW from the fact-check; all applied. What they caught: + # · a MUTATION THE STORY PREDICTED THAT CANNOT HAPPEN — the gap-hunt COMPILED the case: deleting + # `Verdict::Disqualifying` gives `error[E0599]` twice and NEVER the `E0308` M3 announced, which + # the story's own AC1 already said two pages earlier ("alternatives along one repair path, + # never simultaneous"). Same class as 5.3's self-contradicting AC1b, caught BEFORE dev this time; + # · A GAP NO AC, DOC OR REGISTER ENTRY COVERED: a `Decision` whose `Conclusion` names a rule + # ABSENT from its own `verdict_vector`, or a `Match` with an EMPTY vector, is representable and + # nothing refuses it — precisely what the two quotes the story leans on (D13's "the list IS the + # explanation", D19's "a rule that fires without leaving its rule_id…") exist to prevent. Now an + # AC3 bullet and a register item, owner 5.4b (the first place conclusion and vector are built + # together, hence the first place a test can red); + # · THE COUNTING DEFECT, four times in one file — 2/4/5 doc blocks and 4/5 tests. The story's own + # inherited lesson #3 ("a count in a doc is a claim"), violated by the story that states it. + # Now SIX doc locations, five of them falsified and rewritten, counted once and listed once; + # · AC6's blanket "every claim is pinned by a test" was UNSATISFIABLE: six binding claims are + # carried by an ABSENT derive and are untestable without a compile-fail harness this project + # does not have. Scoped to behaviour/shape, with the six named; + # · a STALE MEASUREMENT RE-INTRODUCED from 5.3's pre-merge notes — `Outcome::Abstained` has NINE + # construction sites, not six, and 5.3's OWN completion record already said so (:452-453 of + # this file). Re-introducing a corrected number is the same defect as inventing one; + # · a D19 "quote" that was `deferred-work.md`'s PARAPHRASE, not architecture.md's sentence; two + # greps whose reported output was a hand-picked subset presented as the whole; ~a dozen line + # cites off by one to four; and `Uuid::new_v4()` — which does NOT compile here (`features = + # ["v7","serde"]`), so Task 6 now names the crate's own `Uuid::from_u128` idiom. + # BOTH AGENTS INDEPENDENTLY RE-DERIVED THE D13 TABLE GAP and confirmed it: exactly ONE uncovered + # input class, the one the story names. The fact-check also confirmed the type design compiles as + # specified (derives, E0252, visibility, orphan rules all checked) and that all six baseline + # measurements reproduce exactly. + # ⚠️ The agents DISAGREED on one point and the tree settled it: register annotations sit ABOVE + # their target at :241/:319 and BELOW it at :490 — both idioms exist. What 5.3's review actually + # settled is that an annotation must NAME its target, not which side it sits on. AC7 now says so. + # Two documents falsified by 5.4b's insertion were fixed at contexting rather than left for the + # dev: `docs/project-context.md` and `CLAUDE.md` both said "15 stories"; `epics.md:22` said 15 too, + # while `:1313` already said sixteen — a half-updated document the fact-check caught. + # + # 5.4 IMPLEMENTED 2026-07-29 → review (branch `story-5.4-decision-and-ruleset-version`). + # In the weaker true sentence: `identity/cascade.rs` holds FIVE new types — `Verdict` (D13's five, + # spelled as architecture.md:964 spells them), `RuleVerdict { rule, verdict, evidence: Vec }`, + # `RulesetVersion(u32)`, `Conclusion { Match | NoMatch | Abstained }` and `Decision { conclusion, + # verdict_vector, ruleset_version }` — plus `Verdict::all()` and `Decision::rule()`. Four tests pin + # their claims. NOTHING combines a verdict set, no rule produces a `Verdict`, nothing produces a + # `Decision`. No byte moved under fixtures/ (verified empty, MANIFEST untouched). + # 267 → 271 tests (135 bin + 94 core + 42 xtask); only core moved, as predicted. Both forms of + # clippy green INCLUDING the CI form. `cargo doc` → the same THREE pre-existing warnings, so none + # of the new full-path links is broken. `cascade.rs` = 444 CODE lines, ceiling 2000. + # FOUR mutations run: + # · M1 (drop `ruleset_version`) → SIX errors: 5× E0560 at construction sites + 1× E0609 at + # :653:22, the assertion that READS it. That is "mandatory by construction", measured; + # · M2 (`rule()` returns None for NoMatch) → TWO failing tests, both assertion PANICS, both new. + # The only one of the four that reds an assertion rather than a name; + # · M3 (delete `Verdict::Disqualifying`) → `error[E0599]` at THREE sites and NEVER `E0308` — + # exactly as the validation agent measured by compiling the case, and NOT as the story's first + # draft predicted. The prediction was corrected BEFORE dev; the measurement agrees with the + # correction; + # · M4 (drop `evidence`) → FOUR errors (3× E0560, 1× E0609) PLUS an unpredicted side effect, + # recorded rather than smoothed: `warning: unused import: crate::observation::ObsId` at :30:5, + # which the CI form of clippy turns into a hard error. The field is what keeps that import live. + # ⚠️ TWO of the four reds are COMPILER-carried (M3 a spelling error, M4 a construction-site error); + # M1 and M2 are not. The story does NOT claim to close deferred-work.md:1045-1053 — owner stays 5.5. + # `the_verdict_vector_carries_the_whole_triple_in_order` has NO possible behavioural mutation: a + # `pub` field has no code to break, and the record says so. + # ONE count in the story's own Dev Notes is falsified by its own commit and corrected in the + # Completion Notes rather than left standing: `Decision` occurred TWICE in prose at 505379e and now + # occurs 26 times, because the type exists. That is the exact defect 5.3's review caught. + # SIX doc locations changed, FIVE because this story falsified them (cascade.rs module doc, + # identity/mod.rs, score.rs's "Not named `Decision`", score.rs's VerdictVectorEntry element + # sentence — its "uninhabited"/"provably empty" claims SURVIVE — and trap.rs's `RuleId` doc, whose + # "Epic 5 names them" prediction the seven-corpus-rule-names measurement refutes). + # REGISTER: deferred-work.md:1073-1076 CLOSED by append-and-strike (this story was its named + # owner — its AC8 ends at the PR). THREE entries annotated ↺ and NONE struck: the two `NoMatch` + # bullets (owner → 5.4b/5.5, the owner string struck in place inside 5.3's annotation bullets, so + # no bullet was rewritten) and the firing-rule/evidence contract (owner → 5.5). The new annotation + # sits ABOVE its target, the idiom of its two nearest neighbours, and :319-327 was RE-READ after + # insertion: 5.3's own "entry BELOW"/"entry above it" both still resolve, so no amendment was + # needed. A new `## Deferred from: story-5.4` section appended at the END opens FIFTEEN items — + # THIRTEEN name a story, TWO name the CONDITION that would produce an owner (`whoever writes D20's + # ADR`, `whoever revisits the crate's re-export policy`), and the section's preamble states that + # split rather than claiming fifteen named owners. + 5-4-decision-and-ruleset-version: review + # + # 5.4b INSERTED 2026-07-29 with Guy, at 5.4's contexting and before any Epic 5 engine code existed + # — the same shape as 5.2b's insertion, and for the same reason: the thing being split out is the + # one whose correctness has to be the SUBJECT of a story, not a subsection of one. It owns + # `decide` (D13's six rows as a TOTAL pure function, including the uncovered class above), the + # order-independent choice of which rule a decision names when several qualify (which pre-empts + # 5.11), and the `cargo xtask ci` gate that reds on any `f32`/`f64` under `identity/` — in the + # idiom of the existing DDL-collation and retired-vocabulary greps (D56/D65: every gate lives in + # Rust, never in YAML). The letter suffix keeps 5.5–5.14 at their numbers (D56b idiom). + 5-4b-verdict-algebra-and-float-gate: backlog 5-5-l1-join-pure: backlog 5-6-blocker-and-recall-assertion: backlog 5-7-trap-runner-stops-scoring-nothing: backlog diff --git a/_bmad-output/planning-artifacts/epics.md b/_bmad-output/planning-artifacts/epics.md index bee5a3b..4e63e88 100644 --- a/_bmad-output/planning-artifacts/epics.md +++ b/_bmad-output/planning-artifacts/epics.md @@ -19,9 +19,9 @@ resumePoint: > engine) get a design nod at story-creation time. EPICS 3 and 4 are COMPLETE (Epic 4 closed 2026-07-25, retrospective held 2026-07-26; story 4.19 was SPLIT, 4.19b re-scoped to Epic 11 via issue #34). - EPIC 5 (Identité d'interface fiable, v0.3) is DECOMPOSED into 15 stories - (5.1–5.14 on 2026-07-26 with Guy, plus 5.2b INSERTED the same day — see the - Epic 5 preamble) below. Three arbitrations were taken at + EPIC 5 (Identité d'interface fiable, v0.3) is DECOMPOSED into 16 stories + (5.1–5.14 on 2026-07-26 with Guy, plus 5.2b INSERTED the same day and 5.4b + INSERTED 2026-07-29 at story 5.4's contexting — see the Epic 5 preamble) below. Three arbitrations were taken at decomposition time and live in the stories that carry them: (a) NFR4 CANNOT go green in Epic 5 — D18 gates at the DEVICE level, and of the 24 committed traps 13 name an `l1-*` rule, 8 an `l2-*` rule and 3 a cause; the Epic List entry was @@ -1310,11 +1310,11 @@ Build the L1 interface-identity join and drive it against the corpus Epic 4 froz _Decomposed 2026-07-26 with Guy, immediately after the Epic 4 retrospective. Three arbitrations were taken at decomposition time and are recorded in the stories that carry them: the NFR4 level boundary (see the Epic List entry above and story 5.8), the engine's own abstention cause (5.3), and what persistence Epic 5 does and does not create (5.9)._ -_**Three** of the fifteen stories are **inherited debt**, placed at the HEAD on Guy's decision: the corpus byte-fidelity and corpus privacy themes had each accumulated three to four unowned entries in `deferred-work.md`. They come first because **this epic bumps the corpus**, and hardening after the bump means replaying every entry against artefacts that have moved._ +_**Three** of the sixteen stories are **inherited debt**, placed at the HEAD on Guy's decision: the corpus byte-fidelity and corpus privacy themes had each accumulated three to four unowned entries in `deferred-work.md`. They come first because **this epic bumps the corpus**, and hardening after the bump means replaying every entry against artefacts that have moved._ _**Story 5.2b was INSERTED on 2026-07-26**, one day after the decomposition and before any Epic 5 code existed. It was surfaced while preparing story 5.1: four committed trap families (randomized-mac, multi-nic, shared-hardware-vm, cloned-mac) turned out to be named by no test at all, so 5.1's AC1 — which strengthens byte-pins that EXIST — could not reach them. It was neither absorbed into 5.1 nor left in the register, because the corpus is the oracle the L1 join is about to be judged against: hardening it after its first consumer exists means bending the engine to fit whatever the corpus happens to say. The letter suffix is the house idiom for an inserted item (D56b, AC5b/7b/7c), chosen so 5.3–5.14 keep their numbers._ -_Build order: the three debt stories (5.1, 5.2, 5.2b) -> the engine's vocabulary (5.3, 5.4) -> the pure join (5.5) -> the blocker (5.6) -> wiring it to the corpus (5.7, 5.8) -> persistence (5.9, 5.10) -> the invariants (5.11, 5.12, 5.13) -> the operator-visible surface (5.14). No story depends on a later one._ +_Build order: the three debt stories (5.1, 5.2, 5.2b) -> the engine's vocabulary (5.3, 5.4) -> the verdict algebra (5.4b) -> the pure join (5.5) -> the blocker (5.6) -> wiring it to the corpus (5.7, 5.8) -> persistence (5.9, 5.10) -> the invariants (5.11, 5.12, 5.13) -> the operator-visible surface (5.14). No story depends on a later one._ ### Story 5.1: The corpus pins the obs_id-to-line binding, and every stream goes through the connector @@ -1434,13 +1434,43 @@ So that the explanation is free (D13) and improving the engine is not a silent d **Given** D13's refusal of `rule -> confidence: f64` ("if the output is a float, B has won in disguise") **When** the type is reviewed -**Then** no float crosses a decision boundary; any ranking value is an INTEGER in milli-units and may order candidates for display only. +**Then** no float crosses a decision boundary; any ranking value is an INTEGER in milli-units and may order candidates for display only. _(SPLIT 2026-07-29: the TYPE-level half is story 5.4's — no type it defines carries a float or a magnitude; the **gate** that holds it mechanically is story 5.4b's, below. Measured at the split: zero `f32`/`f64` in the whole Rust workspace, so the rule was true by accident.)_ **Given** D14's "`ruleset_version` is mandatory" **When** a decision is produced **Then** it carries the version of the ruleset that produced it. -**And** the verdict algebra of D13 (`Decisive` / `Supports` / `Neutral` / `Opposes` / `Disqualifying`) is expressed as an enumeration, and combining verdicts is an algebra, never a sum. +**And** the verdict algebra of D13 (`Decisive` / `Supports` / `Neutral` / `Opposes` / `Disqualifying`) is expressed as an enumeration, and combining verdicts is an algebra, never a sum. _(SPLIT 2026-07-29 at story 5.4's contexting: the enumeration is story 5.4's; **combining** them is story 5.4b's, below.)_ + +### Story 5.4b: The verdict algebra is a total function, and no float can reach it + +_Inserted 2026-07-29 with Guy, at story 5.4's contexting and before any Epic 5 engine code existed. Two reasons, both measured. **(1)** Enumerating D13's table [architecture.md:967-974] over the PRESENCE of each verdict yields seven input classes and the six rows cover six of them: **`≥1 Opposes` with no `Decisive`, no `Supports` and no `Disqualifying` is covered by no row.** A function that must be total therefore needs an arbitration D13 does not supply, and that arbitration deserves to be the subject of a story rather than a subsection of one. **(2)** D13's *"if the output is a float, B has won in disguise"* is held today by nothing — measured at `505379e`: zero `f32`/`f64` in the whole Rust workspace, so the rule is true by accident. The letter suffix follows the house idiom for an INSERTED item (D56b, AC5b/7b/7c, story 5.2b) so 5.5–5.14 keep their numbers._ + +As the identity engine, +I want the verdict algebra as a total pure function, and a gate that refuses a float in the identity subtree, +So that no input class falls through the table unnoticed and no weight can enter through the back door. + +**Acceptance Criteria:** + +**Given** story 5.4's `Verdict`, `RuleVerdict`, `Conclusion`, `Decision` and `RulesetVersion`, and D13's six-row table +**When** the algebra is written +**Then** `decide` is a **pure function over a verdict set** — no clock, no I/O, no SQL — implementing every row, with each arm citing the architecture line it comes from. + +**Given** the input class D13's table does not cover — `≥1 Opposes`, no `Decisive`, no `Supports`, no `Disqualifying` +**When** it is met +**Then** the conclusion is **`Abstained { AbsenceOfProof }`** _(Guy's arbitration, 2026-07-29)_: nothing argues FOR the merge, so there is no merge to refuse, and D13 deliberately reserves the refusal-that-names-a-rule for `Disqualifying`. The gap and the arbitration are documented at the function, and recorded as a correction to be carried into D13 at a milestone — never patched into `architecture.md` inside a story. + +**Given** that a decision names ONE rule while several rules may be `Disqualifying` or `Decisive` at once +**When** more than one qualifies +**Then** the rule named is chosen **deterministically and independently of the order the verdicts arrive in** — a property tested by permuting the input, not asserted in prose. *"The one written first… is not a decision, it is an accident of file order"* [architecture.md:936-937]; this also pre-empts story 5.11. + +**Given** D13's *"REFUSED: `rule -> confidence: f64`… if the output is a float, B has won in disguise"* [architecture.md:956-958] +**When** `cargo xtask ci` runs +**Then** a gate reds on any `f32` or `f64` under `crates/opencmdb-core/src/identity/`, in the idiom of the existing DDL-collation and retired-vocabulary greps (D56/D65 — every gate lives in Rust, never in YAML), with its own prove-to-red. + +**And** no ranking value is invented: D13's milli-units corollary [architecture.md:988-993] binds the day a float would otherwise appear, and no candidate ordering exists in Epic 5 (L1 is a deterministic lookup). The deferral is registered with its owner. + +**And** the table's totality is proven by exercising **every** input class, not a sample — and each guard is proven to red before it passes, the mutation recorded (house rule, story 1.3). ### Story 5.5: The L1 join, as a pure function diff --git a/crates/opencmdb-core/src/identity/cascade.rs b/crates/opencmdb-core/src/identity/cascade.rs index a093714..182c6f5 100644 --- a/crates/opencmdb-core/src/identity/cascade.rs +++ b/crates/opencmdb-core/src/identity/cascade.rs @@ -1,14 +1,318 @@ -//! The identity cascade — and, today, only the vocabulary its abstentions speak. +//! The identity cascade — and, today, the vocabulary it speaks and the type it returns. //! //! D13 fixes the cascade's shape: **all rules are evaluated** (never first-match-wins), each yields //! an enumerated verdict, and the verdicts combine by an **algebra, never a sum** — *"if the output -//! is a float, B has won in disguise"* [architecture.md:956-965]. **Story 5.4 writes that algebra -//! and the `Verdict` enum it combines; this file holds neither.** +//! is a float, B has won in disguise"* [architecture.md:956-965]. This file holds the vocabulary +//! that algebra reads and writes — [`Verdict`], [`RuleVerdict`], [`Conclusion`], [`Decision`], +//! [`RulesetVersion`] and [`IdentityAbstentionCause`]. **It does not hold the algebra: nothing here +//! combines a verdict set into a conclusion. That is story 5.4b's**, and no rule produces a +//! [`Verdict`] yet. //! -//! What it holds today is [`IdentityAbstentionCause`]: the two names the cascade may give for not -//! concluding. The vocabulary is chosen before the engine on purpose — the same order D19 imposed -//! on the metrics harness in Epic 4, *"a metric written after the engine is bent to fit the -//! engine"* — and because story 4.6a recorded the choice as Epic 5's to make. +//! The vocabulary is chosen before the engine on purpose — the same order D19 imposed on the metrics +//! harness in Epic 4, *"a metric written after the engine is bent to fit the engine"* — and because +//! story 4.6a recorded the abstention half of the choice as Epic 5's to make. +//! +//! # Four types say "verdict", and they are four different judgements +//! +//! | type | whose judgement | of what | variants | +//! |---|---|---|---| +//! | [`Verdict`] | ONE rule | one candidate pair | 5 | +//! | [`Conclusion`] | the CASCADE | a whole verdict set | 3 | +//! | [`crate::score::Outcome`] | the trap harness | what something ANSWERED about a trap | 3 | +//! | [`crate::score::TrapVerdict`] | the trap RUNNER | whether one trap passed | 3 | +//! +//! [`Conclusion`] and [`crate::score::Outcome`] look redundant and are not. `Outcome` is what a +//! harness writes down about *any* answer, including a hand-authored one in a test; `Conclusion` is +//! what the engine concluded, and it never travels alone — it travels inside a [`Decision`], with +//! its verdict vector and its ruleset version. **Nothing converts between them**, and the day they +//! meet is story 5.7's; see [`Decision`] for why no `From` impl exists. + +use crate::observation::ObsId; +use crate::trap::RuleId; + +/// What ONE rule says about ONE candidate pair — D13's enumerated verdict. +/// +/// D13 writes this enum out by name: `enum Verdict { Decisive, Supports, Neutral, Opposes, +/// Disqualifying }` [architecture.md:964]. The five variants and their spelling are the +/// architecture's, not a paraphrase. +/// +/// # This type is the vocabulary; combining is story 5.4b's +/// +/// D13's contract is that **all rules are evaluated** and that *"verdicts combine by an **algebra, +/// not a sum**"* [architecture.md:960-961], because *"REFUSED: `rule -> confidence: f64` … if the +/// output is a float, B has won in disguise"* [architecture.md:956-958]. **Nothing in this file +/// combines anything** — the six-row table at [architecture.md:967-974] is implemented by story +/// 5.4b, and no rule produces a `Verdict` until story 5.5. +/// +/// ⚠️ **The six rows do not cover every input.** Enumerated over the PRESENCE of each variant, the +/// table leaves exactly one class unanswered: at least one `Opposes`, with no `Decisive`, no +/// `Supports` and no `Disqualifying`. It is not *"only `Neutral` / nothing"* and it is not +/// *"`Supports` AND `Opposes`"*. **Story 5.4b arbitrates it**; this doc names the gap so a reader +/// does not mistake five variants and six rows for a complete specification. +/// +/// # Why there is no ordering +/// +/// D20 refuses evidence STRENGTH as a default and specifies the shape of its return if it ever +/// comes back: *"if strength returns, it returns as an ORDINAL, not a weight: `Opposes(Weak) | +/// Opposes(Strong)`. The enum grows, the table grows, it stays finite and exhaustively enumerable. +/// No float decides."* [architecture.md:1374-1376], under a four-condition ADR +/// [architecture.md:1378-1394]. Deriving `Ord` here would let two verdicts be COMPARED by magnitude +/// today, which is the move that ADR exists to gate. +// Deliberately absent, each with a reason: `Serialize`/`Deserialize` (nothing persists a verdict — +// story 5.9 if it persists one), `PartialOrd`/`Ord` (see the doc above — D20's ADR owns it), +// `Display` (story 5.14 renders through the `t!()` seam), and `#[non_exhaustive]` (never: +// `opencmdb-bin` is another crate, so it would force a `_` arm downstream and destroy the +// `error[E0004]` that makes a sixth variant break story 5.4b's table). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + /// This rule alone settles the pair — the strongest thing a rule may say. + /// + /// It does not settle the DECISION: D13's table pairs a `Decisive` with `Opposes` to produce + /// `Ambiguous`, the cloned-MAC case [architecture.md:971]. A rule is decisive about its own + /// evidence, never about the outcome. + Decisive, + /// This rule argues FOR the pair being one interface, without settling it. + Supports, + /// This rule has nothing to say about this pair. + /// + /// The honest answer of a rule that does not KNOW — D20 names the opposite as the common bug: + /// *"the rule that wrongly `Opposes` should return `Neutral`: it does not KNOW, it BELIEVES it + /// knows… nine parasitic abstentions out of ten are that"* [architecture.md:1383-1387]. + Neutral, + /// This rule argues AGAINST the pair, without disqualifying it. + Opposes, + /// This rule forbids the pair outright — D13's *"absolute priority, short-circuits + /// everything"* [architecture.md:969]. + /// + /// Its two committed instances are STRUCTURAL facts read at ingestion, never scored: the + /// IANA-reserved VRRP/HSRP MAC prefixes and the U/L bit — *"**Both are** `Disqualifying` as + /// grouping anchors, known at ingestion."* [architecture.md:1002]. D13 is explicit that + /// *"confusing an IANA fact with scoring turns a fact into a probability — and that is how + /// weights get invented"* [architecture.md:996-998]. + Disqualifying, +} + +impl Verdict { + /// Every variant, so a caller can exercise the whole vocabulary without listing it. + /// + /// # What the witness below guarantees, and what it does not + /// + /// Adding a variant makes the `match` non-exhaustive — **`error[E0004]`, which stops the + /// build** and forces a human decision at this exact site. That is the guarantee. + /// + /// **It does not mechanically force the new variant into the array, and that was MEASURED on + /// this idiom's first instance ([`IdentityAbstentionCause::all`]), not assumed.** Repairing only + /// the `error[E0004]` — adding a bare arm — leaves this function returning the old list while + /// the whole suite stays green. Widening the literal *without* widening the return type is a + /// separate `error[E0308]`; the two errors are **alternatives along one repair path, never + /// simultaneous**. The array length is pinned at `5` in the signature so the second error exists + /// at all. + /// + /// The residue is registered with an owner (story 5.14) rather than closed here: the two + /// candidate closures were built and measured on the sibling enum and both were rejected. + pub fn all() -> [Self; 5] { + let all = [ + Self::Decisive, + Self::Supports, + Self::Neutral, + Self::Opposes, + Self::Disqualifying, + ]; + // ⚠️ If you arrived here from an error[E0004] after adding a variant: adding a bare + // `Self::NewThing => {}` arm silences this and is the WRONG repair — `all()` would then + // return a list missing your variant, and every test that loops over it would skip the + // variant in silence (measured on the sibling enum: the suite stays green). Add it to the + // literal above and widen the return type. And read D20 first: a sixth verdict is the + // "evidence strength" question, which needs an ADR before any code. + for v in all { + match v { + Self::Decisive => {} + Self::Supports => {} + Self::Neutral => {} + Self::Opposes => {} + Self::Disqualifying => {} + } + } + all + } +} + +/// One entry of the complete verdict vector: the `(rule, verdict, evidence)` triple. +/// +/// D13: *"**explanation is free** (the list of `(rule, verdict, evidence)` IS the explanation)"* +/// [architecture.md:977-978]. D18 makes recording the whole list a requirement rather than a +/// courtesy: *"The harness records, for every case, the COMPLETE VERDICT VECTOR, not just the +/// outcome… **the anti-drift is not discipline, it is a data requirement.**"* +/// [architecture.md:1396-1399]. +/// +/// # Nothing produces one, and nothing validates one +/// +/// There is no rule, so every `RuleVerdict` in the tree today is a test literal. The fields are +/// `pub` with no constructor — the [`crate::score::ScoredRecord`] precedent — and the two +/// validations a producer would want are **registered rather than invented**: that a verdict which +/// ARGUES leaves non-empty evidence, and that a [`Decision`]'s conclusion names a rule its own +/// vector contains. Both need story 5.4b or 5.5 to have something that could red. +/// +/// # Its relationship to [`crate::score::VerdictVectorEntry`] +/// +/// That type is the HARNESS-side placeholder for this same triple, and it is **uninhabited** so +/// that `ScoredRecord::verdict_vector` is provably empty until an engine fills it. This story does +/// not replace it: there is still no producer, and replacing it would falsify four places at once. +/// **Story 5.7 owns the unification**, when the trap runner first records a real run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleVerdict { + /// The rule that spoke. A [`RuleId`] rather than an enum: five of the seven rule names the + /// committed corpus writes are `l2-*`, which Epic 6 designs, so closing the enum here would + /// enumerate rules nobody has specified. + pub rule: RuleId, + /// What it said. + pub verdict: Verdict, + /// The observations the rule read to say it, by the stable id stories 4.1/4.2 froze — never a + /// line number, which *"would silently shift under the truth"*. + /// + /// This is the SMALLEST evidence that is not invented. The architecture requires a firing rule + /// to leave evidence behind — *"a rule that fires without leaving its `rule_id` in the database + /// is a rule we cannot debug in production"* [architecture.md:1309-1310] — and **shapes it + /// nowhere**. A richer payload (the fact values, the candidate pair, a rendered sentence) has no + /// producer until story 5.5 and would be a design taken without one. + pub evidence: Vec, +} + +/// The version of the rule set a [`Decision`] was produced under. +/// +/// D14: *"**`ruleset_version` is mandatory:** without it, improving the engine is **a silent data +/// migration — the worst kind**."* [architecture.md:1044-1045]. D20 says what an increment means: +/// *"Any reintroduction increments `ruleset_version`; existing links are not recomputed (they carry +/// the version they were decided under)."* [architecture.md:1392-1393]. +/// +/// # No constant, no default, no ordering +/// +/// **There is no `CURRENT_RULESET_VERSION` and no `Default`**, because there is no ruleset: no rule +/// exists. A constant would be a value asserting that the rules it versions are there. The version +/// arrives as a parameter when a producer builds a [`Decision`] — story 5.5 is the first story with +/// rules to version, and it owns the constant. +/// +/// **No value is refused, including `RulesetVersion(0)`.** D14's "mandatory" is about PRESENCE, not +/// meaning; validating a number against nothing would be the same invention this story refuses for +/// evidence. Registered, owner story 5.5. +/// +/// **No `PartialOrd`/`Ord`**: the first consumer that ORDERS two versions is persistence (D20's +/// "existing links are not recomputed" is a claim about which version a row carries, not a +/// comparison anything performs today). Story 5.9 adds it if it needs it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RulesetVersion( + /// The version number itself. Opaque: nothing derives meaning from its value yet. + pub u32, +); + +/// What the cascade concluded — D13's three-way decision. +/// +/// D13: *"candidate generation (blocking) -> verdicts -> **three-way decision** (`match` / +/// `no-match` / **`ambiguous` -> abstain**)"* [architecture.md:931-932]. +/// +/// # A decision names a RULE; an abstention names a CAUSE +/// +/// This mirrors [`crate::score::Outcome`]'s shape deliberately, so that [`Decision::rule`] can +/// return `None` for an abstention **by construction** — the property +/// [`crate::score::run_trap`] already relies on to leave an abstention out of its `(verdict, rule)` +/// assertion without a runtime guard. Story 4.2 fixed the rule half in the truth format: +/// *"Every decision names a rule; only an abstention names a cause"* (`trap.rs`), because *"a +/// refusal without a named rule cannot be told apart from an engine that simply found nothing, and +/// that is undebuggable in production"*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Conclusion { + /// These observations describe one interface, and this rule settled it — D13's row *"a + /// `Decisive`, no `Opposes`"* [architecture.md:970]. + Match { + /// The rule that settled it. + rule: RuleId, + }, + /// These observations describe different interfaces, and this rule FORBADE the pair — D13's row + /// *"any `Disqualifying`"* [architecture.md:969]. + /// + /// ⚠️ **D13's `NoMatch` covers two situations and only ONE of them lands here.** The other — + /// *"only `Neutral` / nothing → `NoMatch` (absence of proof)"* [architecture.md:974] — has **no + /// rule to name**, so it cannot be represented by this variant and becomes + /// [`Self::Abstained`] with [`IdentityAbstentionCause::AbsenceOfProof`]. That fork is why this + /// variant carries a rule and cannot be given an optional one: an engine that mapped all of + /// `NoMatch` onto a refusal *"would fail every honest `must-abstain` trap"*, the exact case D18 + /// says must NOT be gated. **This story builds the fork; story 5.4b decides which side an input + /// falls on.** + NoMatch { + /// The rule that opposed the pair — never the rule that was merely tempting. + rule: RuleId, + }, + /// The cascade took no decision, for this cause. + Abstained { + /// Why it did not conclude. + cause: IdentityAbstentionCause, + }, +} + +/// What the identity cascade returns: the conclusion, the explanation, and the ruleset version. +/// +/// A struct rather than an enum, and the reason is D14. `ruleset_version` is carried **once**, so +/// "mandatory" rests on one declaration instead of on three variants agreeing — and a variant added +/// to [`Conclusion`] later cannot forget it. [`crate::score::Outcome`] is an enum because it has no +/// common field to carry; the two types differ in envelope and agree in algebra, which is exactly +/// what [`Self::rule`] pins. +/// +/// # Nothing produces one +/// +/// There is no rule, no blocker and no join, so every `Decision` in the tree is a test literal. +/// **No `From for Outcome` exists in either direction**: mapping the engine's return onto +/// the harness's record is a decision about the release gate, and it belongs to story 5.7 with a +/// story behind it — not to a silent conversion. The same refusal, for the same reason, kept the +/// two abstention vocabularies unbridged in story 5.3. +/// +/// # No float, anywhere +/// +/// D13's universal rule: *"floats may RANK, never DECIDE… The moment a float decides, +/// explainability and the truth table die the same day"* [architecture.md:988-990]. No field here +/// is a float and none is a magnitude: [`RulesetVersion`] is an identifier, [`Verdict`] is +/// enumerated and unordered. There is no ranking value at all, because L1 is a deterministic lookup +/// with nothing to rank; D13's milli-units corollary [architecture.md:991-993] binds the day one +/// appears. Story 5.4b adds the `cargo xtask ci` gate that holds this mechanically. +/// +/// # What is representable and not refused +/// +/// The fields are `pub` with no constructor ([`crate::score::ScoredRecord`]'s precedent), so a +/// `Decision` whose [`Self::conclusion`] names a rule **absent from its own +/// [`Self::verdict_vector`]** compiles, and so does a [`Conclusion::Match`] with an empty vector — +/// *"merged, with no explanation"*, which is what D13's *"the list … IS the explanation"* exists to +/// prevent. Nothing refuses either, and the reason is a missing producer rather than a preference: +/// story 5.4b's combining function is the first place a conclusion and a vector are built together, +/// hence the first place a test could red. Registered, owner story 5.4b. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decision { + /// What the cascade concluded. + pub conclusion: Conclusion, + /// The complete verdict vector — D18's word, and D18's data requirement. Every rule that spoke, + /// what it said, and what it read. + pub verdict_vector: Vec, + /// The version of the ruleset that produced this decision (D14). + pub ruleset_version: RulesetVersion, +} + +impl Decision { + /// The rule a DECISION named, or `None` for an abstention — the mirror of + /// [`crate::score::Outcome::rule`]. + /// + /// A match names the rule that settled the pair; a refusal names the rule that FORBADE it. An + /// abstention took no decision, so it carries a cause and no rule — **the type says so**, which + /// is what lets a future consumer leave an abstention out of a `(verdict, rule)` comparison by + /// construction rather than by a runtime guard. + /// + /// It lives on `Decision` and not on [`Conclusion`], and there is no `Conclusion::rule()`: the + /// consumer that needs it holds a decision, so an accessor on the inner enum would have no + /// caller. Same no-consumer argument that keeps serde off these types. + pub fn rule(&self) -> Option<&RuleId> { + match &self.conclusion { + Conclusion::Match { rule } | Conclusion::NoMatch { rule } => Some(rule), + Conclusion::Abstained { .. } => None, + } + } +} /// Why the identity cascade did not conclude — the engine's own abstention vocabulary. /// @@ -119,10 +423,38 @@ impl IdentityAbstentionCause { } } +/// Tests for this file's types. +/// +/// # Where a test lives, decided once +/// +/// **A test lives with the item whose CLAIM it pins; the items it merely READS are dependencies, +/// imported and not owned.** Story 5.3's review left this convention to "whoever next adds a test +/// to `cascade.rs`", and these are those tests. It decides two cases: +/// +/// - `a_decision_names_a_rule_and_an_abstention_does_not` and +/// `the_conclusion_mirrors_the_outcomes_rule_shape` pin claims about [`Decision`]'s shape. +/// [`crate::score::Outcome`] is the dependency the second one reads to express the mirror, not +/// its subject. Both belong here. +/// - `an_abstention_names_no_rule_whatever_its_cause` (story 5.3) pins a claim about the abstention +/// VOCABULARY — *"an abstention has no rule to name, for EVERY cause"* — and reads +/// `Outcome::rule()` as the mechanism that expresses it. Its subject is +/// [`IdentityAbstentionCause`], so it stays here too, by the convention rather than by fiat. +/// +/// The truth-table tests of `score()` stay in `score.rs` under the same rule: their subject is +/// `score`, and this module's types are what they read. #[cfg(test)] mod tests { use super::*; use crate::score::Outcome; + use uuid::Uuid; + + fn obs(n: u128) -> ObsId { + ObsId::from_uuid(Uuid::from_u128(n)) + } + + fn rule(name: &str) -> RuleId { + RuleId(name.into()) + } /// The variant SET, not its length: on `[Self; 2]` the length is a tautology the return type /// already guarantees, so asserting it would prove nothing. What this pins is *which* two — @@ -159,4 +491,170 @@ mod tests { ); } } + + /// D13 names five verdicts [architecture.md:964] and this pins that set — renaming one, or + /// swapping one for another, reds here. + /// + /// ⚠️ **What it does NOT catch, stated because the name reads stronger than the body:** a SIXTH + /// variant added to the enum leaves the five in `all()`, so this test stays green. What stops + /// that is [`Verdict::all`]'s `error[E0004]`, and its doc says exactly how far that goes. The + /// length is carried by the `[Self; 5]` return type, not by the assertions below, so asserting + /// it here would prove nothing. + #[test] + fn the_five_verdicts_of_d13_are_present() { + let all = Verdict::all(); + for expected in [ + Verdict::Decisive, + Verdict::Supports, + Verdict::Neutral, + Verdict::Opposes, + Verdict::Disqualifying, + ] { + assert!( + all.contains(&expected), + "{expected:?} is one of D13's five verdicts and must be in the witness" + ); + } + } + + /// A decision names a rule; an abstention names a cause and no rule — for EVERY cause, not one + /// hand-picked variant. This is the property that lets a consumer skip an abstention in a + /// `(verdict, rule)` comparison by construction. + #[test] + fn a_decision_names_a_rule_and_an_abstention_does_not() { + let matched = Decision { + conclusion: Conclusion::Match { + rule: rule("l1-exact-mac"), + }, + verdict_vector: Vec::new(), + ruleset_version: RulesetVersion(1), + }; + assert_eq!( + matched.rule(), + Some(&rule("l1-exact-mac")), + "a Match names the rule that settled the pair" + ); + + let refused = Decision { + conclusion: Conclusion::NoMatch { + rule: rule("l1-distinct-mac"), + }, + verdict_vector: Vec::new(), + ruleset_version: RulesetVersion(1), + }; + assert_eq!( + refused.rule(), + Some(&rule("l1-distinct-mac")), + "a NoMatch names the rule that forbade the pair" + ); + + for cause in IdentityAbstentionCause::all() { + let abstained = Decision { + conclusion: Conclusion::Abstained { cause }, + verdict_vector: Vec::new(), + ruleset_version: RulesetVersion(1), + }; + assert_eq!( + abstained.rule(), + None, + "an abstention with cause {cause:?} took no decision, so it names no rule" + ); + } + } + + /// The claim the epic makes about `Decision` — *"the same shape `Outcome` mirrors, so + /// `run_trap`'s existing assertion needs no runtime guard"* — made executable. + /// + /// Conclusion by counterpart outcome: a rule is named on exactly the same two of three. The + /// ENVELOPES differ deliberately (`Decision` is a struct carrying the ruleset version, + /// `Outcome` an enum), so what is asserted is the algebra, not the layout. + #[test] + fn the_conclusion_mirrors_the_outcomes_rule_shape() { + let r = rule("l1-exact-mac"); + let pairs: [(&str, Conclusion, Outcome); 3] = [ + ( + "match / merged", + Conclusion::Match { rule: r.clone() }, + Outcome::Merged { rule: r.clone() }, + ), + ( + "no-match / refused", + Conclusion::NoMatch { rule: r.clone() }, + Outcome::Refused { rule: r.clone() }, + ), + ( + "abstained / abstained", + Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + }, + Outcome::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + }, + ), + ]; + + for (label, conclusion, outcome) in pairs { + let decision = Decision { + conclusion, + verdict_vector: Vec::new(), + ruleset_version: RulesetVersion(1), + }; + assert_eq!( + decision.rule().is_some(), + outcome.rule().is_some(), + "{label}: Decision and Outcome must agree on whether a rule is named" + ); + } + } + + /// The `(rule, verdict, evidence)` triple survives the trip in full — D13's *"the list of + /// `(rule, verdict, evidence)` IS the explanation"*. Three entries, three different verdicts, + /// three distinct non-empty evidence lists, order preserved. + #[test] + fn the_verdict_vector_carries_the_whole_triple_in_order() { + let vector = vec![ + RuleVerdict { + rule: rule("l1-exact-mac"), + verdict: Verdict::Decisive, + evidence: vec![obs(1), obs(2)], + }, + RuleVerdict { + rule: rule("l2-hostname-agrees"), + verdict: Verdict::Supports, + evidence: vec![obs(3)], + }, + RuleVerdict { + rule: rule("l2-different-switch"), + verdict: Verdict::Opposes, + evidence: vec![obs(4), obs(5)], + }, + ]; + let decision = Decision { + conclusion: Conclusion::Abstained { + cause: IdentityAbstentionCause::Ambiguous, + }, + verdict_vector: vector.clone(), + ruleset_version: RulesetVersion(7), + }; + + assert_eq!( + decision.verdict_vector, vector, + "the verdict vector is carried verbatim, in order, with every field intact" + ); + assert_eq!( + decision.verdict_vector[1].verdict, + Verdict::Supports, + "each entry keeps its own verdict" + ); + assert_eq!( + decision.verdict_vector[2].evidence, + vec![obs(4), obs(5)], + "each entry keeps its own evidence, and the three lists are distinct" + ); + assert_eq!( + decision.ruleset_version, + RulesetVersion(7), + "the ruleset version is carried alongside, not derived" + ); + } } diff --git a/crates/opencmdb-core/src/identity/mod.rs b/crates/opencmdb-core/src/identity/mod.rs index 14a1ba9..665d6d5 100644 --- a/crates/opencmdb-core/src/identity/mod.rs +++ b/crates/opencmdb-core/src/identity/mod.rs @@ -5,8 +5,10 @@ //! riskiest problem in the product, and D13 fixes its shape — **all rules are evaluated**, each //! yields an enumerated verdict, and the verdicts combine by an **algebra, never a sum**. //! -//! **What lives here today is [`cascade`], and inside it only the abstention vocabulary.** There is -//! no rule, no verdict set and no join yet; story 5.4 writes the algebra that produces one. +//! **What lives here today is [`cascade`], and inside it the engine's vocabulary and its return +//! type** — the verdict, the `(rule, verdict, evidence)` triple, the conclusion, the ruleset version +//! and the abstention cause. There is still no rule, no candidate pair and no join, and **nothing +//! combines a verdict set into a conclusion**: story 5.4b writes that algebra. //! //! The architecture's source tree names an `IdentityError` on this module [architecture.md:3366]. //! It is absent because there is no fallible operation to carry it: choosing a cause enum cannot diff --git a/crates/opencmdb-core/src/lib.rs b/crates/opencmdb-core/src/lib.rs index cab6910..4c47952 100644 --- a/crates/opencmdb-core/src/lib.rs +++ b/crates/opencmdb-core/src/lib.rs @@ -37,7 +37,9 @@ pub use testing::{FixedClock, ScriptedConnector, ScriptedOutcome, run_connector_ pub use clock::Clock; pub use connector::{Connector, ConnectorError, ObservationSink, PollSummary, VecSink}; pub use gap::{AbstentionCause, Gap, Reconciliation, reconcile}; -pub use identity::cascade::IdentityAbstentionCause; +pub use identity::cascade::{ + Conclusion, Decision, IdentityAbstentionCause, RuleVerdict, RulesetVersion, Verdict, +}; pub use observation::{ Capabilities, ConnectorId, Fact, FactKind, HostnameSource, L2DomainId, MacAddr, MacParseError, ObsId, Observation, Scope, Timestamp, VantageId, diff --git a/crates/opencmdb-core/src/score.rs b/crates/opencmdb-core/src/score.rs index 199ef58..f12fda1 100644 --- a/crates/opencmdb-core/src/score.rs +++ b/crates/opencmdb-core/src/score.rs @@ -43,8 +43,12 @@ use crate::trap::{Expectation, RuleId, TrapId}; /// in [`score`], and would hold if `Outcome` carried nothing at all. The payloads are here so that /// story 4.7 can add `(outcome, rule)` comparison without changing this type. /// -/// **Not named `Decision`.** The architecture reserves that name for the engine's real return type -/// and never lists its fields; taking it here would squat a type Epic 5 has to define. +/// **Not named `Decision`, and since story 5.4 that name is taken.** +/// [`crate::identity::cascade::Decision`] is the ENGINE's return — a conclusion, its verdict vector +/// and the ruleset version that produced it. This type is the HARNESS's record of an answer, which +/// may equally be hand-authored in a test. The two mirror each other's algebra (a decision names a +/// rule, an abstention names a cause) and differ in envelope, and **nothing converts between them**: +/// mapping one onto the other is a decision about the release gate, and story 5.7 owns it. /// /// # The two sides of a trap speak different abstention vocabularies (story 5.3) /// @@ -196,6 +200,10 @@ pub fn score(expected: &Expectation, actual: &Outcome) -> Score { /// The verdict of RUNNING one trap: [`score`]'s truth table, plus the `(verdict, rule)` assertion. /// +/// ⚠️ **Not [`crate::identity::cascade::Verdict`]**, which is what ONE RULE says about one candidate +/// pair (D13's five-variant enum). This type is what the RUNNER says about one trap. The four +/// judgements in play are tabulated in `identity::cascade`'s module doc. +/// /// D46b's surviving criterion (D64): *"compare `(verdict, rule)`, never `verdict` alone"*. [`score`] /// answers the verdict question and stays rule-blind; this adds the rule question ON TOP, so the two /// failure modes stay distinct — a wrong verdict and a right verdict by the wrong rule are not the @@ -273,9 +281,12 @@ pub enum SourceState {} /// Without it the A-vs-B question is undecidable after the fact… **the anti-drift is not /// discipline, it is a data requirement.**"* /// -/// The vector's element is `(rule, verdict, evidence)` and none of the three exists: rules arrive -/// in Epic 5. Rather than invent the type, this placeholder is **uninhabited** — so the field is -/// provably empty, by the same standard as [`SourceState`], instead of being empty by comment. +/// The vector's element is `(rule, verdict, evidence)`, and since story 5.4 that triple HAS a type +/// — [`crate::identity::cascade::RuleVerdict`], on the engine's side. **Nothing produces one**: no +/// rule speaks, so no verdict vector is ever built. This placeholder therefore stays **uninhabited** +/// rather than being replaced, so the field is provably empty by the same standard as +/// [`SourceState`], instead of being empty by comment. **Story 5.7 owns the unification**, when the +/// trap runner first records a run a real engine produced. /// /// This is also what PINS story 4.7a's AC6 forward contract: [`run_trap`] asserts `(verdict, rule)` /// today, but the requirement that *a firing rule leave its `rule_id` and evidence behind* needs an diff --git a/crates/opencmdb-core/src/trap.rs b/crates/opencmdb-core/src/trap.rs index f671a70..e76a6fb 100644 --- a/crates/opencmdb-core/src/trap.rs +++ b/crates/opencmdb-core/src/trap.rs @@ -30,9 +30,12 @@ use crate::observation::ObsId; /// The identifier of an engine rule, as an expectation names it. /// -/// A `String` for now because no rule exists yet — Epic 5 names them. It closes into an enum -/// when it does: architecture.md:2652 requires *a decision on every variant*, and an -/// `Other(String)` cannot satisfy an `expect_rule`. +/// A `String` for now because no rule exists yet. It closes into an enum when **every rule the +/// corpus names is designed** — architecture.md:2652 requires *a decision on every variant*, and an +/// `Other(String)` cannot satisfy an `expect_rule`. That is not Epic 5: the committed corpus writes +/// **seven** distinct rule names and **five of them are `l2-*`**, which Epic 6 specifies. Closing +/// the enum sooner would either enumerate rules nobody has designed or make five sha256-locked trap +/// files unparseable. *(This doc said "Epic 5 names them" until story 5.4 measured the seven.)* #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct RuleId(pub String); diff --git a/docs/project-context.md b/docs/project-context.md index a2b3032..2d0f419 100644 --- a/docs/project-context.md +++ b/docs/project-context.md @@ -42,7 +42,7 @@ Docker Hub; the binary runs and scans on Guy's NAS (frontend on macvlan, no Trae | **E2** Le contrat de connecteur | ✅ done (5 stories) | | **E3** Mon premier écart réel — **v0.1** | ✅ done (10 stories), retrospective held | | **E4** Infra fixtures & corpus de pièges — v0.2 | ✅ **done 2026-07-25** (19/19 authored; 4.19 split — see below) | -| **E5** Identité d'interface fiable — v0.3 | 🔵 **in-progress** since 2026-07-27 — 15 stories; **5.1, 5.2 and 5.2b done** (PR #41, #44, #46, all merged 2026-07-28). Epic 5's **three inherited-debt stories are closed**; the engine proper starts at **5.3, `done`** (PR #48, merged 2026-07-29) — it ships the identity engine's own abstention vocabulary and NO engine. Next is 5.4 | +| **E5** Identité d'interface fiable — v0.3 | 🔵 **in-progress** since 2026-07-27 — **16 stories** (5.4b INSERTED 2026-07-29 at 5.4's contexting: the verdict algebra as a TOTAL function, plus the `xtask` gate that refuses a float under `identity/`); **5.1, 5.2 and 5.2b done** (PR #41, #44, #46, all merged 2026-07-28). Epic 5's **three inherited-debt stories are closed**; the engine proper starts at **5.3, `done`** (PR #48, merged 2026-07-29) — it ships the identity engine's own abstention vocabulary and NO engine. **5.4 is `review`** — the engine's return type (`Verdict`, `RuleVerdict`, `RulesetVersion`, `Conclusion`, `Decision`) and still NO algebra. Next is 5.4b | | **E6–E23** | backlog | Live status is `_bmad-output/implementation-artifacts/sprint-status.yaml`, not this file. @@ -60,14 +60,16 @@ across nine families** (randomized-mac, multi-nic, shared-hardware-vm, cloned-ma vrrp-virtual-mac, hostname-collision, docker-veth, hostname-absence — each in positive AND negative form, each naming the RULE rather than the outcome), the scoring algebra, the metrics harness, the trap runner, the reality-debt register, and the wire-format spec. Test counts on -master at Epic 4's close: **119 (bin) + 86 (core) + 42 (xtask)**; **267** on master today -(135 + 90 + 42) — story 5.1 added the two +master at Epic 4's close: **119 (bin) + 86 (core) + 42 (xtask)**; **271** on the story-5.4 branch +(135 + 94 + 42) — story 5.1 added the two corpus-wide walks, story 5.2 the trap-text scan, the `raw` scan and the scanner's six closed evasions, and story 5.2b five byte-pin tests over six of the eight streams that had none (the four unpinned families plus `example-traps.jsonl`, `dhcp-churn`'s existing pin extended) together with the trap→`obs_id` binding pin; story **5.3** the four tests of the identity engine's own abstention vocabulary (`IdentityAbstentionCause`, two variants, in the new `opencmdb-core/src/identity/`) — a -TYPE and no engine. **5.2b's trap→`obs_id` binding pin** closed a hole measured TWICE: exchanging two `observations` +TYPE and no engine; story **5.4** four more, for the engine's RETURN type — `Verdict` (D13's five), +`RuleVerdict { rule, verdict, evidence }`, `RulesetVersion`, `Conclusion` and `Decision` — still with +no algebra, no rule and no producer, the combining function being story 5.4b's. **5.2b's trap→`obs_id` binding pin** closed a hole measured TWICE: exchanging two `observations` vectors in `cloned-mac.toml`, and later in `hostname-collision.toml`, each made the corpus DEMAND a false merge while the whole suite stayed green. **All 24 committed traps across all ten trap files now have their `observations` vector, `Expectation` and `family` pinned** — the scope grew from From 3d63544afaea81e1774ac6cf01e452924c85df22 Mon Sep 17 00:00:00 2001 From: Guy Corbaz Date: Wed, 29 Jul 2026 20:29:21 +0200 Subject: [PATCH 2/2] Story 5.4 code review: the story committed two of the lessons it quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three parallel layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor), none failed. The SCOPE held and no behavioural defect was found in the five types: no decide(), no table, no rule, no producer; VerdictVectorEntry still uninhabited, trap.rs:38 byte-unchanged, fixtures/ xtask/ opencmdb-bin/ and Cargo.lock untouched. All four mutation records re-derive against the tree — no under-reporting of a red set, which is what the last three reviews caught. The residue was in the prose the story wrote, and twice it is a lesson the story quotes about itself: - HIGH. "TWO of the four reds are COMPILER-carried ... M1 and M2 are not" is false. M1 (delete ruleset_version) produced SIX compile errors and ZERO executed assertions — five E0560 at construction sites, the exact class the same sentence assigns to M4 — and it contradicted the M2 bullet six lines above, which already called M2 the only one that reds an assertion. THREE of four are compiler-carried; only M2 reds an assertion. Two layers measured it independently. The weaker true sentence shipped. - HIGH. Every line citation into deferred-work.md written by this branch used PRE-commit numbering while the commit's own insertions shifted the file by +21. Three layers converged. That is inherited lesson #1 — "a check that its own commit falsifies is worse than no check" — committed by the commit that quotes it. Fixed by citing entries BY TITLE, which does not rot; post-review positions recorded once in the Completion Notes. - MEDIUM. The register preamble said "fifteen items: thirteen name a story". Counted bullet by bullet: twelve named a story, one named Epic 6, two named a condition — an epic called a story in the sentence forbidding exactly that. - MEDIUM. The #[non_exhaustive] rationale claimed the attribute would destroy the E0004 protecting story 5.4b's table. It would not: #[non_exhaustive] is inert within the defining crate and 5.4b's table lives in this one. The real loss is downstream exhaustiveness in opencmdb-bin. AC1 carries the same error, so the code faithfully copied a false spec. Also: "folded into the existing entry" was never folded (now performed); the test-placement entry Task 6 declared closed was byte-untouched (now closed, naming the convention and both cases it decides); AC5's four-name table lacked the owner column it binds and its heading was untrue of two of its four rows; the Verdict <-> TrapVerdict cross-reference was one-way; "five tests" where four shipped; "evidence in four places" where the citations give five. One patch is more than a doc edit: the vector test's message claimed "the three lists are distinct" while asserting nothing of the kind. The fix ADDS the pairwise-distinctness assertion rather than deleting the clause, because distinctness is what stops that test passing on three identical evidence lists — the vacuous shape Task 6 banned when it refused Uuid::nil() three times. Measured, not predicted: replacing RuleVerdict's derived PartialEq with a total eq() leaves the crate green, so the_verdict_vector_carries_the_whole_triple_in_ order pins a SHAPE and no behaviour. Its doc now says so. Two findings were handed to story 5.4b's contexting rather than answered here (Guy's call), and epics.md was NOT edited — writing 5.4b's criteria from inside 5.4 would be the act 5.4 refused when it declined to write decide(): - this commit introduces the workspace's FIRST f32/f64 token (1 on this branch, 0 on master) — a QUOTATION of D13's own refusal at cascade.rs:42, sitting in exactly the subtree 5.4b's line-grep gate is specified to scan; - three documents name 5.4b owner of the conclusion<->verdict_vector invariant while its six criteria cover neither it nor what decide() returns. Three findings were REFUTED by measurement and dropped: "five sha256-locked trap files" (measured: exactly five of ten name an l2-* rule), the missing #[must_use] (the workspace carries one in total), and M1's "elided" sites (Task 7 asked for the first error and the count, and got them). Register: 15 -> 18 items in the story-5.4 section, fifteen naming a story, one naming an epic, two naming a condition — counted mechanically after the last edit. One item deferred under a new code-review section (the six Decision literals with an empty verdict_vector that 5.4b will have to rewrite). Gate re-run WHOLE after the last edit: fmt clean; clippy --all-targets clean; clippy WITHOUT --all-targets clean (the CI form); 135 + 94 + 42 = 271 tests, zero failures; cargo xtask ci five gates green with views-hash STALE exit 0, NOT regenerated (issue #50); git status fixtures/ empty; cargo doc the same three pre-existing warnings, so no new intra-doc link is broken. The review's own patches falsified a count the story had measured, and it was re-measured rather than carried: cascade.rs is 690 lines, first #[cfg(test)] at :457 -> 456 CODE lines (was 660 / :445 / 444), ceiling 2000. Story stays at `review`. `done` is the MERGE's business in this project — the code-review workflow's default of setting it here is wrong and was not applied. Co-Authored-By: Claude Opus 5 (1M context) --- .../5-4-decision-and-ruleset-version.md | 386 ++++++++++++++++-- .../implementation-artifacts/deferred-work.md | 132 +++++- .../sprint-status.yaml | 72 ++-- crates/opencmdb-core/src/identity/cascade.rs | 58 ++- 4 files changed, 570 insertions(+), 78 deletions(-) diff --git a/_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md b/_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md index 75c92ba..2ce2439 100644 --- a/_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md +++ b/_bmad-output/implementation-artifacts/5-4-decision-and-ruleset-version.md @@ -583,6 +583,269 @@ specification. This story's docs may **name** the gap; they must not pre-impleme false; honouring it is on the author). Branch `story-5.4-decision-and-ruleset-version`. **The story ends at status `review` with the PR open and CI green.** The merge is a separate act. +### Review Findings (AI, 2026-07-29) + +_Three parallel layers, each in its own context — Blind Hunter (diff only, no spec, no repo), Edge +Case Hunter (diff + repo, no spec), Acceptance Auditor (diff + spec + repo). No layer failed._ + +**What the review did NOT find, stated first because it is the story's own stated failure mode:** +the SCOPE held. Two layers independently grepped for `fn decide` / `fn combine` / `fn conclude` / +`-> Decision` and found nothing; `VerdictVectorEntry` is untouched and still uninhabited, +`ScoredRecord` untouched, `trap.rs:38` byte-unchanged, `fixtures/`, `xtask/`, `opencmdb-bin/` and +`Cargo.lock` untouched. **All eight gates re-run independently and green** (271 = 135 + 94 + 42, +both clippy forms, `xtask ci`, `fmt`, `git status fixtures/` empty). Every one of the four mutation +records re-derives arithmetically against the tree — **no under-reporting of a red set was found**, +which is the defect class the last three reviews caught. + +⚠️ **The residue is almost entirely COUNTS AND CITATIONS in the prose this story wrote** — the same +class as 5.2b's and 5.3's, one level deeper. Two of them the story predicted about itself and then +committed anyway. + +**Three findings were REFUTED by measurement and dropped**, recorded so they are not re-raised: +*"five sha256-locked trap files is unevidenced"* (measured: `grep -rlP 'rule\s*=\s*"l2-' fixtures/` +returns exactly **five** files, of ten — the claim is true) · *"no `#[must_use]` on the two new +methods"* (the workspace carries one `must_use` in total, in `main.rs`; adding it here would be the +deviation) · *"M1's record names two of five `E0560` sites"* (Task 7's own instruction is *"quote the +first error and the site count"*, and it was followed). + +#### Decision needed — BOTH RESOLVED 2026-07-29 (Guy) + +**Both calls land the same way, and the shared reason is worth stating once: `epics.md` stays +UNTOUCHED.** AC8 says an edit there is a finding rather than a task, and both of these are questions +about a story that has not been contexted yet. Writing 5.4b's acceptance criteria from inside 5.4 +would be the same act 5.4 refused when it declined to write `decide()` — arbitrating, in a story +whose ACs never asked the question, something that was deliberately split out so it would be visible. +Both therefore become RECORDS, not edits: the measurement and the hand-off go in the Completion Notes +and in the register, where 5.4b's contexting will meet them. + +- [x] [Review][Decision] **RESOLVED — record only.** The `f64` token is measured and recorded; the + gate's design (whether it strips `///`/`//!` lines) is 5.4b's to decide at contexting, next to + the table it ships with. No code moves, no AC is written in advance. + ~~This commit introduces the workspace's FIRST `f64` token, in a doc + comment, in exactly the subtree story 5.4b's gate is specified to grep** — Measured: + `grep -rn "\bf32\b\|\bf64\b" crates xtask --include=*.rs` returns **one** line on this branch + and **zero** on `master`. It is `cascade.rs:42`, quoting D13's own *"REFUSED: + `rule -> confidence: f64`"*. AC3 is NOT violated — no type carries a float — but `epics.md`'s + new 5.4b AC says *"a gate reds on any `f32` or `f64` under + `crates/opencmdb-core/src/identity/`, in the idiom of the existing DDL-collation and + retired-vocabulary greps"*, and the DDL/retired-vocabulary greps are line greps. 5.4b's gate + would red on day one, on a quotation of the decision that forbids floats. The Completion Notes + re-measure four other numbers and not this one. Options: (a) record it in the Completion Notes + and as a note on 5.4b, leaving the gate's design to 5.4b's contexting; (b) also amend 5.4b's + AC now to say the gate strips `///`/`//!` lines; (c) reword `cascade.rs:42` to avoid the + token.~~ **(a) chosen.** + *(Raised by the Acceptance Auditor; verified independently.)* +- [x] [Review][Decision] **RESOLVED — record the hand-off, do not pre-write the AC.** The gap + between the owner and the spec is real and is written down in both the Completion Notes and the + register, so 5.4b's contexting finds the invariant laid in front of it — which is where the + D13 fork is arbitrated with Guy anyway. + ~~Story 5.4b is named owner of the conclusion↔`verdict_vector` coherence + invariant in THREE places, and the 5.4b acceptance criteria this same commit wrote never + mention it** — Owner assigned at `cascade.rs`'s `Decision` doc (*"# What is representable and + not refused"*), at `deferred-work.md:1126-1131`, and in `sprint-status.yaml`. `epics.md`'s new + `### Story 5.4b` block carries six criteria (total `decide`, the uncovered class, + order-independent rule choice, the float gate, the milli-units deferral, totality by + exhaustive classes) and **none** covers *"a `Conclusion`'s rule must appear in its own + `verdict_vector`"* or *"a `Match` may not carry an empty vector"*. Related, same block: the ACs + never state what `decide` RETURNS (a `Conclusion`? a `Decision`? a `Decision` minus its + version?) nor who supplies `ruleset_version`. Options: (a) add the `Given/When/Then` to 5.4b + now; (b) leave it to 5.4b's contexting and record the hand-off; (c) move the owner + elsewhere.~~ **(b) chosen.** + *(Raised by the Blind Hunter with no access to `epics.md`'s history.)* + +**Both resolutions add a patch each** (a Completion-Notes measurement and a register note); they are +the last two items of the patch list below. + +#### Patch — ALL 22 APPLIED 2026-07-29 + +_Gate re-run WHOLE after the last edit, not per-patch: `cargo fmt --all --check` clean · +`cargo clippy --workspace --locked --all-targets -- -D warnings` clean · **`cargo clippy --workspace +--locked -- -D warnings` clean** (the CI form) · `cargo test --workspace --locked` → **135 + 94 + 42 += 271, zero failures** (unchanged: the review added one assertion to an existing test, not a test) · +`cargo xtask ci` → five gates green, `ℹ views-hash STALE` exit 0, **not regenerated** · +`git status --porcelain fixtures/` **empty** · `cargo doc --workspace --no-deps` → **the same three +pre-existing warnings** (`ing`, `comparable_fields`, `ScoredRecord`), so none of the review's new +intra-doc links is broken._ + +⚠️ **The review's own patches falsified a count the story had measured, and it was re-measured rather +than carried:** `cascade.rs` is now **690 lines, first `#[cfg(test)]` at `:457` → 456 CODE lines** +(was 660 / `:445` / 444). Ceiling 2000; `file-size` reports 22 files under, largest 884. Applying +the fix for "a count in a doc is a claim" while breaking a count would have been the joke this +project keeps almost telling. + +**One patch went beyond a doc edit and is named so it is not mistaken for one:** the vector test's +message claimed *"the three lists are distinct"* while asserting nothing of the kind, and the fix +ADDS the pairwise-distinctness assertion rather than deleting the clause — because distinctness is +exactly what stops that test passing on three identical evidence lists, the vacuous shape Task 6 +banned when it refused `Uuid::nil()` three times. Everything else is prose, docs and register. + +- [x] [Review][Patch] **HIGH — "TWO of the four reds are COMPILER-carried … M1 and M2 are not" is + false; M1 is compiler-carried, so THREE of four are** [`sprint-status.yaml:610`, + `5-4-decision-and-ruleset-version.md:891-894`] — M1 (delete `ruleset_version`) produced **six + compile errors and zero executed assertions**: five `E0560` at construction sites — the exact + class the same sentence labels compiler-carried for M4 — plus one `E0609`. The record + contradicts itself six lines earlier, where M2 is already called *"the only one of the four + that reds an assertion rather than a name"*. True statement: *"three of the four reds are + compiler-carried (M1 and M4 construction-site errors, M3 a spelling error); only M2 reds an + assertion."* **Two layers measured this independently.** This is the over-claim class the story + itself inherited as lesson #5 — and it makes the story's *"M1 and M2 are the two that are + not"* the second consecutive completion record to overstate its own guards. +- [x] [Review][Patch] **HIGH — every line citation into `deferred-work.md` written by this commit + uses PRE-commit numbering, and this commit shifts them by +21** [`deferred-work.md:1178`, + `:1195`; `sprint-status.yaml` (`:1073-1076`, twice); `5-4-…md:254`, `:729`, `:894`, `:906`] — + the register's hunks add 4 + 12 + 5 lines above line 1000 (`@@ -1070,13 +1091,108 @@` + confirms the offset). Measured on the post-commit tree: `:1025-1044` → **`:1046-1065`** · + `:1067-1072` → **`:1088-1093`** · `:1045-1053` → **`:1066-1074`** · `:1073-1076` → + **`:1094-1101`**. As written, `:1025-1044` lands on the 5.3 section preamble and `:1067-1072` + on the *"two of 5.3's four tests"* entry. **Three layers converged.** This is inherited lesson + #1 — *"a check that its own commit falsifies"* — committed by the commit that quotes it. +- [x] [Review][Patch] **MEDIUM — "thirteen name a story as owner" is twelve; the thirteenth names an + EPIC** [`deferred-work.md:1113`, `:1156`; `sprint-status.yaml:628`; `5-4-…md:315`, `:917`] — + counted item by item: twelve `Owner: story N`, one **`Owner: Epic 6`** (the `RuleId` entry), + two conditions. The preamble's own next sentence is *"what it does not allow is calling one a + name"*. True statement: *"Fifteen items: twelve name a story, one names an epic (Epic 6), two + name the CONDITION that would produce an owner."* **Two layers counted independently.** +- [x] [Review][Patch] **MEDIUM — "plus five tests and five rewritten doc blocks"; four tests and six + doc locations shipped** [`sprint-status.yaml:514`] — falsified by the same file at `:592` + (*"Four tests pin"*) and by the doc-location count of six. This is the residue of the very + *"2/4/5 doc blocks and 4/5 tests"* counting defect the same block records the validation agents + as having caught: the correction reached the implementation record and not the summary above + it. **Two layers.** +- [x] [Review][Patch] **MEDIUM — "Folded into the existing entry at `:1025-1044` rather than + duplicated" — nothing was folded** [`deferred-work.md:1178`] — the register's four hunks are at + 243, 310, 339 and 1091; the target entry (post-commit `:1046-1065`) is byte-untouched, and the + `Verdict::all()` item is a standalone bullet counted among the fifteen, i.e. duplicated. Fix: + append the M3 re-measurement to `:1046-1065` and reword this bullet as a cross-reference, or + say the fold was traded for a cross-reference and why. **Two layers.** +- [x] [Review][Patch] **MEDIUM — the `#[non_exhaustive]` rationale states a consequence that does not + hold** [`cascade.rs:60-64`, and AC1's own binding text at `5-4-…md:81-85`] — the comment says + it *"would force a `_` arm downstream and destroy the `error[E0004]` that makes a sixth variant + break **story 5.4b's table**"*. `#[non_exhaustive]` only forces a wildcard arm in **downstream + crates**; 5.4b's table lives in `identity/` — the same crate as `Verdict` — so its match would + stay exhaustive and its `E0004` would survive. The true, narrower loss is `E0004` for matches + in `opencmdb-bin`. *"Prefer the weaker true sentence"* applies to the second half of that + comment, and to AC1's *"`Verdict` is the type where that matters most"*. +- [x] [Review][Patch] **MEDIUM — the four-name table omits the owner column AC5 binds, and its + heading is false of two of its four rows** [`cascade.rs:15-22`] — AC5: *"a table naming all + four, one line each, saying whose judgement it is **and which story owns it**"*. Shipped + columns: `type | whose judgement | of what | variants`. The story's own Dev Notes table + (`5-4-…md:652-657`) HAS the owner column, so the shipped table is a narrowing. Separately, the + heading *"Four types say \"verdict\""* is untrue of `Conclusion` and `Outcome`, while + `RuleVerdict` and `crate::score::VerdictVectorEntry` — which do carry the word, and are both + named elsewhere in this same file — are absent from the table. Fix: add the owner column + (`this story` / `this story` / `4.6a` / `4.7a`) and retitle to what the table is. +- [x] [Review][Patch] **MEDIUM — the `Verdict` ↔ `TrapVerdict` cross-reference is one-way** + [`cascade.rs:33-65`] — AC5 requires the `TrapVerdict` doc to point at `Verdict` by full path + *"and vice versa"*; Task 5's last bullet says *"Reciprocal in `Verdict`'s own doc"*. The + `score.rs` side WAS delivered (`score.rs:203-205`). `grep -n "TrapVerdict" + crates/opencmdb-core/src/identity/cascade.rs` returns **one line, `:22`** — the module-doc + table. `Verdict`'s own item doc never names it. Fix: one sentence in `Verdict`'s doc pointing + at `` [`crate::score::TrapVerdict`] `` by full path. +- [x] [Review][Patch] **MEDIUM — Task 6 says it closes the test-placement register entry; the entry + is byte-untouched and still names an open owner** [`deferred-work.md:1075-1080`] — Task 6: + *"This closes `deferred-work.md:1054-1059`."* The entry (now `:1075-1080`) still reads *"Owner: + whoever next adds a test to `cascade.rs` — decide the convention once, in one place"*, and + AC7's enumeration of register edits does not list it. **The convention WAS written** + (`cascade.rs:426-444`) and it does resolve both named cases, so AC6's substance is met — what + is missing is the closure, and the AC6/AC7 conflict itself is unrecorded. Fix: append `✅ + **CLOSED by story 5.4**` pointing at the test-module doc, or state in the Completion Notes why + AC7 deliberately excluded it. +- [x] [Review][Patch] **MEDIUM — "the architecture requires evidence in four places" vs "the five + lines that mention the identity link's evidence", both written by this commit** + [`deferred-work.md:317` vs `:1115-1118`] — the enumerated citations are five (`:978`, `:1015`, + `:1032`, `:1309`, `:3378`), and `sprint-status.yaml` also says five. `four` is the outlier. +- [x] [Review][Patch] **MEDIUM — *"`:319-327` was re-read after the insertion"* cites the + PRE-insertion range** [`sprint-status.yaml:625`, `5-4-…md:912`] — post-commit, 5.3's annotation + with its *"the entry BELOW"* / *"the firing-rule/evidence entry above it"* sits at + **`:335-343`**; `:319-327` now contains neither phrase. **The ordering claim itself is + correct** on the post-commit tree (5.4 annotation → firing-rule entry → 5.3 annotation → + `NoMatch` entry, `:311-353`) — only the coordinates are stale, which is the same +21 shift as + the HIGH above. +- [x] [Review][Patch] **MEDIUM — a `verdict_vector` carrying the SAME `RuleId` twice, and an + `Abstained { Ambiguous }` with an EMPTY vector, are representable, unenforced and registered + nowhere** [`cascade.rs:287-295`] — D13 is *"all rules are evaluated … **each** yields an + enumerated verdict"* [architecture.md:960]; a vector with `l1-exact-mac → Supports` and + `l1-exact-mac → Opposes` compiles and fabricates D13's *"`Supports` AND `Opposes`"* row from + one rule. The register owns the NEIGHBOURING states (`:1126-1131` conclusion-names-an-absent-rule + and `Match`-with-empty-vector, owner 5.4b; `:1121-1125` empty evidence, owner 5.5) and is + silent on these two. Fix: one register item, owner **5.4b** by the same argument as its + sibling — the vector and the conclusion are first built together by `decide`. +- [x] [Review][Patch] **MEDIUM — `the_verdict_vector_carries_the_whole_triple_in_order`'s doc asserts + a property its assertions cannot discriminate** [`cascade.rs:610-613`] — the doc says the + triple *"survives the trip in full … order preserved"*. There is no trip: the value is + `clone()`d into the literal and compared with itself. **MEASURED** — replacing `RuleVerdict`'s + derived `PartialEq` with `fn eq(&self, _: &Self) -> bool { true }` leaves `94 passed; 0 + failed`. `sprint-status.yaml:612-614` concedes the gap honestly, so this is not an unrecorded + over-claim — but the test's OWN doc does not, and the doc is what a reader meets. Fix: the + weaker true sentence — *"pins the SHAPE against a field removal or a type change, not a + behaviour; nothing transports the triple yet."* (For contrast, the same layer verified the + other three tests are genuine: shortening `all()`'s literal with the length preserved reds + `the_five_verdicts_of_d13_are_present`, and M2 reds the other two.) **Two layers.** +- [x] [Review][Patch] **LOW — an assertion message names a property the assertion does not check** + [`cascade.rs`, the vector test] — `assert_eq!(decision.verdict_vector[2].evidence, …, "each + entry keeps its own evidence, **and the three lists are distinct**")` inspects entry `[2]` + only. Drop the second clause or add the comparison it names. +- [x] [Review][Patch] **LOW — M1's recorded error sites are MUTATED-tree coordinates presented as + file locations** [`5-4-…md:871`, `sprint-status.yaml:600`] — the record says `cascade.rs:528`, + `:541`, `E0609` at `:653:22`; on the committed tree `:528` is a closing brace and `:653` is + `};`. The real construction sites are `:530`, `:543`, `:555`, `:600`, `:637` and the read is + `:655`. The numbers are internally consistent (a two-line field+doc deletion), so this is a + labelling fix, not a wrong measurement: say *"on the mutated tree"*. +- [x] [Review][Patch] **LOW — `~~strikethrough~~` now carries two incompatible meanings in the + register, and the convention is documented in the wrong file** [`deferred-work.md`, hunks at + 243/339 vs 1091] — at `:1094` it marks a CLOSED entry; at `:244` and `:339` it strikes only the + owner clause **inside entries that declare themselves unstruck two lines later**. A reader of + the register alone sees struck text in an entry saying *"Still not struck."* The convention is + stated only in `sprint-status.yaml`. Fix: one line in the register's own preamble. *(Minor, + same hunks: the strike at `:339` swallows the sentence's terminating period.)* +- [x] [Review][Patch] **LOW — a stale `NEXT` survives in `sprint-status.yaml`, contradicted by the + same file's `last_updated`** [`sprint-status.yaml`, the comment above `5-3-…: done`] — it still + reads *"NEXT = `create-story` 5.4 … then its MANDATORY validation pass"*, forty lines above a + block recording 5.4 as contexted, validated, implemented and in `review`. +- [x] [Review][Patch] **LOW — AC7's "one appended line each" landed on the 5.3 ANNOTATION bullets, + not on the two `NoMatch` entries** [`deferred-work.md:244-252`, `:335-348`] — AC7 and Task 8 + name them as two separate acts (append the fork line to each entry; strike the owner string in + the annotation). Both landed in the annotations. The required content is present and adjacent + and neither entry is struck, so **no over-claim results** — fix by appending the fork line to + `:253-260` and `:349-353`, or by recording the deliberate consolidation. +- [x] [Review][Patch] **LOW — the AC7 closure attributes to AC8 a clause AC8 does not contain** + [`deferred-work.md:1094-1101`] — it reads *"Its AC8 requires \"branch → PR → green CI…\" and + **says in the same clause** that the merge is a separate act"*. The quoted text is SILENT about + the merge; being silent is what resolves the contradiction. Also: the struck entry demanded + *"whoever writes 5.4's **AC7**"* and the closure answers with **AC8**, unremarked. Fix: *"Its + AC8 stops at the open PR and says nothing about the merge, so the two halves no longer + contradict."* +- [x] [Review][Patch] **LOW — "exactly as the validation agent measured" — the agent measured two + `E0599` sites, this story measured three** [`sprint-status.yaml`, the M3 record] — three is + CORRECT (the third site is the test this story added), and the error KIND is what the agent + established. Fix: *"the same error kind the validation agent measured; three sites rather than + two, because this story adds the third reference."* + +- [x] [Review][Patch] **From D1 — record the `f64` measurement** [Completion Notes; + `deferred-work.md`] — add to the Completion Notes' re-measured counts: *"⚠️ This commit + introduces the workspace's FIRST `f64` token — `cascade.rs:42`, a CITATION of D13's own + refusal. Measured: `grep -rn "\bf32\b\|\bf64\b" crates xtask --include=*.rs` → **1** on this + branch, **0** on `master`. No TYPE carries a float, so AC3 holds; story 5.4b's gate will have + to decide whether it strips `///`/`//!` lines, and this line is its committed test case."* + Plus a one-line note against 5.4b in the register. **`epics.md` is not edited.** +- [x] [Review][Patch] **From D2 — record the 5.4b owner/spec hand-off** [Completion Notes; + `deferred-work.md`] — state that three documents name 5.4b owner of the + conclusion↔`verdict_vector` invariant while `epics.md`'s six 5.4b criteria cover neither it nor + what `decide` RETURNS, and that this is handed to 5.4b's contexting deliberately rather than + pre-written here. **`epics.md` is not edited.** + +#### Deferred + +- [x] [Review][Defer] **The four new tests normalise the exact state story 5.4b is chartered to + refuse** [`cascade.rs`, `a_decision_names_a_rule_and_an_abstention_does_not` and + `the_conclusion_mirrors_the_outcomes_rule_shape`] — six `Decision` literals build + `Conclusion::Match { rule }` / `NoMatch { rule }` with `verdict_vector: Vec::new()`, which is + precisely the *"merged, with no explanation"* shape `Decision`'s own doc and + `deferred-work.md:1126-1131` call the thing D13 exists to prevent. **Nothing is wrong today** — + there is no constructor and no enforcement — but every one of those literals must be rewritten + the day 5.4b enforces the invariant it is named owner of. Deferred, owner **5.4b**, so it is + not discovered as a surprise. *(Raised by the Blind Hunter.)* + ## Dev Notes ### What was measured, before the story was written @@ -852,7 +1115,10 @@ No byte moved under `fixtures/` (`git status fixtures/` and `MANIFEST.toml` both - **267 → 271 tests: 135 bin + 94 core + 42 xtask**, zero failures. Only `core` moved, by the four new tests, as predicted. -- `cascade.rs`: **660 lines total, first `#[cfg(test)]` at `:445` → 444 CODE lines**, ceiling 2000. +- `cascade.rs`: **690 lines total, first `#[cfg(test)]` at `:457` → 456 CODE lines**, ceiling 2000. + *(660 / `:445` / 444 at the end of dev-story; the code review's doc patches added 12 code lines. + Re-measured after the last review edit rather than carried — this story's own inherited lesson #3 + applied to the review that enforces it.)* The `file-size` gate reports 22 files under the ceiling, largest 884. - `cargo doc --workspace --no-deps` → **3 warnings, the same three that were pre-existing** (`ing`, `comparable_fields`, `ScoredRecord`). None of the many new full-path intra-doc links is broken. @@ -888,12 +1154,31 @@ five gates green, `ℹ views-hash STALE` exit 0 (expected, GitHub issue #50, **n `warning: unused import: crate::observation::ObsId` at `:30:5` — under the CI form of clippy that warning is a hard error, so the `evidence` field is what keeps that import live in the lib build. -⚠️ **Two of the four reds are carried by the COMPILER, not by an assertion, and the record says so** -rather than claiming the residue closed. M3's red is a spelling error and M4's a construction-site -error — both would fire identically on a test body of `assert_eq!(1, 1)`. M1 and M2 are the two that -are not. `deferred-work.md:1045-1053`'s owner **stays 5.5**; this story does not close it, and -`the_verdict_vector_carries_the_whole_triple_in_order` has **no possible behavioural mutation** — -a `pub` field has no code to break. +⚠️ **THREE of the four reds are carried by the COMPILER, not by an assertion — corrected at code +review, and the correction makes this record weaker and true.** M1's and M4's reds are +construction-site errors and M3's is a spelling error; **all three would fire identically on a test +body of `assert_eq!(1, 1)`. Only M2 reds an assertion.** *(This paragraph said "two … M1 and M2 are +the two that are not", which contradicted the M2 bullet above — the bullet already called M2 "the +only one of the four that reds an assertion rather than a name". Two review layers measured M1 +independently: six compile errors, **zero tests executed, zero assertions evaluated**, which is the +exact class this same sentence assigned to M4. Deleting a field breaks its construction sites +whatever the test body says; that is what "compiler-carried" means, and M1 is not exempt because the +field it deletes is the one AC3 cares about.)* The *"two of story 5.3's four new tests are carried by +the type system"* entry keeps owner **5.5**; this story does not close it, and +`the_verdict_vector_carries_the_whole_triple_in_order` has **no possible behavioural mutation** — a +`pub` field has no code to break. **Measured at review**: replacing `RuleVerdict`'s derived +`PartialEq` with a total `fn eq(&self, _: &Self) -> bool { true }` leaves the crate green, so that +test pins a SHAPE and no behaviour. Its doc comment now says exactly that. + +⚠️ **This commit introduces the workspace's FIRST `f32`/`f64` token.** Measured at review: +`grep -rn "\bf32\b\|\bf64\b" crates xtask --include=*.rs` → **1** on this branch, **0** on `master`. +The hit is `identity/cascade.rs:42`, a **quotation** of D13's own refusal (*"REFUSED: `rule -> +confidence: f64`"*). **No TYPE carries a float, so AC3 holds** — but it sits in exactly the subtree +story 5.4b's gate is specified to grep, and that gate as written in `epics.md` is a line grep, so it +would red on day one on a citation of the decision it enforces. **Guy's call, 2026-07-29: record it, +do not pre-write 5.4b's AC** — whether the gate strips `///`/`//!` lines is a design question for the +story that writes the gate. Registered; `epics.md` was NOT edited. This is the fifth number +re-measured on the final tree and the only one the story's own Completion Notes had missed. **Six doc locations changed, five of them because this story falsified them** — `cascade.rs`'s module doc, `identity/mod.rs:8-9`, `score.rs:46-47` (*"Not named `Decision`"* — the name is now taken), @@ -903,25 +1188,78 @@ doc, `identity/mod.rs:8-9`, `score.rs:46-47` (*"Not named `Decision`"* — the n false and gained the reciprocal cross-reference. **Every one was re-read against the final tree after the last edit.** -**The register.** `deferred-work.md:1073-1076` (*"Owner: whoever writes 5.4's AC7 — require the PR and -stop"*) is **CLOSED by append-and-strike**, this story being the owner it names. Three entries are -annotated `↺` and **none is struck**: the two `NoMatch` bullets, whose owner moves to **5.4b/5.5** -(the owner string lives in 5.3's ANNOTATION bullets, so it was struck in place there and the update -appended — nothing was rewritten), and the firing-rule/evidence contract, whose owner moves to -**5.5**. ⚠️ The new annotation was placed **above** its target, the idiom of its two nearest -neighbours, and **`:319-327` was re-read afterwards**: 5.3's own *"the entry BELOW"* and *"the -firing-rule/evidence entry above it"* both still resolve — the section reads 5.4's annotation → -firing-rule entry → 5.3's annotation → `NoMatch` entry. No amendment was needed. - -A new `## Deferred from: story-5.4` section is appended at the END (the file is chronological) with -**fifteen items — thirteen naming a story, two naming the condition that would produce an owner** -(`whoever writes D20's ADR`, `whoever revisits the crate's re-export policy`). The section's own -preamble states that split rather than claiming fifteen named owners. +⚠️ **The cross-reference was one-way until code review, and the four-name table was a narrowing of +the one AC5 binds.** `score.rs`'s side shipped (*"⚠️ Not `crate::identity::cascade::Verdict`"*); the +*"and vice versa"* half did not — `Verdict`'s own item doc never named `TrapVerdict`, only the module +table did. Both are now delivered. AC5 also binds the table to say *"which story owns it"* and the +shipped table had four columns without an owner, while the story's own Dev Notes table has five; +the owner column is now there. The table's heading claimed *"four types SAY verdict"*, which is +untrue of `Conclusion` and `Outcome` while `RuleVerdict` and `score::VerdictVectorEntry` — both named +elsewhere in the same file — do carry the word and are not rows; the heading now names what the table +actually is, and a note says why those two are excluded. + +⚠️ **One doc comment was FALSE and is corrected:** the `#[non_exhaustive]` rationale said the +attribute *"would destroy the `error[E0004]` that makes a sixth variant break story 5.4b's table"*. +It would not. `#[non_exhaustive]` is **inert within the defining crate**, and 5.4b's table lives in +this crate; the real loss is downstream exhaustiveness in `opencmdb-bin`, and that is the whole of +it. AC1's own binding text carries the same error (*"`Verdict` is the type where that matters +most — 5.4b's table matches on it"*), so the spec was wrong and the code faithfully copied it. The +weaker true sentence shipped. + +**The test-placement register entry is now actually CLOSED.** Task 6 asserted it (*"This closes +`deferred-work.md:1054-1059`"*) and the entry was byte-untouched, still reading *"Owner: whoever next +adds a test to `cascade.rs`"* — an assertion of closure with no closure behind it, which is the +inverse of the failure that entry's own AC warns about. The convention **was** written +(`cascade.rs`'s test-module doc) and does resolve both named cases, so AC6's substance was met; what +was missing was the register act, and AC7's enumeration had not listed it. Closed at review, naming +the convention and both cases it decides. + +**The register.** The **AC7-drafting** entry (*"Owner: whoever writes 5.4's AC7 — require the PR and +stop"*) is **CLOSED by append-and-strike**, this story being the owner it names. ⚠️ The wording that +answers it is this story's **AC8**, not its AC7, and the closure now says so rather than substituting +one for the other silently; that AC8 **says nothing at all about the merge**, and the silence is what +resolves the contradiction. Three entries are annotated `↺` and **none is struck**: the two `NoMatch` +bullets, whose owner moves to **5.4b/5.5** (the owner string lives in 5.3's ANNOTATION bullets, so it +was struck in place there and the update appended — nothing was rewritten), and the +firing-rule/evidence contract, whose owner moves to **5.5**. ⚠️ The new annotation was placed +**above** its target, the idiom of its two nearest neighbours, and 5.3's own annotation was re-read +afterwards: its *"the entry BELOW"* and *"the firing-rule/evidence entry above it"* both still +resolve — the section reads 5.4's annotation → firing-rule entry → 5.3's annotation → `NoMatch` +entry. No amendment was needed. **At code review, the fork line AC7 asks for was also appended to the +two `NoMatch` entries themselves**, which had received the annotation but not the line. + +⚠️ **CITATIONS — the defect this story warned itself about and committed anyway.** Every line +citation into `deferred-work.md` written by this commit used **pre-commit** numbering, while the +commit's own insertions shifted the file by **+21**. Three review layers converged on it, and it is +inherited lesson #1 (*"a check that its own commit falsifies is worse than no check"*) committed by +the commit that quotes it. Fixed by citing entries **by title**, which does not rot. Post-review +positions, measured after the last edit: AC7-drafting closure **`:1117-1126`** · *"two of 5.3's four +tests"* **`:1080-1088`** · `IdentityAbstentionCause::all()` residue **`:1054-1079`** · +test-placement convention (now CLOSED) **`:1089-1103`** · flat re-export **`:1111-1116`**. The +pre-commit numbers still standing in the ACs and Tasks above are historical and correct **for the +tree the story was written against**; they are not updated, because a spec is a record of what was +specified. + +A new `## Deferred from: story-5.4` section is appended at the END (the file is chronological). It +now carries **eighteen items — fifteen naming a story, ONE naming an epic (`Epic 6`), two naming the +condition that would produce an owner** (`whoever writes D20's ADR`, `whoever revisits the crate's +re-export policy`). *(It shipped as "fifteen items, thirteen naming a story"; the code review counted +them bullet by bullet and found the thirteenth was `Epic 6` — an epic called a story in the very +sentence forbidding that. The review then added three items: a `verdict_vector` carrying the same +`RuleId` twice, the first `f64` token, and 5.4b's owner/spec gap. Both new numbers were counted +mechanically after the last edit.)* **Two `Owner: Epic 5` entries were deliberately NOT touched**, and saying so turns silence into a -scope statement: `:285-290` (lattice monotonicity — needs an engine producing verdicts across -capability subsets, story 5.13) and `:336-344` (`RuleId` whitespace/case normalization in `run_trap` -— it bites when a PRODUCER emits a `RuleId`, story 5.5). +scope statement: lattice monotonicity (needs an engine producing verdicts across capability subsets, +story 5.13) and `RuleId` whitespace/case normalization in `run_trap` (it bites when a PRODUCER emits +a `RuleId`, story 5.5). + +⚠️ **Handed to story 5.4b's contexting, deliberately unanswered here.** Three documents name 5.4b +owner of the conclusion↔`verdict_vector` coherence invariant (`cascade.rs`'s `Decision` doc, the +register, `sprint-status.yaml`) while `epics.md`'s six 5.4b criteria cover neither it nor **what +`decide` returns** nor who supplies `ruleset_version`. **Guy's call, 2026-07-29: record the hand-off, +do not pre-write the AC** — writing 5.4b's criteria from inside 5.4 would be the same act this story +refused when it declined to write `decide()`. `epics.md` was NOT edited. **Scope held.** No `decide()`, no table, no `xtask` gate (5.4b) · no rule, no join, no producer (5.5) · no `From for Outcome`, `VerdictVectorEntry` untouched and still uninhabited (5.7) · no diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index d443f48..2f02fc0 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -258,6 +258,10 @@ not, and one guarantee changed shape. Stated against the existing bullets withou abstains because there is NOT ENOUGH SIGNAL is being honest… We do not gate that"*). The story-4.6a entry above records the `Ambiguous`-has-no-cause half; this is the other half. **Owner: Epic 5**, with 4.7 as the first place it can bite. + ↺ **Story 5.4 built the FORK at the type level and nothing more:** `Conclusion::NoMatch { rule }` + and `Conclusion::Abstained { cause }` are now two distinct variants, so the two halves have + somewhere to land. **The MAPPING still has no producer** — nothing decides which side an input + falls on. Owner of that decision: **story 5.4b**. Not struck. ## Deferred from: story-4.6b (2026-07-22) @@ -313,8 +317,9 @@ not, and one guarantee changed shape. Stated against the existing bullets withou - ↺ **PARTLY closed by story 5.4 — this annotation belongs to the entry BELOW** (*"The firing-rule contract (AC6) is RECORDED, not built"*), **and is deliberately NOT struck.** The TYPE that carries the `(rule, verdict, evidence)` triple now exists: `identity::cascade::RuleVerdict`, with - `evidence: Vec` — the smallest shape that is not invented, since the architecture requires - evidence in four places and shapes it in none. **Nothing produces one**: no rule speaks, so no + `evidence: Vec` — the smallest shape that is not invented, since the architecture mentions + the identity link's evidence on **five** lines and shapes it on none of them (`architecture.md:978`, + `:1015`, `:1032`, `:1309`, `:3378`). **Nothing produces one**: no rule speaks, so no verdict vector is ever built, and *"a test must red if it does not"* still has nothing to red. `score::VerdictVectorEntry` therefore stays uninhabited and `ScoredRecord::verdict_vector` stays provably empty — story 5.7 owns that unification. ⚠️ Nor does anything enforce that a verdict which @@ -351,6 +356,9 @@ not, and one guarantee changed shape. Stated against the existing bullets withou rule matched" is a `Refused` (a decision, names an opposing rule) or an `Abstained` (no decision, names a cause) is an engine-design question the identity cascade owns. Recorded so 4.7a's silence on it reads as scope, not oversight. + ↺ **Story 5.4 built the FORK at the type level and nothing more:** the cascade can now say + `NoMatch { rule }` or `Abstained { cause }` and the two are different types of answer. **Which one + a given input gets is still undecided and unproduced** — owner **story 5.4b**. Not struck. ## Deferred from: code review of story-4.7a (2026-07-23) @@ -1063,6 +1071,12 @@ PATCHED in the story, not deferred; what follows is the residue._ comment naming the wrong repair, so the developer who arrives on the `error[E0004]` reads it. **Owner: story 5.14** — the first story with a real reason to add or split a variant, and the first place a mechanism would earn its keep. + ↺ **RE-MEASURED on a second enum by story 5.4, same idiom, same limit.** `Verdict::all()` copies + this mechanism at five variants; deleting `Verdict::Disqualifying` gives `error[E0599]` at three + sites (the `all()` literal, the witness match arm, and the one test that spells the variant) and + **never `error[E0308]`** — the two errors are alternatives along one repair path, never + simultaneous, exactly as recorded here for two variants. The residue is therefore inherited, not + duplicated: **this entry owns it for both enums.** Owner unchanged (5.14). - **Two of story 5.3's four new tests are carried by the type system, not by their assertions.** `the_vocabulary_is_exactly_ambiguous_and_absence_of_proof` red under M1 only because it SPELLS `Ambiguous` (an `E0599`), and `an_abstention_names_no_rule_whatever_its_cause` red under M5 only @@ -1072,12 +1086,21 @@ PATCHED in the story, not deferred; what follows is the residue._ a violation; it is recorded because "four tests pin its claims" is carried, for two of the four, by the compiler. Owner: story 5.5 — the first story with a producer, where an abstention becomes reachable from something other than a literal. -- **`an_abstention_names_no_rule_whatever_its_cause` inverts the test-placement convention its own +- ✅ **CLOSED by story 5.4**, which is the owner this bullet names — it is the story that next added + a test to `cascade.rs`, and its AC6 required the convention be *"decided and written down once, in + one place"*. It is now the test module's own doc comment in `cascade.rs`: **a test lives with the + item whose CLAIM it pins; the items it merely READS are dependencies, imported and not owned.** + The convention RESOLVES both cases rather than merely stating a rule: 5.4's mirror test pins a + claim about `Decision`'s shape (`Outcome` is the dependency it reads) and belongs in `cascade.rs`; + `an_abstention_names_no_rule_whatever_its_cause` pins a claim about the abstention VOCABULARY's + relationship to a rule (`Outcome::rule()` is the mechanism it reads to express it) and therefore + **stays where it is by the convention rather than by fiat**. + ~~**`an_abstention_names_no_rule_whatever_its_cause` inverts the test-placement convention its own story invoked.** Task 5 established that *"a test module tests the items of its own file, importing other modules only as dependencies"* and used it to keep the truth-table tests in `score.rs`; this test lives in `cascade.rs` and asserts a property of `crate::score::Outcome::rule()`. The SPEC bound the placement, so the implementation is conformant and moving it now would deviate from an approved - AC. Owner: whoever next adds a test to `cascade.rs` — decide the convention once, in one place. + AC. Owner: whoever next adds a test to `cascade.rs` — decide the convention once, in one place.~~ - **A trap file may declare `must-abstain = { cause = "OutOfPerimeter" }` and nothing refuses it.** `TrapFile::validate` has no arm for the cause's SEMANTICS, so a sha256-locked corpus artefact can commit an abstention cause the identity cascade has no row for. Harmless today — scoring is @@ -1091,10 +1114,12 @@ PATCHED in the story, not deferred; what follows is the residue._ same argument, not applied. PRE-EXISTING idiom: `lib.rs` re-exports every module's public surface flat, and deviating for one type would be the inconsistency. Recorded so the asymmetry reads as inherited rather than chosen. Owner: whoever revisits the crate's re-export policy. -- ✅ **CLOSED by story 5.4**, which is the owner this bullet names. Its AC8 requires *"branch → PR → - green CI. The story ends at status `review` and the PR open"* and says in the same clause that the - merge is a separate act — so the two halves no longer contradict. The wording is available to - 5.4b–5.14 to inherit. +- ✅ **CLOSED by story 5.4.** ⚠️ The bullet asks for *"whoever writes 5.4's **AC7**"*; the wording + that answers it is 5.4's **AC8**, because 5.4's AC7 turned out to be the register itself. Named + rather than silently substituted. That AC8 requires *"branch → PR → green CI. The story ends at + status `review` and the PR open"* and **says nothing at all about the merge** — being silent is + what resolves the contradiction, not a clause asserting the merge is separate. The wording is + available to 5.4b–5.14 to inherit. ~~**AC7's drafting requires a squash merge inside a workflow that must end at `review`.** It reads *"branch → PR → green CI → squash merge, ending at status `review`, never `done`"* — the two halves cannot both hold, because the merge is what makes a story `done` in this project. Every Epic 5 @@ -1110,8 +1135,19 @@ PATCHED in the story, not deferred; what follows is the residue._ _The story wrote five TYPES and their tests, and no algebra. Everything below is open because it needs a producer, a consumer, or a decision no code yet forces — not because it was skipped. -**Fifteen items: thirteen name a story as owner, two name the CONDITION that would produce one.** The -register's idiom allows a condition in place of a name; what it does not allow is calling one a name._ +**Eighteen items: fifteen name a story as owner, ONE names an EPIC (`Epic 6`), two name the +CONDITION that would produce an owner.** The register's idiom allows a condition in place of a name; +what it does not allow is calling one a name — nor calling an epic a story. ⚠️ _(This preamble read +**"fifteen items: thirteen name a story, two name the condition"** until story 5.4's code review +counted them bullet by bullet: twelve named a story, one named `Epic 6`, two named a condition. The +thirteenth "story" was an epic, mis-labelled in the very sentence that forbids exactly that. The +review then added three items — hence eighteen. **Both numbers here were re-counted mechanically +after the last edit, not carried from the sentence they replace.**)_ + +⚠️ **Two marks, two meanings, stated here because this file now uses both.** A `~~struck~~` bullet +wrapped by `✅ **CLOSED by story X**` is a CLOSED entry. A `~~struck~~` clause **inside** a live +bullet retires only that clause — typically an owner string that moved — and such a bullet often +says *"Not struck"* two lines later, meaning the ENTRY is not struck. Both appear above._ - **`RuleVerdict::evidence` is `Vec`, the smallest shape that is not invented.** The architecture requires a firing rule to leave evidence — *"a rule that fires without leaving its @@ -1172,10 +1208,12 @@ register's idiom allows a condition in place of a name; what it does not allow i it. **Owner: story 5.7**, when the harness first records a run a real engine produced. - **`Verdict::all()` inherits the measured lazy-repair residue of `IdentityAbstentionCause::all()`.** Same idiom, same limit: the witness stops the build on a new variant (`error[E0004]`) but does not - force it into the list, and the two errors are alternatives along one repair path, never - simultaneous — **re-measured on this enum by story 5.4's M3: deleting `Disqualifying` gives - `error[E0599]` at three sites and NEVER `error[E0308]`.** Folded into the existing entry at - `:1025-1044` rather than duplicated. **Owner: story 5.14.** + force it into the list. **This is a CROSS-REFERENCE, not a second entry**: the measurement itself + was appended to the `IdentityAbstentionCause::all()` entry in `## Deferred from: code review of + story-5.3`, which now owns the residue for both enums. **Owner: story 5.14**, there. + ⚠️ *(This bullet said "Folded into the existing entry at `:1025-1044` rather than duplicated" while + no such fold had been made — caught by two review layers. The fold has since been performed and + this sentence now describes it.)* - **D13's six-row table does not cover every input, and story 5.4 only NAMES the gap.** Enumerated over the PRESENCE of each verdict, the table leaves exactly one class unanswered: at least one `Opposes`, with no `Decisive`, no `Supports` and no `Disqualifying`. It is not *"only `Neutral` / @@ -1191,8 +1229,70 @@ register's idiom allows a condition in place of a name; what it does not allow i [architecture.md:1378-1394]. An orderable `Verdict` would let magnitude compile today, which is the move that ADR gates. **Owner: whoever writes D20's ADR** — there is no story, and inventing one would be the reintroduction the ADR exists to refuse. -- **`lib.rs`'s flat re-export block grew by five names with no consumer**, aggravating the entry at - `:1067-1072` by a measured amount: `Conclusion`, `Decision`, `RuleVerdict`, `RulesetVersion` and +- **`lib.rs`'s flat re-export block grew by five names with no consumer**, aggravating the + *"`lib.rs` re-exports every module's public surface flat"* entry in `## Deferred from: code review + of story-5.3` by a measured amount: `Conclusion`, `Decision`, `RuleVerdict`, `RulesetVersion` and `Verdict` are all re-exported at the crate root and nothing imports them from there. Following the crate's idiom was chosen over deviating for one module, and the cost is recorded rather than hidden. **Owner: whoever revisits the crate's re-export policy** (that entry's own wording). + +_The three items below were added by story 5.4's CODE REVIEW (2026-07-29), not by the story. They +are in this section rather than in a review section of their own because they are properties of what +the story shipped._ + +- **A `verdict_vector` may carry the SAME `RuleId` twice, and an `Abstained { Ambiguous }` may carry + an EMPTY vector. Nothing refuses either.** D13 is *"all rules are evaluated … **each** yields an + enumerated verdict"* [architecture.md:960] — one verdict per rule — yet + `vec![RuleVerdict { rule: rule("l1-exact-mac"), verdict: Supports, .. }, RuleVerdict { rule: + rule("l1-exact-mac"), verdict: Opposes, .. }]` compiles and fabricates D13's *"`Supports` AND + `Opposes`"* row [architecture.md:973] out of a single rule contradicting itself. Symmetrically, + *"ambiguous"* with nothing arguing either way is a conclusion none of D13's `Ambiguous` rows can + produce. Same shape and same reason as the sibling entry above (a conclusion naming a rule absent + from its own vector): `pub` fields, no constructor, and the only place a test could red is where + the vector and the conclusion are first built together. **Owner: story 5.4b.** + *(Found by the Edge Case Hunter at story 5.4's code review; no AC, doc or register entry covered + it, whereas both neighbouring representable states were already owned.)* +- **Story 5.4 introduced the workspace's FIRST `f32`/`f64` token, and it sits in the subtree story + 5.4b's gate is specified to grep.** Measured: `grep -rn "\bf32\b\|\bf64\b" crates xtask + --include=*.rs` returns **1** on the story-5.4 branch and **0** on `master`. The single hit is + `identity/cascade.rs:42`, a **quotation** of D13's own refusal (*"REFUSED: `rule -> confidence: + f64`"*). **No TYPE carries a float**, so story 5.4's AC3 holds and nothing is wrong in the code — + but `epics.md`'s story-5.4b criterion says *"a gate reds on any `f32` or `f64` under + `crates/opencmdb-core/src/identity/`, in the idiom of the existing DDL-collation and + retired-vocabulary greps"*, and those greps are LINE greps. As specified, the gate reds on day one + on a citation of the decision it enforces. **Owner: story 5.4b**, at its contexting — whether the + gate strips `///`/`//!` lines is a design question for the story that writes it, deliberately not + answered here. **Guy's call, 2026-07-29: record it, do not pre-write the AC.** This line is the + gate's committed test case either way. +- **Three documents name story 5.4b owner of the conclusion↔`verdict_vector` coherence invariant, + and 5.4b's acceptance criteria do not mention it.** The owner is assigned in + `identity/cascade.rs`'s `Decision` doc, in the sibling entry of this section, and in + `sprint-status.yaml`; `epics.md`'s `### Story 5.4b` block carries six criteria (a total `decide`, + the uncovered input class, order-independent rule choice, the float gate, the milli-units + deferral, totality by exhaustive classes) and **none** covers *"a `Conclusion`'s rule must appear + in its own `verdict_vector`"* or *"a `Match` may not carry an empty vector"*. Related and + unanswered in the same block: what `decide` RETURNS (a `Conclusion`? a `Decision`? a `Decision` + minus its version?) and who supplies `ruleset_version`. **Owner: story 5.4b**, at its contexting. + **Guy's call, 2026-07-29: hand it over, do not pre-write the AC** — writing 5.4b's criteria from + inside 5.4 would be the same act 5.4 refused when it declined to write `decide()`. + *(Found by the Blind Hunter, which had no access to `epics.md`'s history.)* + +## Deferred from: code review of story-5.4 (2026-07-29) + +_Three parallel layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor), none failed. The scope +held and all eight gates re-ran green; the residue the review found was almost entirely COUNTS AND +CITATIONS in this story's own prose, and it was PATCHED in the story rather than deferred. **One item +is deferred, because it has nothing to fix today.**_ + +- **The four new tests in `cascade.rs` normalise the exact state story 5.4b is chartered to refuse.** + Six `Decision` literals across `a_decision_names_a_rule_and_an_abstention_does_not` and + `the_conclusion_mirrors_the_outcomes_rule_shape` build `Conclusion::Match { rule }` or + `NoMatch { rule }` with `verdict_vector: Vec::new()` — precisely the *"merged, with no + explanation"* shape that `Decision`'s own doc and the entry above (a `Conclusion` naming a rule + absent from its own vector, and `Match` with an empty vector) call the thing D13 exists to prevent. + **Nothing is wrong today**: there is no constructor, nothing enforces the invariant, and the tests + pin claims about `rule()` that are indifferent to the vector. But the day 5.4b enforces the + invariant it is already named owner of, **every one of those six literals has to be rewritten**, + and a test suite that has to be rewritten by the story that adds a guard is the kind of surprise + this register exists to remove. Recorded so 5.4b budgets it rather than discovers it. + **Owner: story 5.4b.** *(Raised by the Blind Hunter, which had no access to 5.4b's charter.)* diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 1a0989a..687a5f2 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -503,16 +503,20 @@ development_status: # engine speaks its own abstention vocabulary, and NOTHING produces one yet. `done` is the MERGE's # business — the `code-review` workflow's default of setting it at the end of the review was again # not applied, as for 5.1/5.2/5.2b. - # NEXT = `create-story` 5.4 (`Decision` and `ruleset_version`), then its MANDATORY validation pass - # by two fresh-context agents before dev-story. + # (This entry carried a "NEXT = `create-story` 5.4" line until story 5.4's code review struck it: + # 5.4 has since been contexted, validated, implemented and put in `review`, so a second NEXT forty + # lines above the file's own `last_updated` pointed at work already done.) 5-3-engine-abstention-cause: done # # 5.4 CONTEXTED 2026-07-29 — the engine's return type, and NO algebra: no `decide`, no rule, no # join, no producer. It writes FIVE types in the existing `identity/cascade.rs` — `Verdict` (D13's # five, spelled as architecture.md:964 spells them), `RuleVerdict { rule, verdict, evidence }`, # `RulesetVersion`, `Conclusion` (Match | NoMatch | Abstained, mirroring `score::Outcome`'s shape) - # and `Decision { conclusion, verdict_vector, ruleset_version }` — plus five tests and five - # rewritten doc blocks. + # and `Decision { conclusion, verdict_vector, ruleset_version }` — plus four tests and SIX touched + # doc locations, five of them rewritten because this story falsifies them. + # (This line read "five tests and five rewritten doc blocks" until the code review counted them — + # the same 4/5-tests and 2/4/5-doc-blocks counting defect the validation pass had already caught + # once in this file, corrected in the implementation record below and not in this summary.) # SIX measurements taken against master (505379e) BEFORE the story was written, so the dev # re-derives nothing and a surprise reads as a FINDING: # · `cargo test --workspace --locked` → 135 bin + 90 core + 42 xtask = 267, zero failures; @@ -546,8 +550,9 @@ development_status: # Guy's arbitration 2026-07-29: it concludes `Abstained { AbsenceOfProof }`. The arbitration # belongs to 5.4b (the function that must be total); 5.4 may NAME the gap and must not answer it. # Story 5.4 also CLOSES the register entry that named it by name: "Owner: whoever writes 5.4's AC7 - # — require the PR and stop" (deferred-work.md:1073-1076). Its AC8 ends at the PR; the merge is a - # separate act. + # — require the PR and stop" (the AC7-drafting entry under `## Deferred from: code review of + # story-5.3`). Its AC8 stops at the PR and says nothing about the merge — the SILENCE is what + # resolves the contradiction, and the wording that answers the entry is 5.4's AC8, not its AC7. # VALIDATED 2026-07-29 by two fresh-context agents (fact-check + gap-hunt). Both verified against # the tree, and every finding they raised was checked before being applied. 6 HIGH + 8 MEDIUM from # the gap-hunt, 5 HIGH + 8 MEDIUM/LOW from the fact-check; all applied. What they caught: @@ -594,21 +599,32 @@ development_status: # `Decision`. No byte moved under fixtures/ (verified empty, MANIFEST untouched). # 267 → 271 tests (135 bin + 94 core + 42 xtask); only core moved, as predicted. Both forms of # clippy green INCLUDING the CI form. `cargo doc` → the same THREE pre-existing warnings, so none - # of the new full-path links is broken. `cascade.rs` = 444 CODE lines, ceiling 2000. + # of the new full-path links is broken. `cascade.rs` = 456 CODE lines after the code review's doc + # patches (444 at the end of dev-story), ceiling 2000; re-measured, not carried. # FOUR mutations run: + # (Line numbers below are MUTATED-tree coordinates — the positions after each mutation's own + # deletion, which is what the compiler printed. On the committed tree the M1 construction sites are + # :530/:543/:555/:600/:637 and the read is :655. Labelled after the code review found `:528` + # presented as a file location when it is a closing brace.) # · M1 (drop `ruleset_version`) → SIX errors: 5× E0560 at construction sites + 1× E0609 at - # :653:22, the assertion that READS it. That is "mandatory by construction", measured; + # :653:22, the read. That is "mandatory by construction", measured; # · M2 (`rule()` returns None for NoMatch) → TWO failing tests, both assertion PANICS, both new. # The only one of the four that reds an assertion rather than a name; - # · M3 (delete `Verdict::Disqualifying`) → `error[E0599]` at THREE sites and NEVER `E0308` — - # exactly as the validation agent measured by compiling the case, and NOT as the story's first - # draft predicted. The prediction was corrected BEFORE dev; the measurement agrees with the - # correction; + # · M3 (delete `Verdict::Disqualifying`) → `error[E0599]` at THREE sites and NEVER `E0308` — the + # same error KIND the validation agent measured by compiling the case, at three sites rather + # than its two, because this story adds the third reference (the test that spells the variant). + # NOT as the story's first draft predicted: that prediction was corrected BEFORE dev, and the + # measurement agrees with the correction; # · M4 (drop `evidence`) → FOUR errors (3× E0560, 1× E0609) PLUS an unpredicted side effect, # recorded rather than smoothed: `warning: unused import: crate::observation::ObsId` at :30:5, # which the CI form of clippy turns into a hard error. The field is what keeps that import live. - # ⚠️ TWO of the four reds are COMPILER-carried (M3 a spelling error, M4 a construction-site error); - # M1 and M2 are not. The story does NOT claim to close deferred-work.md:1045-1053 — owner stays 5.5. + # ⚠️ THREE of the four reds are COMPILER-carried: M1 and M4 are construction-site errors and M3 is + # a spelling error — all three would fire identically on a test body of `assert_eq!(1, 1)`. ONLY M2 + # reds an assertion. (This line said "TWO … M1 and M2 are not" until story 5.4's code review, where + # two layers measured M1 producing six compile errors and ZERO executed assertions — the same class + # this very line already assigned to M4, and contradicting the M2 bullet six lines above. Correcting + # it makes the record weaker and true.) The story does NOT claim to close the "two of 5.3's four + # tests are carried by the type system" entry (deferred-work.md:1080-1088) — owner stays 5.5. # `the_verdict_vector_carries_the_whole_triple_in_order` has NO possible behavioural mutation: a # `pub` field has no code to break, and the record says so. # ONE count in the story's own Dev Notes is falsified by its own commit and corrected in the @@ -618,16 +634,24 @@ development_status: # identity/mod.rs, score.rs's "Not named `Decision`", score.rs's VerdictVectorEntry element # sentence — its "uninhabited"/"provably empty" claims SURVIVE — and trap.rs's `RuleId` doc, whose # "Epic 5 names them" prediction the seven-corpus-rule-names measurement refutes). - # REGISTER: deferred-work.md:1073-1076 CLOSED by append-and-strike (this story was its named - # owner — its AC8 ends at the PR). THREE entries annotated ↺ and NONE struck: the two `NoMatch` - # bullets (owner → 5.4b/5.5, the owner string struck in place inside 5.3's annotation bullets, so - # no bullet was rewritten) and the firing-rule/evidence contract (owner → 5.5). The new annotation - # sits ABOVE its target, the idiom of its two nearest neighbours, and :319-327 was RE-READ after - # insertion: 5.3's own "entry BELOW"/"entry above it" both still resolve, so no amendment was - # needed. A new `## Deferred from: story-5.4` section appended at the END opens FIFTEEN items — - # THIRTEEN name a story, TWO name the CONDITION that would produce an owner (`whoever writes D20's - # ADR`, `whoever revisits the crate's re-export policy`), and the section's preamble states that - # split rather than claiming fifteen named owners. + # REGISTER: the AC7-drafting entry CLOSED by append-and-strike (this story was its named owner — + # its AC8 stops at the PR and says NOTHING about the merge, which is what resolves the + # contradiction). THREE entries annotated ↺ and NONE struck: the two `NoMatch` bullets (owner → + # 5.4b/5.5) and the firing-rule/evidence contract (owner → 5.5). The new annotation sits ABOVE its + # target, the idiom of its two nearest neighbours, and 5.3's own annotation was RE-READ after + # insertion: its "entry BELOW"/"entry above it" both still resolve, so no amendment was needed. + # A new `## Deferred from: story-5.4` section appended at the END (the file is chronological). + # ⚠️ CITATIONS: this entry cited deferred-work.md by LINE, using pre-commit numbers that the same + # commit shifted by +21 — found by three review layers and fixed by citing entries by TITLE, which + # does not rot. Post-review positions, measured after the last edit: AC7-drafting closure + # :1117-1126 · "two of 5.3's four tests" :1080-1088 · `IdentityAbstentionCause::all()` residue + # :1054-1079 · test-placement convention (now CLOSED) :1089-1103 · flat re-export :1111-1116. + # The section now opens EIGHTEEN items — FIFTEEN name a story, ONE names an EPIC (`Epic 6`), TWO + # name the CONDITION that would produce an owner (`whoever writes D20's ADR`, `whoever revisits + # the crate's re-export policy`). It read "FIFTEEN items, THIRTEEN name a story" until the code + # review counted them: the thirteenth was `Epic 6`, an epic being called a story in the sentence + # that forbids exactly that. The review then added three items (a duplicate-`RuleId` vector, the + # first `f64` token, and 5.4b's owner/spec gap), which is where eighteen comes from. 5-4-decision-and-ruleset-version: review # # 5.4b INSERTED 2026-07-29 with Guy, at 5.4's contexting and before any Epic 5 engine code existed diff --git a/crates/opencmdb-core/src/identity/cascade.rs b/crates/opencmdb-core/src/identity/cascade.rs index 182c6f5..1597e19 100644 --- a/crates/opencmdb-core/src/identity/cascade.rs +++ b/crates/opencmdb-core/src/identity/cascade.rs @@ -12,14 +12,20 @@ //! harness in Epic 4, *"a metric written after the engine is bent to fit the engine"* — and because //! story 4.6a recorded the abstention half of the choice as Epic 5's to make. //! -//! # Four types say "verdict", and they are four different judgements +//! # Four judgements, four types, and none of them is the others //! -//! | type | whose judgement | of what | variants | -//! |---|---|---|---| -//! | [`Verdict`] | ONE rule | one candidate pair | 5 | -//! | [`Conclusion`] | the CASCADE | a whole verdict set | 3 | -//! | [`crate::score::Outcome`] | the trap harness | what something ANSWERED about a trap | 3 | -//! | [`crate::score::TrapVerdict`] | the trap RUNNER | whether one trap passed | 3 | +//! | type | whose judgement | of what | variants | owner | +//! |---|---|---|---|---| +//! | [`Verdict`] | ONE rule | one candidate pair | 5 | this file, story 5.4 | +//! | [`Conclusion`] | the CASCADE | a whole verdict set | 3 | this file, story 5.4 | +//! | [`crate::score::Outcome`] | the trap harness | what something ANSWERED about a trap | 3 | story 4.6a | +//! | [`crate::score::TrapVerdict`] | the trap RUNNER | whether one trap passed | 3 | story 4.7a | +//! +//! ⚠️ **The table is about JUDGEMENTS, not about names.** Two types that carry the word *verdict* +//! are deliberately not rows in it, because neither is a judgement: [`RuleVerdict`] is the +//! `(rule, verdict, evidence)` triple — an ELEMENT that carries a [`Verdict`] — and +//! [`crate::score::VerdictVectorEntry`] is the harness-side placeholder for that same triple, +//! uninhabited until story 5.7 unifies the two. //! //! [`Conclusion`] and [`crate::score::Outcome`] look redundant and are not. `Outcome` is what a //! harness writes down about *any* answer, including a hand-authored one in a test; `Conclusion` is @@ -36,6 +42,10 @@ use crate::trap::RuleId; /// Disqualifying }` [architecture.md:964]. The five variants and their spelling are the /// architecture's, not a paraphrase. /// +/// ⚠️ **Not [`crate::score::TrapVerdict`]**, which is what the trap RUNNER says about one trap. +/// This is what ONE RULE says about ONE candidate pair. The two names are four letters apart and +/// the judgements are not comparable; the module doc above tabulates all four. +/// /// # This type is the vocabulary; combining is story 5.4b's /// /// D13's contract is that **all rules are evaluated** and that *"verdicts combine by an **algebra, @@ -60,9 +70,11 @@ use crate::trap::RuleId; /// today, which is the move that ADR exists to gate. // Deliberately absent, each with a reason: `Serialize`/`Deserialize` (nothing persists a verdict — // story 5.9 if it persists one), `PartialOrd`/`Ord` (see the doc above — D20's ADR owns it), -// `Display` (story 5.14 renders through the `t!()` seam), and `#[non_exhaustive]` (never: -// `opencmdb-bin` is another crate, so it would force a `_` arm downstream and destroy the -// `error[E0004]` that makes a sixth variant break story 5.4b's table). +// `Display` (story 5.14 renders through the `t!()` seam), and `#[non_exhaustive]` (never: it would +// force a `_` arm in `opencmdb-bin`, which is a DIFFERENT crate, and destroy the `error[E0004]` +// that makes a sixth variant break every match there). ⚠️ It would NOT weaken story 5.4b's table: +// `#[non_exhaustive]` is inert within the defining crate, and 5.4b's table lives in this one. The +// loss is downstream exhaustiveness, and that is the whole of it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { /// This rule alone settles the pair — the strongest thing a rule may say. @@ -607,9 +619,18 @@ mod tests { } } - /// The `(rule, verdict, evidence)` triple survives the trip in full — D13's *"the list of - /// `(rule, verdict, evidence)` IS the explanation"*. Three entries, three different verdicts, - /// three distinct non-empty evidence lists, order preserved. + /// `RuleVerdict` really is a TRIPLE and `Decision` really carries a vector of them — three + /// entries, three different verdicts, three distinct non-empty evidence lists. + /// + /// ⚠️ **What this does NOT prove, stated because the assertions look stronger than they are.** + /// There is no trip: the vector is `clone()`d into the literal and compared with itself, so + /// every assertion below reads a `pub` field with no code between it and the value the test + /// wrote. **Measured**: replacing `RuleVerdict`'s derived `PartialEq` with a total + /// `fn eq(&self, _: &Self) -> bool { true }` leaves the whole crate green. This test pins the + /// SHAPE — it reds when a field is removed or retyped (story 5.4's M4: three `error[E0560]` + /// plus one `error[E0609]`) — and it pins no BEHAVIOUR, because a `pub` field has no code to + /// break. The behavioural version needs a producer; that is story 5.5's, and the residue is + /// registered in `deferred-work.md` under story 5.3's code review. #[test] fn the_verdict_vector_carries_the_whole_triple_in_order() { let vector = vec![ @@ -649,7 +670,16 @@ mod tests { assert_eq!( decision.verdict_vector[2].evidence, vec![obs(4), obs(5)], - "each entry keeps its own evidence, and the three lists are distinct" + "each entry keeps its own evidence" + ); + // Asserted rather than merely asserted ABOUT: the three evidence lists being distinct is + // what stops this test passing on three identical ones, which is the vacuous shape the + // story banned outright when it refused `Uuid::nil()` three times. + assert!( + decision.verdict_vector[0].evidence != decision.verdict_vector[1].evidence + && decision.verdict_vector[1].evidence != decision.verdict_vector[2].evidence + && decision.verdict_vector[0].evidence != decision.verdict_vector[2].evidence, + "the three evidence lists are pairwise distinct, so no entry can stand in for another" ); assert_eq!( decision.ruleset_version,