diff --git a/docs/ADRs/014_claims_and_the_guards_that_carry_them.md b/docs/ADRs/014_claims_and_the_guards_that_carry_them.md new file mode 100644 index 0000000..13292df --- /dev/null +++ b/docs/ADRs/014_claims_and_the_guards_that_carry_them.md @@ -0,0 +1,154 @@ +# ADR-014: Claims, and the guards that carry them + +**Status:** Accepted +**Date:** 2026-08-02 +**Deciders:** Project maintainers (PRIO MD&D Team) +**Arises from:** epic [#181](https://github.com/views-platform/views-postprocessing/issues/181) +**Related:** [ADR-003](003_authority_of_declarations_over_inference.md) (declarations over +inference), [ADR-005](005_testing_as_mandatory_critical_infrastructure.md) (testing as +infrastructure), [ADR-010](010_technical_risk_register.md) (the register) + +--- + +## Context + +Over two days in August 2026, this repository found **seven** places where it said one +thing and did another. None was a bug in the delivery. All were claims that had outlived +what they described: + +- three register entries filed as open with their own stated closing conditions met + (C-43, C-59, C-61) — one of them saying, in its own body, *"closes when + `tests/test_gaul_lookup_fidelity.py` is committed and green"*, with the file committed + and green; +- two issues closed with their work partly undone (#154, #158) — #158's rename also + broke a URL by find-and-replacing a filename inside it; +- a test file asserting *"the logic tested matches `unfao.py:_validate()` exactly"* about + a method it had stopped resembling seven weeks earlier, while running 43 cases against + a function it defined itself; +- a security-adjacent guard scanning **6 files where it declared 17**, because four of + its five declared roots had been moved and `rglob` on a vanished directory returns + silence rather than an error. + +The pattern is not carelessness. Each was written accurately and became false when +something else moved. What they share is that **nothing was attached to them that would +notice**. + +ADR-003 already forbids inferring what should be declared. This ADR is that rule applied +one level up: **a declaration that nothing validates is an inference with better +grammar.** + +--- + +## Decision + +### §1 A guarantee is attached to a check, or it is not a guarantee + +When this repository asserts something a reader would rely on — in code, in a docstring, +in an ADR, in the register — the assertion is accompanied by something that fails when it +stops being true. If no such check is possible, §4 applies. + +This extends to the repository's own governance artefacts. The register is checked by +`tests/test_register_integrity.py`; the living docs by `tests/test_doc_accuracy.py`; the +package boundary by `tests/test_clone_readiness.py`. **A governance artefact is not exempt +from the rule it exists to enforce.** + +### §2 A guard is mutation-proven, or it is decoration + +A new guard is demonstrated to **fail on the defect it was written for**, and that +demonstration is recorded in the pull request. A guard that has never been watched fail is +a guard whose shape nobody knows. + +Two failure modes, both observed here, and both must be considered: + +- **too narrow** — the þing-01 redaction guard passed while covering a third of its + declared surface; +- **too broad** — a first draft of the coordinate-copy check flagged a *function name* in + this repository as a leaked value. + +Where a guard's inputs are declared (a path list, a module list, a set of names), **assert +that the inputs are real**. `rglob` on a nonexistent directory yields nothing rather than +raising; a list of paths that no longer exist does not fail a scan, it empties it. + +### §3 Prefer a false negative to a false alarm + +A guard that cries wolf gets deleted, and then the rule it carried is unguarded — which is +strictly worse than the narrow guard that would have caught most cases. When a guard fires +on something legitimate, **the first question is whether the matching is wrong, not +whether the scope is too wide.** In the one instance where scope was narrowed instead, the +result missed the shape the defect would actually take. + +Corollary, learned expensively: **existence is not reachability.** A cross-repo pin was +verified to exist, its files were verified to exist at it, and every check written at the +time passed — but it sat on an unmerged branch, declared a version never ratified, and was +withdrawn. Where a claim is about what another repository *says*, check reachability from +that repository's `main`. + +### §4 A deferral is attached to a trigger and an owner, or it is not a deferral + +Deliberate duplication, an unbuilt fix, an unanswered question: each is recorded with the +**named event** that should reopen it. Not "later", not "when convenient" — a thing that +will observably happen. + +Worked examples now in force: the two entry-validation modules stay duplicated until *a +third one is written*; sidecar version-stamping waits for *the next ADR-013 version bump*; +scheduled cross-repo checks wait until *an upstream change reaches the partner through +this repo without anyone noticing first*. + +The counter-example is why this clause exists. Epic #148's retired code path was kept +"until run 0 proves the contract path live" — a real condition, with no owner and no +trigger. Run 0 happened on 2026-07-27 and nobody opened the box; the cleanup took a +subsequent epic. + +### §5 A change that names a record disposes of that record in the same change + +If a pull request cites a register entry, an issue, a CIC or an ADR as the thing it +addresses, it updates that artefact **in the same pull request**. Not in a follow-up, not +at closeout, not from memory. + +**No test enforces this and none is proposed.** It was attempted: no expression reliably +separates *"this entry describes work that is done"* from prose, and a guard that guesses +is one that gets deleted (§3). This clause is therefore a habit, and it is written down +precisely because it is the one rule here that cannot be mechanised — which makes it the +one most likely to lapse. + +The evidence that it does lapse is this epic's own record. C-71 was fixed on the morning of +2026-08-02 and sat filed open for the rest of the day while eight further stories shipped. +The CIC lagged the change in **three consecutive stories** and was corrected by review each +time, never by anything mechanical. + +--- + +## Consequences + +**Cost.** Every guard now carries a mutation demonstration, which is roughly a third again +of the work of writing it. Every deferral needs an argued trigger, which is harder than +writing "later" and is meant to be: if no trigger can be named, the deferral is probably a +decision being avoided. + +**What this does not license.** Not every statement needs a test. §1 is about assertions a +reader would *rely on* — a contract, a boundary, a count, a closing condition. Prose that +explains reasoning is not a claim in this sense, and three times during this epic a guard +fired on the very sentence describing it. The fix each time was to name the thing without +spelling it, **never to weaken the guard** — three weakenings would have left nothing. + +**Where it will be violated first.** §5, because it is the only clause a contributor can +break while every test passes. + +--- + +## Alternatives considered + +**Write nothing; the guards teach themselves.** Genuinely tempting for §1–§3: a +contributor who violates the register's closing-condition convention gets a failure naming +the exact strings and the fix, at the moment of violation — better teaching than an ADR +nobody opens. Rejected because it leaves §4 and §5 unrecorded, and those are the two +clauses no test carries. An ADR that exists for the mechanisable rules would be ceremony; +one that exists for the *un*-mechanisable ones is the only vehicle available. + +**A checklist in the PR template instead.** Cheaper and closer to the moment of work. +Rejected as a replacement, not as a supplement: a checklist records what to do and not why, +and the reasoning is what survives a contributor deciding the rule does not apply to them. +Worth adding later, pointing here. + +**Widen `test_register_integrity` to catch §5.** Attempted and abandoned — see §5. Recorded +so the next person does not spend the same afternoon on it. diff --git a/docs/ADRs/README.md b/docs/ADRs/README.md index 77f4045..9e8ae78 100644 --- a/docs/ADRs/README.md +++ b/docs/ADRs/README.md @@ -72,6 +72,13 @@ These ADRs form the architectural constitution of the repository. sidecar, and the no-collapse boundary (delivery/draws.py). Supersedes the phantom "platform ADR-046". Adopted 2026-07-15 via views-models#149. +- **ADR-014** — Claims, and the Guards That Carry Them + ADR-003 applied to the repository's own statements: a guarantee is attached to a check, + a guard is mutation-proven or it is decoration, a false negative beats a false alarm, a + deferral names a trigger and an owner, and a change that names a record disposes of that + record in the same change. Arises from epic #181, which found seven places where this + repo said one thing and did another. + ADRs numbered 010 and above define: - Domain-specific decisions diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 8c60d48..7230a3f 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -90,6 +90,40 @@ covered a single open entry (see Historical clusters below). **The lesson worth carrying, and it is D-11's:** WET-before-DRY was applied *correctly* — the pandas and frame seams ran as deliberate siblings through the migration, and a premature abstraction would have outlived the implementation it existed to unify. What went wrong was not the duplication; it was that the removal condition (*"until run 0 proves the contract path live"*) was written down without a **named trigger to act on**, so the box expired on 2026-07-27 and nobody opened it. D-11 predicted exactly this. **A deferral needs an owner and a trigger, not just a reason.** +### Epic #181 closeout — "every claim checkable" (2026-08-02) + +**Eleven stories, ten register entries closed, one cluster closed, one ADR written.** Recorded here rather than only in the issue tracker, because an epic that ends in a closed issue ends nowhere a future reader looks. + +| closed | by | proven by | +|---|---|---| +| **C-71** ADR-008 in the entry validator | S1 | a check parametrised over **both** validators, so the pair cannot drift again | +| **C-43, C-59, C-61** Cluster K's build-time guarantees | S2 | `tests/test_gaul_lookup_fidelity.py`, 26 tests | +| **C-03** the `_validate` replica | S4 | 43 self-referential tests replaced by 14 against the real gate | +| **C-60** the lookup's declared version | S5 | the artifact rebuilt, values byte-identical, stamp unchanged | +| **C-57** coordinate-registry drift | S6 | four checks, each mutation-proven | +| **C-46** the machine-specific path | S7 | `grep -rn "/home/"` over the repo → 0 | +| **C-74** the þing-01 redaction guard | S10 | scan coverage 6 → 17 files | +| **C-22** the correction procedure | S8 | written for the delivery that exists; partner-facing step decided 2026-08-02 | + +Plus, outside the register: #158's rename finished and the **broken URL it created** in ADR-013 §7d repaired (Erratum E2); #154's undone half completed across four living documents; #15 superseded; a cross-repo pin re-taken after #196 showed it sat on an unmerged branch. + +**Open count 24 → 15.** Every remaining entry is classified: **six** blocked on the views-pipeline-core 3.0.0 publish (Cluster M), **five** owned by another repo (C-13, C-24, C-26, C-27, C-28), **four** deliberately deferred (C-15, C-30, C-33, C-40). None is unexamined. + +**What the epic did NOT do, stated because a closeout that reports only successes is the defect this epic exists to fix:** + +1. **Cluster M is untouched and correctly so.** Six entries, two of them Tier 2, all resolving on one upstream publish. No engineering here moves them. +2. **The CI question is decided in writing but not implemented.** Three gated cross-repo checks run nowhere automatic. C-46's residual carries the argued recommendation — *do not couple per-PR CI to another repo's default branch; if wanted, a weekly scheduled check that opens an issue on divergence* — with a named trigger. **It is a decision awaiting an owner, not a task awaiting effort.** +3. **Withdrawal of a bad delivery is the chosen policy and is not built.** Supersession is in force because it is what the wire does. Deliberately not started: it needs an ADR-013 amendment plus views-faoapi work, and FAO's answer on audit requirements (Pre-Release Note 07, B.2) decides whether it is wanted at all. +4. **Two questions are with the UN FAO**, not with us — recipients and notification timing (B.1), withdrawal versus supersession (B.2). +5. **`test_datafactory_deploy_readiness`'s `xfail` tuning was left alone**, deliberately: S7 fixed how the checkout is found, not what the gate asserts. If it needs re-pinning now that datafactory has moved past `v1.4.0`, that is a separate judgement. +6. **The local Python is 3.10 while `pyproject` declares `>=3.11`**, so three checks skip on the maintainer's machine and run in CI. Not a repo defect; recorded because "a gate that does not run" is the shape C-46 was open for. + +**The lessons are in [ADR-014](../docs/ADRs/014_claims_and_the_guards_that_carry_them.md)** — including §5, the one rule no test can carry, which is written down *because* it cannot be mechanised. The attempt to mechanise it is recorded there too, so the next person does not repeat it. + +**The sharpest thing the epic produced** is smaller than any of its stories: **existence is not reachability.** A cross-repo pin existed, its files existed at it, every check written at the time passed — and it had never reached `main`. + +--- + ### Cluster M: Six open concerns, one upstream publish **Root cause:** this repo pins `views-pipeline-core >=2.1.3,<3.0.0`, which resolves 2.3.0 from PyPI. Every fix and every removal below exists **only** on pipeline-core's unreleased 3.0.0. None is engineering work here; all five arrive together with one pin bump, and none can be taken before that bump. **Entries:** **C-44** (the bump itself, deliberately held), **C-62** (the transitive drag — 3.4 GB venv, 31 of 32 Dependabot alerts), **C-72** (the pyarrow CVE whose fix our ceiling excludes), **C-73** (the Tier-2 stale-run selection defect, fixed upstream in their #341), **C-58** (the Tier-2 auto-provision-instead-of-raise, fixed upstream in their #322/#331/#332), **C-07** (the undeclared `appwrite` dependency, whose transitive path their #345 withdraws).