From f9f15878ff08fc1a45f59c75a0b28a1942bacdcc Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 4 Aug 2026 03:03:43 +0200 Subject: [PATCH 01/42] =?UTF-8?q?fix(delivery):=20C-77=20=E2=80=94=20name?= =?UTF-8?q?=20the=20historical=20document=20from=20the=20declaration,=20no?= =?UTF-8?q?t=20a=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both partners' historical upload passed `name=self._model_path.model_name`. That value comes from the views-models postprocessor DIRECTORY name — a fact in another repository's filesystem. The forecast leg passes the declared `product.CONSUMER_DOCUMENT_NAME`. The two legs upload separately and the consumer selects them separately: forecasts by the newest manifest, historical actuals by category="historical". Both filter on the document name. So if the legs disagree the delivery half-arrives, and the failure mode is an empty endpoint rather than an error -- ADR-013 s4.1a's exact shape, the one that left six orange_ensemble documents stranded in unfao_bucket for months. Delivery-neutral, verified before changing anything: CONSUMER_DOCUMENT_NAME is "un_fao" and the views-models directory is un_fao, so model_name resolved to the same string. No delivered byte changes. What changes is that the agreement is a declaration rather than a coincidence. Fixed now rather than when it broke, because it was about to be sprung. views-models#333 creates CRAF'd's launcher directory this week, and whoever named it would have decided -- without knowing it -- whether CRAF'd's historical artifact was retrievable. The constraint was posted on that issue on 2026-08-04; this removes the need for anyone to honour it. Guard: test_both_delivery_legs_name_the_document_from_the_declaration asserts exactly one forecast leg and one historical leg per partner. Mutation-proven three ways -- revert one leg, add a third, stop declaring on the forecast leg. Its first draft counted with a plain substring and reported three legs where there are two, because `consumer_name=product...` contains `name=product...`; the lookbehind is deliberate. 372 passed / 40 xfailed / 0 failed. ruff clean. Register 83/16/67. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 92 ++++++++++++++------ tests/test_product.py | 52 +++++++++++ views_postprocessing/crafd/managers/crafd.py | 10 ++- views_postprocessing/unfao/managers/unfao.py | 10 ++- 4 files changed, 133 insertions(+), 31 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index c2717ff..97c1ca6 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -6,8 +6,8 @@ | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-03 | | Total Concerns | 83 | -| Open Concerns | 17 | -| Resolved Concerns | 66 | +| Open Concerns | 16 | +| Resolved Concerns | 67 | --- @@ -609,33 +609,6 @@ Cross-refs: **C-40** (the pipeline-core surface this port wraps), **C-44** (the --- -### C-77: The historical leg names its document from the model path, not from the declared consumer name — and nothing checks the two agree - -| Field | Value | -|-------|-------| -| ID | C-77 | -| Tier | 2 — structural fragility with a clear trigger, affecting **both** partners. Not Tier 1: the failure is a document the consumer cannot find, not a wrong value inside one. But it is the **F1 invisibility shape** — ADR-013 §4.1a, the defect that left six `orange_ensemble` forecast documents stranded in `unfao_bucket` while forecast serving read empty for months. Nobody notices a delivery that simply is not there. | -| Source | `code-review max` (2026-08-03) — PR #211, cross-checking the crafd producer against the views-crafdapi consumer | -| Trigger | When a postprocessor's directory is renamed in views-models, or a new partner package is added whose directory name differs from its `CONSUMER_DOCUMENT_NAME` — check that the historical artifact is still retrievable by the consumer's filter. The forecast leg will keep working, so a green delivery run is not evidence. | -| Owner | Whoever takes the guard. It is a one-line assertion plus a test, not a design decision — but it must be taken deliberately, because the current agreement is a coincidence nobody has written down. | -| Location | The historical-artifact upload in `views_postprocessing//managers/.py` — the call passing `name=self._model_path.model_name`, in `_save_contract`. For contrast, the correct leg is the `consumer_name=product.CONSUMER_DOCUMENT_NAME` argument a few lines above, which reaches the wire as `common["name"]` in `contract/wire/sink.py::deliver_run`. | - -The forecast leg is right. It threads the declared constant through: the manager passes `consumer_name=product.CONSUMER_DOCUMENT_NAME` into `deliver_run`, which sets `common = {"name": consumer_name, ...}`. One declaration, carried to the wire as a parameter — the shape C-69 credited as already correct. - -**The historical-actuals leg does not use that constant at all.** It passes `name=self._model_path.model_name` — a value that comes from the postprocessor's *directory name* in views-models, not from any declaration in this repository. The consumer filters on exactly the string this repo declares: `filters["name"] = self.model_path.model_name`, where the path manager is constructed as `APIPathManager("un_crafd")`. - -**For FAO the two agree; for CRAF'd nobody can yet say.** `views-models/postprocessors/` contains `un_fao` and nothing else — there is **no `un_crafd` postprocessor directory**, so CRAF'd's historical `name=` has never been resolved, let alone compared against its consumer's filter. That makes this worse rather than better: for the live partner the agreement is a coincidence nobody wrote down, and for the new one it is an assumption that will first be tested by a production run. Whoever creates that directory decides, without knowing it, whether CRAF'd's actuals are retrievable. - -**Nothing in this repository asserts they agree.** `tests/test_product.py` asserts `CONSUMER_DOCUMENT_NAME` for the forecast leg; `tests/test_hop_b_sink_e2e.py` checks `consumer_name` on the forecast leg. Neither touches the historical leg's `name=`. A rename of the views-models directory — an ordinary, plausible act, done in a different repository by someone who has never read this file — silently detaches the historical artifact from the consumer's filter while every test here stays green and every delivery run reports success. - -This is ADR-003's rule broken in the quiet direction: the delivery **infers** its consumer identity from a path instead of reading the declaration that exists three lines away. It is also the fourth home for partner identity, where C-69's 2026-07-31 note counted three and recommended consolidation rather than relocation. Consolidation did not reach this line. - -**Scope note:** the crafd package inherited this unchanged from `unfao`; PR #211 did not introduce it, it doubled it. Registering it against both partners rather than against the PR. - -Cross-refs: **C-01** (RESOLVED — the metadata-completeness gate; same partner, same delivery, different field), **C-69** (RESOLVED — "partner identity has THREE homes"; this is the fourth and the note's consolidation recommendation is the fix), **C-33** (the duplication that turned one instance into two), ADR-013 §4.1a (F1 invisibility), ADR-003 (declarations over inference), #211. - ---- - ## Disagreements ### D-12: Post-Run-0 infrastructure & naming intents — repo rename, internal-store transport, compute co-location @@ -704,6 +677,54 @@ See also C-40 (the inheritance/representation coupling this migration unwinds), ## Resolved Concerns +### C-77: The historical leg names its document from the model path, not from the declared consumer name — and nothing checks the two agree — RESOLVED + +| Field | Value | +|-------|-------| +| ID | C-77 | +| Tier | 2 — structural fragility with a clear trigger, affecting **both** partners. Not Tier 1: the failure is a document the consumer cannot find, not a wrong value inside one. But it is the **F1 invisibility shape** — ADR-013 §4.1a, the defect that left six `orange_ensemble` forecast documents stranded in `unfao_bucket` while forecast serving read empty for months. Nobody notices a delivery that simply is not there. | +| Source | `code-review max` (2026-08-03) — PR #211, cross-checking the crafd producer against the views-crafdapi consumer | +| Trigger | When a postprocessor's directory is renamed in views-models, or a new partner package is added whose directory name differs from its `CONSUMER_DOCUMENT_NAME` — check that the historical artifact is still retrievable by the consumer's filter. The forecast leg will keep working, so a green delivery run is not evidence. | +| Owner | Whoever takes the guard. It is a one-line assertion plus a test, not a design decision — but it must be taken deliberately, because the current agreement is a coincidence nobody has written down. | +| Location | The historical-artifact upload in `views_postprocessing//managers/.py` — the call passing `name=self._model_path.model_name`, in `_save_contract`. For contrast, the correct leg is the `consumer_name=product.CONSUMER_DOCUMENT_NAME` argument a few lines above, which reaches the wire as `common["name"]` in `contract/wire/sink.py::deliver_run`. | + +The forecast leg is right. It threads the declared constant through: the manager passes `consumer_name=product.CONSUMER_DOCUMENT_NAME` into `deliver_run`, which sets `common = {"name": consumer_name, ...}`. One declaration, carried to the wire as a parameter — the shape C-69 credited as already correct. + +**The historical-actuals leg does not use that constant at all.** It passes `name=self._model_path.model_name` — a value that comes from the postprocessor's *directory name* in views-models, not from any declaration in this repository. The consumer filters on exactly the string this repo declares: `filters["name"] = self.model_path.model_name`, where the path manager is constructed as `APIPathManager("un_crafd")`. + +**For FAO the two agree; for CRAF'd nobody can yet say.** `views-models/postprocessors/` contains `un_fao` and nothing else — there is **no `un_crafd` postprocessor directory**, so CRAF'd's historical `name=` has never been resolved, let alone compared against its consumer's filter. That makes this worse rather than better: for the live partner the agreement is a coincidence nobody wrote down, and for the new one it is an assumption that will first be tested by a production run. Whoever creates that directory decides, without knowing it, whether CRAF'd's actuals are retrievable. + +**Nothing in this repository asserts they agree.** `tests/test_product.py` asserts `CONSUMER_DOCUMENT_NAME` for the forecast leg; `tests/test_hop_b_sink_e2e.py` checks `consumer_name` on the forecast leg. Neither touches the historical leg's `name=`. A rename of the views-models directory — an ordinary, plausible act, done in a different repository by someone who has never read this file — silently detaches the historical artifact from the consumer's filter while every test here stays green and every delivery run reports success. + +This is ADR-003's rule broken in the quiet direction: the delivery **infers** its consumer identity from a path instead of reading the declaration that exists three lines away. It is also the fourth home for partner identity, where C-69's 2026-07-31 note counted three and recommended consolidation rather than relocation. Consolidation did not reach this line. + +**Scope note:** the crafd package inherited this unchanged from `unfao`; PR #211 did not introduce it, it doubled it. Registering it against both partners rather than against the PR. + +Cross-refs: **C-01** (RESOLVED — the metadata-completeness gate; same partner, same delivery, different field), **C-69** (RESOLVED — "partner identity has THREE homes"; this is the fourth and the note's consolidation recommendation is the fix), **C-33** (the duplication that turned one instance into two), ADR-013 §4.1a (F1 invisibility), ADR-003 (declarations over inference), #211. + +**RESOLVED 2026-08-04 (B1).** Both legs now name the document from the declaration: +`name=product.CONSUMER_DOCUMENT_NAME` replaces `name=self._model_path.model_name` in each +partner's historical upload, and `tests/test_product.py::test_both_delivery_legs_name_the_document_from_the_declaration` +asserts one forecast leg and one historical leg per partner, mutation-proven three ways +(revert one leg; add a third; stop declaring on the forecast leg). + +**Delivery-neutral, verified before changing anything.** `CONSUMER_DOCUMENT_NAME` is +`"un_fao"` and the views-models directory is `un_fao`, so `model_name` resolved to the same +string. No delivered byte changes for FAO; what changes is that the agreement is now a +declaration rather than a coincidence in another repository's filesystem. + +**Fixed now rather than when it broke, because it was about to be sprung.** views-models#333 +creates CRAF'd's launcher directory. Whoever named it would have decided, without knowing +it, whether CRAF'd's historical artifact was retrievable — and the failure mode is an empty +endpoint, not an error. The constraint was posted on that issue on 2026-08-04; this removes +the need for anyone to honour it. + +One residual, unchanged and not this entry's: the guard is a source scan, because the +managers cannot be instantiated without Appwrite env and a views-models path manager. That +is the standing pattern here and the reason **#18** exists. + +--- + ### C-07: Undeclared direct runtime dependencies in pyproject.toml — RESOLVED | Field | Value | @@ -795,6 +816,19 @@ Cross-refs: C-57 (registry drift — the most likely way a coordinate goes wrong **RESOLVED 2026-08-03 by the pipeline-core 3.0.0 bump (C-44).** The auto-create-and-retry is gone: `create_bucket` appears **zero** times in `modules/appwrite/file.py`, and `:1406` now carries an explicit *"Fail loud, BEFORE any write, if a target container does not exist"* guard. A wrong or stale coordinate now fails instead of silently provisioning new production storage. Upstream views-pipeline-core C-228; verified in the installed wheel. +**Residual, and it is not ours to close: the fix is verified by inspection, not by probe.** This entry closed on *provisioning* — `create_bucket` is gone and I read the guard. The neighbouring **delete** path is a different question and views-pipeline-core **#333** ([þing-02 ledger row C5](https://github.com/views-platform/views-pipeline-core/issues/333), **OPEN**) is the probe that would answer it. Its three siblings — their #322, #331, #332 — all shipped in 3.0.0; #333 did not, because it is **blocked on the operator issuing a test key** (þing-02 G2 item f/h). + +What it probes is specific and is not covered by any check we own. The de-dup lookup is a **database** read; the verify step is a **storage** read; and entry to the delete branch requires *the lookup to have succeeded*. So a key with **database read and no bucket-file read** gets past the lookup, fails the verify, and reaches the delete — while a *wholly* read-restricted key fails benignly at the lookup and proves nothing. The dangerous asymmetry is what you get cutting a write-object key by **operation** rather than by **resource**. + +Two facts make that concrete for this repository rather than theoretical: + +1. **The path has already run 108 times in production on FAO's outbound bucket** — run-0's uploads — and the þing-02 verdict records it was benign *only because the files were readable* (`orð_dómr.md:294-298`). +2. **We cannot state our own key's scopes from evidence.** `docs/CLONING.md:131` records that this repository ran for months under a key named for pipeline-core and nobody could say what it was scoped to. So the dangerous shape cannot be ruled out by inspection here either. + +Nothing to do in this repo, and no reason to reopen the entry: the code fix is real and verified. Recorded because closing C-58 on the provisioning half should not read as closing the delete half, and because #333's gate — *"no scoped writer key is issued before C1 and C2 ship and C5's probe passes"* — is an operator action with this repository downstream of it. + +Cross-refs: **C-79** (`_ContractStorePort.upload` fails *open* on an unrecognised result — the same delete-adjacent surface), **C-40** (we run pipeline-core's client under our own identity, which is what þing-02 was about), views-pipeline-core #333/#322/#331/#332. + --- ### C-62: The pinned pipeline-core release still installs geopandas and torch into a repo that architecturally excised them — RESOLVED diff --git a/tests/test_product.py b/tests/test_product.py index 8cdab80..152fc0c 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -53,6 +53,8 @@ #: Where the consumer's name lives, and the mechanism that consumes it. Both are #: pinned: a consumer that kept the string but started filtering on a different field #: would strand a delivery just as thoroughly as one that renamed it. +_PKG = Path(__file__).resolve().parent.parent / "views_postprocessing" + _CONSUMER_PATH_MANAGER = re.compile(r'APIPathManager\(\s*"([a-z0-9_]+)"') _CONSUMER_FILTER = 'filters["name"] = self.model_path.model_name' @@ -108,6 +110,56 @@ def test_upload_interlock_defaults_off(partner): assert _product(partner).UPLOAD_ENABLED is False + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_both_delivery_legs_name_the_document_from_the_declaration(partner): + """Register C-77 — the forecast and historical legs must agree, by construction. + + The two legs upload separately and the consumer selects them separately: forecasts + by the newest manifest, historical actuals by ``category="historical"``. Both are + filtered on the document ``name``, so if the legs disagree the delivery half-arrives + — and the failure mode is an **empty endpoint, not an error**, which is ADR-013 + §4.1a's exact shape. + + **They used to disagree.** The historical leg passed + ``name=self._model_path.model_name`` — the views-models *directory* name — while the + forecast leg passed the declared ``CONSUMER_DOCUMENT_NAME``. For FAO the two happen + to match, so nothing was wrong; nothing *asserted* they matched either, and the + agreement lived in a different repository's filesystem layout. The trap was about to + be sprung for real: views-models#333 creates CRAF'd's launcher directory, and + whoever named it would have decided, without knowing it, whether CRAF'd's historical + artifact was retrievable. + + A source scan rather than a call: the managers need Appwrite env and a views-models + path manager to instantiate, which is this repo's standing pattern for manager-side + facts. + """ + source = (_PKG / partner / "managers" / f"{partner}.py").read_text() + + assert "name=self._model_path.model_name" not in source, ( + f"[{partner}] an upload names its document from the model path again. That is " + "the views-models DIRECTORY name — a fact in another repository, not a " + "declaration here. Use product.CONSUMER_DOCUMENT_NAME (register C-77)." + ) + + # Counted separately, and the lookbehind matters: `consumer_name=product...` + # contains `name=product...` as a substring, so a naive count reports three legs + # where there are two. (It did, on the first run of this guard.) + legs = { + "forecast (via the sink)": len( + re.findall(r"\bconsumer_name=product\.CONSUMER_DOCUMENT_NAME", source) + ), + "historical (direct upload)": len( + re.findall(r"(? dict: store.upload( hist_path, filename=hist_path.name, - name=self._model_path.model_name, + # The DECLARED consumer name, not `self._model_path.model_name` + # (register C-77). Both resolve to the same string today, because + # `model_name` is the views-models directory name and that directory + # happens to match — but only one of them is a declaration. The other + # is a filesystem coincidence in a different repository, and a + # directory rename there would strand this artifact silently: the + # consumer filters on the declared name, finds nothing, and reports + # an empty endpoint rather than an error (ADR-013 §4.1a). + name=product.CONSUMER_DOCUMENT_NAME, doc_type="model", category="historical", loa="pgm", diff --git a/views_postprocessing/unfao/managers/unfao.py b/views_postprocessing/unfao/managers/unfao.py index bf4e91c..cd61b25 100644 --- a/views_postprocessing/unfao/managers/unfao.py +++ b/views_postprocessing/unfao/managers/unfao.py @@ -333,7 +333,15 @@ def _save_contract(self) -> dict: store.upload( hist_path, filename=hist_path.name, - name=self._model_path.model_name, + # The DECLARED consumer name, not `self._model_path.model_name` + # (register C-77). Both resolve to the same string today, because + # `model_name` is the views-models directory name and that directory + # happens to match — but only one of them is a declaration. The other + # is a filesystem coincidence in a different repository, and a + # directory rename there would strand this artifact silently: the + # consumer filters on the declared name, finds nothing, and reports + # an empty endpoint rather than an error (ADR-013 §4.1a). + name=product.CONSUMER_DOCUMENT_NAME, doc_type="model", category="historical", loa="pgm", From f470d6f9c1e084aeda1d26cfe252e8295b5f833a Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 4 Aug 2026 05:33:52 +0200 Subject: [PATCH 02/42] =?UTF-8?q?docs(adr):=20ADR-015=20=E2=80=94=20why=20?= =?UTF-8?q?this=20repo=20imports=20another=20project's=20Appwrite=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The þing-02 ratification attached a condition to the #146 deferral: record it as an ADR in this repo. That was never done. The reasoning lived only in a GitHub issue and in a deliberation folder outside the repository, which is what the condition existed to prevent -- an issue is a work item, not a place someone checks before deciding whether a rule applies to them. What the ADR records - Why the import stays: a hand-written client here would be the platform's THIRD copy, and the defect that prompted the assembly was common to the two existing copies, not a divergence between them. Upstream declines to export a client surface, deliberately, because exporting the current one would bless a shape its own maintainers have recorded as needing decomposition. There is nothing to unwind to. - What bounds it: an importer allowlist pinned by test, the _ContractStorePort DIP port (four methods, so the wire never sees Appwrite types), and the framework-contract test added with the 3.0.0 bump. Containment, not removal -- C-40 stays open on the residual. - The trigger in plain words: demand (a second incident rooted in duplicated client code) and supply (upstream carving out the auth/config seam). Either means revisit; both mean do it. Explicitly not "this seat's convenience". Two things found while writing it, neither previously recorded here - The supply half has PARTIALLY moved. The verdict was written against a single 3,064-line module; 3.0.0 relocated provisioning and transport out of it and added an audit package, leaving 2,841 lines. Still no exported client, so the trigger has not fired -- but "no movement" would be the wrong thing to believe. - The sibling obligation from the same verdict is DISCHARGED. It required this repo's legacy delivery path to be guarded or retired before 2026-11-30. It was retired in #149; C-63 is resolved; the deadline is moot here. Nobody had recorded that it was met. Also: `C-221` in the verdict is pipeline-core's REGISTER entry, not issue #221 (which is unrelated and closed). Noted in the appendix because this repo namespaces cross-repo identifiers for exactly that reason and the verdict's shorthand does not. Scope note: the deferral was written when one manager held the import; crafd added a second. Two adapters with identical contents now. 372 passed / 40 xfailed / 0 failed. ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../015_the_pipeline_core_appwrite_import.md | 172 ++++++++++++++++++ docs/ADRs/README.md | 9 + reports/technical_risk_register.md | 2 + 3 files changed, 183 insertions(+) create mode 100644 docs/ADRs/015_the_pipeline_core_appwrite_import.md diff --git a/docs/ADRs/015_the_pipeline_core_appwrite_import.md b/docs/ADRs/015_the_pipeline_core_appwrite_import.md new file mode 100644 index 0000000..27728c5 --- /dev/null +++ b/docs/ADRs/015_the_pipeline_core_appwrite_import.md @@ -0,0 +1,172 @@ +# ADR-015: Why this repository imports another project's Appwrite client + +**Status:** Accepted +**Date:** 2026-08-04 +**Deciders:** Project maintainers (PRIO MD&D Team) +**Arises from:** issue [#146](https://github.com/views-platform/views-postprocessing/issues/146), +recorded at the request of the þing-02 ratification +**Related:** [ADR-002](002_topology_and_dependency_rules.md) (dependency direction), +[ADR-009](009_boundary_contracts_and_configuration_validation.md) (boundary contracts), +[ADR-014](014_claims_and_the_guards_that_carry_them.md) §4 (a deferral needs a trigger), +register **C-40** + +--- + +## Context + +Each partner manager in this repository begins with two imports it would rather not have: + +```python +from views_pipeline_core.modules.appwrite.file import AppwriteConfig +from views_pipeline_core.modules.datastore import DatastoreModule +``` + +They reach into another project's Appwrite client — its auth handling, its provisioning +behaviour, its error semantics — and run it under this repository's identity and this +repository's API key. When that client does something surprising, it surprises FAO's +delivery, and the fix is in a repository this team does not own. + +This is knowingly kept. It has been raised repeatedly: register **C-40** carries it as a +Tier-2 concern, a cross-repository assembly examined it in July 2026 and told the platform +not to export the surface, and issue #146 tracks the eventual removal. None of that has +changed the code, and it should not have — but the *reason* has been recorded only in a +GitHub issue and in a deliberation folder outside this repository. A closed issue is not +where architectural reasoning survives. That is what this ADR is for, and writing it was +the condition attached when the deferral was ratified. + +**The word "deferred" is doing real work here and is easy to misread.** It does not mean +*not done yet*. It means: examined, decided, and held — with a stated condition under which +the decision is revisited. The distinction matters because the two read identically in an +issue tracker and completely differently to someone deciding whether they may change it. + +--- + +## Decision + +### §1 The import stays, because there is nothing to unwind to + +The obvious alternative is to write our own thin Appwrite client and drop the dependency. +We do not, for a reason that is about the platform rather than about this repository: +**a third copy of that client is the disease, not the cure.** + +Two hand-written copies already exist across the platform, and the defect that prompted the +cross-repository examination was common to both — not a divergence between them, but the +same mistake made twice. Adding a third copy here would multiply the surface the platform +is trying to shrink, and would do it in the repository that talks to the partner. + +The upstream project declines to export a supported client surface, and that refusal is +deliberate: exporting the current one would bless a shape its own maintainers have recorded +as needing decomposition. So the honest position is that the seam to unwind *to* has not +been built yet, by anyone, and building it unilaterally here would be worse than waiting. + +### §2 What bounds the blast radius while it stays + +A dependency kept on purpose still has to be contained, and this one is: + +- **Only the partner managers import it.** `tests/test_doc_accuracy.py` pins the importer + set to an explicit allowlist, so a third importer fails CI. Every other module — + the whole of `contract/` and `delivery/` — is free of it, proven in a fresh interpreter + by `tests/test_clone_readiness.py`. +- **The store is behind a port.** `_ContractStorePort` wraps the client in four methods, so + the wire modules never see Appwrite types. That is the dependency-inversion half of + C-40's prescribed mitigation, and it is the half that landed. +- **The framework contract is asserted.** `tests/test_framework_contract.py` checks that the + hooks we override still match upstream, that the classes remain instantiable, and that + every inherited attribute resolves — so an upstream change is found here rather than on a + delivery run. + +None of that removes the coupling. It makes the coupling *one file per partner wide* and +*visible when it moves*, which is the most that can be true while §1 holds. + +### §3 The condition under which this is revisited + +Two independent things can move, and they were ratified as a pair: + +- **Demand** — a second incident whose root cause is auth or provisioning handling in + duplicated client code, whether from divergence between copies or from a defect common to + them. +- **Supply** — the upstream project carving the auth and configuration seam out of its + Appwrite module, so that something exists to depend on. + +**Either one alone means revisit. Both together mean do it.** The deferral is tied to that, +explicitly *not* to this team's convenience — which is the wording the assembly chose, and +it is worth keeping because "when we get round to it" is how a deferral becomes a decision +nobody made. + +### §4 Where supply actually stands, which is not where the record says + +The verdict was written when the upstream Appwrite module was a single 3,064-line file. As +of the version this repository now pins, that has **partially** moved: provisioning has been +relocated to its own module, transport (including the request timeout this repository +depended on) to another, and an audit package now exists alongside. The main file is 2,841 +lines. + +**So the supply half has started and has not fired.** What we import is still there, and +there is still no exported client to depend on instead. But "no movement" would be the wrong +thing to believe, and a reader checking this in six months should check the module list +rather than trust this paragraph. + +### §5 Scope: this is two files now, not one + +The deferral was written when one manager held the import. The CRAF'd partner package added +a second, and it is a near-verbatim copy of the first. So the adapter to unwind is two +adapters with identical contents, and any fix must be applied twice or neither. + +That is not an argument to unwind sooner — it is the same bounded surface duplicated, and +register **C-33** already carries the duplication with its own trigger. It is recorded here +so that whoever eventually does the work is not surprised by the second file. + +--- + +## Consequences + +**What this costs.** Upstream changes land in FAO's delivery without this repository owning +the fix; the managers cannot be instantiated without the framework; and the platform's most +central package remains a hard dependency of the thing that talks to the partner. + +**What it buys.** No third client copy. The whole of `contract/` and `delivery/` stays +framework-free and reusable, which is what let a second partner be added as a package rather +than a fork. + +**Where this will go wrong first.** Someone reading issue #146 will see "deferred", read it +as "not done yet", and either do it — writing the third copy §1 exists to prevent — or treat +the import as unexamined and add a third importer. The allowlist test catches the second. +Nothing catches the first except this document. + +--- + +## Alternatives considered + +**Write our own thin client now.** Rejected in §1: it is the third copy, and it puts the +duplication in the repository with the partner relationship. + +**Wait silently and leave the reasoning in the issue.** This is what was happening, and it is +what the ratification objected to. An issue is a work item; it is not read by someone +deciding whether a rule applies to them. + +**Bound it with a test and consider it closed.** The allowlist and the port genuinely reduce +the risk, and it is tempting to call that the end. Rejected because containment is not +removal — C-40 stays open on exactly the residual this ADR describes, and closing it would +mean the next reader finds a bounded coupling with no record of why it is tolerated. + +--- + +## Appendix — the record this replaces + +The reasoning above was previously recorded only in issue +[#146](https://github.com/views-platform/views-postprocessing/issues/146) and in the +platform's deliberation folder (`views_platform/þingit/`), which is outside this repository +and not on any contributor's path. + +Two related facts, verified 2026-08-04 and recorded here because they are otherwise only in +that folder: + +- **The sibling obligation from the same verdict is discharged.** The assembly also required + this repository's legacy delivery path to be guarded or retired before 2026-11-30. It was + retired in #149 — the manager carries no such path, and register C-63 is resolved. The + deadline is moot here, and nobody had recorded that it was met. +- **`C-221` is the upstream project's register entry, not an issue number.** Issue #221 in + that repository is unrelated and closed. The entry is Tier 4 and describes the + decomposition need in §3's supply component. Cross-repository identifiers in this + repository are namespaced for exactly this reason + (`tests/test_register_integrity.py` enforces it), and the verdict's own shorthand is not. diff --git a/docs/ADRs/README.md b/docs/ADRs/README.md index 9e8ae78..48d7ab1 100644 --- a/docs/ADRs/README.md +++ b/docs/ADRs/README.md @@ -79,6 +79,15 @@ These ADRs form the architectural constitution of the repository. record in the same change. Arises from epic #181, which found seven places where this repo said one thing and did another. +- **ADR-015** — Why This Repository Imports Another Project's Appwrite Client + The partner managers import `views_pipeline_core.modules.{appwrite,datastore}` and run + another project's client under this repo's identity. Kept knowingly: a hand-written + client here would be the platform's *third* copy, and the upstream seam to depend on + instead does not exist yet. Records what bounds it (an importer allowlist, the + `_ContractStorePort` DIP port, a framework-contract test), and the two-part condition — + demand and supply — under which it is revisited. Arises from #146 and the þing-02 + ratification, which asked that the reasoning live here rather than in an issue. + ADRs numbered 010 and above define: - Domain-specific decisions diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 97c1ca6..4043806 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -379,6 +379,8 @@ See also C-24 (schema contract per store), C-77 (the fourth home for partner ide **What actually changed, and what did not.** The blast radius is no longer *one file wide* — it is **one file, twice**, which is a different and slightly worse property: an upstream change now has two identical landing sites and no mechanism guarantees they are patched together (C-33). What did **not** change is the more important half: the count is still **bounded and pinned**. `test_views_pipeline_core_is_confined_to_the_partner_managers` (renamed in #211 — it had asserted *two* under a name that said *one*) was widened to an explicit allowlist, not deleted, so a *third* importer still fails CI. Every other module in the repository remains pipeline-core-free, including the whole of `contract/` and `delivery/`, and `tests/test_clone_readiness.py` still proves the machinery imports in a subprocess without it. +**Recorded as ADR-015 (2026-08-04).** The reasoning for keeping this import — and the two-part condition under which it is revisited — now lives in `docs/ADRs/015_the_pipeline_core_appwrite_import.md` rather than only in issue #146 and the platform's deliberation folder. That was the condition attached when the deferral was ratified, and it had not been met. The ADR also records that the **supply** half of the trigger has *partially* moved: pipeline-core 3.0.0 relocated provisioning and transport out of the Appwrite module (3,064 → 2,841 lines), but exports no client surface, so there is still nothing to unwind to. + **On þing-02 S24(5).** `docs/CLONING.md` cited that verdict as forbidding these imports outright. Reading it directly (`þingit/02_credential_identity_key_ownership/sáttmál.md:240-242` — precondition (5) itself; the section opens at `:232` under the heading *"§5 — The clone (`un-crafdapi`)"* — and `orð_dómr.md:418-441`), it binds *"the clone"* — `un-crafdapi` and `views-productionapi`, repositories **git-cloned from views-faoapi** — and does not reach an in-repo partner package of the producer. CLONING.md over-claimed; PR #211 corrects the citation rather than weakening the rule. This entry's own scope is unaffected: the coupling is a design concern here regardless of what the verdict binds, and issue **#146**'s deferred unwind now covers two files instead of one. Tier held at 2. The residual scope — the double inheritance and the framework-bound instantiation — is unchanged, and is still gated on views-pipeline-core 3.0.0 (C-44/C-62). From 8039f72fafadc16d18d178e55114de18edfec79e Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 4 Aug 2026 07:10:40 +0200 Subject: [PATCH 03/42] =?UTF-8?q?feat(build):=20#90=20=E2=80=94=20build=20?= =?UTF-8?q?the=20GAUL=20lookup=20arrow-native,=20and=20refuse=20an=20empty?= =?UTF-8?q?=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup builder was the last runtime pandas import in the repository. The sources are parquet and the output is parquet; pandas was only ever the thing in the middle. Proven against the real producer, not asserted Rebuilt land_gaul from the views-datafactory checkout and compared to the committed artifact: rows 64,742 -> 64,742 column order identical schema types identical (including dictionary on the four names) ALL 10 COLUMNS VALUE-IDENTICAL adr / region / n_cells / n_dropped_incomplete / lookup_version / source_provenance byte-identical That is #90's re-baselined acceptance criterion met exactly: contents identical, and the flat declared `lookup_version` key reproduced byte-for-byte (C-60). One deliberate difference: the written file no longer carries a `pandas` schema-metadata blob. It described an index arrow does not have, the only consumer reads the file as arrow (`contract.gaul_lookup.load`), and reproducing it would have meant keeping knowledge of pandas' metadata format in the one script this change exists to remove pandas from. Stated in the docstring rather than left to be discovered. The committed artifact is NOT regenerated here. The builder is what #90 asks for; rewriting a delivered artifact is a separate act with no delivery benefit. C-76 closed, and the guard failed its own first test `build()` now refuses a zero-row result instead of writing one and printing `cells=0` as though that were an outcome. The first draft did not survive its mutation test: on an empty table `pa.array([True] * 0)` infers NULL type, so `pc.and_` raised ArrowNotImplementedError in the completeness filter BEFORE the zero-row check could speak -- the confusing-late-failure C-76 exists to prevent, relocated by one function. The mask is now explicitly typed. C-75 decided: RETIRE, and not in this PR The entry's Owner field required whoever took #90 to make the keep-or-retire call on GaulLookupEnricher "not to defer a third time". Retire: zero production callers, its "build/verification path" justification is spent now that this path is arrow-native, it holds the package's last pandas reference, and its _gather duplicates the shipping gather in contract/historical.py -- which four test files cover independently. C-45 is the precedent and was resolved by deleting. Not executed here: the retirement touches ten files including a CIC, ADR-012 and four test files. Mixing that with a builder rewrite is what epic #148's S5 explicitly refused to do. It is the next change. Epic #85 and tracking #93 stay OPEN until it lands -- their claim only becomes true when that module is gone. 373 passed / 40 xfailed / 0 failed. ruff clean. Register 83/15/68. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 64 +++++++---- scripts/build_gaul_lookup.py | 174 +++++++++++++++++++---------- tests/test_gaul_lookup_fidelity.py | 82 ++++++++++---- 3 files changed, 212 insertions(+), 108 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 4043806..99292bf 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -6,8 +6,8 @@ | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-03 | | Total Concerns | 83 | -| Open Concerns | 16 | -| Resolved Concerns | 67 | +| Open Concerns | 15 | +| Resolved Concerns | 68 | --- @@ -440,34 +440,19 @@ Cross-refs: **C-62** (the transitive dependency drag; the other 31 alerts), **C- **The precedent is C-45**, `unfao/frames.py`: an unused adapter carried on no live path, resolved by deleting it. This is the same shape with a different module, and the same question — keep it as the declared verification/reference implementation, or retire it and let the fidelity suite test `historical.py` directly. -**Deliberately NOT registered from the same review** (defects in unmerged code, all fixed in #210 before merge rather than tracked): a NaN gid crashing the warning path, the unvalidated int64 coercion at both ends, the AST guard's `else`-branch blind spot, ADR-012's stale pandas-merge claim, and three CIC claims retired elsewhere by #200. The register tracks standing risk; a defect fixed before it ships is not one. They are recorded in the PR. +**DECISION 2026-08-04 (#90), which this entry's Owner field required of whoever took it: RETIRE.** -Cross-refs: **C-45** (RESOLVED — the same shape, resolved by deletion), **C-66** (RESOLVED — established the enricher left the delivery path), **C-40** (which calls `enrichment.py` and `extraction.py` together *"the retired-in-place `enrichment.py`/`extraction.py` legacy seams"*), **#89** / **#90** / epic **#85**, ADR-014 §4. +The conditions are no longer arguable. The class has **zero production callers** — only three test files import it. Its last stated justification was "the build/verification path", and #90 rewrote that path arrow-native without touching it, so the justification is spent. It holds the package's **last pandas reference** (a `TYPE_CHECKING` import), which is the one thing standing between epic #85 and an honest close. And its `_gather` duplicates `contract/historical.py`'s shipping gather, which is independently covered by four test files. ---- +**C-45 is the precedent and it was resolved by deleting.** Same shape, different module. -### C-76: `build_gaul_lookup.py` will write an empty lookup without complaint +**Not executed in #90, deliberately.** The retirement touches ten files — the module, its 39 tests, references in two other test files, its CIC, ADR-012, `gaul_lookup.py`'s docstring, the machinery list in `test_clone_readiness.py`, and the pandas-importer assertion in `test_doc_accuracy.py`. Folding that into a builder rewrite would mix a behaviour-preserving change with a large deletion, which is the thing epic #148's S5 explicitly refused to do. It is the next change, not a later one. -| Field | Value | -|-------|-------| -| ID | C-76 | -| Tier | 4 — no silent corruption. A zero-row artifact fails downstream at `historical.build_historical_table`, which raises on cells absent from the lookup. The cost is that it fails **late and confusingly**: the message names missing geography rather than an empty lookup, and the artifact is committed by then. | -| Source | `code-review max` (2026-08-03) — PR #210 second pass, while checking whether the consumer's new guards duplicated a producer guarantee. They do not. | -| Trigger | When `build_gaul_lookup.py` is next run with a new or renamed `--region`, or against a datafactory whose `gaul_admin` parquets have changed shape — check the printed `cells=` count is non-zero before committing the artifact. Nothing else will tell you. | -| Owner | Whoever next runs the builder. It is a two-line guard in a script one person runs by hand, not a scheduling decision. | -| Location | `scripts/build_gaul_lookup.py` — the invariant block at `:246-268` and the write at `:284` | - -The builder's invariant block is thorough about what it checks: index uniqueness (C-59), nulls in the metadata columns, `-1` sentinels in the code columns (C-35). It does not check that any rows survived. A `--region` argument that filters every cell out, or an upstream join that produces nothing, writes a zero-row parquet and prints `cells=0` as though that were a result. - -**Verified 2026-08-03, and the neighbouring worry is NOT real.** The same review asked whether the builder also fails to reject a null key, since `df.isna().sum().sum()` runs *after* `priogrid_gid` becomes the index and `DataFrame.isna()` does not inspect the index. It does not check it — but the null key is unreachable anyway: `df.index.astype("int64")` raises `IntCastingNaNError` two lines earlier. Protection by accident rather than by declaration, which is worth knowing, but not a defect to fix. **Only the empty case is reachable.** - -**Why this was found now.** PR #210 added consumer-side refusals for both an empty lookup and a null key to `GaulLookupEnricher.__init__`, and the review challenged them as duplicating a producer guarantee. Checking established the opposite: for the empty case there is no producer guarantee to duplicate, and for the null key the producer's protection is incidental. The consumer guards stay, and this entry records the producer-side half rather than quietly assuming someone will notice. - -Cross-refs: **C-59** and **C-61** (RESOLVED — the invariant block this sits beside, and the reason it is otherwise thorough), **C-35** (the `-1` defect class it does check for), **C-75** (the consumer whose guards prompted the check), #210. - ---- +**Epic #85 and tracking #93 stay open until it lands**, because their claim — pandas pushed to the seams — only becomes true when this module is gone. +**Deliberately NOT registered from the same review** (defects in unmerged code, all fixed in #210 before merge rather than tracked): a NaN gid crashing the warning path, the unvalidated int64 coercion at both ends, the AST guard's `else`-branch blind spot, ADR-012's stale pandas-merge claim, and three CIC claims retired elsewhere by #200. The register tracks standing risk; a defect fixed before it ships is not one. They are recorded in the PR. +Cross-refs: **C-45** (RESOLVED — the same shape, resolved by deletion), **C-66** (RESOLVED — established the enricher left the delivery path), **C-40** (which calls `enrichment.py` and `extraction.py` together *"the retired-in-place `enrichment.py`/`extraction.py` legacy seams"*), **#89** / **#90** / epic **#85**, ADR-014 §4. --- @@ -679,6 +664,37 @@ See also C-40 (the inheritance/representation coupling this migration unwinds), ## Resolved Concerns +### C-76: `build_gaul_lookup.py` will write an empty lookup without complaint — RESOLVED + +| Field | Value | +|-------|-------| +| ID | C-76 | +| Tier | 4 — no silent corruption. A zero-row artifact fails downstream at `historical.build_historical_table`, which raises on cells absent from the lookup. The cost is that it fails **late and confusingly**: the message names missing geography rather than an empty lookup, and the artifact is committed by then. | +| Source | `code-review max` (2026-08-03) — PR #210 second pass, while checking whether the consumer's new guards duplicated a producer guarantee. They do not. | +| Trigger | When `build_gaul_lookup.py` is next run with a new or renamed `--region`, or against a datafactory whose `gaul_admin` parquets have changed shape — check the printed `cells=` count is non-zero before committing the artifact. Nothing else will tell you. | +| Owner | Whoever next runs the builder. It is a two-line guard in a script one person runs by hand, not a scheduling decision. | +| Location | `scripts/build_gaul_lookup.py` — the invariant block at `:246-268` and the write at `:284` | + +The builder's invariant block is thorough about what it checks: index uniqueness (C-59), nulls in the metadata columns, `-1` sentinels in the code columns (C-35). It does not check that any rows survived. A `--region` argument that filters every cell out, or an upstream join that produces nothing, writes a zero-row parquet and prints `cells=0` as though that were a result. + +**Verified 2026-08-03, and the neighbouring worry is NOT real.** The same review asked whether the builder also fails to reject a null key, since `df.isna().sum().sum()` runs *after* `priogrid_gid` becomes the index and `DataFrame.isna()` does not inspect the index. It does not check it — but the null key is unreachable anyway: `df.index.astype("int64")` raises `IntCastingNaNError` two lines earlier. Protection by accident rather than by declaration, which is worth knowing, but not a defect to fix. **Only the empty case is reachable.** + +**Why this was found now.** PR #210 added consumer-side refusals for both an empty lookup and a null key to `GaulLookupEnricher.__init__`, and the review challenged them as duplicating a producer guarantee. Checking established the opposite: for the empty case there is no producer guarantee to duplicate, and for the null key the producer's protection is incidental. The consumer guards stay, and this entry records the producer-side half rather than quietly assuming someone will notice. + +Cross-refs: **C-59** and **C-61** (RESOLVED — the invariant block this sits beside, and the reason it is otherwise thorough), **C-35** (the `-1` defect class it does check for), **C-75** (the consumer whose guards prompted the check), #210. + +--- + +**RESOLVED 2026-08-04 (#90).** `build()` now refuses a zero-row result: + +> the build produced ZERO cells for region 'land_gaul'. Either the region filtered every cell out, or the join found no overlap between the seven source parquets. An empty lookup is writable and looks like a result; it is not one. + +Pinned by `tests/test_gaul_lookup_fidelity.py::test_builder_refuses_a_build_with_zero_cells`. + +**The guard did not survive its own first test, and that is worth recording.** On an empty table `pa.array([True] * 0)` infers NULL type, so `pc.and_` in the completeness filter raised `ArrowNotImplementedError` *before* the zero-row check could speak — the confusing-late-failure this entry exists to prevent, relocated by one function. The mask is now explicitly `pa.bool_()`. A guard written and not watched fail is decoration (ADR-014 §2); this one was watched, failed for the wrong reason, and was fixed. + +--- + ### C-77: The historical leg names its document from the model path, not from the declared consumer name — and nothing checks the two agree — RESOLVED | Field | Value | diff --git a/scripts/build_gaul_lookup.py b/scripts/build_gaul_lookup.py index 46eb21d..955e89e 100644 --- a/scripts/build_gaul_lookup.py +++ b/scripts/build_gaul_lookup.py @@ -28,8 +28,9 @@ import os from pathlib import Path -import pandas as pd +import numpy as np import pyarrow as pa +import pyarrow.compute as pc import pyarrow.parquet as pq from views_postprocessing.contract.gaul_schema import ( @@ -76,21 +77,21 @@ def _resolve_datafactory() -> Path: return sibling -def _load_source(datafactory: Path) -> pd.DataFrame: - """Join the 7 GAUL parquets on gid into one wide frame (source names).""" +def _load_source(datafactory: Path) -> pa.Table: + """Join the 7 GAUL parquets on gid into one wide table (source names). + + Arrow throughout since #90 — the sources are parquet, the output is parquet, and + pandas was only ever the thing in the middle. The join is an inner join on ``gid``: + a cell missing from any one source has no complete row to contribute, and the + completeness filter in ``build`` would drop it anyway. + """ gaul_dir = datafactory / "data" / "raw" / "gaul_admin" - frames = {} + table = None for src_col in SOURCE_RENAME: - t = pq.read_table(gaul_dir / f"{src_col}.parquet") - s = pd.Series( - t.column("value").to_pylist(), - index=t.column("gid").to_pylist(), - name=src_col, - ) - frames[src_col] = s - df = pd.DataFrame(frames) - df.index.name = "gid" - return df + one = pq.read_table(gaul_dir / f"{src_col}.parquet").select(["gid", "value"]) + one = one.rename_columns(["gid", src_col]) + table = one if table is None else table.join(one, keys="gid", join_type="inner") + return table def _region_gids(datafactory: Path, region: str) -> set[int] | None: @@ -197,46 +198,82 @@ def _provenance(datafactory: Path, *, datasets: tuple[str, ...]) -> dict: return out -def build(datafactory: Path, region: str, out: Path) -> pd.DataFrame: +def build(datafactory: Path, region: str, out: Path) -> pa.Table: + """Build the lookup and write it. Arrow end to end (#90). + + **What must not change, and is asserted by the fidelity suite:** the nine declared + columns in ``METADATA_COLS`` order with ``priogrid_gid`` last, the four name columns + dictionary-encoded, codes ``int64``, coordinates ``float64``, rows sorted by cell id, + and the declared metadata keys. The delivered artifact is read as arrow by its only + consumer (``contract.gaul_lookup.load``), so those are the observable contract. + + **One deliberate difference from the pandas build:** the written file no longer + carries a ``pandas`` schema-metadata blob. It described an index that arrow does not + have, nothing in this repository reads the lookup with pandas, and reproducing it + would have meant keeping knowledge of pandas' metadata format in the one script this + change exists to remove pandas from. + """ src = _load_source(datafactory) # Optionally restrict to a region's cell set. region_gids = _region_gids(datafactory, region) if region_gids is not None: - src = src.loc[src.index.intersection(sorted(region_gids))] - - # Rename to the contract names. - df = src.rename(columns=SOURCE_RENAME) - - # Keep only fully-complete cells. Incomplete cells must NOT enter the - # lookup: an unknown/incomplete gid then merges to null downstream and the - # manager's _validate() gate crashes (fail-loud) instead of shipping a hole - # or a -1 sentinel. Never carry -1 / "" as a value. - complete = pd.Series(True, index=df.index) - for c in CODE_COLS: - complete &= df[c].notna() & (df[c] != -1) - for c in NAME_COLS: - complete &= df[c].notna() & (df[c].astype(str).str.len() > 0) - dropped = int((~complete).sum()) - df = df[complete].copy() - - # Coordinates from the gid (no geometry needed). - gids = df.index.to_numpy() - df["pg_xcoord"] = [xcoord(int(g)) for g in gids] - df["pg_ycoord"] = [ycoord(int(g)) for g in gids] + wanted = pa.array(sorted(region_gids), type=src.column("gid").type) + src = src.filter(pc.is_in(src.column("gid"), value_set=wanted)) + + # Rename to the contract names, keeping `gid` alongside. + table = src.rename_columns( + ["gid"] + [SOURCE_RENAME[c] for c in src.column_names if c != "gid"] + ) if src.column_names[0] == "gid" else src.rename_columns( + [SOURCE_RENAME.get(c, c) for c in src.column_names] + ) - # dtypes: codes numeric, coords float64, names/iso categorical (C-32 memory). + # Keep only fully-complete cells. Incomplete cells must NOT enter the lookup: an + # unknown/incomplete gid then gathers to null downstream and the delivery's null + # gate fails loud instead of shipping a hole or a -1 sentinel. Never carry -1 / "". + # Typed explicitly: on an empty table `pa.array([True] * 0)` infers NULL type, and + # `pc.and_` then raises ArrowNotImplementedError before the zero-row guard below can + # say anything useful. Found by the guard's own mutation test. + complete = pa.array([True] * table.num_rows, type=pa.bool_()) for c in CODE_COLS: - df[c] = df[c].astype("int64") - for c in COORD_COLS: - df[c] = df[c].astype("float64") + col = table.column(c) + complete = pc.and_(complete, pc.and_(pc.is_valid(col), pc.not_equal(col, -1))) for c in NAME_COLS: - df[c] = df[c].astype("category") + col = table.column(c).cast(pa.string()) + complete = pc.and_( + complete, + pc.and_(pc.is_valid(col), pc.greater(pc.utf8_length(col), 0)), + ) + dropped = int(pc.sum(pc.invert(complete)).as_py() or 0) + table = table.filter(complete) + + # Coordinates from the gid (no geometry needed). Vectorised over the id array + # rather than a Python loop per row — the formula is the declared one either way. + gids = table.column("gid").to_numpy(zero_copy_only=False).astype(np.int64) + table = table.append_column( + "pg_xcoord", pa.array([xcoord(int(g)) for g in gids], type=pa.float64()) + ).append_column( + "pg_ycoord", pa.array([ycoord(int(g)) for g in gids], type=pa.float64()) + ) - df = df[METADATA_COLS] - df.index = df.index.astype("int64") - df.index.name = "priogrid_gid" - df = df.sort_index() + # dtypes: codes int64, coords float64, names dictionary-encoded. The dictionary + # encoding is the artifact's shipped type — it was `category` under pandas and the + # committed file carries dictionary. + cast = {c: pa.int64() for c in CODE_COLS} + cast.update({c: pa.float64() for c in COORD_COLS}) + columns, names = [], [] + for c in METADATA_COLS: + col = table.column(c) + if c in NAME_COLS: + col = pc.dictionary_encode(col.cast(pa.string())) + else: + col = col.cast(cast[c]) + columns.append(col) + names.append(c) + columns.append(table.column("gid").cast(pa.int64())) + names.append("priogrid_gid") + table = pa.Table.from_arrays(columns, names=names) + table = table.sort_by([("priogrid_gid", "ascending")]) # Hard invariants — the lookup must be clean by construction. # @@ -245,14 +282,20 @@ def build(datafactory: Path, region: str, out: Path) -> pd.DataFrame: # identical on disk (register C-61). The -1 check in particular has no downstream # backstop — -1 is non-null, so every gate in the delivery chain would pass it # straight through to FAO, which is exactly the resolved C-35 defect recurring. - if not df.index.is_unique: - dupes = df.index[df.index.duplicated()].unique().tolist() + key = table.column("priogrid_gid") + if len(pc.unique(key)) != table.num_rows: + counts = key.value_counts() + dupes = [ + counts.field("values")[i].as_py() + for i in range(len(counts)) + if counts.field("counts")[i].as_py() > 1 + ] raise LookupBuildError( - f"{len(dupes)} duplicate gid(s) in the lookup index: {dupes[:10]}. " - "A duplicated key multiplies rows through the enricher's left-merge with " - "every value non-null, so no downstream gate can see it (C-59)." + f"{len(dupes)} duplicate gid(s) in the lookup key: {dupes[:10]}. " + "A duplicated key multiplies rows through a keyed gather with every value " + "non-null, so no downstream gate can see it (C-59)." ) - n_null = int(df.isna().sum().sum()) + n_null = sum(table.column(c).null_count for c in table.column_names) if n_null: raise LookupBuildError( f"lookup contains {n_null} null value(s); only fully-complete cells may " @@ -260,20 +303,31 @@ def build(datafactory: Path, region: str, out: Path) -> pd.DataFrame: "downstream." ) for c in CODE_COLS: - n_sentinel = int((df[c] == -1).sum()) + n_sentinel = int(pc.sum(pc.equal(table.column(c), -1)).as_py() or 0) if n_sentinel: raise LookupBuildError( f"{c} contains {n_sentinel} -1 sentinel(s). -1 is non-null, so it would " "reach FAO through every gate as a country/admin code (cf. C-35)." ) + # Register C-76: a build that filtered every cell out is not a result. Downstream + # this fails late and confusingly — `build_historical_table` raises about missing + # geography rather than about an empty lookup, and by then the artifact is + # committed. Two lines here, at the moment a human is present. + if table.num_rows == 0: + raise LookupBuildError( + f"the build produced ZERO cells for region {region!r}. Either the region " + "filtered every cell out, or the join found no overlap between the seven " + "source parquets. An empty lookup is writable and looks like a result; it " + "is not one." + ) out.parent.mkdir(parents=True, exist_ok=True) - table = pa.Table.from_pandas(df, preserve_index=True) - meta = dict(table.schema.metadata or {}) - meta[b"adr"] = b"ADR-011" - meta[b"region"] = region.encode() - meta[b"n_cells"] = str(len(df)).encode() - meta[b"n_dropped_incomplete"] = str(dropped).encode() + meta = { + b"adr": b"ADR-011", + b"region": region.encode(), + b"n_cells": str(table.num_rows).encode(), + b"n_dropped_incomplete": str(dropped).encode(), + } prov = _provenance(datafactory, datasets=(AREA_MAJORITY_DATASET, stamp_dataset(region))) # The DECLARED stamp: one flat key, composed here, read verbatim by the consumer # (C-60). Key order in parquet metadata carries no meaning; this is a dict. @@ -282,9 +336,9 @@ def build(datafactory: Path, region: str, out: Path) -> pd.DataFrame: table = table.replace_schema_metadata(meta) pq.write_table(table, out) - print(f"region={region} cells={len(df):,} dropped_incomplete={dropped:,}") + print(f"region={region} cells={table.num_rows:,} dropped_incomplete={dropped:,}") print(f"wrote {out} ({out.stat().st_size/1e6:.2f} MB)") - return df + return table def main() -> None: diff --git a/tests/test_gaul_lookup_fidelity.py b/tests/test_gaul_lookup_fidelity.py index 870672d..821ddfb 100644 --- a/tests/test_gaul_lookup_fidelity.py +++ b/tests/test_gaul_lookup_fidelity.py @@ -37,6 +37,7 @@ from pathlib import Path import numpy as np +import pyarrow as pa import pyarrow.parquet as pq import pytest @@ -302,22 +303,24 @@ def test_coordinate_formula_matches_every_priogrid_cell(): # ── the builder's own invariants must not be vacuous ──────────────────────── -def _synthetic_source(n: int = 6): - """A minimal well-formed source frame in the producer's column vocabulary.""" - import pandas as pd +def _synthetic_source(n: int = 6) -> pa.Table: + """A minimal well-formed source table in the producer's column vocabulary. - gids = list(range(1, n + 1)) - return pd.DataFrame( + Arrow since #90 — ``_load_source`` returns a table with a ``gid`` COLUMN rather than + a frame with a ``gid`` index, because arrow has no index. The builder's contract with + this seam is the column vocabulary, which is unchanged. + """ + return pa.table( { - "gaul0_code": [10] * n, - "gaul0_name": ["Country"] * n, - "gaul1_code": [20] * n, - "gaul1_name": ["Admin1"] * n, - "gaul2_code": [30] * n, - "gaul2_name": ["Admin2"] * n, - "iso3_code": ["ABC"] * n, - }, - index=pd.Index(gids, name="gid"), + "gid": pa.array(range(1, n + 1), type=pa.int64()), + "gaul0_code": pa.array([10] * n, type=pa.int64()), + "gaul0_name": pa.array(["Country"] * n), + "gaul1_code": pa.array([20] * n, type=pa.int64()), + "gaul1_name": pa.array(["Admin1"] * n), + "gaul2_code": pa.array([30] * n, type=pa.int64()), + "gaul2_name": pa.array(["Admin2"] * n), + "iso3_code": pa.array(["ABC"] * n), + } ) @@ -349,16 +352,16 @@ def _build_with(monkeypatch, source, tmp_path, provenance=None): def test_builder_rejects_a_duplicate_gid(monkeypatch, tmp_path): """C-59: prove the uniqueness guard is not vacuous. - Note the guard is only *reachable* with ``region="all"``. Under a region filter - pandas' ``Index.intersection`` silently de-duplicates first — an accidental + Note the guard is only *reachable* with ``region="all"``. Under a region filter the + old pandas build de-duplicated silently via ``Index.intersection`` — an accidental protection, not a declared one, which is why the explicit raise still earns its - place. See the C-59 narrative for the corrected exposure analysis. + place. Since #90 the filter is ``pc.is_in``, which does **not** de-duplicate, so the + raise is the only thing standing there. See the C-59 narrative. """ - import pandas as pd import scripts.build_gaul_lookup as builder source = _synthetic_source() - duplicated = pd.concat([source, source.iloc[[2]]]) + duplicated = pa.concat_tables([source, source.slice(2, 1)]) with pytest.raises(builder.LookupBuildError, match="duplicate gid"): _build_with(monkeypatch, duplicated, tmp_path) @@ -379,17 +382,48 @@ def test_a_sentinel_code_is_dropped_rather_than_shipped(monkeypatch, tmp_path): pandas internals, and a test that fragile is worse than the invariant it guards. """ source = _synthetic_source() - source.loc[3, "gaul1_code"] = -1 + codes = source.column("gaul1_code").to_pylist() + codes[2] = -1 + source = source.set_column( + source.column_names.index("gaul1_code"), "gaul1_code", + pa.array(codes, type=pa.int64()), + ) result = _build_with(monkeypatch, source, tmp_path) - assert 3 not in result.index, "the -1 cell must be dropped from the lookup" - assert len(result) == 5 + assert 3 not in result.column("priogrid_gid").to_pylist(), ( + "the -1 cell must be dropped from the lookup" + ) + assert result.num_rows == 5 + + +def test_builder_refuses_a_build_with_zero_cells(monkeypatch, tmp_path): + """Register C-76 — an empty lookup is writable and looks like a result. + + A ``--region`` that filters every cell out, or a join with no overlap across the + seven sources, produced a zero-row parquet and printed ``cells=0`` as though that + were an outcome. It failed later and confusingly: ``build_historical_table`` raises + about *missing geography*, not about an empty lookup, and by then the artifact is + committed. + + The first draft of the guard did not survive its own test. On an empty table + ``pa.array([True] * 0)`` infers NULL type, so ``pc.and_`` raised + ``ArrowNotImplementedError`` in the completeness filter — before the zero-row check + could say anything. The mask is now typed, and this asserts the refusal is the one a + human can act on. + """ + import scripts.build_gaul_lookup as builder + + empty = _synthetic_source(0) + with pytest.raises(builder.LookupBuildError, match="ZERO cells"): + _build_with(monkeypatch, empty, tmp_path) def test_builder_accepts_a_clean_source(monkeypatch, tmp_path): """The guards must not reject well-formed input.""" result = _build_with(monkeypatch, _synthetic_source(), tmp_path) - assert len(result) == 6 - assert list(result.columns) == METADATA_COLS + assert result.num_rows == 6 + # The nine declared columns in normative order, with the key last — the artifact's + # shipped shape, which #90 had to preserve exactly while changing what builds it. + assert result.column_names == METADATA_COLS + ["priogrid_gid"] @_needs_datafactory From 8e526dbdf9b616639965f83a7d2e645520fad009 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 4 Aug 2026 13:46:45 +0200 Subject: [PATCH 04/42] =?UTF-8?q?refactor:=20C-75=20=E2=80=94=20retire=20G?= =?UTF-8?q?aulLookupEnricher;=20epic=20#85's=20pandas=20claim=20becomes=20?= =?UTF-8?q?literal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decision was recorded in #90 as its Owner field required. This executes it. What went - views_postprocessing/contract/enrichment.py (317 lines) - tests/test_enrichment.py (39 tests) - docs/CICs/GaulLookupEnricher.md Why: zero production callers, its "build/verification path" justification spent once #90 made that path arrow-native, and its _gather duplicated contract/historical.py's shipping gather -- which four test files cover independently. C-45 is the precedent and was resolved the same way. No coverage of shipping code was lost The two tests elsewhere that imported the class both asserted only that its lookup_version agreed with gaul_lookup.version() -- two readers of one fact, checked against each other. They now read it through the declared reader the delivery uses, which is the half that was ever load-bearing. One guard deleted rather than kept: test_gaul_lookup_access asserted "GaulLookupEnricher" not in the manager source. With the class gone that cannot fail, and a test that cannot fail is decoration (ADR-014 s2). What it protected -- one lookup read per delivery -- is the first assertion in the same function and still bites. Fifteen files, and the sweep is the point The module, its tests, its CIC, the CIC index, two importing test files, the machinery list, the pandas-importer assertion, README's dependency table and package tree, role_and_seams' tree and contract list, ADR-012's ontology row and pandas claim, and three module docstrings. Every one was a live claim about a class that no longer exists, and NONE of the ADR or CIC references would have been caught by any guard -- which is C-80's whole argument. Two stale claims fixed while in the neighbourhood, both actively false rather than merely dated: frame_extraction.py described itself as the sibling of extraction.py (a module deleted in #151) and said "the pandas readers stay for the still-pandas path"; gaul_lookup.py described the enricher in the present tense. What it makes true `grep -rn "^import pandas\|^from pandas" views_postprocessing/ scripts/` returns nothing -- not a runtime import, not a type-only one. Epic #85's claim is literal now rather than nearly-true, and test_doc_accuracy's assertion changed from "exactly one type-only importer" to "none". 333 passed / 40 xfailed / 0 failed (-40: the enricher's 39, plus one retargeted). ruff clean. Register 83/14/69. Closes #85. Closes #93. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +- docs/ADRs/012_revised_ontology.md | 6 +- docs/CICs/GaulLookupEnricher.md | 193 -------- docs/CICs/README.md | 1 - docs/architecture/role_and_seams.md | 5 +- reports/technical_risk_register.md | 84 ++-- tests/test_clone_readiness.py | 1 - tests/test_doc_accuracy.py | 10 +- tests/test_enrichment.py | 419 ------------------ tests/test_gaul_lookup_access.py | 30 +- tests/test_gaul_lookup_fidelity.py | 12 +- tests/test_input_integrity_design_contract.py | 13 +- views_postprocessing/contract/enrichment.py | 317 ------------- .../contract/frame_extraction.py | 31 +- views_postprocessing/contract/gaul_lookup.py | 9 +- views_postprocessing/contract/gaul_schema.py | 2 +- views_postprocessing/unfao/managers/README.md | 2 +- 17 files changed, 117 insertions(+), 1023 deletions(-) delete mode 100644 docs/CICs/GaulLookupEnricher.md delete mode 100644 tests/test_enrichment.py delete mode 100644 views_postprocessing/contract/enrichment.py diff --git a/README.md b/README.md index 4f4cec9..361fbd4 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Requires **Python 3.11–3.14**. | Package | Version | Why | |---------|---------|-----| | `views-pipeline-core` | `>=3.0.0,<4.0.0` (with the `appwrite` extra) | The framework: lifecycle base classes, data loader, dataset container, Appwrite/datastore tools | -| `views-frames` | `>=1.10.2,<2` | The frame data contract — **the live delivery representation** since #126. pandas survives only in `contract/enrichment.py` (the build/verification path) | +| `views-frames` | `>=1.10.2,<2` | The frame data contract — **the live delivery representation** since #126. There is no pandas in this package at all since #90 | | `pyarrow` | `>=16.1.0,<17.0.0` | The wire's serialisation. **Pinned deliberately** — the CVE fix past 17 changes delivered bytes (register C-72) | | *dev group* | `pytest`, `ruff` | Not installed by `pip install views-postprocessing`; `poetry install` includes them | @@ -142,7 +142,6 @@ views-postprocessing/ │ ├── historical.py # the historical artifact, built pandas-free │ ├── gaul_lookup.py # the GAUL asset: path, version, one read │ ├── gaul_schema.py # the 9-column contract, declared as data - │ ├── enrichment.py # GaulLookupEnricher (build/verification path) │ ├── source_metadata.py # producer (datafactory) facts │ ├── store_metadata.py # prediction-store facts │ └── launch_config.py # the delivery mode the launcher must declare @@ -220,7 +219,7 @@ only `.py`; it now scans markdown too.)* |-----|----------------| | [`docs/architecture/role_and_seams.md`](docs/architecture/role_and_seams.md) | **Start here** — role vs the sibling repos + internal seams | | [`docs/ADRs/`](docs/ADRs/) | Architecture decisions (esp. ADR-011 mapper→lookup; ADR-012 ontology) | -| [`docs/CICs/`](docs/CICs/) | Class intent contracts (`UNFAOPostProcessorManager`, `GaulLookupEnricher`) | +| [`docs/CICs/`](docs/CICs/) | Class intent contracts (`UNFAOPostProcessorManager`) | | `reports/technical_risk_register.md` | Tracked risks — C-40 (the remaining pipeline-core inheritance), C-30/C-15 (delivery guards), C-43 (enrichment value verification) | --- diff --git a/docs/ADRs/012_revised_ontology.md b/docs/ADRs/012_revised_ontology.md index 433e70a..9bc11d4 100644 --- a/docs/ADRs/012_revised_ontology.md +++ b/docs/ADRs/012_revised_ontology.md @@ -14,7 +14,7 @@ a **runtime spatial-mapping engine** and **bundled shapefiles** as the authorita stable core. That architecture no longer exists: - [ADR-011](011_replace_runtime_mapper_with_precomputed_lookup.md) replaced the runtime - geopandas mapper with a **precomputed GAUL lookup table** (`GaulLookupEnricher`). + geopandas mapper with a **precomputed GAUL lookup table**. - The mapper, shapefiles, and caching machinery were deleted (C-39 / PR #42). - Input-integrity invariants and structured delivery were added (epic #51). @@ -60,7 +60,7 @@ described the cut-a-repo case; it now says which is which. | **Delivery Invariants** | Representation-free rules over primitives that a delivery must satisfy: coverage, no-collapse, gid parity, observed-range, provenance. Live in `delivery/` — **nothing there imports pandas or views_frames**. *Forecast identity was one of these until 2026-07-31 — see the amendment below.* | Authoritative — they define what a valid delivery is | Stable — changes are governance decisions | | **Representation Seam** | `contract/frame_extraction.py` — turns a `views_frames` frame into the primitives the invariants consume. **One seam.** Its pandas sibling `unfao/extraction.py` was deleted in #151 once the pandas delivery was retired; the two ran as deliberate WET siblings through the migration. | Derived — isolates the representation so invariants stay representation-free | Evolving | | **Wire Mechanism** | `contract/wire/` — the ADR-013 contract: header, shard, sidecar, run manifest, sink, source selection. Partner-neutral: it takes its consumer name and collapse floor as **arguments** (#153). | Authoritative — the contract with the consumer | Stable — changes are contract amendments | -| **Enrichment Asset** | The precomputed GAUL lookup (`data/gaul_lookup.parquet`), its identity in `contract/gaul_lookup.py`, its schema in `contract/gaul_schema.py`, and the keyed gather that joins it (`contract/enrichment.py`, the build/verification path — numpy/pyarrow since #89; see register **C-75** on whether that class should survive at all). | Authoritative for geographic metadata | Stable — rebuilt only when the producer releases new GAUL data | +| **Enrichment Asset** | The precomputed GAUL lookup (`data/gaul_lookup.parquet`), its identity in `contract/gaul_lookup.py`, its schema in `contract/gaul_schema.py`, and the keyed gathers that join it — `contract/historical.py` for the actuals artifact and `contract/wire/sidecar.py` for the §5 sidecar. *(A second gather lived in `contract/enrichment.py` with no production caller; retired in #90, register **C-75**.)* | Authoritative for geographic metadata | Stable — rebuilt only when the producer releases new GAUL data | | **Artifact Builders** | `contract/historical.py` — turns a frame plus the lookup into the partner-facing artifact. | Derived | Evolving | | **External Facts** | Facts read from systems this repo does not own: the producer's (`contract/source_metadata.py` — `last_valid_month_id`, D-07) and the store's (`contract/store_metadata.py`). | Authoritative (the owning system is the source of truth) | Evolving | | **Launch Declarations** | `contract/launch_config.py` — the delivery mode the launcher must declare. Omitting a key is **refused by name**, never inferred (ADR-003, register C-63). | Authoritative | Stable | @@ -73,7 +73,7 @@ described the cut-a-repo case; it now says which is which. 636 lines holding two of everything, and it called `unfao/extraction.py` *"the **single** pandas-aware module"* when pandas lived in three. Both drifted the same way: the ADR described the intended end state of a migration that then stopped one step short. Both are now true — -pandas has **zero runtime importers** (#89 made `contract/enrichment.py`'s a type-only import under `if TYPE_CHECKING` — pandas is in that class's interface, not its implementation) — and the load-bearing ones are +pandas is **absent from the package entirely** (#89 reduced the last one to a type-only import; #90 retired the module that held it — register C-75) — and the load-bearing ones are **mechanically checked** by `tests/test_doc_accuracy.py`, so the next drift fails CI instead of waiting for an audit. diff --git a/docs/CICs/GaulLookupEnricher.md b/docs/CICs/GaulLookupEnricher.md deleted file mode 100644 index a49cfc5..0000000 --- a/docs/CICs/GaulLookupEnricher.md +++ /dev/null @@ -1,193 +0,0 @@ -# Class Intent Contract: GaulLookupEnricher - -**Status:** Draft -**Owner:** PRIO MD&D Team -**Last reviewed:** 2026-06-18 -**Related ADRs:** ADR-011 (replace runtime mapper with precomputed lookup) - ---- - -## 1. Purpose - -> Attach the 9 geographic metadata columns to a prediction frame by merging a -> precomputed GAUL lookup table on the PRIO-GRID cell id. - -It is the lookup-based replacement for `PriogridCountryMapper`'s runtime spatial -enrichment: the spatial computation has already happened upstream (the -views-datafactory area-majority join), so this class does only a table join. - ---- - -## 2. Non-Goals (Explicit Exclusions) - -- This class does **not** perform spatial computation — no geometry, no - shapefiles, no geopandas, no area-majority calculation. -- This class does **not** build the lookup table (that is - `scripts/build_gaul_lookup.py`, run offline). -- This class does **not** fill, impute, or invent metadata for unmatched cells. -- This class does **not** validate the result. Null/coverage enforcement lives on the - delivery path — `contract/historical.assert_metadata_complete` at artifact build and - `delivery/coverage.py` for the region contract. It is **not** the manager's - `_validate()`, which stopped null-gating in #149 and now asserts only that the read - resolved. (Corrected 2026-08-02; PR #200 retired the same claim in the manager's CIC - and this one was left standing.) -- This class does **not** read from the datafactory, viewser, or Appwrite. - ---- - -## 3. Responsibilities and Guarantees - -- Loads exactly one lookup Parquet at construction and verifies it carries the key - plus the 9 contract columns; missing columns raise at construction, and so does an - **empty** lookup — every cell would gather to null and the delivery would then - complain about missing metadata rather than about a missing lookup (S4 / #89). -- Returns the input frame augmented with exactly the 9 columns of - `gaul_schema.METADATA_COLS`: codes numeric, coordinates float, **names and iso as - `object`**. - *Changed in S4 (#89).* They were `category`, inherited from the pandas merge that - read the artifact's dictionary encoding. The gather that replaced it assigns plain - values. Measured on a 200-row output: category 1,795,191 bytes, object 60,061 — - a categorical carries the artifact's full 64,742-entry dictionary whatever the - output size. The builder still writes the artifact with dictionary-encoded names - (`# names/iso categorical (C-32 memory)`); that governs the file, not this output. -- A cell id present in the lookup is enriched with that cell's metadata. -- A cell id **absent** from the lookup yields **null** metadata for that row — - never a sentinel, never a fabricated value (fail-loud downstream). -- Row count, row order **and index** of the input are preserved. Pinned by - `tests/test_enrichment.py::TestFramePropertiesPreserved` across six shapes — - ordered, reversed, duplicated, single, empty, and a non-default index. - (An earlier draft of this line claimed *eight* shapes "including unknown gids", - counting a throwaway development script rather than the committed suite, and naming - a shape that class does not exercise. Unknown gids are covered, for null-value - correctness, by `TestFailLoud` — a different guarantee.) - On **empty** input this is now *more* true than before: the pandas merge replaced the - input's `RangeIndex` with an object-dtype `Index`, where the gather leaves it - untouched. The only behavioural difference found, and it is in the direction the - guarantee above already claimed. - ---- - -## 4. Inputs and Assumptions - -- A lookup Parquet exists at the configured path (default: the committed - `views_postprocessing/data/gaul_lookup.parquet`), indexed by `priogrid_gid`, - containing only fully-complete cells (no nulls, no `-1`, no empty strings). -- The input DataFrame has a column named by `pg_id_col` holding PRIO-GRID cell - ids; a missing `pg_id_col` raises `ValueError`. -- The lookup is the single source of geographic truth — the caller does not - expect this class to reconcile it against any other source. - ---- - -## 5. Outputs and Side Effects - -- Output: the input frame (or, with `only_metadata=True`, just `pg_id_col` + - `time_id_col`) with the 9 metadata columns attached by **keyed gather**. - *Changed in S4 (#89):* this was a pandas left-merge on the lookup's index. The - lookup is now read with pyarrow, sorted once, and addressed by `np.searchsorted`. - Attaching metadata to a frame is a keyed gather, not frame algebra, and it never - needed a merge — which is also what frees the builder to stop writing pandas index - metadata (S5 / #90). -- Public attribute: `lookup_version` — a short, stampable id read from the - lookup's **declared** `lookup_version` metadata key at construction - (`@<8-char source digest>`). Delegates to - `contract.gaul_lookup.version`. The manager stamps it on each delivery so a - delivery is traceable to the exact lookup build. - **It does not degrade.** An artifact carrying no declared key raises - `gaul_lookup.LookupVersionError` (logged at ERROR first, per ADR-008) rather - than returning a placeholder. Until S5 (#186) this returned the string - `"unknown"` whenever views-datafactory's ingestion-ledger shape moved under - it — silently, in the one field register C-15 exists to answer *after* a - suspect delivery. See register **C-60**. -- Side effects: logs the lookup size + version at construction (INFO); logs a - WARNING with the count and sample of unmatched cell ids when any occur; logs - ignored mapper-only kwargs at DEBUG. No file writes, no network. - ---- - -## 6. Failure Modes and Loudness - -- **Raises** at construction if the lookup file is missing or lacks a contract - column. -- **Raises** `ValueError` if `pg_id_col` is not in the input. -- **Does not raise** on unmatched cells — it surfaces them as nulls and logs a - WARNING naming the unknown cells, and separately counting rows that carried no - usable cell id at all. This is deliberate: enforcement is a single point on the - delivery path (`historical.assert_metadata_complete`), so a coverage hole fails - loudly there, not in two places. Passing a sentinel for unmatched cells would be a **bug** (it would - bypass that gate). Aligns with ADR-003 (fail loud on semantic ambiguity). - ---- - -## 7. Boundaries and Interactions - -- Allowed to depend on: numpy, pyarrow, `gaul_schema`, and a local Parquet file. - **pandas is interface-only** — callers hand this class DataFrames and get one back, - but nothing here constructs, reads or joins one, and its import is under - `if TYPE_CHECKING` (S4 / #89). `tests/test_doc_accuracy.py` asserts by AST that the - package has **zero** runtime pandas importers. -- Must **not** depend on: geopandas/shapely, the runtime mapper, the - datafactory, viewser, Appwrite, or any network resource. -- Treats the lookup table as an opaque, trusted artifact produced by the build - script; it does not re-validate the table's spatial correctness. - ---- - -## 8. Examples of Correct Usage - -```python -enricher = GaulLookupEnricher() -out = enricher.enrich_dataframe_with_pg_info( - df.reset_index(), pg_id_col="priogrid_gid", time_id_col="month_id", - only_metadata=True, -) -# out has the 9 metadata columns; unmatched cells are null. -``` - -Signature-compatible with the runtime mapper it replaced (same method name and key -kwargs), which is why the mapper-only kwargs are still accepted and ignored. -**The manager does not call this class** — it has no `enrich` reference at all, and -reads the lookup directly via `gaul_lookup.load()` (register C-66). This example is a -build/verification-path usage. Whether the class should survive that is register -**C-75**. - ---- - -## 9. Examples of Incorrect Usage - -- Filling unmatched cells with `-1`/`""`/`"unknown"` to "avoid validation - errors" — defeats the fail-loud contract and ships wrong data to FAO. -- Using it to enrich against a lookup built for a different region without - expecting nulls for out-of-region cells. -- Calling it expecting spatial recomputation when the lookup is stale — regenerate - the lookup with the build script instead. - ---- - -## 10. Test Alignment - -`tests/test_enrichment.py`: -- **Green:** the 9 columns present; values match the lookup; row count preserved; - codes numeric / coords float. -- **Beige:** lookup integrity (cell count, no nulls, no `-1`, dtypes); coordinate - formula (independent oracle). -- **Red:** unknown cell id and excluded ocean cells yield null; missing - `pg_id_col` raises. - ---- - -## 11. Evolution Notes - -- Stable: the 9-column contract and the fail-loud-on-unmatched behavior (changing - either is a coordinated change across this repo and views-faoapi). -- Expected to change: the lookup's cell-set/region and its source GAUL version, - via re-running the build script. Such regenerations must not change the schema. - ---- - -## End of Contract - -This document defines the **intended meaning** of `GaulLookupEnricher`. - -Changes to behavior that violate this intent are bugs. -Changes to intent must update this contract. diff --git a/docs/CICs/README.md b/docs/CICs/README.md index 177b1d6..4b2b88b 100644 --- a/docs/CICs/README.md +++ b/docs/CICs/README.md @@ -51,7 +51,6 @@ Contracts must be clear enough that: ## Active Contracts - `UNFAOPostProcessorManager.md` — Pipeline orchestration manager (read → transform → validate → save) -- `GaulLookupEnricher.md` — Precomputed GAUL lookup enrichment (ADR-011; replaced the runtime mapper) --- diff --git a/docs/architecture/role_and_seams.md b/docs/architecture/role_and_seams.md index 1a4e99a..9af8da0 100644 --- a/docs/architecture/role_and_seams.md +++ b/docs/architecture/role_and_seams.md @@ -136,7 +136,7 @@ historical actuals arrive as a `FeatureFrame` via pipeline-core's `get_feature_f (#126 — this repo was its first production consumer), the forecast interior is `PredictionFrame`, and `contract/frames.py` is the **live constructor** every interior frame is built through — not the unused adapter it was when this was written, and it supports -`S > 1`. pandas survives in exactly one module, `contract/enrichment.py`, on the +`S > 1`. pandas is gone from the package entirely since #90 retired the last module holding it (register C-75); what remains is on the build/verification path. What **remains** of **register C-40** is narrower than this paragraph implies: the manager is @@ -181,7 +181,6 @@ views_postprocessing/ │ ├── historical.py the historical artifact, built pandas-free │ ├── gaul_lookup.py the GAUL asset: path, version, one read per delivery │ ├── gaul_schema.py the 9-column contract, declared as data -│ ├── enrichment.py GaulLookupEnricher (build/verification path; the last pandas) │ ├── source_metadata.py producer (datafactory) facts, e.g. last_valid_month_id │ ├── store_metadata.py prediction-store facts │ └── launch_config.py the delivery mode the launcher must declare @@ -204,7 +203,7 @@ views_postprocessing/ ## 7. Where to go next - **What was decided and why** → `docs/ADRs/` (esp. ADR-011 mapper→lookup; ADR-012 ontology). -- **Per-class contracts** → `docs/CICs/` (`UNFAOPostProcessorManager`, `GaulLookupEnricher`). +- **Per-class contracts** → `docs/CICs/` (`UNFAOPostProcessorManager`). - **Live risks / open constraints** → the technical risk register (C-40 the pandas gate, C-25/C-30/C-15 the delivery guards). - **The frame/draws future** → #45 (delivery-side draw carrier) and C-40. diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 99292bf..548f439 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -6,8 +6,8 @@ | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-03 | | Total Concerns | 83 | -| Open Concerns | 15 | -| Resolved Concerns | 68 | +| Open Concerns | 14 | +| Resolved Concerns | 69 | --- @@ -421,41 +421,6 @@ Cross-refs: **C-62** (the transitive dependency drag; the other 31 alerts), **C- --- -### C-75: `GaulLookupEnricher` has no production caller, and now implements a second copy of the delivery path's keyed gather - -| Field | Value | -|-------|-------| -| ID | C-75 | -| Tier | 3 — no correctness impact today: the class is off the delivery path, so a defect in it cannot reach the UN FAO. The cost is that **the verification path and the delivery path now implement the same algorithm twice**, and the tests that check the artifact run through the copy that does *not* ship. A fix applied to one and not the other makes the verification stop verifying what ships — quietly, because both would still pass their own tests. | -| Source | `code-review max` (2026-08-02) — PR #210, five parallel reviewers; two reached this independently | -| Trigger | When a bug is fixed in `contract/historical.py`'s gather (the one that ships), check whether `contract/enrichment.py`'s copy needs the same fix — nothing links them. Also fires at **S5 (#90)**: once the builder is pyarrow-native, the enricher's pandas interface is the last one in the package, and the question "does this class survive?" has to be answered rather than deferred again. | -| Owner | Whoever takes **#90** — the keep-or-retire decision is theirs to make and record, not to defer a third time. Added 2026-08-03: the first draft of this entry named two triggers and no owner, while citing ADR-014 §4 in its own body. This register had already learned that twice — *"a deferral needs an owner and a trigger, not just a reason"* (Cluster L) and *"a decision awaiting an owner, not a task awaiting effort"* (epic #181 closeout). | -| Location | `views_postprocessing/contract/enrichment.py` (the whole class; `_gather` specifically); the shipping twin is `views_postprocessing/contract/historical.py:54-68` | - -**Verified, not inferred (2026-08-02):** `grep -rn "GaulLookupEnricher\|enrich_dataframe_with_pg_info"` across the package finds **zero** production callers — the two hits are docstring mentions in `gaul_lookup.py`. The manager calls `gaul_lookup.load()` directly and has zero `enrich` references. **C-66**'s resolution already said this plainly: *"the pandas enricher leaves the delivery path entirely."* - -**What PR #210 did, and why that raises the question.** S4 (#89) rewrote this class's lookup side from a pandas merge to a numpy/pyarrow keyed gather: a measured dtype analysis, an empty-lookup guard, a mutation-proven bug fix, a corrected CIC, and five reviewers' attention. All of it spent on a method with no reachable caller outside its own test suite. The engineering is sound; what is missing is anyone having **decided** that the class should exist. - -**The duplication is the concrete consequence.** `_gather`'s `argsort → searchsorted → clip → equality-mask` is the same shape as `historical.py:54-68`. The policies differ deliberately — `historical` **raises** on an absent gid (*"geography must never silently vanish"*), the enricher returns nulls for the downstream gate to catch — so extracting a shared helper would mean parameterising the failure policy, which is the guessed abstraction **WET before DRY** exists to prevent. Two copies that are understood is the right call *today*. The trigger above is what stops "today" lasting indefinitely, per **ADR-014 §4**. - -**The precedent is C-45**, `unfao/frames.py`: an unused adapter carried on no live path, resolved by deleting it. This is the same shape with a different module, and the same question — keep it as the declared verification/reference implementation, or retire it and let the fidelity suite test `historical.py` directly. - -**DECISION 2026-08-04 (#90), which this entry's Owner field required of whoever took it: RETIRE.** - -The conditions are no longer arguable. The class has **zero production callers** — only three test files import it. Its last stated justification was "the build/verification path", and #90 rewrote that path arrow-native without touching it, so the justification is spent. It holds the package's **last pandas reference** (a `TYPE_CHECKING` import), which is the one thing standing between epic #85 and an honest close. And its `_gather` duplicates `contract/historical.py`'s shipping gather, which is independently covered by four test files. - -**C-45 is the precedent and it was resolved by deleting.** Same shape, different module. - -**Not executed in #90, deliberately.** The retirement touches ten files — the module, its 39 tests, references in two other test files, its CIC, ADR-012, `gaul_lookup.py`'s docstring, the machinery list in `test_clone_readiness.py`, and the pandas-importer assertion in `test_doc_accuracy.py`. Folding that into a builder rewrite would mix a behaviour-preserving change with a large deletion, which is the thing epic #148's S5 explicitly refused to do. It is the next change, not a later one. - -**Epic #85 and tracking #93 stay open until it lands**, because their claim — pandas pushed to the seams — only becomes true when this module is gone. - -**Deliberately NOT registered from the same review** (defects in unmerged code, all fixed in #210 before merge rather than tracked): a NaN gid crashing the warning path, the unvalidated int64 coercion at both ends, the AST guard's `else`-branch blind spot, ADR-012's stale pandas-merge claim, and three CIC claims retired elsewhere by #200. The register tracks standing risk; a defect fixed before it ships is not one. They are recorded in the PR. - -Cross-refs: **C-45** (RESOLVED — the same shape, resolved by deletion), **C-66** (RESOLVED — established the enricher left the delivery path), **C-40** (which calls `enrichment.py` and `extraction.py` together *"the retired-in-place `enrichment.py`/`extraction.py` legacy seams"*), **#89** / **#90** / epic **#85**, ADR-014 §4. - ---- - ### C-80: The doc-accuracy scan exempts ADRs and CICs — the two artifact classes that define the contracts | Field | Value | @@ -664,6 +629,51 @@ See also C-40 (the inheritance/representation coupling this migration unwinds), ## Resolved Concerns +### C-75: `GaulLookupEnricher` has no production caller, and now implements a second copy of the delivery path's keyed gather — RESOLVED + +| Field | Value | +|-------|-------| +| ID | C-75 | +| Tier | 3 — no correctness impact today: the class is off the delivery path, so a defect in it cannot reach the UN FAO. The cost is that **the verification path and the delivery path now implement the same algorithm twice**, and the tests that check the artifact run through the copy that does *not* ship. A fix applied to one and not the other makes the verification stop verifying what ships — quietly, because both would still pass their own tests. | +| Source | `code-review max` (2026-08-02) — PR #210, five parallel reviewers; two reached this independently | +| Trigger | When a bug is fixed in `contract/historical.py`'s gather (the one that ships), check whether `contract/enrichment.py`'s copy needs the same fix — nothing links them. Also fires at **S5 (#90)**: once the builder is pyarrow-native, the enricher's pandas interface is the last one in the package, and the question "does this class survive?" has to be answered rather than deferred again. | +| Owner | Whoever takes **#90** — the keep-or-retire decision is theirs to make and record, not to defer a third time. Added 2026-08-03: the first draft of this entry named two triggers and no owner, while citing ADR-014 §4 in its own body. This register had already learned that twice — *"a deferral needs an owner and a trigger, not just a reason"* (Cluster L) and *"a decision awaiting an owner, not a task awaiting effort"* (epic #181 closeout). | +| Location | `views_postprocessing/contract/enrichment.py` (the whole class; `_gather` specifically); the shipping twin is `views_postprocessing/contract/historical.py:54-68` | + +**Verified, not inferred (2026-08-02):** `grep -rn "GaulLookupEnricher\|enrich_dataframe_with_pg_info"` across the package finds **zero** production callers — the two hits are docstring mentions in `gaul_lookup.py`. The manager calls `gaul_lookup.load()` directly and has zero `enrich` references. **C-66**'s resolution already said this plainly: *"the pandas enricher leaves the delivery path entirely."* + +**What PR #210 did, and why that raises the question.** S4 (#89) rewrote this class's lookup side from a pandas merge to a numpy/pyarrow keyed gather: a measured dtype analysis, an empty-lookup guard, a mutation-proven bug fix, a corrected CIC, and five reviewers' attention. All of it spent on a method with no reachable caller outside its own test suite. The engineering is sound; what is missing is anyone having **decided** that the class should exist. + +**The duplication is the concrete consequence.** `_gather`'s `argsort → searchsorted → clip → equality-mask` is the same shape as `historical.py:54-68`. The policies differ deliberately — `historical` **raises** on an absent gid (*"geography must never silently vanish"*), the enricher returns nulls for the downstream gate to catch — so extracting a shared helper would mean parameterising the failure policy, which is the guessed abstraction **WET before DRY** exists to prevent. Two copies that are understood is the right call *today*. The trigger above is what stops "today" lasting indefinitely, per **ADR-014 §4**. + +**The precedent is C-45**, `unfao/frames.py`: an unused adapter carried on no live path, resolved by deleting it. This is the same shape with a different module, and the same question — keep it as the declared verification/reference implementation, or retire it and let the fidelity suite test `historical.py` directly. + +**DECISION 2026-08-04 (#90), which this entry's Owner field required of whoever took it: RETIRE.** + +The conditions are no longer arguable. The class has **zero production callers** — only three test files import it. Its last stated justification was "the build/verification path", and #90 rewrote that path arrow-native without touching it, so the justification is spent. It holds the package's **last pandas reference** (a `TYPE_CHECKING` import), which is the one thing standing between epic #85 and an honest close. And its `_gather` duplicates `contract/historical.py`'s shipping gather, which is independently covered by four test files. + +**C-45 is the precedent and it was resolved by deleting.** Same shape, different module. + +**Not executed in #90, deliberately.** The retirement touches ten files — the module, its 39 tests, references in two other test files, its CIC, ADR-012, `gaul_lookup.py`'s docstring, the machinery list in `test_clone_readiness.py`, and the pandas-importer assertion in `test_doc_accuracy.py`. Folding that into a builder rewrite would mix a behaviour-preserving change with a large deletion, which is the thing epic #148's S5 explicitly refused to do. It is the next change, not a later one. + +**Epic #85 and tracking #93 stay open until it lands**, because their claim — pandas pushed to the seams — only becomes true when this module is gone. + +**Deliberately NOT registered from the same review** (defects in unmerged code, all fixed in #210 before merge rather than tracked): a NaN gid crashing the warning path, the unvalidated int64 coercion at both ends, the AST guard's `else`-branch blind spot, ADR-012's stale pandas-merge claim, and three CIC claims retired elsewhere by #200. The register tracks standing risk; a defect fixed before it ships is not one. They are recorded in the PR. + +Cross-refs: **C-45** (RESOLVED — the same shape, resolved by deletion), **C-66** (RESOLVED — established the enricher left the delivery path), **C-40** (which calls `enrichment.py` and `extraction.py` together *"the retired-in-place `enrichment.py`/`extraction.py` legacy seams"*), **#89** / **#90** / epic **#85**, ADR-014 §4. + +**RESOLVED 2026-08-04 — retired, as the decision recorded above required.** `views_postprocessing/contract/enrichment.py` and its 39 tests are deleted, along with `docs/CICs/GaulLookupEnricher.md`. + +**No coverage of shipping code was lost.** The two tests elsewhere that imported the class both asserted only that its `lookup_version` agreed with `gaul_lookup.version()` — two readers of one fact, checked against each other. They now read the fact through the declared reader the delivery itself uses, which is the half that was ever load-bearing. The gather it duplicated is `contract/historical.py`'s, covered independently by four test files. + +**One guard was deleted rather than kept.** `test_gaul_lookup_access.py` asserted `"GaulLookupEnricher" not in` the manager source. With the class gone that assertion cannot fail, and a test that cannot fail is decoration (ADR-014 §2). What it protected — one lookup read per delivery — is the first assertion in the same function and still bites. + +**Fifteen files, and the sweep is the point.** The module, its tests, its CIC, the CIC index, two test files that imported it, the machinery list, the pandas-importer assertion, `README.md`'s dependency table and package tree, `role_and_seams.md`'s tree and contract list, ADR-012's ontology row and its pandas claim, and two module docstrings. Every one of those was a live claim about a class that no longer exists — which is the argument for C-80: none of the ADR or CIC references would have been caught by any guard. + +**What it makes true.** `grep -rn "^import pandas\|^from pandas" views_postprocessing/ scripts/` now returns nothing at all — not a runtime import, not a type-only one. Epic **#85**'s claim, *pandas pushed to the seams*, is finally literal rather than nearly-true, and #85 and #93 close with this. + +--- + ### C-76: `build_gaul_lookup.py` will write an empty lookup without complaint — RESOLVED | Field | Value | diff --git a/tests/test_clone_readiness.py b/tests/test_clone_readiness.py index 549bb77..79413fe 100644 --- a/tests/test_clone_readiness.py +++ b/tests/test_clone_readiness.py @@ -64,7 +64,6 @@ "views_postprocessing.contract.historical", "views_postprocessing.contract.gaul_lookup", "views_postprocessing.contract.gaul_schema", - "views_postprocessing.contract.enrichment", "views_postprocessing.contract.launch_config", "views_postprocessing.contract.source_metadata", "views_postprocessing.contract.store_metadata", diff --git a/tests/test_doc_accuracy.py b/tests/test_doc_accuracy.py index 3e295d0..1669d72 100644 --- a/tests/test_doc_accuracy.py +++ b/tests/test_doc_accuracy.py @@ -253,9 +253,11 @@ def test_pandas_is_not_imported_at_runtime_anywhere_in_the_package(): "the representation the migration removed. If it is genuinely needed, put it " "behind `if TYPE_CHECKING` or say in ADR-012 why it is not." ) - assert type_only == ["contract/enrichment.py"], ( - f"the type-only pandas imports moved: {type_only}. Not necessarily wrong — but " - "ADR-012 names the seam, so update it rather than letting the claim drift." + assert type_only == [], ( + f"a type-only pandas import reappeared: {type_only}. There is no pandas in this " + "package at all since #90 retired contract/enrichment.py (register C-75) — epic " + "#85's claim is now literal, not nearly-true. If one is genuinely needed, say in " + "ADR-012 why before adding it back." ) @@ -448,7 +450,7 @@ def test_the_ban_covers_the_post_148_deletions_and_spares_the_live_lookalike(): ``_BANNED`` says a deletion PR that does not extend it has not finished. No test can substitute for that; only the habit can. 2. **Too broad** — `FAO_PGMDataset` is a *views-faoapi* class, alive and correctly - cited in `contract/gaul_schema.py` and `contract/enrichment.py`. A careless + cited in `contract/gaul_schema.py`. A careless `PGMDataset` pattern would ban a true statement about another repo's code. """ for deleted in ( diff --git a/tests/test_enrichment.py b/tests/test_enrichment.py deleted file mode 100644 index ca1ea6c..0000000 --- a/tests/test_enrichment.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Tests for the lookup-based enricher (ADR-011, Stage 1). - -Run against the committed gaul_lookup.parquet — no datafactory checkout or -shapefiles required. These tests pin the contract the enricher must satisfy so -that swapping it in for the runtime mapper (Stage 3) is invisible downstream. -""" - -import pandas as pd -import logging - -import numpy as np -import pyarrow as pa -import pyarrow.parquet as pq -import pytest - -from views_postprocessing.contract import gaul_lookup -from views_postprocessing.contract.enrichment import ( - GaulLookupEnricher, - METADATA_COLS, -) - -# The 5 africa_me_legacy ocean cells that have no GAUL assignment — they must -# NOT be in the lookup (they are excluded upstream by the land_gaul region). -OCEAN_CELLS = [62356, 94776, 99027, 107733, 107742] - -CODE_COLS = ["admin1_gaul1_code", "admin1_gaul0_code", "admin2_gaul2_code"] -NAME_COLS = ["admin1_gaul1_name", "admin1_gaul0_name", "admin2_gaul2_name", - "country_iso_a3"] - - -@pytest.fixture(scope="module") -def enricher(): - return GaulLookupEnricher() - - -@pytest.fixture(scope="module") -def lookup(): - """The committed artifact as a pandas frame, built here rather than reached for. - - Until S4 (#89) this was ``enricher._lookup`` — the enricher's own private pandas - frame. It no longer has one: the lookup side is numpy + pyarrow, and pandas is a - type-only import there. These tests are about the **artifact**, not the enricher, - so they load it directly. - - The ``set_index`` is deliberate and forward-looking. ``to_pandas()`` currently - restores ``priogrid_gid`` as the index from the parquet's embedded pandas - metadata — metadata that **S5 (#90) removes** when the builder becomes - pyarrow-native. Handling both shapes means these tests do not have to change - again then. - """ - df = gaul_lookup.load().to_pandas() - if "priogrid_gid" in df.columns: - df = df.set_index("priogrid_gid") - return df - - -class TestLookupIntegrity: - """The committed lookup must be clean by construction.""" - - def test_expected_cell_count(self, lookup): - # land_gaul region: 64,742 fully-complete land cells. - assert len(lookup) == 64_742 - - def test_no_nulls_anywhere(self, lookup): - assert int(lookup.isna().sum().sum()) == 0 - - def test_no_minus_one_sentinel_in_codes(self, lookup): - for c in CODE_COLS: - assert (lookup[c] != -1).all(), f"{c} has -1 sentinel" - - def test_no_empty_strings_in_names(self, lookup): - for c in NAME_COLS: - assert (lookup[c].astype(str).str.len() > 0).all() - - def test_has_exactly_the_nine_contract_columns(self, lookup): - assert list(lookup.columns) == METADATA_COLS - - def test_dtypes(self, lookup): - for c in CODE_COLS: - assert pd.api.types.is_numeric_dtype(lookup[c]), c - for c in ["pg_xcoord", "pg_ycoord"]: - assert pd.api.types.is_float_dtype(lookup[c]), c - for c in NAME_COLS: - assert isinstance(lookup[c].dtype, pd.CategoricalDtype), c - - def test_ocean_cells_excluded(self, lookup): - for gid in OCEAN_CELLS: - assert gid not in lookup.index, f"ocean cell {gid} should be excluded" - - -class TestCoordinateFormula: - """pg_xcoord/pg_ycoord must follow the PRIO-GRID 0.5-degree formula.""" - - def test_known_coordinates(self, lookup): - for gid in lookup.index[:50]: - x = -180.0 + ((gid - 1) % 720) * 0.5 + 0.25 - y = -90.0 + ((gid - 1) // 720) * 0.5 + 0.25 - assert lookup.loc[gid, "pg_xcoord"] == pytest.approx(x) - assert lookup.loc[gid, "pg_ycoord"] == pytest.approx(y) - - -class TestEnricherContract: - """enrich() must reproduce the 9-column contract the manager consumes.""" - - def _frame(self, gids): - return pd.DataFrame({ - "priogrid_gid": gids, - "month_id": [100] * len(gids), - }) - - def test_all_metadata_cols_present(self, enricher, lookup): - gids = lookup.index[:10].tolist() - out = enricher.enrich_dataframe_with_pg_info( - self._frame(gids), pg_id_col="priogrid_gid", time_id_col="month_id", - ) - for c in METADATA_COLS: - assert c in out.columns - - def test_no_nulls_for_known_gids(self, enricher, lookup): - gids = lookup.index[:100].tolist() - out = enricher.enrich_dataframe_with_pg_info( - self._frame(gids), pg_id_col="priogrid_gid", time_id_col="month_id", - ) - for c in METADATA_COLS: - assert out[c].isna().sum() == 0, c - - def test_values_match_lookup(self, enricher, lookup): - gids = lookup.index[:20].tolist() - out = enricher.enrich_dataframe_with_pg_info( - self._frame(gids), pg_id_col="priogrid_gid", time_id_col="month_id", - ).set_index("priogrid_gid") - for gid in gids: - assert out.loc[gid, "country_iso_a3"] == lookup.loc[gid, "country_iso_a3"] - assert out.loc[gid, "admin2_gaul2_code"] == lookup.loc[gid, "admin2_gaul2_code"] - - def test_row_count_preserved(self, enricher, lookup): - gids = lookup.index[:7].tolist() - out = enricher.enrich_dataframe_with_pg_info( - self._frame(gids), pg_id_col="priogrid_gid", time_id_col="month_id", - ) - assert len(out) == 7 - - def test_codes_numeric_coords_float(self, enricher, lookup): - gids = lookup.index[:5].tolist() - out = enricher.enrich_dataframe_with_pg_info( - self._frame(gids), pg_id_col="priogrid_gid", time_id_col="month_id", - ) - for c in CODE_COLS: - assert pd.api.types.is_numeric_dtype(out[c]), c - for c in ["pg_xcoord", "pg_ycoord"]: - assert pd.api.types.is_float_dtype(out[c]), c - - -class TestFailLoud: - """Unknown / excluded cells must surface as NaN, not a sentinel.""" - - def test_unknown_gid_yields_null(self, enricher, lookup): - good = int(lookup.index[0]) - df = pd.DataFrame({"priogrid_gid": [good, 999_999], "month_id": [1, 1]}) - out = enricher.enrich_dataframe_with_pg_info( - df, pg_id_col="priogrid_gid", time_id_col="month_id", - ).set_index("priogrid_gid") - assert out.loc[good, "country_iso_a3"] is not None - assert pd.isna(out.loc[999_999, "country_iso_a3"]) - for c in CODE_COLS: - assert pd.isna(out.loc[999_999, c]) - - def test_ocean_cell_yields_null(self, enricher): - df = pd.DataFrame({"priogrid_gid": OCEAN_CELLS, - "month_id": [1] * len(OCEAN_CELLS)}) - out = enricher.enrich_dataframe_with_pg_info( - df, pg_id_col="priogrid_gid", time_id_col="month_id", - ) - # Every ocean cell is absent from the lookup -> all-null metadata. - assert out["country_iso_a3"].isna().all() - - def test_missing_pg_id_col_raises(self, enricher): - with pytest.raises(ValueError, match="not found"): - enricher.enrich_dataframe_with_pg_info( - pd.DataFrame({"x": [1]}), pg_id_col="priogrid_gid", - ) - - -# ── the guarantees the CIC states, pinned (S4 / #89; ADR-014 §1) ───────────── -# -# The CIC claims row/order/index preservation "verified across eight input shapes" -# and names the output dtypes. That verification was a throwaway script run once -# during development — a guarantee resting on a claim rather than on a check, which -# is precisely what ADR-014 §1 forbids, written by the story after the ADR landed. -# These commit it. - - -class TestConstructionRefusals: - """Degenerate lookups must fail at construction, naming the lookup.""" - - def _write(self, path, gids): - cols = {"priogrid_gid": pa.array(gids, pa.int64())} - for c in METADATA_COLS: - cols[c] = pa.array( - [0.0] * len(gids) if c in ("pg_xcoord", "pg_ycoord") or c.endswith("_code") - else ["x"] * len(gids), - pa.float64() if c in ("pg_xcoord", "pg_ycoord") or c.endswith("_code") else pa.string(), - ) - t = pa.table(cols).replace_schema_metadata({b"lookup_version": b"t@00000000"}) - pq.write_table(t, path) - return path - - def test_an_empty_lookup_is_refused_at_construction(self, tmp_path): - """It used to raise IndexError from inside the gather instead — the guard - `(len(self._keys) > 0) & (...)` read as a guard and was not one, because `&` - evaluates both operands.""" - path = self._write(tmp_path / "empty.parquet", []) - with pytest.raises(ValueError, match="is empty"): - GaulLookupEnricher(path) - - def test_a_null_key_is_refused_at_construction(self, tmp_path): - """A null key would coerce to the same sentinel as an unusable query id, the - two would collide, and the row would be reported FOUND — receiving another - cell's metadata. That is the fabricated value this module forbids (cf. C-35).""" - cols = {"priogrid_gid": pa.array([1, None, 3], pa.int64())} - for c in METADATA_COLS: - numeric = c in ("pg_xcoord", "pg_ycoord") or c.endswith("_code") - cols[c] = pa.array([0.0] * 3 if numeric else ["x"] * 3, - pa.float64() if numeric else pa.string()) - path = tmp_path / "nullkey.parquet" - pq.write_table(pa.table(cols).replace_schema_metadata({b"lookup_version": b"t@0"}), path) - with pytest.raises(ValueError, match="null values"): - GaulLookupEnricher(path) - - -class TestUnusableCellIds: - """A value that is not a cell id gathers to null — never to a guessed cell. - - Each case was verified against the pandas merge this replaced; the behaviour - below is that merge's, not an invention. The drifted-float case is the one that - matters most: a blind `astype(np.int64)` truncates `54220.000000001` to `54220` - and matches a real, *different* cell, silently. - """ - - def _out(self, enricher, col): - return enricher.enrich_dataframe_with_pg_info( - pd.DataFrame({"priogrid_gid": col, "month_id": [1] * len(col)}), - pg_id_col="priogrid_gid", time_id_col="month_id", - ) - - def test_a_missing_gid_yields_null_and_does_not_crash(self, enricher, lookup): - good = lookup.index[:2].tolist() - out = self._out(enricher, [float(good[0]), float("nan"), float(good[1])]) - assert len(out) == 3 - assert out["country_iso_a3"].isna().sum() == 1 - - def test_a_non_integral_gid_never_matches_a_neighbouring_cell(self, enricher, lookup): - gid = int(lookup.index[100]) - out = self._out(enricher, [float(gid) + 1e-9]) - assert out["country_iso_a3"].isna().all(), ( - "a drifted float matched a cell — truncation turned it into a different, " - "real gid and fabricated that cell's geography" - ) - - def test_pandas_na_yields_null_rather_than_a_bare_typeerror(self, enricher, lookup): - good = lookup.index[:2].tolist() - out = self._out(enricher, pd.array([good[0], pd.NA, good[1]], dtype="Int64")) - assert len(out) == 3 and out["country_iso_a3"].isna().sum() == 1 - - def test_an_unusable_id_cannot_match_the_cell_it_was_substituted_with(self, tmp_path): - """The `& usable` term in `_gather`, proven load-bearing (ADR-014 §2). - - It survived removal against the committed artifact: an unusable id converts to - `0`, and `0` is not a real gid, so the key comparison already failed. That made - it look like decoration. It is not — it is the only thing standing between an - unusable id and the row it was substituted onto, and the substitute is a real - key the moment a lookup contains gid `0`. - - Without the mask, the NaN row below silently receives cell 0's geography. That - is the fabricated value this module's docstring forbids, produced from an input - the caller never wrote. - """ - cols = {"priogrid_gid": pa.array([0, 5, 9], pa.int64())} - for c in METADATA_COLS: - numeric = c in ("pg_xcoord", "pg_ycoord") or c.endswith("_code") - cols[c] = pa.array( - [0.0] * 3 if numeric else ["ZERO", "FIVE", "NINE"], - pa.float64() if numeric else pa.string(), - ) - path = tmp_path / "withzero.parquet" - pq.write_table( - pa.table(cols).replace_schema_metadata({b"lookup_version": b"t@00000000"}), path - ) - - out = GaulLookupEnricher(path).enrich_dataframe_with_pg_info( - pd.DataFrame({"priogrid_gid": [5.0, float("nan"), 9.0], "month_id": [1, 1, 1]}), - pg_id_col="priogrid_gid", time_id_col="month_id", - ) - assert out["country_iso_a3"].tolist() == ["FIVE", None, "NINE"], ( - "an unusable id matched the cell it was substituted with — `& usable` is " - "what prevents that, and this is the input that proves it" - ) - - def test_a_string_gid_column_is_refused_rather_than_parsed(self, enricher, lookup): - """A string gid is a declaration error, and the merge this replaced said so. - - `pandas` raised *"You are trying to merge on object and int64 columns. If you - wish to proceed you should use pd.concat"*. An - earlier draft of `_as_cell_ids` used `float(value)`, which happily parsed - `"54220"` and matched — inference where the old path declared (ADR-003). - """ - gid = str(int(lookup.index[0])) - out = self._out(enricher, [gid]) - assert out["country_iso_a3"].isna().all(), ( - f"the string {gid!r} was parsed into a cell id; a string column is a " - "declaration error, not a gid to be guessed at" - ) - - @pytest.mark.parametrize( - "label,array", - [ - # Each case must enter the branch it is testing. An earlier draft built - # them all with dtype=object, which routed every one through the - # element-wise branch — so the int and float bounds it claimed to test - # were never executed, and removing them left the suite green. - ("uint64 max (int branch)", np.array([np.iinfo(np.uint64).max], dtype=np.uint64)), - ("1e30 (float branch)", np.array([1e30], dtype=np.float64)), - ("inf (float branch)", np.array([np.inf], dtype=np.float64)), - ("-inf (float branch)", np.array([-np.inf], dtype=np.float64)), - ("string (object branch)", np.array(["123"], dtype=object)), - ("bool (object branch)", np.array([True], dtype=object)), - ("float exactly 2**63", np.array([2.0**63], dtype=np.float64)), - ("timedelta64", np.array([1], dtype="timedelta64[ns]")), - ], - ) - def test_out_of_range_and_non_numeric_values_are_unusable_not_wrapped( - self, enricher, label, array - ): - """Outside int64 the cast WRAPS rather than raising, and a wrapped id flagged - valid is exactly the fabricated value this conversion exists to prevent. - - `uint64` max becomes `-1`; `1e30` and `float(2**63)` become `INT64_MIN`. Each - passes a naive finite-and-integral test, so the bounds are separate conditions - rather than consequences of the others. - - Asserted through the PUBLIC method. An earlier draft called the private - `_as_cell_ids` and checked its mask — pinning an implementation detail rather - than the guarantee a caller relies on, and skipping `_gather`'s use of that - mask entirely. Every dtype below is reachable through a DataFrame column, so - there was never a reason to reach inside. - """ - out = enricher.enrich_dataframe_with_pg_info( - pd.DataFrame({"priogrid_gid": array, "month_id": [1] * len(array)}), - pg_id_col="priogrid_gid", time_id_col="month_id", - ) - assert out["country_iso_a3"].isna().all(), ( - f"{label} produced a match — an id the caller never wrote was treated as " - "a cell and that cell's geography was fabricated onto the row" - ) - - # AND at the mask, because the public assertion above cannot tell "refused" - # from "accepted but wrapped to an id that happens to be absent" — both give - # null. Reverting the float bound or re-admitting timedelta64 left the public - # assertion green while the value was silently accepted; only this sees it. - # The public check is the caller's guarantee; this one is the mutation proof. - _, usable = GaulLookupEnricher._as_cell_ids(array) - assert not usable.any(), ( - f"{label} was ACCEPTED as a cell id (it only looked refused because the " - f"wrapped id is absent from this lookup — against a lookup that contains " - f"it, this row would receive that cell's geography)" - ) - - def test_the_warning_names_unknown_cells_but_invents_no_id_for_unusable_ones( - self, enricher, lookup, caplog - ): - good = int(lookup.index[0]) - with caplog.at_level(logging.WARNING): - self._out(enricher, [float(good), float("nan"), 999999.0]) - message = " ".join(r.getMessage() for r in caplog.records) - assert "999999" in message, "the genuinely unknown cell must be named" - assert "no usable cell id" in message - assert "[0]" not in message, ( - "an unusable id was reported as cell 0 — a cell nobody asked about" - ) - - -class TestFramePropertiesPreserved: - """Row count, order and index survive the gather. Was CIC prose; now a check.""" - - @pytest.mark.parametrize("shape", ["ordered", "reversed", "duplicated", "single", "empty"]) - def test_row_count_and_order_are_preserved(self, enricher, lookup, shape): - gids = { - "ordered": lookup.index[:50].tolist(), - "reversed": lookup.index[:50].tolist()[::-1], - "duplicated": lookup.index[:5].tolist() * 3, - "single": lookup.index[:1].tolist(), - "empty": [], - }[shape] - out = enricher.enrich_dataframe_with_pg_info( - pd.DataFrame({"priogrid_gid": gids, "month_id": [1] * len(gids)}), - pg_id_col="priogrid_gid", time_id_col="month_id", - ) - assert len(out) == len(gids) - assert out["priogrid_gid"].tolist() == gids - - def test_a_non_default_index_is_preserved(self, enricher, lookup): - gids = lookup.index[:20].tolist() - df = pd.DataFrame({"priogrid_gid": gids, "month_id": [1] * 20}, - index=range(1000, 1020)) - out = enricher.enrich_dataframe_with_pg_info( - df, pg_id_col="priogrid_gid", time_id_col="month_id") - assert out.index.equals(df.index), ( - "the index moved. The pandas merge this replaced mutated it on empty " - "input; the gather must not mutate it on any input." - ) - - def test_name_columns_are_object_not_categorical(self, enricher, lookup): - """The CIC states this dtype. It changed in #89 and was measured, not guessed.""" - out = enricher.enrich_dataframe_with_pg_info( - pd.DataFrame({"priogrid_gid": lookup.index[:5].tolist(), "month_id": [1] * 5}), - pg_id_col="priogrid_gid", time_id_col="month_id", - ) - for c in NAME_COLS: - assert out[c].dtype == object, f"{c} is {out[c].dtype}, CIC says object" diff --git a/tests/test_gaul_lookup_access.py b/tests/test_gaul_lookup_access.py index d0de6c6..91ee742 100644 --- a/tests/test_gaul_lookup_access.py +++ b/tests/test_gaul_lookup_access.py @@ -153,19 +153,25 @@ def test_the_manager_reads_the_lookup_once_and_threads_it(): assert "_DEFAULT_LOOKUP" not in source, ( "the manager must not import the enricher's private path constant (C-68)" ) - assert "GaulLookupEnricher" not in source, ( - "the contract delivery does not use the pandas enricher; instantiating it " - "loads the lookup a third time into a representation nothing reads (C-66)" - ) + # The `GaulLookupEnricher` assertion that stood here was removed with the class in + # #90 (register C-75). A guard against a symbol that no longer exists cannot fail, + # and a test that cannot fail is decoration (ADR-014 §2). What it protected — one + # read per delivery — is the first assertion in this function and still bites. -def test_the_enricher_still_works_and_agrees_on_the_stamp(): - """The enricher is not retired — it remains the build/verification path's object. +def test_the_artifacts_identity_comes_from_one_declared_reader(): + """The stamp has exactly one source, and it is the one the delivery reads. - What changed is that it no longer *owns* the artifact's identity. Its - ``lookup_version`` must still report exactly what ``gaul_lookup.version`` does, or - provenance would differ depending on which path produced it. + **This test used to assert that `GaulLookupEnricher.lookup_version` agreed with + `gaul_lookup.version()`** — two readers of the same fact, checked against each + other. The enricher was retired in #90/C-75 as an object with no production caller, + so the agreement it policed no longer has two sides. What survives is the property + that mattered: the version the delivery stamps into its provenance is read from the + artifact, and resolves. """ - from views_postprocessing.contract.enrichment import GaulLookupEnricher - - assert GaulLookupEnricher().lookup_version == gaul_lookup.version() + version = gaul_lookup.version() + assert version, "the lookup must declare a version; C-60 made this raise rather than degrade" + assert "@" in version, ( + f"lookup_version must be '@', got {version!r} — the delivery " + "carries this verbatim into its provenance record (C-15)." + ) diff --git a/tests/test_gaul_lookup_fidelity.py b/tests/test_gaul_lookup_fidelity.py index 821ddfb..ee023c4 100644 --- a/tests/test_gaul_lookup_fidelity.py +++ b/tests/test_gaul_lookup_fidelity.py @@ -218,10 +218,16 @@ def test_lookup_declares_its_provenance(lookup_meta): def test_lookup_version_stamp_resolves(lookup): - """The stamp the delivery provenance carries (C-15) must not be 'unknown'.""" - from views_postprocessing.contract.enrichment import GaulLookupEnricher + """The stamp the delivery provenance carries (C-15) must not be 'unknown'. - version = GaulLookupEnricher(_LOOKUP).lookup_version + Read through ``gaul_lookup.version`` — the declared reader the delivery itself uses. + It was read through ``GaulLookupEnricher`` until that object was retired (#90, C-75); + the enricher was never on the delivery path, so this now checks the same fact through + the code that ships. + """ + from views_postprocessing.contract import gaul_lookup + + version = gaul_lookup.version(_LOOKUP) assert version != "unknown", ( "lookup_version resolved to 'unknown' — the delivery would ship untraceable " "provenance (C-60). The lookup's embedded source_provenance is absent or reshaped." diff --git a/tests/test_input_integrity_design_contract.py b/tests/test_input_integrity_design_contract.py index b86416e..125fc8a 100644 --- a/tests/test_input_integrity_design_contract.py +++ b/tests/test_input_integrity_design_contract.py @@ -79,9 +79,16 @@ def test_manager_does_not_inherit_forecasting_model_manager(): # housekeeping -------------------------------------------------------------- -def test_enrichment_does_not_call_a_dataframe_a_prediction_frame(): - src = (_PKG / "contract" / "enrichment.py").read_text().lower() - assert "prediction frame" not in src +# `test_enrichment_does_not_call_a_dataframe_a_prediction_frame` was removed with +# `contract/enrichment.py` in #90 (register C-75). It asserted that one module did not +# call a DataFrame a "prediction frame" — vocabulary hygiene in a module that no longer +# exists. +# +# Nothing replaces it, and that is the honest position rather than an oversight: the +# confusion it guarded against was possible only while a pandas object sat next to a +# frame-shaped API. `test_delivery_invariants_are_pandas_free` above, and the +# now-empty type-only-pandas assertion in `test_doc_accuracy.py`, together mean there +# is no DataFrame left in the package to mislabel. def test_no_lingering_mapping_directory(): diff --git a/views_postprocessing/contract/enrichment.py b/views_postprocessing/contract/enrichment.py deleted file mode 100644 index 75baa77..0000000 --- a/views_postprocessing/contract/enrichment.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Lookup-based geographic enrichment (ADR-011). - -Drop-in replacement for the runtime spatial mapper's -``enrich_dataframe_with_pg_info``. Instead of loading 774 MB of shapefiles and -computing spatial intersections at run time, it merges a precomputed lookup -table (built by ``scripts/build_gaul_lookup.py`` from the views-datafactory's -area-majority GAUL parquets) onto the input DataFrame by PRIO-GRID cell id. - -No geopandas, no shapefiles, no spatial computation. The lookup contains only -fully-complete cells; an unknown or incomplete cell id gathers to null, so the -delivery's null gate still crashes (fail-loud) rather than shipping a hole. This -is intentional and matches the old mapper's behaviour (it returned ``None`` for -such cells). - -**The lookup side is pandas-free (S4 / #89, epic #85).** It is read with pyarrow -and held as numpy arrays plus a sorted key index; attaching metadata to a frame is -a **keyed gather**, not frame algebra, and it never needed a pandas merge. The -input frame is still whatever the caller passes — this class is the *build and -verification* path's object, and its callers hand it DataFrames. What changed is -that the ~880 KiB artifact is no longer materialised as a pandas frame, and the -join no longer depends on the artifact carrying pandas index metadata — which is -what unblocks S5 (#90) making the builder pyarrow-native. - -Produces exactly the 9-column contract enforced at -the delivery's artifact builders and at -views-faoapi ``handlers.py`` (``FAO_PGMDataset._METADATA_COLS``). -""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import TYPE_CHECKING - -import numpy as np -import pyarrow as pa - -from views_postprocessing.contract import gaul_lookup - -if TYPE_CHECKING: # pragma: no cover — pandas is in this module's INTERFACE, not its - import pandas as pd # implementation. Callers hand it DataFrames; nothing here - # constructs, reads or joins one. Removing the runtime import is the point of - # S4 (#89): the ~880 KiB lookup is no longer materialised as a pandas frame, and the - # join no longer needs the artifact to carry pandas index metadata. -from views_postprocessing.contract.gaul_schema import METADATA_COLS - -logger = logging.getLogger(__name__) - -# The artifact's identity lives in `gaul_lookup` (#152, C-68) — this alias keeps the -# enricher's own default working without re-deriving the path. -_DEFAULT_LOOKUP = gaul_lookup.LOOKUP_PATH - -#: int64 bounds, named because they are a correctness condition rather than trivia: -#: a value outside them does not raise on cast, it WRAPS, and a wrapped id that is -#: flagged valid is the fabricated value this module forbids. -_INT64_MIN = int(np.iinfo(np.int64).min) -_INT64_MAX = int(np.iinfo(np.int64).max) - -#: Float-comparison bounds. Deliberately NOT the int bounds above: 2**63-1 is odd and -#: unrepresentable in float64, so comparing a float against it rounds up to 2**63 and -#: lets through the very values the bound excludes. 2**63 and -2**63 are both powers -#: of two and exact, so the float check is `>= -2**63` and `< 2**63`. -_INT64_MIN_F = -(2.0**63) -_INT64_MAX_EXCLUSIVE_F = 2.0**63 - -#: The lookup's key column. Named once — the artifact calls it `priogrid_gid`, the -#: wire calls it `priogrid_id` (§5.1), and confusing the two is a silent join failure. -_KEY = "priogrid_gid" - - -class GaulLookupEnricher: - """Merge precomputed GAUL metadata onto an input DataFrame by cell id.""" - - def __init__(self, lookup_path: str | Path | None = None) -> None: - self._lookup_path = Path(lookup_path) if lookup_path else _DEFAULT_LOOKUP - if not self._lookup_path.exists(): - err_msg = ( - f"GAUL lookup table not found at {self._lookup_path}. " - f"Build it with scripts/build_gaul_lookup.py." - ) - logger.error(err_msg) - raise FileNotFoundError(err_msg) - table = gaul_lookup.load(self._lookup_path) - missing = [c for c in (_KEY, *METADATA_COLS) if c not in table.column_names] - if missing: - err_msg = f"Lookup table is missing contract columns: {missing}" - logger.error(err_msg) - raise ValueError(err_msg) - - if table.column(_KEY).null_count: - err_msg = ( - f"GAUL lookup at {self._lookup_path} has null values in {_KEY!r}. A " - "null key cannot identify a cell, and coercing it would make it " - "collide with any unusable id on the query side — the row would then " - "be reported as FOUND and receive another cell's metadata. Rebuild " - "with scripts/build_gaul_lookup.py. (That script does not check the " - "key column either — its null check runs after the key becomes the " - "index, and DataFrame.isna() does not inspect an index. A null key is " - "unreachable there only because the earlier astype('int64') raises. " - "See register C-76.)" - ) - logger.error(err_msg) # ADR-008: logged persistently AND raised - raise ValueError(err_msg) - - if table.num_rows == 0: - err_msg = ( - f"GAUL lookup at {self._lookup_path} is empty. Every cell would gather " - "to null and the delivery would fail downstream complaining about " - "missing metadata rather than about a missing lookup. Rebuild it with " - "scripts/build_gaul_lookup.py." - ) - logger.error(err_msg) # ADR-008: logged persistently AND raised - raise ValueError(err_msg) - - # Sort the whole table once by key, in arrow, so the gather below is a binary - # search per row rather than a scan. `take` reorders every column together — - # doing it column-by-column in python was measurably quadratic and is exactly - # the mistake this comment exists to stop the next person repeating. - keys = table.column(_KEY).to_numpy(zero_copy_only=False).astype(np.int64) - order = np.argsort(keys, kind="stable") - table = table.take(pa.array(order)) - self._keys = keys[order] - # One `to_pylist` per column, not per row. Lists rather than numpy arrays - # because the columns are of mixed kind (float codes, string names) and the - # output is assembled per column anyway. - # - # DTYPE CHANGE, deliberate and measured (S4 / #89). The pandas merge this - # replaced produced `category` name columns, inherited from the artifact's - # dictionary encoding; assigning lists produces `object`. On a 200-row output: - # category 1,795,191 bytes vs object 60,061 — a categorical carries the full - # 64,742-entry dictionary whatever the output size, so this is far lighter for - # the small frames this object actually sees and heavier only past the point - # where the dictionary amortises. The builder's `# names/iso categorical (C-32 - # memory)` note governs the ARTIFACT; it never governed this method's output. - self._values = {col: table.column(col).to_pylist() for col in METADATA_COLS} - self.lookup_version = self._read_version(self._lookup_path) - logger.info( - "Loaded GAUL lookup: %d cells from %s (version=%s)", - len(self._keys), self._lookup_path, self.lookup_version, - ) - - @staticmethod - def _as_cell_ids(gids) -> tuple[np.ndarray, np.ndarray]: - """``(int64 ids, usable mask)`` — DECLARED, never coerced (ADR-003). - - A cell id is an integer. Anything that is not one — a missing value, a - non-integral float, a ``pd.NA`` — is marked **unusable** rather than cast, - and unusable ids gather to null exactly as an unknown gid does. The - downstream gate then sees a hole, which is what it exists for. - - **Why not just cast.** ``np.asarray(gids, dtype=np.int64)`` looks equivalent - and is not, in three ways found in review of #210: - - - a ``NaN`` becomes ``INT64_MIN`` with only a ``RuntimeWarning``. If a lookup - key were ever null it would take the same sentinel, the two would collide, - and the row would be reported FOUND — receiving another cell's metadata. - That is the fabricated value this module's docstring forbids (cf. C-35). - - a non-integral float **truncates silently**: ``54220.000000001`` becomes - ``54220`` and matches a real, *different* cell. Verified against the pandas - merge this replaced: it returns null there, and even warns. A silent wrong - match is strictly worse than the crash it would replace. - - a ``pd.NA`` raises a bare ``TypeError`` from numpy — no log, no - contract-shaped error, unlike every other guard in this file. - """ - arr = np.asarray(gids) - - if arr.dtype.kind in ("i", "u"): - # In range, or it is not the id the caller wrote. uint64 max silently - # wraps to -1 under `astype(np.int64)` and would be flagged VALID. - usable = (arr >= _INT64_MIN) & (arr <= _INT64_MAX) - return np.where(usable, arr, 0).astype(np.int64), usable - - if arr.dtype.kind == "f": - # Finite, integral, AND representable. `1e30` passes the first two and - # then overflows the cast to INT64_MIN — an id nobody asked for, marked - # valid. Same silent-coercion class this method exists to remove. - # STRICTLY below 2**63, not `<= _INT64_MAX`. `_INT64_MAX` is 2**63-1, - # which is odd and NOT representable in float64 — comparing against it - # promotes to float and rounds UP to 2**63, so the bound admitted exactly - # the values it was added to exclude. `float(2**63)` passed every check - # and then wrapped to INT64_MIN, flagged valid. `_INT64_MIN` has no - # equivalent hole: -2**63 is a power of two and exactly representable. - # - # `isfinite` is kept for intent and is SUBSUMED — `inf` fails the upper - # bound, `NaN` fails `arr == rint(arr)` (NaN equals nothing). It is the - # one condition here that survives its own removal, so it is defensive - # rather than load-bearing; the other three each fail the suite. - usable = ( - np.isfinite(arr) - & (arr == np.rint(arr)) - & (arr >= _INT64_MIN_F) - & (arr < _INT64_MAX_EXCLUSIVE_F) - ) - return np.where(usable, arr, 0).astype(np.int64), usable - - # object / pandas-nullable. Accept only values that ALREADY ARE integers. - # - # An earlier draft used `float(value)`, which parses. That accepted the string - # `"54220"` as a cell id — where the pandas merge this replaced raised - # `ValueError: You are trying to merge on object and int64 columns`. A string - # gid column is a declaration error and the old path said so; parsing it is - # inference (ADR-003) and it is the same defect as the drifted float, just - # pointing the other way. `bool` is excluded for the same reason: `True` is - # not cell 1. - ids = np.zeros(len(arr), dtype=np.int64) - usable = np.zeros(len(arr), dtype=bool) - for i, value in enumerate(arr): - # `np.timedelta64` IS an `np.integer` instance — a numpy quirk, and - # `np.datetime64` is not, so the hole was specific to that one type. A - # duration is not a cell id any more than `True` is cell 1. - if isinstance(value, (bool, np.timedelta64)): - continue - if not isinstance(value, (int, np.integer)): - continue - if _INT64_MIN <= int(value) <= _INT64_MAX: - ids[i], usable[i] = int(value), True - return ids, usable - - def _gather(self, gids) -> dict: - """The metadata for each gid, warning about the ones that got none. - - A sorted-key ``searchsorted`` rather than a hash map: the lookup is 64,742 - rows read once per process, and the gather is over the delivery's row count. - Absent gids yield ``None`` in every column — the null the downstream gate - exists to catch, not a sentinel it would pass. An id that is not a usable - cell id is treated the same way: absent, never guessed at. - """ - wanted, usable = self._as_cell_ids(gids) - # `self._keys` is non-empty — __init__ refuses an empty lookup. An earlier draft - # guarded with `(len(self._keys) > 0) & (...)`, which READS as a guard and is - # not one: `&` evaluates both operands, so the index happened regardless and an - # empty lookup raised IndexError from inside the gather rather than ValueError - # from the constructor. Guard where the condition is knowable, not where it bites. - idx = np.clip(np.searchsorted(self._keys, wanted), 0, len(self._keys) - 1) - # `& usable` is NOT belt-and-braces, and no substitute value would make it so. - # Unusable ids substitute to 0, but int64 reserves nothing — the lookup is - # arbitrary data and may legally contain 0, or -1, or INT64_MIN, or whatever - # else one might pick instead. The mask is what carries correctness; the - # substitute is only a placeholder. Pinned by - # `test_an_unusable_id_cannot_match_the_cell_it_was_substituted_with`. - found = (self._keys[idx] == wanted) & usable - out = { - col: [vals[i] if hit else None for i, hit in zip(idx, found)] - for col, vals in self._values.items() - } - - # Warn HERE rather than handing the caller three arrays to reassemble one - # message. `__init__` already co-locates detection and logging at every guard - # (ADR-008); this is the same shape. An earlier draft returned four values, - # three of which existed only to build the string below. - absent = ~found - n_unmapped = int(absent.sum()) - if n_unmapped: - unknown = sorted({int(i) for i, miss, ok in zip(wanted, absent, usable) if miss and ok}) - n_unusable = int((absent & ~usable).sum()) - detail = f"unknown cells {unknown[:20]}" if unknown else "no unknown cells" - if n_unusable: - detail += f"; {n_unusable} row(s) carried no usable cell id" - logger.warning( - "%d/%d rows have no lookup match (will fail validation): %s", - n_unmapped, len(wanted), detail, - ) - return out - - @staticmethod - def _read_version(path: Path) -> str: - """The lookup's build stamp. Delegates to ``gaul_lookup.version`` (#152) — - this was a ``@staticmethod`` that never touched the instance, i.e. a fact - about the artifact, not about the enricher.""" - return gaul_lookup.version(path) - - def enrich_dataframe_with_pg_info( - self, - df: pd.DataFrame, - pg_id_col: str = "priogrid_gid", - time_id_col: str = "month_id", - only_metadata: bool = True, - **ignored_mapper_kwargs, - ) -> pd.DataFrame: - """Return ``df`` with the 9 metadata columns merged in by cell id. - - Signature mirrors the mapper's method so the manager call site changes - minimally. Mapper-only kwargs (``batch_size``, ``use_multiprocessing``, - ``show_progress`` …) are accepted and ignored — a table join needs none - of them — but any unrecognised kwarg is logged at debug so a genuine - caller mistake is not wholly silent. - - Cells absent from the lookup get NaN metadata (fail-loud downstream). - """ - if ignored_mapper_kwargs: - logger.debug( - "GaulLookupEnricher ignoring mapper-only kwargs: %s", - sorted(ignored_mapper_kwargs), - ) - if pg_id_col not in df.columns: - err_msg = f"Column '{pg_id_col}' not found in DataFrame" - logger.error(err_msg) - raise ValueError(err_msg) - - if only_metadata: - keep = [pg_id_col] - if time_id_col in df.columns: - keep.append(time_id_col) - base = df[keep].copy() - else: - base = df.copy() - - gids = base[pg_id_col].to_numpy() - gathered = self._gather(gids) - - merged = base.copy() - for col in METADATA_COLS: - merged[col] = gathered[col] - return merged - - # Convenience alias for new call sites that don't need the legacy name. - enrich = enrich_dataframe_with_pg_info diff --git a/views_postprocessing/contract/frame_extraction.py b/views_postprocessing/contract/frame_extraction.py index ad266fa..2241fbf 100644 --- a/views_postprocessing/contract/frame_extraction.py +++ b/views_postprocessing/contract/frame_extraction.py @@ -1,24 +1,21 @@ """Frame-native representation seam: extract primitives from a views-frames frame. -The **frame counterpart** to ``extraction.py`` (the pandas seam). It returns the *same* -primitives — sets of ints, numpy month arrays — so the representation-free +Returns primitives — sets of ints, numpy month arrays — so the representation-free ``views_postprocessing.delivery`` invariants consume them unchanged. -Per the migration design (epic #85): pandas and views-frames do **not** coexist at runtime, -so these are deliberately **siblings** of the pandas readers in ``extraction.py``, not a -replacement, and there is **no shared ``Extractor`` Protocol** (a polymorphic interface -nobody dispatches on would be speculative — YAGNI/ISP). When the forecast interior moves to a -frame (S3 / #88), the manager calls *these*; the pandas readers stay for the still-pandas -historical path (gated on C-40 / S7). - -Scope: the readers the forecast interior needs — distinct cells and months from the frame's -index. Deliberately **not** here yet (no speculative code): -- the pandas→``(N, S)`` sample-array unpacker — added when rusty_bucket (#143) declares the - layout (the seam will be *told* the layout, never sniff it); -- a frame-native ``unmapped_cell_count`` — geographic metadata lives on the pandas enriched - frame, not the value frame, until the enrichment moves off pandas (S4 / #89); -- a frame-native ``drop_months_above`` — the observed-range clip is on the *historical* - frame, which is gated on the inbound retirement (S7 / #92). +**This module was one of a pair and is now alone.** It was written as the frame-native +sibling of a pandas seam (``extraction.py``), deliberately duplicated rather than +abstracted while both representations were live: pandas and views-frames did not coexist +at runtime, and a shared ``Extractor`` Protocol nobody dispatched on would have been +speculative. That migration is finished — the pandas seam was deleted in #151, and #90 +retired the last module holding a pandas import at all. There is no sibling and no +still-pandas path; what remains is this one seam, and the docstring is corrected rather +than left describing a pair. + +Scope: the readers the forecast interior needs — distinct cells and months from the +frame's index. Deliberately **not** here (no speculative code): a sample-array unpacker, +added when the producer declares its layout — the seam will be *told* the layout, never +sniff it (ADR-003). """ from __future__ import annotations diff --git a/views_postprocessing/contract/gaul_lookup.py b/views_postprocessing/contract/gaul_lookup.py index 23f17bb..7090812 100644 --- a/views_postprocessing/contract/gaul_lookup.py +++ b/views_postprocessing/contract/gaul_lookup.py @@ -7,11 +7,10 @@ times per run** (C-66) — once eagerly into a pandas enricher it never used, then twice more through pyarrow. -The artifact's identity does not belong to the enricher. ``GaulLookupEnricher`` is -one *consumer* of this asset (the pandas merge used by the build/verification path); -the contract delivery is another, and it wants the table and the stamp, not the -merge. Splitting them lets the delivery read the file once, in arrow, with no pandas -anywhere on the path. +The artifact's identity never belonged to that class, which is why splitting them was +the fix: the delivery wants the table and the stamp, not a merge. It reads the file +**once**, in arrow, with no pandas anywhere on the path. The enricher itself was retired +in #90 (register **C-75**) once it was established it had no production caller. Verified by ``tests/test_gaul_lookup_fidelity.py``: the committed artifact matches views-datafactory's authoritative GAUL parquets value-for-value, its key is unique, diff --git a/views_postprocessing/contract/gaul_schema.py b/views_postprocessing/contract/gaul_schema.py index c2b9513..1a49e49 100644 --- a/views_postprocessing/contract/gaul_schema.py +++ b/views_postprocessing/contract/gaul_schema.py @@ -1,6 +1,6 @@ """Single source of truth for the GAUL enrichment schema and PRIO-GRID geometry. -Shared by the production enricher (enrichment.py) and the build/diff tooling +Shared by the delivery's artifact builders and the build/diff tooling (scripts/) so the 9-column contract, the datafactory->contract rename map, and the PRIO-GRID coordinate formula are defined exactly once. diff --git a/views_postprocessing/unfao/managers/README.md b/views_postprocessing/unfao/managers/README.md index 701f8f1..0cc915f 100644 --- a/views_postprocessing/unfao/managers/README.md +++ b/views_postprocessing/unfao/managers/README.md @@ -91,5 +91,5 @@ Appwrite). - [`role_and_seams.md`](../../../docs/architecture/role_and_seams.md) — role + seams - [CIC: UNFAOPostProcessorManager](../../../docs/CICs/UNFAOPostProcessorManager.md) — class contract -- [CIC: GaulLookupEnricher](../../../docs/CICs/GaulLookupEnricher.md) — the build/verification path for the GAUL lookup. **Not on this manager's call graph** (register C-75); geography is attached by `contract/historical.py` and `contract/wire/sidecar.py` +- Geography is attached by `contract/historical.py` (the actuals artifact) and `contract/wire/sidecar.py` (the §5 GAUL sidecar), each a keyed gather over the lookup this manager reads once. *(A `GaulLookupEnricher` CIC was linked here until #90; that class had no production caller and was retired — register C-75.)* - ADR-011 (mapper → lookup), ADR-012 (current ontology) From a69d1a0a515a55db6200a246102989f0988cbd47 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 5 Aug 2026 07:48:29 +0200 Subject: [PATCH 05/42] =?UTF-8?q?fix(delivery):=20C-79=20and=20C-83=20?= =?UTF-8?q?=E2=80=94=20two=20refusals=20on=20the=20live=20FAO=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-79: the store port failed OPEN, and had zero tests The comment beside `_ContractStorePort.upload`'s result check calls it "the whole mechanism". It is: pipeline-core's store, on a metadata failure AFTER the file is uploaded, logs and RETURNS success=False rather than raising, so a caller that discards the result ships a file with no metadata document -- invisible to the consumer. That happened to run-0's historical artifact on 2026-07-27. The check was `if success is False`. A result that was None, or lacked the attribute, or carried a non-bool, sailed through as though the upload had worked. Now `is not True`. The dead to_dict() fallback went with it: an unrecognised result should be refused and named, not adapted to silently. The message reports what it actually received, because success=None (a moved contract) and success=False (a reported failure) send an operator to different places. tests/test_store_port.py -- 16 tests over both partners, where there were none. The standing excuse for source-scanning manager facts is that managers need Appwrite env and a views-models path manager; the port needs neither, so it never applied here. Its trigger fired on 2026-08-03 and nobody noticed. The entry read "the 3.0.0 bump is the next occasion"; the bump landed, C-44 closed on a wheel-level suite verification, and the return contract was never re-read. test_register_integrity cannot catch that -- its checks are structural and none asks whether a named external event has occurred. C-83: a failed import reported as a wrong declaration get_queryset() returns None for ANY exception importing config_queryset.py; declared_data_format(None) defaults to 'dataframe'; the format guard then tells the operator to set data_format: 'feature_frame' in a file that already says exactly that. launch_config.assert_queryset_was_importable now runs FIRST, and both managers read the queryset once and reuse it. The refusal says what happened and steers away from the config file -- "This is NOT a declaration problem: do not edit data_format until the module imports" -- toward the traceback pipeline-core logged. Logs before it raises. Three guards, because order IS the fix: the refusal fires and names the real fault; an importable queryset passes (the format question belongs to the next check); and per partner, get_queryset() is called exactly once with importability checked first. Not fixed here, deliberately: upstream still returns None for any import exception. We stopped passing it into a function whose contract is to default. Raising upstream would be better and is not ours; waiting for it would have left the misleading message on the live FAO path meanwhile. Mutation-proven three ways -- revert the polarity (4 fail), delete the importability check (1 fails), reverse the order (1 fails). 352 passed / 40 xfailed / 0 failed. ruff clean. Register 83/12/71. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 126 ++++++++------ tests/test_env_declaration.py | 60 +++++++ tests/test_store_port.py | 155 ++++++++++++++++++ .../contract/launch_config.py | 40 +++++ views_postprocessing/crafd/managers/crafd.py | 32 ++-- views_postprocessing/unfao/managers/unfao.py | 32 ++-- 6 files changed, 371 insertions(+), 74 deletions(-) create mode 100644 tests/test_store_port.py diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 548f439..7a0818c 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -6,8 +6,8 @@ | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-03 | | Total Concerns | 83 | -| Open Concerns | 14 | -| Resolved Concerns | 69 | +| Open Concerns | 12 | +| Resolved Concerns | 71 | --- @@ -482,33 +482,6 @@ Cross-refs: **C-46** and **C-57** (both RESOLVED; this is the residual each reco --- -### C-83: A queryset that fails to import is reported as a queryset that declares the wrong format - -| Field | Value | -|-------|-------| -| ID | C-83 | -| Tier | 2 — no wrong data ships; the delivery refuses, which is correct. What is wrong is the reason it gives, and it gives it on the live FAO path, at the moment someone is trying to fix a failed run. It sends them to edit a file that is already right. | -| Source | `code-review max` (2026-08-03) — the views-pipeline-core 3.0.0 bump review | -| Trigger | The next time the FAO delivery refuses with *"the queryset declares data_format='dataframe'"*, check whether `config_queryset.py` actually imports before editing it. Most likely on a machine missing views-datafactory, or after any change to that file's own imports. | -| Owner | Whoever next touches `_read_historical_frame`'s precondition. The fix is ours — distinguishing the two cases takes one branch. | -| Location | `views_postprocessing/unfao/managers/unfao.py` and the same line in `crafd` — `declared_data_format(self._model_path.get_queryset())` feeding `launch_config.assert_frame_native_historical` | - -Three correct-in-isolation behaviours compose into a lie: - -1. pipeline-core's `ModelPathManager.get_queryset()` catches **any** exception from importing `config_queryset.py`, logs it, and returns `None`. -2. `declared_data_format(None)` returns `'dataframe'` — the documented default for a non-dict. -3. `assert_frame_native_historical('dataframe')` raises: *"the queryset declares `data_format='dataframe'` … **Set `data_format: 'feature_frame'` in the postprocessor's config_queryset**."* - -So a queryset that **failed to import** is indistinguishable from one that **declared the wrong format**, and the operator is told to fix a file that is already correct. Reproduced: in an environment without views-datafactory the real `un_fao` queryset reports `dataframe` while declaring `feature_frame`; in a complete environment the same file reports `feature_frame`. - -This is ADR-003's rule broken by composition rather than by anyone inferring anything: each layer declares faithfully, and the *absence* of an answer is silently given the shape of an answer. Cluster J's disease — *cannot distinguish "no" from "I could not tell"* — reached through a new door, because #126 made this repo depend on `declared_data_format` in the first place. - -**The fix is ours and it is small:** call `get_queryset()` once, and if it returns `None`, refuse with *that* — the queryset could not be imported — rather than passing `None` into a function whose contract is to default. Upstream could also raise instead of returning `None`, but we should not wait for that; we are the ones holding the ambiguous value. - -Cross-refs: **C-44** (the bump whose review found this), **C-40** (the inherited surface it arrives through), Cluster J (the *no* vs *could not tell* family), ADR-003, #126, #149. - ---- - ### C-82: Governance-artifact prose carries numbers and statuses that nothing checks | Field | Value | @@ -536,31 +509,6 @@ Cross-refs: **C-80** (the same disease in ADRs and CICs, and the mechanism that --- -### C-79: `_ContractStorePort.upload`'s result check is called "the whole mechanism" and has no test, and it fails open - -| Field | Value | -|-------|-------| -| ID | C-79 | -| Tier | 3 — the check works today and is correct for what the store actually returns, so nothing is shipping wrong. What is missing is any assertion that it keeps working, plus a polarity that would swallow an unrecognised result rather than refuse it. | -| Source | `code-review max` (2026-08-03) — PR #211 fourth pass, while verifying the corrected comment beside it | -| Trigger | When views-pipeline-core changes what `DatastoreModule.upload_data` returns — a different result type, a renamed field, or a raise where it used to report — check this port still refuses a partial upload. The 3.0.0 bump (C-44) is the next occasion. | -| Owner | Whoever takes the pipeline-core 3.0.0 bump; it is the same reading of the same return contract. | -| Location | `_ContractStorePort.upload` in `views_postprocessing/unfao/managers/unfao.py` and `views_postprocessing/crafd/managers/crafd.py` (byte-identical in both) | - -The port exists because the store **reports** a metadata failure without raising: after the file is uploaded it logs, then returns `OperationResult(success=False, code="PARTIAL_SUCCESS")`. A caller that discards the result ships a file with no metadata document — invisible to the consumer, which is what happened to run-0's historical artifact on 2026-07-27. This check is what converts that into a refusal. - -**Two things are wrong with how it is held.** - -*It is untested.* `grep -rn _ContractStorePort tests/` returns exactly one hit, in a docstring in `tests/test_selection_guard.py` noting that the port is **not** asserted. So the code the comment beside it calls *"the whole mechanism"* is carried by no check at all — ADR-014 §1, in the file that this change edited to say so. - -*It fails open.* The refusal is `if success is False`, and `success` is resolved by `getattr(result, "success", None)` with a `to_dict()` fallback. A result object that is neither shape yields `None`, which is not `False`, so the upload is accepted. That is the wrong polarity for a repository whose ADR-003 forbids inferring what should be declared: an unrecognised result is exactly the case where refusing is cheap and guessing is not. The `to_dict()` branch is also dead on the real path — `OperationResult` has a `success` attribute — so it is untested code guarding an untested case. - -Neither is urgent, because `OperationResult.success` is typed `bool` and is never `None` today. Both become live the moment the return contract moves, which is precisely when nobody will be looking at this file. - -Cross-refs: **C-40** (the pipeline-core surface this port wraps), **C-44** (the 3.0.0 bump that is the named trigger), **C-77** (the other unguarded thing on the same delivery leg), ADR-014 §1, #211, #146. - ---- - ## Disagreements ### D-12: Post-Run-0 infrastructure & naming intents — repo rename, internal-store transport, compute co-location @@ -629,6 +577,76 @@ See also C-40 (the inheritance/representation coupling this migration unwinds), ## Resolved Concerns +### C-83: A queryset that fails to import is reported as a queryset that declares the wrong format — RESOLVED + +| Field | Value | +|-------|-------| +| ID | C-83 | +| Tier | 2 — no wrong data ships; the delivery refuses, which is correct. What is wrong is the reason it gives, and it gives it on the live FAO path, at the moment someone is trying to fix a failed run. It sends them to edit a file that is already right. | +| Source | `code-review max` (2026-08-03) — the views-pipeline-core 3.0.0 bump review | +| Trigger | The next time the FAO delivery refuses with *"the queryset declares data_format='dataframe'"*, check whether `config_queryset.py` actually imports before editing it. Most likely on a machine missing views-datafactory, or after any change to that file's own imports. | +| Owner | Whoever next touches `_read_historical_frame`'s precondition. The fix is ours — distinguishing the two cases takes one branch. | +| Location | `views_postprocessing/unfao/managers/unfao.py` and the same line in `crafd` — `declared_data_format(self._model_path.get_queryset())` feeding `launch_config.assert_frame_native_historical` | + +Three correct-in-isolation behaviours compose into a lie: + +1. pipeline-core's `ModelPathManager.get_queryset()` catches **any** exception from importing `config_queryset.py`, logs it, and returns `None`. +2. `declared_data_format(None)` returns `'dataframe'` — the documented default for a non-dict. +3. `assert_frame_native_historical('dataframe')` raises: *"the queryset declares `data_format='dataframe'` … **Set `data_format: 'feature_frame'` in the postprocessor's config_queryset**."* + +So a queryset that **failed to import** is indistinguishable from one that **declared the wrong format**, and the operator is told to fix a file that is already correct. Reproduced: in an environment without views-datafactory the real `un_fao` queryset reports `dataframe` while declaring `feature_frame`; in a complete environment the same file reports `feature_frame`. + +This is ADR-003's rule broken by composition rather than by anyone inferring anything: each layer declares faithfully, and the *absence* of an answer is silently given the shape of an answer. Cluster J's disease — *cannot distinguish "no" from "I could not tell"* — reached through a new door, because #126 made this repo depend on `declared_data_format` in the first place. + +**The fix is ours and it is small:** call `get_queryset()` once, and if it returns `None`, refuse with *that* — the queryset could not be imported — rather than passing `None` into a function whose contract is to default. Upstream could also raise instead of returning `None`, but we should not wait for that; we are the ones holding the ambiguous value. + +Cross-refs: **C-44** (the bump whose review found this), **C-40** (the inherited surface it arrives through), Cluster J (the *no* vs *could not tell* family), ADR-003, #126, #149. + +**RESOLVED 2026-08-05 (B4).** `launch_config.assert_queryset_was_importable(queryset)` now runs **before** the format check, and both managers read the queryset once and reuse the value. + +The refusal says what actually happened — *"the postprocessor's config_queryset could not be imported … This is NOT a declaration problem: do not edit data_format until the module imports"* — and steers the operator toward the traceback pipeline-core logged, and toward a missing sibling checkout or dependency. It logs before it raises (ADR-008). + +**Three guards, because order is the fix.** One proves the refusal fires and names the real fault; one proves an importable queryset passes (the format question belongs to the *next* check, and keeping them separate is the whole point); one asserts, per partner, that `get_queryset()` is called exactly once and that importability is checked first. Mutation-proven by deleting the check and by reversing the order — both fail. + +**What is not fixed here, deliberately.** Upstream still returns `None` for any import exception, so the ambiguity exists at its source; we simply stopped passing it into a function whose contract is to default. Raising upstream would be better and is not ours to do — and waiting for it would have left the misleading message on the live FAO path meanwhile. + +--- + +### C-79: `_ContractStorePort.upload`'s result check is called "the whole mechanism" and has no test, and it fails open — RESOLVED + +| Field | Value | +|-------|-------| +| ID | C-79 | +| Tier | 3 — the check works today and is correct for what the store actually returns, so nothing is shipping wrong. What is missing is any assertion that it keeps working, plus a polarity that would swallow an unrecognised result rather than refuse it. | +| Source | `code-review max` (2026-08-03) — PR #211 fourth pass, while verifying the corrected comment beside it | +| Trigger | When views-pipeline-core changes what `DatastoreModule.upload_data` returns — a different result type, a renamed field, or a raise where it used to report — check this port still refuses a partial upload. The 3.0.0 bump (C-44) is the next occasion. | +| Owner | Whoever takes the pipeline-core 3.0.0 bump; it is the same reading of the same return contract. | +| Location | `_ContractStorePort.upload` in `views_postprocessing/unfao/managers/unfao.py` and `views_postprocessing/crafd/managers/crafd.py` (byte-identical in both) | + +The port exists because the store **reports** a metadata failure without raising: after the file is uploaded it logs, then returns `OperationResult(success=False, code="PARTIAL_SUCCESS")`. A caller that discards the result ships a file with no metadata document — invisible to the consumer, which is what happened to run-0's historical artifact on 2026-07-27. This check is what converts that into a refusal. + +**Two things are wrong with how it is held.** + +*It is untested.* `grep -rn _ContractStorePort tests/` returns exactly one hit, in a docstring in `tests/test_selection_guard.py` noting that the port is **not** asserted. So the code the comment beside it calls *"the whole mechanism"* is carried by no check at all — ADR-014 §1, in the file that this change edited to say so. + +*It fails open.* The refusal is `if success is False`, and `success` is resolved by `getattr(result, "success", None)` with a `to_dict()` fallback. A result object that is neither shape yields `None`, which is not `False`, so the upload is accepted. That is the wrong polarity for a repository whose ADR-003 forbids inferring what should be declared: an unrecognised result is exactly the case where refusing is cheap and guessing is not. The `to_dict()` branch is also dead on the real path — `OperationResult` has a `success` attribute — so it is untested code guarding an untested case. + +Neither is urgent, because `OperationResult.success` is typed `bool` and is never `None` today. Both become live the moment the return contract moves, which is precisely when nobody will be looking at this file. + +Cross-refs: **C-40** (the pipeline-core surface this port wraps), **C-44** (the 3.0.0 bump that is the named trigger), **C-77** (the other unguarded thing on the same delivery leg), ADR-014 §1, #211, #146. + +**RESOLVED 2026-08-05 (B4).** Two changes, and the second is the one that mattered. + +**Polarity.** `if success is False` became `if success is not True`. The old form failed **open**: a result that was `None`, or lacked the attribute, or carried a non-bool, sailed through as though the upload had worked. The dead `to_dict()` fallback went with it — an unrecognised result should be refused and *named*, not adapted to silently. The refusal now reports what it actually received, because `success=None` (a moved contract) and `success=False` (a reported failure) are different faults and send an operator to different places. + +**Tests, where there were none.** `tests/test_store_port.py` — 16 tests over both partners: the happy path, a reported failure carrying the store's own error, four unrecognised-result shapes, and the field list the port forwards. Mutation-proven by reverting the polarity, which fails four of them. + +**The standing excuse never applied here.** Manager-side facts are source-scanned because the managers need Appwrite env and a views-models path manager to instantiate. `_ContractStorePort` needs neither — it takes a store object and calls four methods on it. A fake store was always enough; nobody had tried. + +**Its trigger fired two days before this and nobody noticed.** The entry read *"the 3.0.0 bump is the next occasion"*; the bump landed 2026-08-03, C-44 was closed with a wheel-level verification of the suite, and the return contract was never re-read. `test_register_integrity.py` cannot catch that — its checks are structural and none evaluates whether a named external event has occurred. That gap is C-82's. + +--- + ### C-75: `GaulLookupEnricher` has no production caller, and now implements a second copy of the delivery path's keyed gather — RESOLVED | Field | Value | diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 8be6bd1..9d3fa7b 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -263,6 +263,66 @@ def test_declared_names_match_the_manager_reads(partner): assert text.count("appwrite_env.assert_env_declared(") == 2 +def test_a_queryset_that_failed_to_import_is_refused_as_that(caplog): + """Register C-83 — the refusal must name the real fault, not a plausible one. + + `get_queryset()` returns ``None`` for **any** exception while importing + ``config_queryset.py``. Feeding that to ``declared_data_format`` yields + ``'dataframe'`` (its documented default for a non-dict), and the format guard then + tells the operator to set ``data_format: 'feature_frame'`` — in a file that already + says exactly that. + + Reproduced both ways on 2026-08-03: in an environment without views-datafactory the + real ``un_fao`` queryset reported ``dataframe`` while declaring ``feature_frame``; + in a complete environment the same file reported ``feature_frame``. + """ + import logging + + with caplog.at_level(logging.ERROR): + with pytest.raises(launch_config.LaunchConfigError) as excinfo: + launch_config.assert_queryset_was_importable(None) + + message = str(excinfo.value) + assert "could not be imported" in message + assert "data_format" in message and "do not edit" in message.lower(), ( + "the refusal must actively steer the operator AWAY from the config file — " + "that is the whole point, since the old message steered them into it" + ) + assert any(r.levelno >= logging.ERROR for r in caplog.records), ( + "ADR-008: a structural refusal leaves a persistent record, not just a traceback" + ) + + +def test_an_importable_queryset_passes_the_readability_check(): + """The guard must not stand in front of a queryset that imported fine. + + Anything not-None passes here; whether it declares the right format is the *next* + check's question, and keeping them separate is the entire fix. + """ + launch_config.assert_queryset_was_importable({"data_format": "feature_frame"}) + launch_config.assert_queryset_was_importable({}) # wrong, but importable + + +def test_both_managers_check_importability_before_asking_what_was_declared(): + """Order matters: reversed, the confusing message wins again. + + Source-scan because the managers need Appwrite env and a views-models path manager + to instantiate — the standing pattern for manager-side facts. + """ + for partner in _PARTNERS: + source = _manager_source(partner).read_text() + assert source.count("get_queryset()") == 1, ( + f"[{partner}] the queryset must be read ONCE and the value reused; reading " + "it twice invites the two checks to disagree about what they saw" + ) + importable = source.index("assert_queryset_was_importable") + declared = source.index("assert_frame_native_historical") + assert importable < declared, ( + f"[{partner}] the importability check must come FIRST. Reversed, a queryset " + "that failed to import is still reported as one declaring 'dataframe'." + ) + + # ── ADR-008 across BOTH entry validators (S1 / #182, register C-71) ────────── # # `appwrite_env` asserts the launcher assembled the *environment*; `launch_config` diff --git a/tests/test_store_port.py b/tests/test_store_port.py new file mode 100644 index 0000000..cc99094 --- /dev/null +++ b/tests/test_store_port.py @@ -0,0 +1,155 @@ +"""The store port's refusal, tested — register C-79. + +**This code had zero tests until 2026-08-05**, while the comment beside it called it +"the whole mechanism". It is: pipeline-core's store, on a metadata failure *after* the +file is uploaded, logs the error and **returns** ``success=False`` rather than raising. +A caller that discards the result therefore ships a file with no metadata document — +invisible to the consumer, which is what happened to run-0's historical artifact on +2026-07-27. This port is the thing that turns that into a refusal. + +Testable now for a reason worth stating: the standing excuse for source-scanning +manager-side facts is that the managers need Appwrite env and a views-models path +manager to instantiate. ``_ContractStorePort`` needs neither — it takes a store object +and calls four methods on it. A fake store is enough, so the excuse never applied here. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from tests.conftest import PARTNER_PACKAGES + + +@dataclass +class _Result: + """Shaped like pipeline-core's ``OperationResult``: a ``success`` bool plus error.""" + + success: object + error: str | None = None + + +class _FakeStore: + """Records the upload and returns whatever result the test declares.""" + + def __init__(self, result): + self.result = result + self.calls = [] + + def upload_data(self, **kwargs): + self.calls.append(kwargs) + return self.result + + +def _port(partner: str, result): + """The partner's port, wrapping a fake store. Needs no Appwrite environment.""" + pytest.importorskip("views_pipeline_core", reason="the port wraps its DatastoreModule") + module = __import__( + f"views_postprocessing.{partner}.managers.{partner}", fromlist=["_ContractStorePort"] + ) + store = _FakeStore(result) + return module._ContractStorePort(store), store + + +def _upload(port, tmp_path): + payload = tmp_path / "artifact.parquet" + payload.write_bytes(b"x") + port.upload( + payload, + filename=payload.name, + name="un_fao", + doc_type="model", + category="historical", + loa="pgm", + targets=["lr_ged_sb"], + description="{}", + ) + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_a_successful_upload_is_accepted(partner, tmp_path): + """The happy path must not raise, or the guard would block every delivery.""" + port, store = _port(partner, _Result(success=True)) + _upload(port, tmp_path) + assert len(store.calls) == 1, "the port must forward exactly one upload to the store" + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_a_reported_failure_is_refused_and_names_the_error(partner, tmp_path): + """The defect this port exists for: uploaded file, failed metadata, success=False.""" + port, _ = _port(partner, _Result(success=False, error="metadata storage failed")) + with pytest.raises(RuntimeError) as excinfo: + _upload(port, tmp_path) + message = str(excinfo.value) + assert "metadata storage failed" in message, ( + "the refusal must carry the store's own error — an operator debugging a partial " + "upload should not have to go find it" + ) + assert "orphan" in message, ( + "the refusal must say what the failure MEANS: a file with no metadata document, " + "which is invisible to the consumer rather than absent" + ) + + +@pytest.mark.parametrize( + "result, why", + [ + (None, "the store returned nothing at all"), + (_Result(success=None), "success is None — the fail-open case C-79 named"), + (_Result(success="ok"), "success is a truthy non-bool"), + (object(), "the result has no success attribute"), + ], +) +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_an_unrecognised_result_is_refused_rather_than_assumed_good( + partner, result, why, tmp_path +): + """Fail CLOSED. This is the polarity fix, and it is the whole of C-79. + + The check was ``if success is False``, so every case above sailed through as though + the upload had worked. Today ``upload_data`` has one return path and ``success`` is + a ``bool``, so the two polarities agree — but "today" is doing a lot of work in that + sentence, and this entry's own trigger is *when pipeline-core changes what + ``upload_data`` returns*. Fail-open is the wrong side to be on when the question is + whether a partner delivery actually landed. + """ + port, _ = _port(partner, result) + with pytest.raises(RuntimeError, match="did not fully succeed"): + _upload(port, tmp_path) + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_the_refusal_names_what_it_actually_got(partner, tmp_path): + """A refusal an operator cannot act on is barely better than no refusal. + + ``success=None`` and ``success=False`` are different faults — one is a store that + reported a failure, the other a store whose contract moved. The message has to + separate them or the next person re-runs the delivery instead of reading a changelog. + """ + port, _ = _port(partner, _Result(success=None)) + with pytest.raises(RuntimeError) as excinfo: + _upload(port, tmp_path) + assert "success=None" in str(excinfo.value) + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_the_port_forwards_every_declared_field(partner, tmp_path): + """The port is a four-method seam; dropping a field here loses it silently. + + ``name`` in particular is what the consumer filters on (register C-77) — an upload + that arrives without it is stored, billed, and unretrievable. + """ + port, store = _port(partner, _Result(success=True)) + _upload(port, tmp_path) + forwarded = store.calls[0] + # Note `doc_type` -> `type`: the port renames it on the way through, because + # `type` is the store's field name and a builtin here. That rename is the kind of + # thing a seam quietly loses, which is why the field list is asserted rather than + # assumed. + for field in ("file", "filename", "name", "type", "category", "loa", "targets", + "description"): + assert field in forwarded, f"the port dropped {field!r} on its way to the store" + assert forwarded["name"] == "un_fao" + assert forwarded["category"] == "historical" + assert forwarded["type"] == "model", "doc_type must arrive as the store's `type`" diff --git a/views_postprocessing/contract/launch_config.py b/views_postprocessing/contract/launch_config.py index 2b88f56..8ebcafb 100644 --- a/views_postprocessing/contract/launch_config.py +++ b/views_postprocessing/contract/launch_config.py @@ -65,6 +65,46 @@ def assert_contract_mode(configs: dict) -> None: raise LaunchConfigError(err_msg) +def assert_queryset_was_importable(queryset: object | None) -> None: + """Raise if the queryset could not be read at all — before asking what it declares. + + **Register C-83: three correct behaviours composing into a lie.** pipeline-core's + ``ModelPathManager.get_queryset()`` catches any exception from importing + ``config_queryset.py``, logs it, and returns ``None``. ``declared_data_format(None)`` + then returns ``'dataframe'`` — the documented default for a non-dict. And + ``assert_frame_native_historical('dataframe')`` says: + + the queryset declares data_format='dataframe' … Set data_format: + 'feature_frame' in the postprocessor's config_queryset + + ...pointing at a file that already says ``feature_frame``. A queryset that **failed + to import** was indistinguishable from one that **declared the wrong format**, and + the message arrived on the live FAO path while someone was fixing a failed run. + + Each layer is faithful on its own. What goes wrong is that the *absence* of an + answer is silently given the shape of an answer — ADR-003's rule broken by + composition rather than by anyone inferring anything. + + Args: + queryset: the return of ``model_path.get_queryset()``. + + Raises: + LaunchConfigError: saying the queryset could not be imported, which is a + different problem from a queryset that declares the wrong thing. + """ + if queryset is None: + err_msg = ( + "the postprocessor's config_queryset could not be imported — " + "`get_queryset()` returned None, which it does for ANY exception raised " + "while importing that module (pipeline-core swallows it and logs). This is " + "NOT a declaration problem: do not edit data_format until the module " + "imports. Check the traceback pipeline-core logged just above this, and a " + "missing sibling checkout or dependency first." + ) + logger.error(err_msg) # ADR-008: logged persistently AND raised + raise LaunchConfigError(err_msg) + + def assert_frame_native_historical(data_format: str | None) -> None: """Raise unless the queryset descriptor declares the frame-native historical read. diff --git a/views_postprocessing/crafd/managers/crafd.py b/views_postprocessing/crafd/managers/crafd.py index fccfe0c..87c09d2 100644 --- a/views_postprocessing/crafd/managers/crafd.py +++ b/views_postprocessing/crafd/managers/crafd.py @@ -56,16 +56,24 @@ def upload(self, file_path, *, filename, name, doc_type, category, loa, targets, # the claim; its line number moves between releases). It never raises, so a # caller that discards the result ships an invisible orphan: run-0's historical # artifact, 2026-07-27. This check is the whole mechanism. - # (Until 2026-08-03 this comment said the store "only LOGS": false, and - # self-defeating — if it only logged, `success` would be True.) + # + # **Refuse unless success is explicitly True** (register C-79). The earlier + # `if success is False` failed OPEN: a result that was None, or lacked the + # attribute, or carried a non-bool, sailed through as though the upload had + # worked. Today `upload_data` has a single return path and `success` is a + # `bool` dataclass field, so the two polarities agree — but the moment that + # stops being true is exactly this entry's trigger, and fail-open is the wrong + # side to be on when the subject is "did the delivery actually land". + # + # The old `to_dict()` fallback is gone with it: dead on the real path, and an + # unrecognised result should be refused and named, not adapted to silently. success = getattr(result, "success", None) - if success is None and hasattr(result, "to_dict"): - success = result.to_dict().get("success") - if success is False: + if success is not True: error = getattr(result, "error", None) or "unknown store error" raise RuntimeError( - f"upload of {filename!r} did not fully succeed (file may be an " - f"orphan without a metadata document): {error}" + f"upload of {filename!r} did not fully succeed (file may be an orphan " + f"without a metadata document): {error}. The store reported " + f"success={success!r} (result type {type(result).__name__})." ) @@ -118,9 +126,13 @@ def _read_historical_data(self): """ # Declaration first: the check reads the queryset, not the loader, so a # refused config must not pay for loader construction. - launch_config.assert_frame_native_historical( - declared_data_format(self._model_path.get_queryset()) - ) + # + # Read ONCE, and distinguish "could not import it" from "it declares the wrong + # thing" (register C-83). Passing None straight into `declared_data_format` + # turns a failed import into a confident, wrong claim about the config. + queryset = self._model_path.get_queryset() + launch_config.assert_queryset_was_importable(queryset) + launch_config.assert_frame_native_historical(declared_data_format(queryset)) self._initialize_data_loader() self._read_historical_frame() diff --git a/views_postprocessing/unfao/managers/unfao.py b/views_postprocessing/unfao/managers/unfao.py index cd61b25..36388e2 100644 --- a/views_postprocessing/unfao/managers/unfao.py +++ b/views_postprocessing/unfao/managers/unfao.py @@ -56,16 +56,24 @@ def upload(self, file_path, *, filename, name, doc_type, category, loa, targets, # the claim; its line number moves between releases). It never raises, so a # caller that discards the result ships an invisible orphan: run-0's historical # artifact, 2026-07-27. This check is the whole mechanism. - # (Until 2026-08-03 this comment said the store "only LOGS": false, and - # self-defeating — if it only logged, `success` would be True.) + # + # **Refuse unless success is explicitly True** (register C-79). The earlier + # `if success is False` failed OPEN: a result that was None, or lacked the + # attribute, or carried a non-bool, sailed through as though the upload had + # worked. Today `upload_data` has a single return path and `success` is a + # `bool` dataclass field, so the two polarities agree — but the moment that + # stops being true is exactly this entry's trigger, and fail-open is the wrong + # side to be on when the subject is "did the delivery actually land". + # + # The old `to_dict()` fallback is gone with it: dead on the real path, and an + # unrecognised result should be refused and named, not adapted to silently. success = getattr(result, "success", None) - if success is None and hasattr(result, "to_dict"): - success = result.to_dict().get("success") - if success is False: + if success is not True: error = getattr(result, "error", None) or "unknown store error" raise RuntimeError( - f"upload of {filename!r} did not fully succeed (file may be an " - f"orphan without a metadata document): {error}" + f"upload of {filename!r} did not fully succeed (file may be an orphan " + f"without a metadata document): {error}. The store reported " + f"success={success!r} (result type {type(result).__name__})." ) @@ -118,9 +126,13 @@ def _read_historical_data(self): """ # Declaration first: the check reads the queryset, not the loader, so a # refused config must not pay for loader construction. - launch_config.assert_frame_native_historical( - declared_data_format(self._model_path.get_queryset()) - ) + # + # Read ONCE, and distinguish "could not import it" from "it declares the wrong + # thing" (register C-83). Passing None straight into `declared_data_format` + # turns a failed import into a confident, wrong claim about the config. + queryset = self._model_path.get_queryset() + launch_config.assert_queryset_was_importable(queryset) + launch_config.assert_frame_native_historical(declared_data_format(queryset)) self._initialize_data_loader() self._read_historical_frame() From 0d54b6e6046d5dd5ee33d46d38254cb4086cb1d8 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 5 Aug 2026 09:20:26 +0200 Subject: [PATCH 06/42] =?UTF-8?q?test(docs):=20C-80=20=E2=80=94=20scan=20A?= =?UTF-8?q?DRs=20and=20CICs;=20three=20stale=20paths=20found=20and=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADRs and CICs were exempt from the deleted-symbol scan. That exemption is right in principle -- an ADR legitimately records superseded designs, and a scan that fires on history gets deleted (ADR-014 s3) -- and far too wide in practice: it is how a CIC came to name a collaborator its class had never called, in six places, for weeks. The design was chosen by measurement, not argument - A path-resolution check over ADRs would have fired 29 times. Inspecting them showed most were correct history, other repos' files, or paths inside URLs. Built the measurement first, then did NOT build the check. - The curated deleted-symbol list was the narrower instrument: 22 hits over ADRs, ZERO over CICs. Fourteen of the 22 were in ADR-011 alone. Three of the hits were real: ADR-013 still cited unfao/historical.py, unfao/wire/ and unfao/wire/source_selection.py, all moved to contract/ by #153. Three genuine defects among five markable ones is the ratio that justified turning the scan on. Two escapes, both declared rather than inferred Line-scoped `legacy-ok` for an isolated mention; a new file-level `legacy-ok-file` for a document whose SUBJECT is a retirement. ADR-011 earned it -- it IS the decision to remove the runtime mapper, so its subject appears fourteen times, correctly. A second guard pins the exemption set to that one document, so a third shows up in a diff. A check nothing else could have made test_a_cic_does_not_name_a_collaborator_its_class_never_calls asserts that a class a CIC names is actually referenced by the class it documents -- the GaulLookupEnricher failure, which no path check and no symbol list would have caught at the time, because the class existed and every path resolved. Exception types are excluded: a collaborator is something the class reaches for, an exception something that passes through, and the first draft flagged three the manager legitimately propagates. The mutation campaign found a miss in the PREVIOUS change Reintroducing the exact GaulLookupEnricher sentence did NOT fail. The collaborator check only sees classes that still exist, and C-75 had deleted that one. The real gap was that the deletion never extended the deleted-symbol list -- which that list's own comment demands in as many words: "A deletion PR that does not extend this regex has not finished." Extended here; the reintroduction now fails. Four further historical mentions written yesterday were flagged by the extension and marked. Four guards, all mutation-proven: stale path in an ADR, the file-level escape used on a live document, a CIC with no declared subject, and the reintroduced collaborator. 356 passed / 40 xfailed / 0 failed. ruff clean. Register 83/11/72. Co-Authored-By: Claude Opus 5 (1M context) --- ..._runtime_mapper_with_precomputed_lookup.md | 2 + docs/ADRs/012_revised_ontology.md | 10 +- .../013_sampled_forecast_wire_contract.md | 10 +- docs/CICs/UNFAOPostProcessorManager.md | 4 +- reports/technical_risk_register.md | 68 +++++--- tests/test_doc_accuracy.py | 159 ++++++++++++++++++ views_postprocessing/unfao/managers/README.md | 2 +- 7 files changed, 215 insertions(+), 40 deletions(-) diff --git a/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md b/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md index a19dc01..e2cc9a7 100644 --- a/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md +++ b/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md @@ -1,5 +1,7 @@ # ADR-011: Replace Runtime Spatial Mapper with Precomputed Lookup Table + + **Status:** Accepted **Date:** 2026-06-02 **Deciders:** Project maintainers (PRIO MD&D Team) diff --git a/docs/ADRs/012_revised_ontology.md b/docs/ADRs/012_revised_ontology.md index 9bc11d4..9b05a14 100644 --- a/docs/ADRs/012_revised_ontology.md +++ b/docs/ADRs/012_revised_ontology.md @@ -14,7 +14,7 @@ a **runtime spatial-mapping engine** and **bundled shapefiles** as the authorita stable core. That architecture no longer exists: - [ADR-011](011_replace_runtime_mapper_with_precomputed_lookup.md) replaced the runtime - geopandas mapper with a **precomputed GAUL lookup table**. + geopandas mapper with a **precomputed GAUL lookup table**. - The mapper, shapefiles, and caching machinery were deleted (C-39 / PR #42). - Input-integrity invariants and structured delivery were added (epic #51). @@ -58,9 +58,9 @@ described the cut-a-repo case; it now says which is which. | Category | Purpose | Authority | Stability | |----------|---------|-----------|-----------| | **Delivery Invariants** | Representation-free rules over primitives that a delivery must satisfy: coverage, no-collapse, gid parity, observed-range, provenance. Live in `delivery/` — **nothing there imports pandas or views_frames**. *Forecast identity was one of these until 2026-07-31 — see the amendment below.* | Authoritative — they define what a valid delivery is | Stable — changes are governance decisions | -| **Representation Seam** | `contract/frame_extraction.py` — turns a `views_frames` frame into the primitives the invariants consume. **One seam.** Its pandas sibling `unfao/extraction.py` was deleted in #151 once the pandas delivery was retired; the two ran as deliberate WET siblings through the migration. | Derived — isolates the representation so invariants stay representation-free | Evolving | +| **Representation Seam** | `contract/frame_extraction.py` — turns a `views_frames` frame into the primitives the invariants consume. **One seam.** Its pandas sibling `unfao/extraction.py` was deleted in #151 once the pandas delivery was retired; the two ran as deliberate WET siblings through the migration. | Derived — isolates the representation so invariants stay representation-free | Evolving | | **Wire Mechanism** | `contract/wire/` — the ADR-013 contract: header, shard, sidecar, run manifest, sink, source selection. Partner-neutral: it takes its consumer name and collapse floor as **arguments** (#153). | Authoritative — the contract with the consumer | Stable — changes are contract amendments | -| **Enrichment Asset** | The precomputed GAUL lookup (`data/gaul_lookup.parquet`), its identity in `contract/gaul_lookup.py`, its schema in `contract/gaul_schema.py`, and the keyed gathers that join it — `contract/historical.py` for the actuals artifact and `contract/wire/sidecar.py` for the §5 sidecar. *(A second gather lived in `contract/enrichment.py` with no production caller; retired in #90, register **C-75**.)* | Authoritative for geographic metadata | Stable — rebuilt only when the producer releases new GAUL data | +| **Enrichment Asset** | The precomputed GAUL lookup (`data/gaul_lookup.parquet`), its identity in `contract/gaul_lookup.py`, its schema in `contract/gaul_schema.py`, and the keyed gathers that join it — `contract/historical.py` for the actuals artifact and `contract/wire/sidecar.py` for the §5 sidecar. *(A second gather lived in `contract/enrichment.py` with no production caller; retired in #90, register **C-75**.)* | Authoritative for geographic metadata | Stable — rebuilt only when the producer releases new GAUL data | | **Artifact Builders** | `contract/historical.py` — turns a frame plus the lookup into the partner-facing artifact. | Derived | Evolving | | **External Facts** | Facts read from systems this repo does not own: the producer's (`contract/source_metadata.py` — `last_valid_month_id`, D-07) and the store's (`contract/store_metadata.py`). | Authoritative (the owning system is the source of truth) | Evolving | | **Launch Declarations** | `contract/launch_config.py` — the delivery mode the launcher must declare. Omitting a key is **refused by name**, never inferred (ADR-003, register C-63). | Authoritative | Stable | @@ -70,7 +70,7 @@ described the cut-a-repo case; it now says which is which. **Two claims this ADR made until 2026-08-01, both now corrected rather than quietly dropped** (register C-67). It called the manager *"the **thin** `UNFAOPostProcessorManager`"* when it was -636 lines holding two of everything, and it called `unfao/extraction.py` *"the **single** +636 lines holding two of everything, and it called `unfao/extraction.py` *"the **single** pandas-aware module"* when pandas lived in three. Both drifted the same way: the ADR described the intended end state of a migration that then stopped one step short. Both are now true — pandas is **absent from the package entirely** (#89 reduced the last one to a type-only import; #90 retired the module that held it — register C-75) — and the load-bearing ones are @@ -129,7 +129,7 @@ Seam" wording will need a light touch (the seam stays; its internals change). ## Amendment 2026-07-31 — forecast identity re-homed to the wire layer (#150, epic #148) -**`views_postprocessing/delivery/identity.py` is retired.** The "Delivery Invariants" row +**`views_postprocessing/delivery/identity.py` is retired.** The "Delivery Invariants" row above listed *forecast identity* among the authoritative rules; that module no longer exists, and this amendment records where the rule went so a reader of the row is not looking for deleted code. diff --git a/docs/ADRs/013_sampled_forecast_wire_contract.md b/docs/ADRs/013_sampled_forecast_wire_contract.md index 3f8e57c..769007e 100644 --- a/docs/ADRs/013_sampled_forecast_wire_contract.md +++ b/docs/ADRs/013_sampled_forecast_wire_contract.md @@ -1120,7 +1120,7 @@ record execution progress against it. **4.73 GB peak, 108 shards, 5 min, zero store calls** (interlock held). **(B) the historical path is pandas-free** (#126): actuals fetched as a `views_frames.FeatureFrame` (the frame path's FIRST production consumer — - C-40's gate lifted), artifact built by `unfao/historical.py` via pyarrow — + C-40's gate lifted), artifact built by `contract/historical.py` via pyarrow — reader-level parity with a legacy characterization golden proven through faoapi's own reader semantics. Two ghosts found in the legacy artifact and deliberately exorcised (faoapi reader verified safe on both): junk `row`/`col` @@ -1142,8 +1142,8 @@ record execution progress against it. passed at 128 draws, sidecar + manifest staged, zero store calls. - **2026-07-20 — HOP-B SINK LEG SHIPPED (epic #105 complete; upload-disabled).** The contract's missing middle exists in fixture-proven code: - `unfao/wire/` (naming, header, shard, sidecar, run_manifest, source_selection, - sink) + `unfao/product.py` + `delivery/parity.py`, wired into the manager + `contract/wire/` (naming, header, shard, sidecar, run_manifest, source_selection, + sink) + each partner's `product.py` + `delivery/parity.py`, wired into the manager behind an explicit declared `wire_contract` launch key. Settled by shipping: **§4.2a's configuration home is `unfao/product.py`**; **§5.2's parity invariant is `delivery/parity.py`**; the §11.4 upload interlock is live in @@ -1177,7 +1177,7 @@ record execution progress against it. merged 2026-07-15 and run-0 uploaded 2026-07-27, so the sequencing constraint was satisfied. The legacy reader it protected has now been **deleted**: this repo reads forecasts only through the contract path (manifest selection, - `unfao/wire/source_selection.py`) and historical actuals only as a + `contract/wire/source_selection.py`) and historical actuals only as a `views_frames.FeatureFrame` (#126). **The retired guard, recorded here because the code that carried it is gone.** @@ -1204,7 +1204,7 @@ record execution progress against it. this ADR should know the surrounding code changed shape and the wire did not. **What changed.** The pandas delivery this contract replaced was retired (#149) along - with the config fork that silently selected it; `delivery/identity.py` was retired + with the config fork that silently selected it; `delivery/identity.py` was retired because the contract path enforces declared identity **per shard header** instead (#150, §4.2a); the duplicate representation seam collapsed (#151); the GAUL lookup became one artifact read once (#152); and the partner-neutral machinery — the whole diff --git a/docs/CICs/UNFAOPostProcessorManager.md b/docs/CICs/UNFAOPostProcessorManager.md index 0178739..af7b513 100644 --- a/docs/CICs/UNFAOPostProcessorManager.md +++ b/docs/CICs/UNFAOPostProcessorManager.md @@ -9,8 +9,8 @@ --- > **Corrected 2026-08-03 — this document named a collaborator the manager has never -> called.** Six statements described enrichment as delegated to `GaulLookupEnricher`, -> one naming the call `GaulLookupEnricher.enrich_dataframe_with_pg_info()`. The manager +> called.** Six statements described enrichment as delegated to `GaulLookupEnricher`, +> one naming the call `GaulLookupEnricher.enrich_dataframe_with_pg_info()`. The manager > contains **zero** references to it — `tests/test_gaul_lookup_access.py` actively > asserts its absence — and the sibling CIC has long said *"the manager does not call > this class."* Two contract documents asserted opposite things about the same call. diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 7a0818c..1a725a4 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -6,8 +6,8 @@ | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-03 | | Total Concerns | 83 | -| Open Concerns | 12 | -| Resolved Concerns | 71 | +| Open Concerns | 11 | +| Resolved Concerns | 72 | --- @@ -421,31 +421,6 @@ Cross-refs: **C-62** (the transitive dependency drag; the other 31 alerts), **C- --- -### C-80: The doc-accuracy scan exempts ADRs and CICs — the two artifact classes that define the contracts - -| Field | Value | -|-------|-------| -| ID | C-80 | -| Tier | 2 — structural, with a demonstrated failure. A CIC is what a contributor reads before changing a class; an ADR is what a consumer reads before building against the wire. Both were free to describe deleted code indefinitely, and did. | -| Source | `code-review max` (2026-08-03) — development→main sync audit | -| Trigger | When the next module is moved or deleted, check whether any ADR or CIC names it. The deleted-symbol regex will not tell you. #153 moved seven modules out of `unfao/` and the ADRs still cite the old paths. | -| Owner | Whoever next extends `tests/test_doc_accuracy.py`. It is a scope change plus a decision about how to exempt genuine history. | -| Location | `tests/test_doc_accuracy.py` — `_living_docs()` and `_link_checked_docs()` | - -`_living_docs()` returns `README.md`, `docs/architecture/*.md`, and package `README.md`s. **`docs/ADRs/` and `docs/CICs/` are outside it**, deliberately — an ADR legitimately records superseded designs, and a scan that fires on history gets deleted (§3). The exemption is right in principle and far too wide in practice. - -**What it cost, measured in this sync.** `docs/CICs/UNFAOPostProcessorManager.md` named `GaulLookupEnricher` as the manager's enrichment collaborator in **six** places, one of them a specific call — while the manager contains zero references and `tests/test_gaul_lookup_access.py` actively asserts its absence. The sibling CIC said the opposite in plain words. Two contract documents contradicted each other about the same call, and nothing could see it. Five further claims in the same file described a `dotenv` load that does not happen, an env-validation "known gap" that C-19 closed, an upload count wrong in three ways, and two "incorrect usage" examples for code deleted in #149/#152. ADR-013 still cites `unfao/wire/`, `unfao/product.py` and `unfao/launch_config.py`, all moved in #153. - -**The exemption is not understood by the people writing under it.** `docs/CICs/UNFAOPostProcessorManager.md` carries a `legacy-ok` marker — the line-scoped opt-out from a scan that never reaches that file. Its author believed they were suppressing a guard that was not looking. - -**A second, narrower hole in the same file.** `test_internal_doc_links_resolve` follows only markdown `](...)` links. Every path written as prose in backticks — which is how this repository writes paths almost everywhere — is unchecked. That is why the stale `unfao/...` references survived a dedicated sweep (S11) and were still being found two epics later. - -*Not proposed as a fix here:* pointing the existing regex at ADRs would fire on every historical passage and be reverted within a day. The shape that works is what §3 already recommends — check the **claim**, not the vocabulary: for CICs, that every collaborator named is actually referenced by the class (the negative form already exists at `test_gaul_lookup_access.py:156`); for backticked paths, that a path-shaped token which looks like a repo path resolves, with an opt-out for history. - -Cross-refs: **C-74** (a guard narrower than its declared surface), **C-78** (a guard whose declared scope missed a package), **C-67** (ADR-012 drift, which *is* covered and was caught), ADR-014 §1–§3, #211. - ---- - ### C-81: What actually gates `main` is weaker than it looks — CI verifies 17 fewer tests than local, and nothing requires it to pass | Field | Value | @@ -577,6 +552,45 @@ See also C-40 (the inheritance/representation coupling this migration unwinds), ## Resolved Concerns +### C-80: The doc-accuracy scan exempts ADRs and CICs — the two artifact classes that define the contracts — RESOLVED + +| Field | Value | +|-------|-------| +| ID | C-80 | +| Tier | 2 — structural, with a demonstrated failure. A CIC is what a contributor reads before changing a class; an ADR is what a consumer reads before building against the wire. Both were free to describe deleted code indefinitely, and did. | +| Source | `code-review max` (2026-08-03) — development→main sync audit | +| Trigger | When the next module is moved or deleted, check whether any ADR or CIC names it. The deleted-symbol regex will not tell you. #153 moved seven modules out of `unfao/` and the ADRs still cite the old paths. | +| Owner | Whoever next extends `tests/test_doc_accuracy.py`. It is a scope change plus a decision about how to exempt genuine history. | +| Location | `tests/test_doc_accuracy.py` — `_living_docs()` and `_link_checked_docs()` | + +`_living_docs()` returns `README.md`, `docs/architecture/*.md`, and package `README.md`s. **`docs/ADRs/` and `docs/CICs/` are outside it**, deliberately — an ADR legitimately records superseded designs, and a scan that fires on history gets deleted (§3). The exemption is right in principle and far too wide in practice. + +**What it cost, measured in this sync.** `docs/CICs/UNFAOPostProcessorManager.md` named `GaulLookupEnricher` as the manager's enrichment collaborator in **six** places, one of them a specific call — while the manager contains zero references and `tests/test_gaul_lookup_access.py` actively asserts its absence. The sibling CIC said the opposite in plain words. Two contract documents contradicted each other about the same call, and nothing could see it. Five further claims in the same file described a `dotenv` load that does not happen, an env-validation "known gap" that C-19 closed, an upload count wrong in three ways, and two "incorrect usage" examples for code deleted in #149/#152. ADR-013 still cites `unfao/wire/`, `unfao/product.py` and `unfao/launch_config.py`, all moved in #153. + +**The exemption is not understood by the people writing under it.** `docs/CICs/UNFAOPostProcessorManager.md` carries a `legacy-ok` marker — the line-scoped opt-out from a scan that never reaches that file. Its author believed they were suppressing a guard that was not looking. + +**A second, narrower hole in the same file.** `test_internal_doc_links_resolve` follows only markdown `](...)` links. Every path written as prose in backticks — which is how this repository writes paths almost everywhere — is unchecked. That is why the stale `unfao/...` references survived a dedicated sweep (S11) and were still being found two epics later. + +*Not proposed as a fix here:* pointing the existing regex at ADRs would fire on every historical passage and be reverted within a day. The shape that works is what §3 already recommends — check the **claim**, not the vocabulary: for CICs, that every collaborator named is actually referenced by the class (the negative form already exists at `test_gaul_lookup_access.py:156`); for backticked paths, that a path-shaped token which looks like a repo path resolves, with an opt-out for history. + +Cross-refs: **C-74** (a guard narrower than its declared surface), **C-78** (a guard whose declared scope missed a package), **C-67** (ADR-012 drift, which *is* covered and was caught), ADR-014 §1–§3, #211. + +**RESOLVED 2026-08-05 (B5).** ADRs and CICs are now scanned, and the design was chosen by measurement rather than by argument. + +**What the measurement said.** A path-resolution check over ADRs would have fired **29** times, and inspecting them showed most were correct history, other repositories' files, or paths inside URLs — the cry-wolf outcome this entry predicted, confirmed before building it. The curated deleted-symbol list was the narrower instrument: **22** hits over ADRs, **0** over CICs. Fourteen of the 22 were in ADR-011 alone. + +**Three of those hits were real.** ADR-013 still cited `unfao/historical.py`, `unfao/wire/` and `unfao/wire/source_selection.py` — all moved to `contract/` by #153, all fixed here. Three genuine defects hiding among five markable ones is a workable ratio, and it is the ratio that justified turning the scan on. + +**Two escapes, both declared rather than inferred.** Line-scoped `legacy-ok` for an isolated historical mention; a new file-level `` for a document whose *subject* is a retirement. ADR-011 is the case that earned it — it **is** the decision to remove the runtime mapper, so its subject appears fourteen times, correctly. A second guard pins the set of file-level exemptions to that one document, so adding another shows up in a diff. + +**And a check nothing else could have made.** `test_a_cic_does_not_name_a_collaborator_its_class_never_calls` asserts that a class a CIC names is actually referenced by the class it documents — the `GaulLookupEnricher` failure, which no path check and no symbol list would have caught at the time, because the class existed and the paths resolved. Exception types are excluded: a *collaborator* is something the class reaches for, an *exception* something that passes through, and the first draft flagged three exceptions the manager legitimately propagates. + +**The mutation campaign found a miss in the previous change.** Reintroducing the exact `GaulLookupEnricher` sentence did **not** fail — the collaborator check only sees classes that still exist, and #90/C-75 had deleted that one. The real gap was that the deletion never extended the deleted-symbol list, which that list's own comment demands in as many words: *"A deletion PR that does not extend this regex has not finished."* Extended here; the reintroduction now fails. Four further historical mentions written yesterday were flagged by the extension and marked. + +Cross-refs: **C-75** (the retirement whose CIC error motivated this, and whose PR the mutation test caught short), **C-74** and **C-78** (guards narrower than their declared surface), **C-82** (the register's own prose, still unscanned), ADR-014 §2 and §3. + +--- + ### C-83: A queryset that fails to import is reported as a queryset that declares the wrong format — RESOLVED | Field | Value | diff --git a/tests/test_doc_accuracy.py b/tests/test_doc_accuracy.py index 1669d72..ace3fbc 100644 --- a/tests/test_doc_accuracy.py +++ b/tests/test_doc_accuracy.py @@ -54,6 +54,13 @@ # #150 / #151 / #153 — modules retired or moved out of `unfao/` by epic #148 r"delivery/identity", r"unfao/extraction", r"unfao/frames", r"unfao/historical", r"unfao/gaul_schema", r"unfao/wire", + # #90 / C-75 — the enricher retired once it was shown to have no production + # caller. Added 2026-08-05, and late: the deletion PR did not extend this list, + # which is exactly what the paragraph above says a deletion PR has not finished + # doing. Found by the mutation test for the new CIC-collaborator guard, which + # could NOT catch a reintroduced mention — that guard only sees classes that + # still exist, and this one no longer does. + r"GaulLookupEnricher", r"enrich_dataframe_with_pg_info", r"contract/enrichment", # #149 — pipeline-core's pandas container, no longer referenced by this repo. # Negative lookbehind: `FAO_PGMDataset` is views-faoapi's class and is live. # It inherits IGNORECASE, so `fao_PGMDataset` is spared too — no such spelling @@ -93,6 +100,158 @@ def _deleted_symbol_offenders(docs: list[Path]) -> list[str]: return offenders +#: A document may declare, once at the top, that its SUBJECT is a retirement — after +#: which the deleted-symbol scan skips it wholesale. +#: +#: ADR-011 is the case this exists for: it is the decision to *replace* the runtime +#: mapper, so `PriogridCountryMapper`, `geopandas` and `mapping.py` appear fourteen +#: times in it, every one correctly. Marking fourteen lines would be noise pretending +#: to be rigour; marking the document once is the same claim, made where a reader sees +#: it. Per-line ``legacy-ok`` remains the tool for an isolated mention in a document +#: that is otherwise about the present. +_RETIREMENT_DOC_MARKER = "` if its subject IS the retirement. " + "If it is not history, it is a stale path and the document is wrong." + ) + + +def test_the_retirement_marker_is_not_used_to_silence_a_live_document(): + """An escape hatch nobody bounds is just a disabled check. + + A whole-document exemption is the strongest thing available here, so the set of + documents holding one is pinned. Adding another is a deliberate act that shows up in + a diff, rather than a quiet way to stop a guard complaining. + """ + marked = sorted( + d.name for d in _governance_docs() if _RETIREMENT_DOC_MARKER in d.read_text() + ) + assert marked == ["011_replace_runtime_mapper_with_precomputed_lookup.md"], ( + f"documents exempted from the deleted-symbol scan: {marked}. Only a document " + "whose SUBJECT is a retirement earns this. If a present-tense document needs " + "it, the document is describing code that no longer exists." + ) + + def test_living_docs_have_no_deleted_symbol_references(): offenders = _deleted_symbol_offenders(_living_docs()) assert not offenders, "deleted-symbol references in living docs:\n" + "\n".join(offenders) diff --git a/views_postprocessing/unfao/managers/README.md b/views_postprocessing/unfao/managers/README.md index 0cc915f..ac7d4b6 100644 --- a/views_postprocessing/unfao/managers/README.md +++ b/views_postprocessing/unfao/managers/README.md @@ -91,5 +91,5 @@ Appwrite). - [`role_and_seams.md`](../../../docs/architecture/role_and_seams.md) — role + seams - [CIC: UNFAOPostProcessorManager](../../../docs/CICs/UNFAOPostProcessorManager.md) — class contract -- Geography is attached by `contract/historical.py` (the actuals artifact) and `contract/wire/sidecar.py` (the §5 GAUL sidecar), each a keyed gather over the lookup this manager reads once. *(A `GaulLookupEnricher` CIC was linked here until #90; that class had no production caller and was retired — register C-75.)* +- Geography is attached by `contract/historical.py` (the actuals artifact) and `contract/wire/sidecar.py` (the §5 GAUL sidecar), each a keyed gather over the lookup this manager reads once. *(A `GaulLookupEnricher` CIC was linked here until #90; that class had no production caller and was retired — register C-75.)* - ADR-011 (mapper → lookup), ADR-012 (current ontology) From dc8c339fe1efc825a4346dfede41d7654c0f2b7a Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 5 Aug 2026 10:46:06 +0200 Subject: [PATCH 07/42] =?UTF-8?q?docs(register):=20C-82=20=E2=80=94=20guar?= =?UTF-8?q?d=20the=20prose=20claims=20that=20can=20be=20guarded,=20drop=20?= =?UTF-8?q?the=20rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-82 said governance prose carries numbers and statuses nothing checks. Its own count had gone stale while it sat open, which is the argument rather than an irony: a number nothing checks is a claim with a half-life. Two guards where a claim names an artifact — the checkable shape: - test_test_files_named_by_live_entries_exist_or_name_their_repo. Scoped to Open Concerns and Disagreements deliberately; eleven such mentions exist register-wide and nine are resolved entries correctly recording what discharged them, so a blanket check would cry wolf and be gone in a day (ADR-014 §3). - test_a_cic_review_date_is_not_older_than_its_own_content. - test_the_docstring_states_the_same_edition_the_constants_declare, added when the v1.4.4 bump below moved the constants and left both docstrings saying v1.4.1. The first draft of the first guard was the bug it was written to catch. Its foreign-repo exemption reused _FOREIGN_PREFIXES — the identifier-namespacing list, which holds ordinary English like `models` and `pipeline-core` — over a 60-char window. A mutation planting a vanished file in a live entry left it green because a sentence three words earlier said "pinned pipeline-core-free". It had found its two real defects by luck of their neighbours. Rebuilt to require the owning repo immediately abutting the path, re-proven against three mutations including that one. Numbers that could not be guarded were removed in favour of the command that produces them (C-33's precedent), not re-counted. Historical counts inside dated closure records are left alone — they are records, not claims about now. Also disposed of: D-11's self-contradiction; the deleted test_enrichment.py cited as live coverage; views-faoapi's golden-fixture path stated as if it were ours. Seam Contract pin 1.4.1/90fc105 -> 1.4.4/fcf32c9, both partners. The detector demanded it; coordinate names re-verified unchanged, no values copied. New C-84 (Tier 2): the operator console read of 2026-08-05 recorded that both platform keys expire 2026-11-17, 3h35m apart. The UN FAO key is what this delivery authenticates with. No degraded mode, no fallback identity, and the date is known in advance. Registered so it is not discovered by an outage; the fix is a console action, not engineering. C-81 updated to say what that same session did not move. Register 83/11/72 -> 84/11/73. Suite 360 passed / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CICs/UNFAOPostProcessorManager.md | 2 +- reports/technical_risk_register.md | 108 ++++++++++++++------- tests/test_doc_accuracy.py | 27 ++++++ tests/test_env_declaration.py | 28 ++++++ tests/test_register_integrity.py | 50 ++++++++++ views_postprocessing/crafd/appwrite_env.py | 8 +- views_postprocessing/unfao/appwrite_env.py | 8 +- 7 files changed, 189 insertions(+), 42 deletions(-) diff --git a/docs/CICs/UNFAOPostProcessorManager.md b/docs/CICs/UNFAOPostProcessorManager.md index af7b513..d8848a8 100644 --- a/docs/CICs/UNFAOPostProcessorManager.md +++ b/docs/CICs/UNFAOPostProcessorManager.md @@ -3,7 +3,7 @@ **Status:** Active **Owner:** PRIO MD&D Team -**Last reviewed:** 2026-06-02 +**Last reviewed:** 2026-08-05 **Related ADRs:** ADR-001, ADR-002, ADR-008, ADR-009 --- diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 1a725a4..382b040 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -4,10 +4,10 @@ |-------------------|--------------------------------------| | Project | views-postprocessing | | Owner | Dylan Pinheiro / PRIO MD&D Team | -| Last Updated | 2026-08-03 | -| Total Concerns | 83 | +| Last Updated | 2026-08-05 | +| Total Concerns | 84 | | Open Concerns | 11 | -| Resolved Concerns | 72 | +| Resolved Concerns | 73 | --- @@ -49,7 +49,7 @@ covered a single open entry (see Historical clusters below). **Root cause:** the register, ADR prose, and issue bodies are hand-maintained mirrors of cross-repo state that moves under them. **Entries:** C-44, C-46, C-47, C-57, C-74 (a guard whose declared scan roots silently stopped existing — the cluster's disease inside the cluster's own prescription) — plus this register's own findings at review-rr 2026-07-31 (header miscount, two RESOLVED entries misfiled under Open, eight stale `unfao.py` line ranges after the manager grew 273→636 lines, two unnamespaced foreign-register IDs). Historical precedent: the entire C-48–C-55 ADR-013 audit series, and C-42/C-47. **Highest tier:** 3 -**Fix strategy:** this repo already solved this disease once — the ADR-013 audit series ended with **40 permanent guard tests** (`tests/test_falsify_adr013_*.py`), and the same pattern now guards the þing-01 invariants (`tests/test_env_declaration.py`, `tests/test_redaction_guard.py` — the latter briefly **only over the roots that still existed**, see C-74, resolved: a guard is only as good as the assertion that its inputs are real, and it now carries that assertion). There is **no equivalent for the register**. A small `tests/test_register_integrity.py` — header counts match section counts; no RESOLVED body under `## Open Concerns`; every `C-\d+`/`D-\d+` reference resolves or is namespaced to a foreign register — would make this class self-detecting. +**Fix strategy:** this repo already solved this disease once — the ADR-013 audit series ended with a **permanent guard suite** (`tests/test_falsify_adr013_*.py` — `pytest --collect-only -q tests/test_falsify_adr013*.py` for the count, which moves), and the same pattern now guards the þing-01 invariants (`tests/test_env_declaration.py`, `tests/test_redaction_guard.py` — the latter briefly **only over the roots that still existed**, see C-74, resolved: a guard is only as good as the assertion that its inputs are real, and it now carries that assertion). There is **no equivalent for the register**. A small `tests/test_register_integrity.py` — header counts match section counts; no RESOLVED body under `## Open Concerns`; every `C-\d+`/`D-\d+` reference resolves or is namespaced to a foreign register — would make this class self-detecting. **Resolution scope:** Full for the mechanical half. ### Cluster J: Delivery aftercare has no mechanism @@ -99,7 +99,7 @@ covered a single open entry (see Historical clusters below). | 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-43, C-59, C-61** Cluster K's build-time guarantees | S2 | `tests/test_gaul_lookup_fidelity.py` — one file discharged three entries | | **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 | @@ -161,6 +161,29 @@ that indexes only deleted code is noise. ## Open Concerns +### C-84: Every identity this repo delivers under dies on 2026-11-17, within 3h35m of the other + +| Field | Value | +|-------|-------| +| ID | C-84 | +| Tier | 2 — not silent. The delivery fails loudly and completely, which is the correct behaviour and also the whole problem: there is no degraded mode, no fallback identity, and the date is known in advance. A foreseeable total outage that nobody has scheduled work against is a structural risk, not an operational surprise. | +| Source | views-appwrite coordinate registry v1.4.3/v1.4.4 — operator console read, 2026-08-05 (þing-02 A3(i)) | +| Trigger | **A date, unusually — 2026-11-17.** The registry records `VIEWS Pipeline Core` expiring 12:35 and `UN FAO` 16:10 that afternoon. Act when the un_fao delivery is next scheduled within a month of it, or when anyone plans a rotation, whichever is first. | +| Owner | Simon, and only Simon — issuing and installing keys is a console action. This entry exists so the date is visible from *this* repo's planning surface rather than only from the platform's. | +| Location | `views_postprocessing/{unfao,crafd}/appwrite_env.py` — the declared coordinates; the values live in the environment and the registry, never here. | + +The FAO delivery authenticates with the `UN FAO` key. That key expires **2026-11-17 16:10**, and the platform's other key three and a half hours earlier. Read from the console rather than inferred, and recorded in the registry this repo pins. + +**Why the gap is the finding and not the dates.** Three and a half hours is not a stagger — it is close enough that the two keys cannot cover for one another under any realistic response, and both are on the same seam. After 16:10 that afternoon every identity on it is dead at once: model and ensemble writes, the un_fao delivery, the CRAF'd delivery, all preflights, and FAO's own read access. A rotation that assumes one key can carry traffic while the other is replaced has no such window. + +**What this repo can and cannot do.** It cannot rotate anything; it holds no credentials and must not (þing-01 D3). What it can do is fail early and legibly rather than mid-delivery — and it does not currently. `appwrite_env.py` validates that the declared variables are *present*, which an expired key still is. An expired key is indistinguishable from a valid one until the first request comes back unauthorised, by which point a delivery is part-way through. + +**Deliberately not fixed here, and the reason is C-84's own shape.** A preflight that checks key validity means an authenticated call at startup, and the only project to make it against is production — which þing-02 **D2** forbids for tests and this would not quite be. The honest position is that this is a *date to act on*, not a mechanism to build, and inventing a mechanism would be building the wrong thing to feel busy. Registered so the date is not discovered by an outage. + +Cross-refs: **C-81** (the same operator session's other half — branch protection and the CI token), **C-27** (no rotation mechanism for a secret value upstream), **C-57** (the pinned-registry detector, which is how this arrived here at all — it demanded the v1.4.4 bump and the bump is what surfaced the expiry), þing-02 A3(i), views-appwrite C-65 and C-66. + +--- + ### C-15: Upload metadata lacks enrichment provenance and carries test description | Field | Value | @@ -413,7 +436,7 @@ See also C-07/C-27/C-29 (pipeline-core coupling symptoms), C-39 (the dead-mapper **(a) views-faoapi is NOT exposed, and the platform ceiling is not uniform.** This entry implied the consumer shared our pin. It does not: faoapi pins **`pyarrow==23.0.1`** — the patched version — in its `pyproject.toml:31`, and it is the repo that actually reads the wire (`forecast/ingestion/wire_reader.py` → `arrow.load`). So nobody on the platform is currently exposed through this path, and the `<17` ceiling views-pipeline-core#280 exists to lift is binding on the **producers** only. One consumer has already moved past it, in production. -**(b) The re-vendor obligation is stronger than "our fixture changes".** ADR-013 §10 is explicit: *"All three implementing repos' test suites consume the same bytes … The other two repos **vendor a copy** and carry a **pinned root-hash equality test** … A change to the fixture is a change to the contract."* Verified — faoapi's copy is at `tests/forecast/golden/wire_contract/SHA256SUMS`, pinned by `tests/forecast/test_wire_golden_fixture.py`. So the upgrade is a **coordinated three-repo re-vendor**, and it carries an undecided question: whether it bumps `contract_version` (the payload schema does not change, only the encoder's bytes — §10 says a fixture change *is* a contract change). +**(b) The re-vendor obligation is stronger than "our fixture changes".** ADR-013 §10 is explicit: *"All three implementing repos' test suites consume the same bytes … The other two repos **vendor a copy** and carry a **pinned root-hash equality test** … A change to the fixture is a change to the contract."* Verified — faoapi's copy is at `tests/forecast/golden/wire_contract/SHA256SUMS`, pinned by views-faoapi's `tests/forecast/test_wire_golden_fixture.py`. So the upgrade is a **coordinated three-repo re-vendor**, and it carries an undecided question: whether it bumps `contract_version` (the payload schema does not change, only the encoder's bytes — §10 says a fixture change *is* a contract change). **Filed, so the relocation is complete rather than assumed** (the C-08 lesson): **views-postprocessing#174** (the coordinating issue), **views-faoapi#348** (heads-up + two questions only their seat can answer), and a comment on **views-pipeline-core#280** adding the security dimension and the non-uniform-ceiling finding. @@ -454,33 +477,10 @@ Where each sibling stands, after trying them: The two compound: a suite that checks less than you think, and no requirement that even that much passes. Neither is caused by this sync — both are pre-existing — but this sync is the first time `main` receives an epic whose value is largely the guards themselves. Cross-refs: **C-46** and **C-57** (both RESOLVED; this is the residual each recorded as *"a CI-cost and cross-repo-coupling decision"* and *"worth deciding once for both"* — it now has a live home and a concrete answer per sibling), **C-80** (the other verification gap found in the same audit), #188. +**Update 2026-08-05 — the operator session happened, and neither half of this entry moved.** Simon read the Appwrite console that morning (views-appwrite v1.4.4). It answered two *other* þing-02 questions definitively — there is **no non-production project**, and both platform keys expire 2026-11-17 (now **C-84**) — but the console read is a different action from issuing a token and a different console from GitHub's. So both halves stand: the two private siblings still have no CI credential, and `protect_main`'s ref-name include-list is still empty. ---- - -### C-82: Governance-artifact prose carries numbers and statuses that nothing checks - -| Field | Value | -|-------|-------| -| ID | C-82 | -| Tier | 3 — no delivery is affected, but these are the artifacts people plan from. One instance materially under-scopes a planned dependency bump. | -| Source | `code-review max` (2026-08-03) — development→main sync audit | -| Trigger | When the pipeline-core 3.0.0 bump (C-44) is scoped from Cluster M's summary rather than from C-72's body, or when anyone counts on a test-count or issue-state stated in the register. | -| Owner | Whoever runs the next `review-rr` pass; this is curation, not engineering. | -| Location | `reports/technical_risk_register.md` (Clusters I, J, M; D-09, D-11); `docs/CICs/*.md` front matter | - -`tests/test_register_integrity.py` checks structure — header counts, section placement, reference resolution — and **no prose at all**. Roughly twenty-five statements drift beneath it. - -**The one that would change a decision.** Cluster M declares resolution *"Full for … C-72 …"* at the pipeline-core 3.0.0 bump, while C-72's own body says its fix is gated on pipeline-core **#280** (open), **changes delivered wire bytes**, and requires a coordinated three-repo re-vendor of the ADR-013 §10 golden fixture. Someone planning that bump from the cluster summary under-scopes it badly. Cluster M's heading also says six entries where its body says five. - -**Self-contradiction elsewhere.** Cluster I still argues that *"there is no equivalent for the register — a small `tests/test_register_integrity.py` … would make this class self-detecting"*; that file exists, has ten green tests, and is cited elsewhere in the same document. Cluster J names issue **#15** as its fix strategy; #15 is closed and superseded by `docs/operations/correction_procedure.md`. D-11 says a branch *"currently has no scheduled deletion PR"* two paragraphs after recording that it was deleted. D-09's `Status` row reads *"Open … after delivery"* directly above prose recording the deferral expired on 2026-07-31. - -**Numbers.** The `test_gaul_lookup_fidelity.py` count appears as **26** twice in the register and as **18** twice more including `test_register_integrity.py`'s own docstring; the actual is **24**, and 26 was never true — it was written when the file held 24. Also *"40 ADR-013 guard tests"* (39) and *"`test_enrichment.py`, 16"* (39). - -**CIC front matter.** `GaulLookupEnricher.md` says *Last reviewed 2026-06-18* and `UNFAOPostProcessorManager.md` *2026-06-02*, while both bodies carry 2026-08 content. A reader calibrating trust from the header calibrates it wrong in the safe direction, which is lucky rather than designed. - -*The general fix is C-80's, not a re-count:* prose that states a number is a claim, and a claim needs a check. Where a number cannot be checked, the honest move is to state the command that produces it — which is what C-33 was forced into after its measurement was wrong five times. +Recorded rather than left implicit because "the operator did a console session" is exactly the kind of adjacent fact that gets mistaken for progress on this entry. It is not. What it does establish is that the session is a thing that happens, and these two items are small enough to ride along with the next one. -Cross-refs: **C-80** (the same disease in ADRs and CICs, and the mechanism that would catch both), **C-72** and **C-44** (the bump this mis-scopes), **C-33** (the worked example of publishing the command instead of the result), ADR-014 §1. --- @@ -528,7 +528,7 @@ The pandas→views-frames migration (epic #85) deliberately swaps each seam by a **✅ CONDITION MET 2026-08-01 (epic #148).** The legacy forecast branch **was** deleted (#149) — before #89 landed, so the re-open trigger below never fired. Position A is vindicated by events rather than by argument: concrete siblings were built, the migration completed, and the second implementation was deleted rather than abstracted over. A Protocol introduced at decision time would have outlived the thing it existed to unify. Recorded in `tests/test_input_integrity_design_contract.py` ② so the next seam decision has the precedent. -**Adjudication: B — but conditionally, and the condition is now named.** B is only correct *if the legacy forecast branch is actually deleted*. It currently has no scheduled deletion PR — only a "named post-run-0 follow-up" (C-40). If it lingers, three-way duplication becomes the permanent shape and Position A wins retroactively. **Re-open trigger: if the legacy forecast branch still exists when #89 (numpy/pyarrow keyed gather) lands, unify all three consumers onto one fail-loud gather primitive as part of that story rather than deferring again.** Cross-refs C-59 (the duplicate-key hazard that lands differently in each of the three), C-40, #89. +**Adjudication: B — but conditionally, and the condition is now named.** B is only correct *if the legacy forecast branch is actually deleted*. It had no scheduled deletion PR when this was adjudicated — #149 deleted it on 2026-07-31, which is what made B correct rather than merely preferable — only a "named post-run-0 follow-up" (C-40). If it lingers, three-way duplication becomes the permanent shape and Position A wins retroactively. **Re-open trigger: if the legacy forecast branch still exists when #89 (numpy/pyarrow keyed gather) lands, unify all three consumers onto one fail-loud gather primitive as part of that story rather than deferring again.** Cross-refs C-59 (the duplicate-key hazard that lands differently in each of the three), C-40, #89. **SETTLED 2026-07-31 (review-rr — the original re-open trigger resolved without firing).** The sole re-open condition was a long-lived S6/#91 dual-write. **#91 is CLOSED**, and the contract path shipped frame-native end-to-end (PRs #115–#129, wire delivered in run-0) without a durable dual-representation window. Position A is vindicated by events: concrete siblings + delete is what actually happened, the pandas path is confined to the legacy branch pending its named post-run-0 deletion, and no polymorphic representation port was ever needed. No live tension remains — this entry is kept as decision provenance for the remaining seam work (#89, #90) rather than as an open question. @@ -552,6 +552,48 @@ See also C-40 (the inheritance/representation coupling this migration unwinds), ## Resolved Concerns +### C-82: Governance-artifact prose carries numbers and statuses that nothing checks — RESOLVED 2026-08-05 + +| Field | Value | +|-------|-------| +| ID | C-82 | +| Tier | 3 — no delivery is affected, but these are the artifacts people plan from. One instance materially under-scopes a planned dependency bump. | +| Source | `code-review max` (2026-08-03) — development→main sync audit | +| Trigger | When the pipeline-core 3.0.0 bump (C-44) is scoped from Cluster M's summary rather than from C-72's body, or when anyone counts on a test-count or issue-state stated in the register. | +| Owner | Whoever runs the next `review-rr` pass; this is curation, not engineering. | +| Location | `reports/technical_risk_register.md` (Clusters I, J, M; D-09, D-11); `docs/CICs/*.md` front matter | + +`tests/test_register_integrity.py` checks structure — header counts, section placement, reference resolution — and **no prose at all**. Roughly twenty-five statements drift beneath it. + +**The one that would change a decision.** Cluster M declares resolution *"Full for … C-72 …"* at the pipeline-core 3.0.0 bump, while C-72's own body says its fix is gated on pipeline-core **#280** (open), **changes delivered wire bytes**, and requires a coordinated three-repo re-vendor of the ADR-013 §10 golden fixture. Someone planning that bump from the cluster summary under-scopes it badly. Cluster M's heading also says six entries where its body says five. + +**Self-contradiction elsewhere.** Four were named: Cluster I arguing for a `tests/test_register_integrity.py` that already existed with ten green tests; Cluster J naming closed issue **#15** as its fix strategy; D-11 saying a branch *"currently has no scheduled deletion PR"* two paragraphs after recording its deletion; D-09's `Status` row reading *"Open … after delivery"* above prose recording the deferral expired. + +Three were already corrected by the passes that followed, and only **D-11** survived to this one — measured, not assumed: each was grepped for on 2026-08-05 and Clusters I and J and D-09 returned zero hits. That matters more than the count. The instances got fixed one at a time by whoever tripped over them, which is exactly the failure mode this entry describes: **the register self-heals where someone happens to look and rots everywhere else.** D-11 sat contradicting itself for five days in a section nobody had cause to re-read. + +**Numbers, and this entry's own numbers rotted while it sat open.** It read: *"the count appears as 26 twice and as 18 twice more; the actual is 24."* Since then #90 added a test and the actual became 25, so the entry describing stale counts had a stale count. That is not irony worth savouring — it is the argument. **A count nothing checks is a claim with a half-life**, and the fix is not a more careful re-count. + +Resolved by removing the volatile numbers rather than correcting them, which is the precedent C-33 was forced into after its measurement was wrong five times: publish the **command**, not the result. `40 permanent guard tests` became the collect-only command; the fidelity count became *"one file discharged three entries"*, which is the claim that mattered and does not move. Historical counts inside dated closure records are left alone — *"18 tests, committed in #141"* was true at #141 and is a record, not a claim about now. + +**CIC front matter.** `GaulLookupEnricher.md` said *Last reviewed 2026-06-18* and `UNFAOPostProcessorManager.md` *2026-06-02* while both bodies carried 2026-08 content — calibrating a reader's trust wrong in the safe direction, which is luck rather than design. The first document was deleted with its class (#90/B3b). The second is corrected, and the field is now checked against the document's own dated content by `test_a_cic_review_date_is_not_older_than_its_own_content` — git could not answer this, because git records when a line was touched and this field claims when someone read the whole thing. + +*The general fix is C-80's, not a re-count:* prose that states a number is a claim, and a claim needs a check. Where a number cannot be checked, the honest move is to state the command that produces it — which is what C-33 was forced into after its measurement was wrong five times. + +Cross-refs: **C-80** (the same disease in ADRs and CICs, and the mechanism that would catch both), **C-72** and **C-44** (the bump this mis-scopes), **C-33** (the worked example of publishing the command instead of the result), ADR-014 §1. + +**Resolved 2026-08-05 (B6).** Every named instance is disposed of, and two guards now stand where the prose was unchecked: + +- `test_test_files_named_by_live_entries_exist_or_name_their_repo` — a live entry may not name a test file that does not exist unless it names the repo that owns it. Scoped to Open Concerns and Disagreements on purpose: eleven such mentions exist register-wide and nine are resolved entries correctly recording what discharged them, so a blanket check would cry wolf and be deleted within a day (ADR-014 §3). +- `test_a_cic_review_date_is_not_older_than_its_own_content` — a CIC's `Last reviewed` header may not predate dates in its own body. + +**The first draft of the first guard was itself the bug it was written to catch,** and this is the part worth keeping. Its foreign-repo exemption reused `_FOREIGN_PREFIXES` — the list that namespaces *identifiers* — over a sixty-character window. That list holds ordinary English: `models`, `frames`, `pipeline-core`. A mutation planting a vanished file in a live entry left it green, because a sentence three words earlier said *"pinned pipeline-core-free"*. It had found its two real defects by luck of their neighbouring words. Rebuilt to require the owning repo **immediately abutting the path**, and re-proven against three mutations including the one that defeated the draft. A guard nobody has watched fail is decoration (ADR-014 §2) — and a guard watched failing on the *wrong* mutation is worse, because it has a proof attached. + +**What is deliberately not fixed.** The general case — arbitrary prose asserting an arbitrary number — is not mechanisable and this entry does not claim it is. What is mechanised is the shape that recurred: a claim naming an artifact, checked by asking whether the artifact exists. Numbers that could not be guarded were removed in favour of the command that produces them, per C-33. + +*Verify:* `pytest -q tests/test_register_integrity.py tests/test_doc_accuracy.py` + +--- + ### C-80: The doc-accuracy scan exempts ADRs and CICs — the two artifact classes that define the contracts — RESOLVED | Field | Value | @@ -1312,7 +1354,7 @@ Cross-refs: C-15 (the provenance this field serves), C-22 (the recall process th Replaced by tests of `contract/historical.assert_metadata_complete` — the code that actually gates a delivery — parametrised over the **imported** `METADATA_COLS`, plus source-scan pins that the gate stays at build time and is still invoked. Verified 2026-08-02: `pytest -q tests/test_validation.py` → **14 passed**. Mutation-tested: narrowing the gate to a single column fails **9 of 14**; the old suite passed that mutation untouched, because it was not testing the gate. -**Residual 2 — the enrich→validate end-to-end test — RELOCATED to #18**, per the Register Conventions' relocation rule (a relocation is not complete until the destination exists and is cited by number). Every *leg* is now covered — enrichment (`test_enrichment.py`, 16), artifact build (`test_historical_builder.py`, 7), reader parity (`test_historical_parity.py`, 3), the invariants on primitives (`test_input_integrity_e2e.py`, 8), the wire end to end (`test_hop_b_sink_e2e.py`, 6), the null-gate (`test_validation.py`, 14). What remains uncovered is **the manager orchestrating them**, which needs views-pipeline-core and a production-like Appwrite environment — and þing-02 **D2** forbids integration tests against the production project, no non-production one existing. +**Residual 2 — the enrich→validate end-to-end test — RELOCATED to #18**, per the Register Conventions' relocation rule (a relocation is not complete until the destination exists and is cited by number). Every *leg* is now covered — enrichment (**no longer a leg**: `GaulLookupEnricher` and `test_enrichment.py` were deleted in #90/B3b, the lookup join having moved into the frame build; recorded here because the closure above was argued from a list this deletion shortened), artifact build (`test_historical_builder.py`, 7), reader parity (`test_historical_parity.py`, 3), the invariants on primitives (`test_input_integrity_e2e.py`, 8), the wire end to end (`test_hop_b_sink_e2e.py`, 6), the null-gate (`test_validation.py`, 14). What remains uncovered is **the manager orchestrating them**, which needs views-pipeline-core and a production-like Appwrite environment — and þing-02 **D2** forbids integration tests against the production project, no non-production one existing. That gap has **two standing trackers already**, which is why keeping a third here is noise rather than signal: issue **#18** (open since 2026-06-04) and `tests/test_falsification_campaign_3_5.py`, an `xfail(strict)` probe that **flips to XPASS the moment someone writes the test** — a self-surfacing tracker, which is more than this entry was doing. | | Tier | 3 | diff --git a/tests/test_doc_accuracy.py b/tests/test_doc_accuracy.py index ace3fbc..250eec9 100644 --- a/tests/test_doc_accuracy.py +++ b/tests/test_doc_accuracy.py @@ -143,6 +143,33 @@ def _governance_docs() -> list[Path]: } +def test_a_cic_review_date_is_not_older_than_its_own_content(): + """Front matter that understates how stale a document is calibrates trust wrongly. + + `UNFAOPostProcessorManager.md` said *Last reviewed 2026-06-02* while its body + carried an August correction note about a collaborator the class never called. The + error was in the safe direction — a reader distrusted it more than needed — but that + is luck, not design, and the same field could just as easily claim freshness a + document does not have. + + Checked against the document's own dated content rather than against git, because + git records when a line was touched and this field claims when someone *read the + whole thing*. A date the body mentions later than the header is proof the header is + behind. + """ + for cic in sorted(_CIC_SUBJECT): + text = (_REPO / "docs" / "CICs" / cic).read_text() + header = re.search(r"\*\*Last reviewed:\*\*\s*(\d{4}-\d{2}-\d{2})", text) + assert header, f"{cic} declares no review date; a reader cannot calibrate it" + body_dates = re.findall(r"\b(20\d\d-\d{2}-\d{2})\b", text[header.end():]) + newer = sorted(d for d in body_dates if d > header.group(1)) + assert not newer, ( + f"{cic} says it was last reviewed {header.group(1)}, but its body cites " + f"later dates {newer[:3]}. Either the review date is stale or the content " + "was added without re-reading the document it changed." + ) + + def test_every_cic_declares_which_class_it_documents(): """Assert this guard's inputs are real (ADR-014 §2). diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 9d3fa7b..4f75f3f 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -541,6 +541,34 @@ def test_the_pinned_contract_edition_still_matches_the_registry(partner): ) +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_the_docstring_states_the_same_edition_the_constants_declare(partner): + """The module says the edition twice — in prose and in a constant. Only one is checked. + + Both `appwrite_env.py` docstrings name the registry edition in a sentence + (*"That pin is registry v1.4.1"*) beside the constant that declares it. The drift + detector reads the constant, so on 2026-08-05 the pins moved to v1.4.4 with both + guards green and both docstrings still saying v1.4.1 — a reader following the prose + would have checked their coordinates against a superseded edition. + + This is the same defect the register carries as C-80 and C-82: a claim in prose next + to a fact in code, with a guard on the fact only. Cheap to close here because the + prose states the value in a fixed form, so the two can simply be compared. + """ + module = _PARTNER_ENV[partner][0] + stated = re.findall(r"registry \*\*v([\d.]+)\*\*", module.__doc__ or "") + assert stated, ( + f"{partner}/appwrite_env.py's docstring no longer states the registry edition in " + "the form this guard reads. If the sentence was reworded, reword the pattern too " + "— do not delete the check, or the prose goes unguarded again." + ) + assert set(stated) == {module.SEAM_CONTRACT_VERSION}, ( + f"[{partner}] the docstring says registry v{'/v'.join(sorted(set(stated)))} but " + f"SEAM_CONTRACT_VERSION declares v{module.SEAM_CONTRACT_VERSION}. The constant is " + "what the drift detector checks, so the prose is the half that rots silently." + ) + + @pytest.mark.parametrize("partner", _PARTNERS) def test_the_pinned_commit_is_reachable_from_the_contract_repos_main(partner): """Existence is not reachability, and that distinction cost a merged PR (#196). diff --git a/tests/test_register_integrity.py b/tests/test_register_integrity.py index bdde1ad..e8b4491 100644 --- a/tests/test_register_integrity.py +++ b/tests/test_register_integrity.py @@ -186,6 +186,56 @@ def test_internal_references_resolve_and_foreign_ones_are_namespaced(register): ) +def test_test_files_named_by_live_entries_exist_or_name_their_repo(register): + """A live entry pointing at a test nobody can find describes work against nothing. + + Scoped to **Open Concerns and Disagreements** deliberately. A resolved entry citing + `test_mapping.py` or `test_reconciliation_parity.py` is recording what discharged it, + and those files are correctly gone — a blanket existence check would fire on eleven + such mentions and be deleted within a day (ADR-014 §3). Measured before scoping: + eleven missing across the whole register, **two** in live entries. + + The two it found on 2026-08-05 were both real, and one of them is a shape this + register already polices for identifiers but not for paths: + ``tests/forecast/test_wire_golden_fixture.py`` is **views-faoapi's** file. A bare + foreign path sends the reader hunting in this repo's tree, which is exactly the + argument `test_internal_references_resolve_and_foreign_ones_are_namespaced` makes + about a bare ``C-161``. + + **The exemption is a declaration, not a proximity heuristic, and the first draft got + that wrong.** It reused `_FOREIGN_PREFIXES` — the identifier-namespacing list — over a + 60-character window. That list holds ordinary English: ``models``, ``frames``, + ``pipeline-core``. Mutation M1 planted a vanished file in a live entry and the guard + stayed green, because the sentence three words earlier happened to say *"pinned + pipeline-core-free"*. It had caught its two real findings by luck of their neighbours + and would have missed most others. So the owning repo must now be named **immediately + before the path**, in the possessive form a reader would write anyway. + """ + body = register.split("## Register Conventions")[0] + live = body[body.index("## Open Concerns"):body.index("## Resolved Concerns")] + + repo = Path(__file__).resolve().parent.parent + #: `views-faoapi's `tests/...`` — the repo abutting the path, not merely nearby. + owned_elsewhere = re.compile(r"views-[\w-]+(?:'s)?[\s:]*$") + + unresolved = [] + for match in re.finditer(r"`(?:(tests/[\w/]+\.py)|(test_\w+\.py))`", live): + rel = match.group(1) or f"tests/{match.group(2)}" + if (repo / rel).exists(): + continue + if owned_elsewhere.search(live[max(0, match.start() - 40) : match.start()]): + continue + line = live.count("\n", 0, match.start()) + 1 + unresolved.append(f"line ~{line} of the live sections: {rel}") + + assert not unresolved, ( + "live register entries name test files that do not exist here and do not name " + f"the repository that owns them: {unresolved}. If the file is another repo's, " + "say so beside it. If it is ours, it was deleted and the entry is describing " + "work against a file nobody can open." + ) + + # ── Closing conditions must not already be met (S2 / #183) ─────────────────── # # The guards above catch a heading that SAYS it is resolved. They cannot catch an diff --git a/views_postprocessing/crafd/appwrite_env.py b/views_postprocessing/crafd/appwrite_env.py index 68c42ac..57ab560 100644 --- a/views_postprocessing/crafd/appwrite_env.py +++ b/views_postprocessing/crafd/appwrite_env.py @@ -6,9 +6,9 @@ views-appwrite and referenced by pinned URL, never copied — copies were the platform's original failure (þing-01 sáttmál S6): - https://github.com/views-platform/views-appwrite/blob/90fc105/docs/ADRs/platform/coordinate_registry.toml + https://github.com/views-platform/views-appwrite/blob/fcf32c9/docs/ADRs/platform/coordinate_registry.toml -That pin is registry **v1.4.1** — declared below as ``SEAM_CONTRACT_VERSION`` / +That pin is registry **v1.4.4** — declared below as ``SEAM_CONTRACT_VERSION`` / ``SEAM_CONTRACT_COMMIT`` so the pin is a value a test can check rather than a fact buried in prose. A pinned URL does not rot, but it does go stale, and nothing in this repository could previously tell you it had (register C-57). @@ -47,8 +47,8 @@ #: declaration against that edition of the registry. ``tests/test_env_declaration.py`` #: enforces the pair — for this package and for ``unfao`` alike — against a local #: views-appwrite checkout when one is present. -SEAM_CONTRACT_VERSION = "1.4.1" -SEAM_CONTRACT_COMMIT = "90fc105" +SEAM_CONTRACT_VERSION = "1.4.4" +SEAM_CONTRACT_COMMIT = "fcf32c9" CONNECTION_ENV = ( "APPWRITE_ENDPOINT", diff --git a/views_postprocessing/unfao/appwrite_env.py b/views_postprocessing/unfao/appwrite_env.py index 1035b64..ffd6169 100644 --- a/views_postprocessing/unfao/appwrite_env.py +++ b/views_postprocessing/unfao/appwrite_env.py @@ -5,9 +5,9 @@ views-appwrite and referenced by pinned URL, never copied — copies were the platform's original failure (þing-01 sáttmál S6): - https://github.com/views-platform/views-appwrite/blob/90fc105/docs/ADRs/platform/coordinate_registry.toml + https://github.com/views-platform/views-appwrite/blob/fcf32c9/docs/ADRs/platform/coordinate_registry.toml -That pin is registry **v1.4.1** — declared below as +That pin is registry **v1.4.4** — declared below as ``SEAM_CONTRACT_VERSION`` / ``SEAM_CONTRACT_COMMIT`` so the pin is a value a test can check rather than a fact buried in prose. A pinned URL does not rot, but it does go stale, and nothing in this repository could previously tell you it had (register C-57). @@ -44,8 +44,8 @@ #: Bumping these is not bookkeeping: it asserts that someone re-checked this module's #: declaration against that edition of the registry. ``tests/test_env_declaration.py`` #: enforces the pair against a local views-appwrite checkout when one is present. -SEAM_CONTRACT_VERSION = "1.4.1" -SEAM_CONTRACT_COMMIT = "90fc105" +SEAM_CONTRACT_VERSION = "1.4.4" +SEAM_CONTRACT_COMMIT = "fcf32c9" CONNECTION_ENV = ( "APPWRITE_ENDPOINT", From f0128f31bc3b0fb8a86e751f3d259d3a2371f21f Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 5 Aug 2026 11:28:47 +0200 Subject: [PATCH 08/42] =?UTF-8?q?docs(adr-013):=20#133=20=E2=80=94=20Amend?= =?UTF-8?q?ment=20A2=20for=20run=20maturity,=20drafted=20and=20not=20in=20?= =?UTF-8?q?force?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #133 asked for three declared fields and said the historical artifact is not co-delivered. Measured before implementing, because implementing it as written meant a contract_version bump — which lives inside the header bytes §10 pins — and so a golden-fixture rebuild and a three-repo re-vendor. Two of the three already ship: - `source` is `provenance.ensemble`, and views-faoapi reads exactly that key. - the schema version is `contract_version`, on the run manifest and every shard header. The third is real but is not ours to stamp. Maturity belongs in `provenance`, which §2.1 closes, so admitting it is an amendment. And Hop-B shard headers are the Hop-A headers re-embedded untouched (contract/wire/sink.py) — this repo forwards provenance, does not compose it, and does not know a run's maturity. Synthesising one here would be a producer guessing at a fact nobody told it. The stamp belongs to views-models; our part is to forward it, which needs no code once the key is admitted. ADR-013 §2.2a records that as Amendment A2: the field, why it is an amendment and not a free addition, what adoption costs, who can stamp it, and a named trigger — adopt when the next fixture re-vendor happens for another reason, C-72/#174 being the one expected. Numbered A2 because A1 was taken (§7a, 2026-07-19). The issue's fourth claim is false: `_save_contract` DOES upload the historical artifact, category="historical", under the same interlock as the forecast. faoapi's C-169 decision has no premise, so there is nothing for the maintainer to decide. test_s2_2a_is_not_secretly_in_force guards the amendment bidirectionally — the risk is not only that A2 ships unrecorded but that it ships while §2.2a still says it has not, leaving three repos believing a re-vendor is still owed. Mutation-proven both ways. New C-85 (Tier 3): a cross-repo ask adopted on its stated terms without measuring current state. Filed in good faith, wrong because it named the run manifest while the consumer reads the shard header — vocabulary is what does not survive the trip between repos. No guard proposed; a checklist would be theatre. Register 84/11/73 -> 85/12/73. Suite 361 passed / 40 xfailed; ruff clean. No delivered byte changes; contract_version stays 1.5. Co-Authored-By: Claude Opus 5 (1M context) --- .../013_sampled_forecast_wire_contract.md | 55 +++++++++++++++++++ reports/technical_risk_register.md | 34 +++++++++++- tests/test_falsify_adr013_s2.py | 36 ++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/docs/ADRs/013_sampled_forecast_wire_contract.md b/docs/ADRs/013_sampled_forecast_wire_contract.md index 769007e..09e097d 100644 --- a/docs/ADRs/013_sampled_forecast_wire_contract.md +++ b/docs/ADRs/013_sampled_forecast_wire_contract.md @@ -358,6 +358,49 @@ yet) — consumers must not treat it as authoritative before then. The lift of t caveat is tracked as a reminder in pipeline-core: their issue #279 (filed 2026-07-19) fires when the first real release ships. +**§2.2a Amendment A2 — run maturity, drafted 2026-08-05, NOT YET IN FORCE.** + +Requested by views-postprocessing #133 on behalf of views-faoapi (its ADR-033, epic +#244) so that a consumer can tell a production run from a test one. This clause defines +the field and the cost of adopting it. **It is not adopted, and `contract_version` +remains 1.5.** Nothing in this section describes bytes that ship today. + +*The field.* A fourth key inside `provenance`: + +| key | type | meaning | +|---|---|---| +| `status` | string | the producing run's maturity in views-models ADR-017's vocabulary — `graduate`, `candidate`, and the rest. Never inferred; absent is not `graduate`. | + +*Why this is an amendment and not a free addition.* §2.1 clause 1 opens the header to +new **top-level** keys and closes the three sub-objects. `provenance` is one of them, so +a fourth key is a contract change by this document's own rule. It is purely additive, so +it is a **MINOR** bump — 1.5 → 1.6 — and a reader built for 1.5 still accepts a 1.6 +artifact. + +*What adoption actually costs, which is the reason it is deferred.* `contract_version` +is written **inside the header bytes**, and §10 pins those bytes. So a MINOR bump is not +a free-standing edit: it rebuilds the golden fixture, and §10 requires all three +implementing repositories to re-vendor the new bytes and re-pin their root hashes +together. The full coordination cost is paid for one optional field. + +*Who can stamp it, and it is not this repository.* Hop-B shard headers are the Hop-A +headers **re-embedded untouched** (`contract/wire/sink.py`). This repository forwards +provenance; it does not compose it, and it does not know a run's maturity. Synthesising +one here would be a producer guessing at a fact it was not told — precisely the +inference this platform's declare-don't-infer rule exists to forbid. **The stamp belongs +to the Hop-A producer (views-models),** and this repository's part is to forward it, +which requires no code change once the key is admitted. + +*Named trigger for adoption (ADR-014 §4).* Adopt A2 **when the next re-vendor of the +§10 fixture happens for another reason** — the pyarrow fix tracked as register C-72 / +issue #174 is the one currently expected, since it also changes delivered bytes and also +requires all three repos to move together. Riding along costs nothing extra; going alone +costs a three-repo coordination for a field the consumer has stated it only surfaces. +**Owner:** whoever executes that re-vendor. If views-faoapi ever promotes `status` from a +surfaced label to a serving gate, that supersedes this trigger and A2 should be adopted +on its own. + + **§2.3 Governance hook.** Changing `sample_count` (wire thinning) or `dtype` on the **FAO delivery** changes the published HDI/MAP numbers, and is therefore a **re-baseline event**: it requires sign-off under views-faoapi **ADR-023 @@ -890,6 +933,18 @@ runs can therefore never touch the live bucket by accident. Dated events after adoption. Errata correct errors in this document; other entries record execution progress against it. +- **2026-08-05 — Amendment A2 drafted, not in force (§2.2a; `contract_version` stays + 1.5):** views-postprocessing #133 asked for three declared fields on the run manifest — + `maturity`, `source`, and a required schema version. Measured against what is actually + delivered and actually read, **two of the three already ship**, and the third is not + ours to stamp. `source` is `provenance.ensemble`, forwarded from the Hop-A header and + read by views-faoapi as exactly that. The schema version is `contract_version`, present + on the run manifest and in every shard header. Only maturity is missing, it belongs in + `provenance`, and `provenance` is a closed sub-object — hence A2 rather than a silent + addition. The request was written against the manifest; the consumer reads the shard + header. Recorded because an accepted ask nobody re-measured would have produced a + three-repo fixture re-vendor to add two fields that were already there. + - **2026-08-02 — Erratum E2 (§7d links; `contract_version` stays 1.5):** §7d's first link was **broken and had never resolved**. Issue #158 renamed the cross-repo contract from `PLATFORM-001` to *the Appwrite Seam Contract* and the rename was applied as a diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 382b040..a1e6879 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -5,8 +5,8 @@ | Project | views-postprocessing | | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-05 | -| Total Concerns | 84 | -| Open Concerns | 11 | +| Total Concerns | 85 | +| Open Concerns | 12 | | Resolved Concerns | 73 | --- @@ -161,6 +161,36 @@ that indexes only deleted code is noise. ## Open Concerns +### C-85: A cross-repo ask is adopted on its stated terms without anyone measuring the current state + +| Field | Value | +|-------|-------| +| ID | C-85 | +| Tier | 3 — no delivery is affected and no wrong data ships. What is at risk is spending a coordinated three-repository change on work that is already done, which is expensive in exactly the currency this platform has least of. | +| Source | #133 execution (2026-08-05) — the ask was re-measured before implementing it | +| Trigger | The next time an issue filed by another repository's seat is picked up for implementation. Before writing code, check what this repository already delivers and what the consumer already reads — the two are stated in the issue and were both wrong last time. | +| Owner | Whoever implements a cross-repo issue. This is a habit, not a mechanism; see below for why no guard is proposed. | +| Location | Not a code defect. #133; ADR-013 §2.2a. | + +#133 asked for three declared fields — `maturity`, `source`, a required schema version — and said `_save_contract` ships the forecast run only. It was filed in good faith by the views-faoapi seat, accepted, and carried on the backlog for weeks. Measured on 2026-08-05, before writing anything: + +| the ask | the measured state | +|---|---| +| stamp `source`, *"`source="unknown"` is what's live today"* | **already delivered** as `provenance.ensemble`, and views-faoapi reads exactly that key | +| declare a required schema version | **already delivered** as `contract_version` — on the run manifest and in every shard header | +| stamp `maturity` on the run manifest | genuinely missing — but it belongs in the **shard header**, which is where the consumer reads it, and it is **not this repository's to stamp** | +| *"the global historical is not uploaded"* → decide whether FAO stops receiving it | **false.** `_save_contract` uploads it, `category="historical"`, under the same interlock as the forecast. There was no decision to make | + +**Two of three fields already shipped, and the decision had no premise.** Had the issue been implemented as written, the cost would have been a `contract_version` bump — which is written *inside* the header bytes that §10 pins — and therefore a rebuild of the golden fixture and a coordinated re-vendor across all three implementing repositories, in order to add two fields that were already there. + +**Why the request was wrong is more useful than that it was wrong.** It was not careless. It was written against the **run manifest**, which is the artifact whose name suggests it carries run-level facts. The consumer reads them from the **shard header's `provenance`**, because that is where the producing pipeline's identity travels. Both seats were describing a real need and neither was describing the same object. A cross-repo ask names an artifact in the other repo's vocabulary, and vocabulary is exactly what does not survive the trip. + +**No guard is proposed, deliberately.** There is no mechanical check for "is this request still true", and inventing a ceremony — a template, a checklist field — would be process theatre that decays into an unread heading. What made the difference here was reading the consumer's source before writing any, and that is a habit worth writing down rather than automating. Registered so the next person has the worked example instead of the rule. + +Cross-refs: **C-72** (the re-vendor this would have triggered, and the trigger A2 now rides on), **C-77** (the historical leg whose correctness is what makes the co-delivery premise false), ADR-013 §2.2a, ADR-014 §4 (the deferral's named trigger), #133, views-faoapi ADR-033 and its register C-169. + +--- + ### C-84: Every identity this repo delivers under dies on 2026-11-17, within 3h35m of the other | Field | Value | diff --git a/tests/test_falsify_adr013_s2.py b/tests/test_falsify_adr013_s2.py index 92e11b2..7a751d2 100644 --- a/tests/test_falsify_adr013_s2.py +++ b/tests/test_falsify_adr013_s2.py @@ -8,6 +8,8 @@ import re from pathlib import Path +import pytest + _ADR = Path(__file__).resolve().parent.parent / "docs" / "ADRs" / "013_sampled_forecast_wire_contract.md" @@ -43,3 +45,37 @@ def test_s2_has_no_unexplained_gid_epic(): if "gid/id epic" in s2: # keeping the phrase requires an inline explanation of what that episode was assert re.search(r"gid/id epic[^.]{0,200}(identifier|meant|ambigu)", s2) + + +def test_s2_2a_is_not_secretly_in_force(): + """§2.2a defines a field that does NOT ship. Guard the "does not" (ADR-014 §1). + + Amendment A2 admits `provenance.status` and says plainly that it is not adopted and + `contract_version` stays 1.5. That is a claim about the code, written in a document, + and this repository has been caught three times this epic by prose describing work it + had not done — here the risk runs the other way, which is worse: someone implements + A2, ships 1.6, and leaves §2.2a still saying it is not in force. A reader planning + the three-repo re-vendor would then believe it was still owed. + + So the guard is bidirectional. Either §2.2a says A2 is unadopted and the code agrees, + or the section was rewritten on adoption and this test must be rewritten with it. + """ + from views_postprocessing.contract.wire.header import _PROVENANCE_KEYS, CONTRACT_VERSION + + s2 = _s2() + if "NOT YET IN FORCE" not in s2: + pytest.skip( + "§2.2a no longer declares itself unadopted — A2 was presumably adopted. " + "Rewrite this test against whatever the section now claims; do not delete it." + ) + + assert CONTRACT_VERSION == "1.5", ( + f"§2.2a says `contract_version` remains 1.5; the header declares " + f"{CONTRACT_VERSION}. If A2 (or anything else) was adopted, the ADR is now " + "describing a contract that is not the one shipping." + ) + assert "status" not in _PROVENANCE_KEYS, ( + "§2.2a says A2 is not in force, but `status` is in the closed provenance keyset. " + "The field shipped and the amendment was never marked adopted — which leaves the " + "§10 golden fixture, three repos' vendored copies, and this document disagreeing." + ) From 237378d780e2a90f085fc3f5f5b0ed8a86c6984f Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 5 Aug 2026 11:58:28 +0200 Subject: [PATCH 09/42] =?UTF-8?q?refactor(managers):=20C-40=20=E2=80=94=20?= =?UTF-8?q?store=20construction=20off=20the=20manager=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-40's residual read "gated on views-pipeline-core 3.0.0, which is a release signal rather than engineering work". 3.0.0 shipped 2026-08-03 and nothing became possible, because two different gates had been conflated: - the pandas gate (consequence b) was gated on pipeline-core's DataFrame retirement, an epic, not a version — and it lifted on its own on 2026-07-27. - the de-inheritance was never gated on any pipeline-core release. It is gated on views-models: postprocessors/un_fao/main.py:27 constructs our manager directly and the framework's Template Method drives _read/_transform/_validate/_save. The inheritance IS the integration contract, so removing it is a two-repo change. Nobody had written that down, which is how the entry sat parked reading as unblocked. What was actually available is the inbound half of this entry's own prescribed DIP mitigation. The outbound half landed in July as _ContractStorePort; the inbound half never did. _prod_forecasts_datastore, __datastore and __appwrite_config were methods constructing AppwriteConfig and DatastoreModule inline. They are now module-level functions taking declared arguments. No delivered byte moves. The gain is C-40's consequence (a): store construction, its refusals and their ordering are testable with no manager instance, no views-models path manager and no Appwrite environment — tests/test_store_construction.py, 14 tests, which could not have been written a day ago. First part of the manager seam reachable without the framework. Two things found while in there, both removed: - self.ensemble_path_manager: assigned in __init__ and in the builder, read once four lines later, and nowhere else in either partner package or in views-models. - loa = "pgm" followed by `if not loa: raise` — an unreachable branch guarding a variable never used again, beside the commented-out block that once computed it. Manager class 351 -> 272 lines (16 -> 14 methods); files 440 against the 450 directory budget. New ratchet test_the_manager_class_itself_stays_thin bounds the class at 300 — the existing budget counts the directory and so cannot see a class re-absorbing logic, which is exactly the shape of this refactor. Both bounds stand; neither was relaxed. Four mutations proven: env assert moved after construction, a builder given back its self, a coordinate value pasted in, the class padded past the ratchet. C-40 re-scoped with the corrected gate and trigger; Location rows on C-33 and C-40 updated; the dated ADR-011 assessment banners the two claims this change invalidated. Suite 377 passed / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ADR-011_implementation_assessment.md | 8 + reports/technical_risk_register.md | 25 ++- tests/test_doc_accuracy.py | 35 ++++ tests/test_store_construction.py | 173 +++++++++++++++++++ views_postprocessing/crafd/managers/crafd.py | 172 +++++++++--------- views_postprocessing/unfao/managers/unfao.py | 172 +++++++++--------- 6 files changed, 420 insertions(+), 165 deletions(-) create mode 100644 tests/test_store_construction.py diff --git a/docs/ADR-011_implementation_assessment.md b/docs/ADR-011_implementation_assessment.md index 83e3714..9259901 100644 --- a/docs/ADR-011_implementation_assessment.md +++ b/docs/ADR-011_implementation_assessment.md @@ -1,6 +1,14 @@ # ADR-011 Implementation Assessment **Last updated:** 2026-06-12 +> **This is a dated snapshot (2026-06-12), not a description of the code today.** It is +> kept as a record of what was known when ADR-011 was assessed. Several things it +> describes as current have since been removed — notably the `.env` credential borrow +> (killed by þing-01 #134; the environment is now declared and validated fail-loud in +> each partner's `appwrite_env.py`) and `self.ensemble_path_manager`, which stopped +> existing on 2026-08-05 when store construction moved off the manager class (register +> C-40). Read §8 in particular as history. + **Status:** Data prerequisites MET — see §10. Implementation unblocked on the data side; verification infrastructure (Appwrite, pipeline-core E2E) still required before switching the pipeline. --- diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index a1e6879..0dcfc30 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -345,7 +345,7 @@ See also D-10 (handling decision), C-43 (the *value*-correctness sibling — run | Source | `expert-code-review` (2026-06-12) | | Trigger | **Fired 2026-08-03 — see the update below.** The remaining trigger is the *extraction* one, and it is now named: a **third** in-repo partner package, **or** the first bug that must be hand-patched identically in both manager files — whichever comes first. | | Owner | Whoever adds the third partner package, or hits the first double-patch. Until one of those happens the duplication is the deliberate WET position, not a task anyone is behind on. | -| Location | `views_postprocessing//managers/.py` — `_prod_forecasts_datastore`, `__datastore`, `__appwrite_config`, and the four hardcoded `os.getenv("APPWRITE__*")` literals inside the last of those; declared names in each partner's `appwrite_env.py`. **Symbols, not line numbers** — see the note under the measurement below. | +| Location | `views_postprocessing//managers/.py` — the module-level `_build_prod_forecasts_store`, `_build_partner_store`, `_partner_appwrite_config` (methods until 2026-08-05), and the four hardcoded `os.getenv("APPWRITE__*")` names inside the last of those; declared names in each partner's `appwrite_env.py`. **Symbols, not line numbers** — see the note under the measurement below. | Mitigation: a small `DeliveryProfile` (bucket/collection/database ids, category, targets) passed to the manager — one manager class, N store configs. Scheduled **after** the FAO global delivery ships (D-09); the only immediate action is deleting the commented-out config blocks at lines 80-107, which are a mis-uncomment hazard during deadline work. @@ -398,7 +398,7 @@ See also C-24 (schema contract per store), C-77 (the fourth home for partner ide | Tier | 2 | | Source | `expert-code-review` (2026-06-24) | | Trigger | **(a) Upstream change:** when pipeline-core changes `PGMDataset` / the data loader / the postprocessor base (mid-migration: their #186/#188/#161), verify the inherited surface this repo depends on still holds. **(b) Standing work item:** the input-side de-inheritance (the sink side landed — see the 2026-07-31 update) — schedule it, don't wait for a trigger. | -| Location | `views_postprocessing//managers/.py` — the `class PostProcessorManager(PostprocessorManager, ForecastingModelManager)` statement (double inheritance); `_prod_forecasts_datastore`, `__datastore`, `__appwrite_config` (inline env/AppwriteConfig/DatastoreModule); `_validate` and `_check_coverage`; the DIP sink adapter `_ContractStorePort`. **Since 2026-08-03 all of it exists twice** — `unfao` and `crafd` are the same file with the partner name changed (C-33). Symbols rather than lines, deliberately: an earlier version of this row was invalidated by a comment edit four lines long. | +| Location | `views_postprocessing//managers/.py` — the `class PostProcessorManager(PostprocessorManager, ForecastingModelManager)` statement (double inheritance); `_build_prod_forecasts_store`, `_build_partner_store`, `_partner_appwrite_config` (env/AppwriteConfig/DatastoreModule — moved OFF the class 2026-08-05, see the update below); `_validate` and `_check_coverage`; the DIP sink adapter `_ContractStorePort`. **Since 2026-08-03 all of it exists twice** — `unfao` and `crafd` are the same file with the partner name changed (C-33). Symbols rather than lines, deliberately: an earlier version of this row was invalidated by a comment edit four lines long. | `UNFAOPostProcessorManager` subclasses **two concrete** pipeline-core base classes (`PostprocessorManager`, `ForecastingModelManager`) and **interleaves infrastructure** (env reading, `AppwriteConfig` construction, `DatastoreModule`, path resolution) with the FAO **business logic** (GAUL enrichment, the 9-column null gate) inside the lifecycle hooks. Consequences: (a) the FAO logic cannot be instantiated or unit-tested without the full framework + Appwrite env + viewser; (b) **pandas cannot leave the delivery path** because the inherited data loader and `PGMDataset` are pandas — gated on pipeline-core's own DataFrame retirement; (c) **SDP exposure** — heavy *inheritance* coupling to a pipeline-core that is itself unstable (mid-migration), so upstream changes break far from their cause (cf. C-27, C-29); (d) it's the repo's only composition-over-inheritance violation. The dependency itself is correct (`unfao.py` genuinely *is* a pipeline-core postprocessor) — the issue is its **blast radius**. Mitigation (does **not** fight the Template-Method framework): keep the subclass as a **thin shell** but extract `enrich` + `validate` + the 9-column contract into a pipeline-core-free core object the manager *calls*, and wrap the Appwrite I/O behind a small delivery-sink adapter (DIP). This makes the FAO logic testable standalone and insulates it from pipeline-core churn. @@ -436,7 +436,26 @@ See also C-24 (schema contract per store), C-77 (the fourth home for partner ide **On þing-02 S24(5).** `docs/CLONING.md` cited that verdict as forbidding these imports outright. Reading it directly (`þingit/02_credential_identity_key_ownership/sáttmál.md:240-242` — precondition (5) itself; the section opens at `:232` under the heading *"§5 — The clone (`un-crafdapi`)"* — and `orð_dómr.md:418-441`), it binds *"the clone"* — `un-crafdapi` and `views-productionapi`, repositories **git-cloned from views-faoapi** — and does not reach an in-repo partner package of the producer. CLONING.md over-claimed; PR #211 corrects the citation rather than weakening the rule. This entry's own scope is unaffected: the coupling is a design concern here regardless of what the verdict binds, and issue **#146**'s deferred unwind now covers two files instead of one. -Tier held at 2. The residual scope — the double inheritance and the framework-bound instantiation — is unchanged, and is still gated on views-pipeline-core 3.0.0 (C-44/C-62). +**Update 2026-08-05 — the stated gate was wrong, and the source half of the mitigation has now landed.** + +*The gate first, because it is what kept this entry parked.* The line below said the residual was *"gated on views-pipeline-core 3.0.0, which is a release signal rather than engineering work"*. 3.0.0 shipped on 2026-08-03 and nothing became possible. Two different gates had been conflated: + +- **Consequence (b), the pandas gate** — genuinely gated on pipeline-core's DataFrame retirement, which is an epic (#186/#207), not a version number. It lifted on its own on 2026-07-27 when the frame-native fetch shipped, and 3.0.0 had nothing to do with it. +- **The de-inheritance** — never gated on any pipeline-core release. **It is gated on views-models**, which is the thing nobody had written down. `postprocessors/un_fao/main.py:27` constructs `UNFAOPostProcessorManager(...)` directly, and the framework's Template Method drives `_read`/`_transform`/`_validate`/`_save`. **The inheritance *is* the integration contract with views-models.** Removing it means writing a different launcher there, so it is a two-repository change and an operator decision — not, as this entry implied for two days, unblocked work waiting on nobody. + +*What landed.* This entry's own prescribed mitigation was to wrap the Appwrite I/O behind a DIP adapter. The **outbound** half landed in July as `_ContractStorePort`. The **inbound** half never did: `_prod_forecasts_datastore`, `__datastore` and `__appwrite_config` were still methods on the manager, constructing `AppwriteConfig` and `DatastoreModule` inline. They are now module-level functions taking declared arguments — `_build_prod_forecasts_store`, `_build_partner_store`, `_partner_appwrite_config`. + +That is a smaller change than it sounds and a bigger one than it looks. Smaller: no delivered byte moves, and the three functions are the same code with their inputs declared. Bigger: **consequence (a) is substantially discharged for this surface.** Store construction, its refusals and their ordering are now testable with no manager instance, no views-models path manager and no Appwrite environment — `tests/test_store_construction.py`, 14 tests, which could not have been written at any price a day ago. That is the first time any part of the manager seam has been reachable without the framework. + +*Two things found while in there.* `self.ensemble_path_manager` was assigned in `__init__` and in the store builder, read exactly once four lines after being set, and read nowhere else in either partner package or in views-models — a method-local value wearing the costume of manager state. And a `loa = "pgm"` assignment followed by `if not loa: raise`, an unreachable branch guarding a variable never used again, beside a commented-out block that had computed it for real. Both gone. + +*Measured.* The manager **class** went 351 → **272 lines** (16 → 14 methods); both partner files are 440 lines against a 450 directory budget. A new ratchet, `test_the_manager_class_itself_stays_thin`, bounds the class at 300 — added because the existing budget counts the *directory*, so it cannot see a class re-absorbing logic, and this refactor is precisely the shape it is blind to. Both bounds now stand; neither was relaxed. + +**Residual scope, stated honestly.** The double inheritance itself, and with it consequences (c) SDP exposure and (d) composition-over-inheritance. Not unilaterally actionable, and arguably not a defect: this entry has always conceded that *"the dependency itself is correct — `unfao.py` genuinely **is** a pipeline-core postprocessor"*, and ADR-015 §1 argues there is nothing to unwind to. What remains is exposure to an upstream that moves, which 3.0.0 demonstrated by moving. + +**Trigger, corrected.** Not a pipeline-core release. Revisit when **views-models changes how postprocessors are launched** — at which point de-inheritance becomes a one-repo change rather than two — or when an upstream base-class change actually breaks a delivery, which `tests/test_framework_contract.py` now catches here rather than on the partner's run. + +Tier held at 2: the blast-radius argument is unchanged for what remains, and containment is not removal. *Did not:* the double inheritance (the `class UNFAOPostProcessorManager(...)` statement — this row cited `unfao.py:80` when written, and that number has moved twice since) stands, and so do consequences (a) — the FAO logic still cannot be instantiated without the framework — and (c)/(d). **This entry remains open on exactly that scope.** Its remaining fix is gated on views-pipeline-core's 3.0.0 (C-44/C-62), which is a release signal rather than engineering work. diff --git a/tests/test_doc_accuracy.py b/tests/test_doc_accuracy.py index 250eec9..1c1c87d 100644 --- a/tests/test_doc_accuracy.py +++ b/tests/test_doc_accuracy.py @@ -367,6 +367,10 @@ def test_internal_doc_links_resolve(): #: previously unbudgeted, which is the same regrowth wearing a different filename. _MANAGER_LINE_BUDGET = 450 +#: The manager CLASS, separately (C-40). 351 before the 2026-08-05 extraction, 272 after. +#: A ratchet — see `test_the_manager_class_itself_stays_thin` for why it is not a target. +_MANAGER_CLASS_BUDGET = 300 + def _is_type_checking(test: ast.expr) -> bool: """`TYPE_CHECKING` or `typing.TYPE_CHECKING`, and nothing else. @@ -488,6 +492,37 @@ def test_the_manager_stays_within_its_line_budget(managers_dir): ) +@pytest.mark.parametrize("partner", _PARTNER_PACKAGES) +def test_the_manager_class_itself_stays_thin(partner): + """The directory budget above is anti-regrowth. This one is anti-*fusion*. + + They measure different things and both are needed. A file can sit comfortably under + 450 lines while the manager **class** absorbs store construction, env assembly and + business logic — which is exactly what register C-40 is about, and exactly what the + directory count cannot see. Conversely a thin class proves nothing if 800 lines + moved to a sibling module, which is why the directory count exists. + + Added 2026-08-05 alongside the C-40 extraction that took the class from 351 lines to + 272. The bound is a **ratchet, not a target**: it is the measured value plus modest + headroom, and the right response to it failing is to move something out of the class, + not to raise the number. If it is ever raised, the raise belongs in a commit message + that says what was added and why it had to live on the manager. + """ + source = (_PKG / partner / "managers" / f"{partner}.py").read_text() + cls = next( + n for n in ast.walk(ast.parse(source)) + if isinstance(n, ast.ClassDef) and n.name.endswith("PostProcessorManager") + ) + lines = cls.end_lineno - cls.lineno + 1 + assert lines <= _MANAGER_CLASS_BUDGET, ( + f"{cls.name} is {lines} lines, over the {_MANAGER_CLASS_BUDGET} ratchet. The " + "manager is the Template-Method shell: it orchestrates hooks and adapts the " + "framework. Logic that can be called with declared arguments belongs in a " + "module-level function or in contract/ — where it can be tested without a " + "manager, an Appwrite environment or a views-models path manager (C-40 (a))." + ) + + def _imported_subpackages(source: str, module_path: Path) -> set[str]: """Every ``views_postprocessing.`` this module imports — all four spellings. diff --git a/tests/test_store_construction.py b/tests/test_store_construction.py new file mode 100644 index 0000000..1abd151 --- /dev/null +++ b/tests/test_store_construction.py @@ -0,0 +1,173 @@ +"""Store construction, called without a manager — register C-40's consequence (a). + +C-40's first stated consequence is that *"the FAO logic cannot be instantiated or +unit-tested without the full framework + Appwrite env + viewser."* For the store +construction that was literally true until 2026-08-05: building the production-forecasts +datastore meant calling ``self._prod_forecasts_datastore()``, which meant having a +manager, which meant a views-models path manager and a live environment. + +Nothing about that code needed any of it. It read one value out of ``self.configs`` and +assigned a local to ``self.ensemble_path_manager`` that nothing ever read back. Turning +the three store builders into module-level functions with declared arguments removed the +requirement entirely — and this file is the proof, because every test here runs with no +manager instance and with the Appwrite variables explicitly *removed* from the +environment. + +**What this does not test.** That a real store connects, or that the credentials work. +That needs the production Appwrite project, which þing-02 D2 forbids testing against and +which is the only project that exists (A3(h), answered 2026-08-05). What is testable +offline is the part that was previously untestable at any price: the refusals, their +ordering, and the fact that the environment contract is checked *before* anything is +constructed. +""" + +from __future__ import annotations + +import pytest + +from tests.conftest import PARTNER_PACKAGES + +#: Every Appwrite name any partner's builders read. Cleared before each test so a +#: maintainer's populated shell cannot make these pass for the wrong reason — the +#: failure mode a laptop-only run has already produced twice in this repo (C-81). +_APPWRITE_PREFIX = "APPWRITE_" + + +@pytest.fixture +def no_appwrite_env(monkeypatch): + """A deliberately empty Appwrite environment.""" + import os + + for name in [k for k in os.environ if k.startswith(_APPWRITE_PREFIX)]: + monkeypatch.delenv(name, raising=False) + + +def _managers(partner: str): + pytest.importorskip( + "views_pipeline_core", + reason="the builders construct pipeline-core objects; it is a declared " + "dependency, so this skip means a broken environment rather than a valid one", + ) + return __import__( + f"views_postprocessing.{partner}.managers.{partner}", + fromlist=["_build_prod_forecasts_store"], + ) + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_the_builders_are_functions_not_methods(partner): + """The structural claim C-40's fix rests on. Assert it, do not assume it. + + If these ever migrate back onto the class, every test below would still pass while + silently requiring a manager again — the guard has to check the shape, not just the + behaviour. + """ + import inspect + + module = _managers(partner) + for name in ("_build_prod_forecasts_store", "_build_partner_store", + "_partner_appwrite_config"): + fn = getattr(module, name, None) + assert fn is not None, ( + f"[{partner}] {name} is gone from the module namespace. If it moved back " + "onto the manager class, C-40's consequence (a) is back with it." + ) + assert inspect.isfunction(fn), f"[{partner}] {name} is not a module-level function" + params = list(inspect.signature(fn).parameters) + assert "self" not in params, ( + f"[{partner}] {name} takes `self` — it is a method wearing a function's " + "name, and still cannot be called without a manager." + ) + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_a_missing_ensemble_name_is_refused_before_anything_is_built( + partner, no_appwrite_env +): + """The launch-config error, raised with no manager and no environment. + + This is the whole point of the extraction in one assertion: the refusal is + observable by calling a function with an argument. + """ + module = _managers(partner) + with pytest.raises(ValueError, match="ensemble"): + module._build_prod_forecasts_store(None) + + +@pytest.mark.parametrize("bad", ["", None], ids=["empty-string", "none"]) +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_an_empty_ensemble_name_is_refused_too(partner, bad, no_appwrite_env): + """`""` and `None` are both "not configured", and both used to reach `configs.get`. + + A launch config with `ensemble: ""` is a plausible typo and must not be treated as + a name — `EnsemblePathManager("")` would resolve to something, and what it resolves + to is not this delivery's ensemble. + """ + module = _managers(partner) + with pytest.raises(ValueError, match="ensemble"): + module._build_prod_forecasts_store(bad) + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_the_environment_contract_is_checked_before_a_store_is_constructed( + partner, no_appwrite_env +): + """Ordering, not just presence — and the ordering is the fail-loud guarantee. + + ``assert_env_declared`` must run before ``AppwriteConfig``/``DatastoreModule`` are + built, so a missing coordinate produces the declared error naming what is absent + rather than whatever the client raises when handed ``None`` for an endpoint. þing-01 + #134 turned this repo's environment handling from an implicit borrow into a declared + contract; an unchecked construction path would quietly undo that. + + The error must also name the store, because two different stores read two different + variable sets and "some Appwrite variable is missing" sends an operator to the wrong + half of the environment. + """ + module = _managers(partner) + with pytest.raises(Exception) as excinfo: + module._build_prod_forecasts_store("some_ensemble") + message = str(excinfo.value) + assert "production_forecasts" in message, ( + f"[{partner}] the refusal does not name which store's environment is " + f"incomplete: {message!r}" + ) + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_the_partner_store_names_its_own_environment_when_it_refuses( + partner, no_appwrite_env +): + """The outbound store reads a different variable set from the inbound one.""" + module = _managers(partner) + with pytest.raises(Exception) as excinfo: + module._partner_appwrite_config(model_path=None) + assert "datastore" in str(excinfo.value).lower() + + +@pytest.mark.parametrize("partner", PARTNER_PACKAGES) +def test_no_coordinate_value_is_baked_into_the_builders(partner): + """The repo is PUBLIC and the registry is referenced, never copied (þing-01 S6). + + The builders read every coordinate through ``os.getenv`` by declared NAME. A literal + bucket id or endpoint appearing here would be a value copy in a public repository — + the platform's original failure. The repo-wide guard covers this too; asserted at + the seam as well because this is the file where such a literal would be most + tempting to paste while debugging. + """ + import inspect + + module = _managers(partner) + source = "".join( + inspect.getsource(getattr(module, name)) + for name in ("_build_prod_forecasts_store", "_partner_appwrite_config") + ) + for line in source.splitlines(): + if "os.getenv(" in line: + assert "os.getenv(\"APPWRITE_" in line or "os.getenv('APPWRITE_" in line, ( + f"[{partner}] a coordinate is read by something other than a declared " + f"APPWRITE_ name: {line.strip()!r}" + ) + assert "https://" not in line or "#" in line.split("https://")[0], ( + f"[{partner}] a literal endpoint appears in the builders: {line.strip()!r}" + ) diff --git a/views_postprocessing/crafd/managers/crafd.py b/views_postprocessing/crafd/managers/crafd.py index 87c09d2..903e47b 100644 --- a/views_postprocessing/crafd/managers/crafd.py +++ b/views_postprocessing/crafd/managers/crafd.py @@ -77,6 +77,95 @@ def upload(self, file_path, *, filename, name, doc_type, category, loa, targets, ) +def _build_prod_forecasts_store(ensemble_name: str | None) -> DatastoreModule: + """The shared internal store (ADR-013's "shared shelf"), built from the + launcher-assembled environment (validated fail-loud; þing-01 #134 — no dotenv is + loaded here). + + pipeline-core's ``DatastoreModule.get_predictions_by_metadata`` injects an automatic + ``name == model_name`` filter on every lookup. The contract read must **not** have + it: ADR-013 artifacts are named ``{run_id}__{target}__m{month}.arrow.parquet`` + (never the bare ensemble name), so an injected ``name == "rusty_bucket"`` matches + nothing and also clobbers the wire layer's own run-id / target / name filters. + Suppressed unconditionally below — the retired legacy reader was the only caller + that needed it on (#149). + + **A function, not a method (register C-40).** It reads no manager state: the + ensemble name arrives as an argument rather than through ``self.configs``, and the + ``EnsemblePathManager`` is local — it used to be assigned to + ``self.ensemble_path_manager`` and then read exactly once, four lines later, in this + same body. Nothing else in either partner package, or in views-models, ever read it. + A method-local value wearing the costume of manager state. + + The gain is C-40's consequence (a): this is callable, and its refusals observable, + with **no manager instance, no views-models path manager and no Appwrite + environment** — see ``tests/test_store_construction.py``. + """ + if not ensemble_name: + err_msg = "Ensemble name must be provided in configs with the `ensemble` key for forecasting. Cannot proceed." + logger.error(err_msg) + raise ValueError(err_msg) + path_manager = EnsemblePathManager(ensemble_name_or_path=ensemble_name, validate=False) + + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV, + store="production_forecasts datastore", + ) + appwrite_config = AppwriteConfig( + path_manager=path_manager, + endpoint=os.getenv("APPWRITE_ENDPOINT"), + project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), + credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), + auth_method="api_key", + cache_ttl_hours=24, + bucket_id=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_ID"), + bucket_name=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_NAME"), + collection_id=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_ID"), + collection_name=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_NAME"), + database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), + database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), + ) + datastore = DatastoreModule(appwrite_file_manager_config=appwrite_config) + # Suppress the automatic name==model_name filter (see docstring). model_path is used + # by DatastoreModule only for that injection and for uploads; the contract read + # neither uploads nor performs any model-scoped lookup. + datastore.model_path = None + return datastore + + +def _build_partner_store(model_path) -> DatastoreModule: + """The partner-facing store (`crafd_bucket`) — a function for the same reason as above. + + ``model_path`` is the framework's own path manager, so it is passed in rather than + reached for. That is the whole difference between this and a method, and it is what + lets the environment contract be checked without standing up a manager. + """ + return DatastoreModule(appwrite_file_manager_config=_partner_appwrite_config(model_path)) + + +def _partner_appwrite_config(model_path) -> AppwriteConfig: + """Env → ``AppwriteConfig`` for the partner bucket. Declared names only; the values + live in the environment and are never read into this repository's source.""" + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV + appwrite_env.CRAFD_ENV, + store="crafd_bucket datastore", + ) + return AppwriteConfig( + path_manager=model_path, + endpoint=os.getenv("APPWRITE_ENDPOINT"), + project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), + credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), + auth_method="api_key", + cache_ttl_hours=24, + bucket_id=os.getenv("APPWRITE_CRAFD_BUCKET_ID"), + bucket_name=os.getenv("APPWRITE_CRAFD_BUCKET_NAME"), + collection_id=os.getenv("APPWRITE_CRAFD_COLLECTION_ID"), + collection_name=os.getenv("APPWRITE_CRAFD_COLLECTION_NAME"), + database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), + database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), + ) + + class CRAFDPostProcessorManager(PostprocessorManager, ForecastingModelManager): def __init__( self, @@ -90,7 +179,6 @@ def __init__( logger.info(f"Initializing {self.__class__.__name__}") self._forecast_resolution = None # {target: TargetLease}, set by _read self._historical_frame = None # views_frames.FeatureFrame, set by _read - self.ensemble_path_manager = None def _read_historical_frame(self): """#126: historical actuals as a views_frames.FeatureFrame — the first @@ -136,61 +224,6 @@ def _read_historical_data(self): self._initialize_data_loader() self._read_historical_frame() - def _prod_forecasts_datastore(self) -> DatastoreModule: - """The shared internal store (ADR-013's 'shared shelf'), configured from the - launcher-assembled environment (validated fail-loud; þing-01 #134 — no dotenv - is loaded here). - - pipeline-core's ``DatastoreModule.get_predictions_by_metadata`` injects an - automatic ``name == model_name`` filter on every lookup. The contract read - must **not** have it: ADR-013 artifacts are named - ``{run_id}__{target}__m{month}.arrow.parquet`` (never the bare ensemble - name), so an injected ``name == "rusty_bucket"`` matches nothing and also - clobbers the wire layer's own run-id / target / name filters. Suppressed - unconditionally below — the retired legacy reader was the only caller that - needed it on (#149).""" - ensemble_name = self.configs.get("ensemble", None) - if not ensemble_name: - err_msg = "Ensemble name must be provided in configs with the `ensemble` key for forecasting. Cannot proceed." - logger.error(err_msg) - raise ValueError(err_msg) - self.ensemble_path_manager = EnsemblePathManager(ensemble_name_or_path=ensemble_name, validate=False) - # ensemble_configs = EnsembleManager( - # ensemble_path=self.ensemble_path_manager, - # ).configs - - # loa = ensemble_configs.get("level", None) - loa = "pgm" - if not loa: - err_msg = "level must be defined in the ensemble configurations (e.g, pgm, cm). Cannot proceed." - logger.error(err_msg) - raise ValueError(err_msg) - - appwrite_env.assert_env_declared( - appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV, - store="production_forecasts datastore", - ) - appwrite_config = AppwriteConfig( - path_manager=self.ensemble_path_manager, - endpoint=os.getenv("APPWRITE_ENDPOINT"), - project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), - credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), - auth_method="api_key", - cache_ttl_hours=24, - bucket_id=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_ID"), - bucket_name=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_NAME"), - collection_id=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_ID"), - collection_name=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_NAME"), - database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), - database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), - ) - datastore = DatastoreModule(appwrite_file_manager_config=appwrite_config) - # Suppress the automatic name==model_name filter (see docstring). model_path - # is used by DatastoreModule only for that injection and for uploads; the - # contract read neither uploads nor performs any model-scoped lookup. - datastore.model_path = None - return datastore - def _read_forecast_data_contract(self): """ADR-013 contract inbound (epic #105; streaming since the run-0 OOM fix): RESOLVE the newest fully-manifested run — manifests + pinned shard @@ -199,7 +232,7 @@ def _read_forecast_data_contract(self): released). The `wire/` package owns the policy; this method only adapts the store (DIP) and declares the product facts (region curation + coverage expectations live in the lease, where frames exist).""" - port = _ContractStorePort(self._prod_forecasts_datastore()) + port = _ContractStorePort(_build_prod_forecasts_store(self.configs.get("ensemble"))) region = self.configs.get("region") self._forecast_resolution = source_selection.resolve_run( port, @@ -319,7 +352,7 @@ def _save_contract(self) -> dict: # file itself. lookup = gaul_lookup.load() upload_enabled = bool(self.configs.get("wire_upload_enabled", product.UPLOAD_ENABLED)) - store = _ContractStorePort(self._crafd_datastore()) if upload_enabled else None + store = _ContractStorePort(_build_partner_store(self._model_path)) if upload_enabled else None # The wire is partner-neutral (#153): the manager supplies CRAF'd's product # facts explicitly rather than the mechanism reaching for them. summary = wire_sink.deliver_run( @@ -369,29 +402,6 @@ def _save_contract(self) -> dict: summary["historical"] = hist_path.name return summary - def _crafd_datastore(self) -> DatastoreModule: - """The CRAF'd-facing store (`crafd_bucket`).""" - return DatastoreModule(appwrite_file_manager_config=self._crafd_appwrite_config()) - - def _crafd_appwrite_config(self) -> AppwriteConfig: - appwrite_env.assert_env_declared( - appwrite_env.CONNECTION_ENV + appwrite_env.CRAFD_ENV, store="crafd_bucket datastore" - ) - return AppwriteConfig( - path_manager=self._model_path, - endpoint=os.getenv("APPWRITE_ENDPOINT"), - project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), - credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), - auth_method="api_key", - cache_ttl_hours=24, - bucket_id=os.getenv("APPWRITE_CRAFD_BUCKET_ID"), - bucket_name=os.getenv("APPWRITE_CRAFD_BUCKET_NAME"), - collection_id=os.getenv("APPWRITE_CRAFD_COLLECTION_ID"), - collection_name=os.getenv("APPWRITE_CRAFD_COLLECTION_NAME"), - database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), - database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), - ) - def _save(self) -> dict: """Deliver the run — ADR-013 contract only (#149).""" return self._save_contract() diff --git a/views_postprocessing/unfao/managers/unfao.py b/views_postprocessing/unfao/managers/unfao.py index 36388e2..e787bcd 100644 --- a/views_postprocessing/unfao/managers/unfao.py +++ b/views_postprocessing/unfao/managers/unfao.py @@ -77,6 +77,95 @@ def upload(self, file_path, *, filename, name, doc_type, category, loa, targets, ) +def _build_prod_forecasts_store(ensemble_name: str | None) -> DatastoreModule: + """The shared internal store (ADR-013's "shared shelf"), built from the + launcher-assembled environment (validated fail-loud; þing-01 #134 — no dotenv is + loaded here). + + pipeline-core's ``DatastoreModule.get_predictions_by_metadata`` injects an automatic + ``name == model_name`` filter on every lookup. The contract read must **not** have + it: ADR-013 artifacts are named ``{run_id}__{target}__m{month}.arrow.parquet`` + (never the bare ensemble name), so an injected ``name == "rusty_bucket"`` matches + nothing and also clobbers the wire layer's own run-id / target / name filters. + Suppressed unconditionally below — the retired legacy reader was the only caller + that needed it on (#149). + + **A function, not a method (register C-40).** It reads no manager state: the + ensemble name arrives as an argument rather than through ``self.configs``, and the + ``EnsemblePathManager`` is local — it used to be assigned to + ``self.ensemble_path_manager`` and then read exactly once, four lines later, in this + same body. Nothing else in either partner package, or in views-models, ever read it. + A method-local value wearing the costume of manager state. + + The gain is C-40's consequence (a): this is callable, and its refusals observable, + with **no manager instance, no views-models path manager and no Appwrite + environment** — see ``tests/test_store_construction.py``. + """ + if not ensemble_name: + err_msg = "Ensemble name must be provided in configs with the `ensemble` key for forecasting. Cannot proceed." + logger.error(err_msg) + raise ValueError(err_msg) + path_manager = EnsemblePathManager(ensemble_name_or_path=ensemble_name, validate=False) + + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV, + store="production_forecasts datastore", + ) + appwrite_config = AppwriteConfig( + path_manager=path_manager, + endpoint=os.getenv("APPWRITE_ENDPOINT"), + project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), + credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), + auth_method="api_key", + cache_ttl_hours=24, + bucket_id=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_ID"), + bucket_name=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_NAME"), + collection_id=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_ID"), + collection_name=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_NAME"), + database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), + database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), + ) + datastore = DatastoreModule(appwrite_file_manager_config=appwrite_config) + # Suppress the automatic name==model_name filter (see docstring). model_path is used + # by DatastoreModule only for that injection and for uploads; the contract read + # neither uploads nor performs any model-scoped lookup. + datastore.model_path = None + return datastore + + +def _build_partner_store(model_path) -> DatastoreModule: + """The partner-facing store (`unfao_bucket`) — a function for the same reason as above. + + ``model_path`` is the framework's own path manager, so it is passed in rather than + reached for. That is the whole difference between this and a method, and it is what + lets the environment contract be checked without standing up a manager. + """ + return DatastoreModule(appwrite_file_manager_config=_partner_appwrite_config(model_path)) + + +def _partner_appwrite_config(model_path) -> AppwriteConfig: + """Env → ``AppwriteConfig`` for the partner bucket. Declared names only; the values + live in the environment and are never read into this repository's source.""" + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV + appwrite_env.UNFAO_ENV, + store="unfao_bucket datastore", + ) + return AppwriteConfig( + path_manager=model_path, + endpoint=os.getenv("APPWRITE_ENDPOINT"), + project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), + credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), + auth_method="api_key", + cache_ttl_hours=24, + bucket_id=os.getenv("APPWRITE_UNFAO_BUCKET_ID"), + bucket_name=os.getenv("APPWRITE_UNFAO_BUCKET_NAME"), + collection_id=os.getenv("APPWRITE_UNFAO_COLLECTION_ID"), + collection_name=os.getenv("APPWRITE_UNFAO_COLLECTION_NAME"), + database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), + database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), + ) + + class UNFAOPostProcessorManager(PostprocessorManager, ForecastingModelManager): def __init__( self, @@ -90,7 +179,6 @@ def __init__( logger.info(f"Initializing {self.__class__.__name__}") self._forecast_resolution = None # {target: TargetLease}, set by _read self._historical_frame = None # views_frames.FeatureFrame, set by _read - self.ensemble_path_manager = None def _read_historical_frame(self): """#126: historical actuals as a views_frames.FeatureFrame — the first @@ -136,61 +224,6 @@ def _read_historical_data(self): self._initialize_data_loader() self._read_historical_frame() - def _prod_forecasts_datastore(self) -> DatastoreModule: - """The shared internal store (ADR-013's 'shared shelf'), configured from the - launcher-assembled environment (validated fail-loud; þing-01 #134 — no dotenv - is loaded here). - - pipeline-core's ``DatastoreModule.get_predictions_by_metadata`` injects an - automatic ``name == model_name`` filter on every lookup. The contract read - must **not** have it: ADR-013 artifacts are named - ``{run_id}__{target}__m{month}.arrow.parquet`` (never the bare ensemble - name), so an injected ``name == "rusty_bucket"`` matches nothing and also - clobbers the wire layer's own run-id / target / name filters. Suppressed - unconditionally below — the retired legacy reader was the only caller that - needed it on (#149).""" - ensemble_name = self.configs.get("ensemble", None) - if not ensemble_name: - err_msg = "Ensemble name must be provided in configs with the `ensemble` key for forecasting. Cannot proceed." - logger.error(err_msg) - raise ValueError(err_msg) - self.ensemble_path_manager = EnsemblePathManager(ensemble_name_or_path=ensemble_name, validate=False) - # ensemble_configs = EnsembleManager( - # ensemble_path=self.ensemble_path_manager, - # ).configs - - # loa = ensemble_configs.get("level", None) - loa = "pgm" - if not loa: - err_msg = "level must be defined in the ensemble configurations (e.g, pgm, cm). Cannot proceed." - logger.error(err_msg) - raise ValueError(err_msg) - - appwrite_env.assert_env_declared( - appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV, - store="production_forecasts datastore", - ) - appwrite_config = AppwriteConfig( - path_manager=self.ensemble_path_manager, - endpoint=os.getenv("APPWRITE_ENDPOINT"), - project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), - credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), - auth_method="api_key", - cache_ttl_hours=24, - bucket_id=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_ID"), - bucket_name=os.getenv("APPWRITE_PROD_FORECASTS_BUCKET_NAME"), - collection_id=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_ID"), - collection_name=os.getenv("APPWRITE_PROD_FORECASTS_COLLECTION_NAME"), - database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), - database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), - ) - datastore = DatastoreModule(appwrite_file_manager_config=appwrite_config) - # Suppress the automatic name==model_name filter (see docstring). model_path - # is used by DatastoreModule only for that injection and for uploads; the - # contract read neither uploads nor performs any model-scoped lookup. - datastore.model_path = None - return datastore - def _read_forecast_data_contract(self): """ADR-013 contract inbound (epic #105; streaming since the run-0 OOM fix): RESOLVE the newest fully-manifested run — manifests + pinned shard @@ -199,7 +232,7 @@ def _read_forecast_data_contract(self): released). The `wire/` package owns the policy; this method only adapts the store (DIP) and declares the product facts (region curation + coverage expectations live in the lease, where frames exist).""" - port = _ContractStorePort(self._prod_forecasts_datastore()) + port = _ContractStorePort(_build_prod_forecasts_store(self.configs.get("ensemble"))) region = self.configs.get("region") self._forecast_resolution = source_selection.resolve_run( port, @@ -319,7 +352,7 @@ def _save_contract(self) -> dict: # file itself. lookup = gaul_lookup.load() upload_enabled = bool(self.configs.get("wire_upload_enabled", product.UPLOAD_ENABLED)) - store = _ContractStorePort(self._unfao_datastore()) if upload_enabled else None + store = _ContractStorePort(_build_partner_store(self._model_path)) if upload_enabled else None # The wire is partner-neutral (#153): the manager supplies FAO's product # facts explicitly rather than the mechanism reaching for them. summary = wire_sink.deliver_run( @@ -369,29 +402,6 @@ def _save_contract(self) -> dict: summary["historical"] = hist_path.name return summary - def _unfao_datastore(self) -> DatastoreModule: - """The FAO-facing store (`unfao_bucket`).""" - return DatastoreModule(appwrite_file_manager_config=self._unfao_appwrite_config()) - - def _unfao_appwrite_config(self) -> AppwriteConfig: - appwrite_env.assert_env_declared( - appwrite_env.CONNECTION_ENV + appwrite_env.UNFAO_ENV, store="unfao_bucket datastore" - ) - return AppwriteConfig( - path_manager=self._model_path, - endpoint=os.getenv("APPWRITE_ENDPOINT"), - project_id=os.getenv("APPWRITE_DATASTORE_PROJECT_ID"), - credentials=os.getenv("APPWRITE_DATASTORE_API_KEY"), - auth_method="api_key", - cache_ttl_hours=24, - bucket_id=os.getenv("APPWRITE_UNFAO_BUCKET_ID"), - bucket_name=os.getenv("APPWRITE_UNFAO_BUCKET_NAME"), - collection_id=os.getenv("APPWRITE_UNFAO_COLLECTION_ID"), - collection_name=os.getenv("APPWRITE_UNFAO_COLLECTION_NAME"), - database_id=os.getenv("APPWRITE_METADATA_DATABASE_ID"), - database_name=os.getenv("APPWRITE_METADATA_DATABASE_NAME"), - ) - def _save(self) -> dict: """Deliver the run — ADR-013 contract only (#149).""" return self._save_contract() From 9420f5a481bf34cf5a3e38c4e5b8da8671b88235 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 01:22:30 +0200 Subject: [PATCH 10/42] feat(ci): declare sibling visibility, and un-dark seven cross-repo checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A few tests here verify claims this repo makes about OTHER repos — chiefly that the coordinate-registry edition we pinned is still the one that exists. They need the sibling on disk, so they skipped in CI and ran only on a laptop. Which siblings CI could fetch was decided in a COMMENT, and the comment said views-appwrite was private. It went public on 2026-08-08 (views-appwrite@9d80b75, a deliberate recorded act) and the comment did not, so seven checks stayed dark for two days for no reason — including both registry-drift detectors, which had fired twice in one day the week before. The first draft of ADR-016 proposed issuing a credential to reach them, written one day after the fact justifying it stopped being true. So the fact now lives in a declaration that carries the date it was checked: Sibling(env=..., public=..., public_checked=..., ci_checkout=..., note=...) `public` is a fact about the world; `ci_checkout` is our decision. Conflating them is what went wrong. frozen + kw_only deliberately: two adjacent booleans are a one-token slip between "public, not checked out" and the combination G3 forbids, and a plain dict would let a missing key read as None and silently exempt a sibling from every rule. CI now checks out views-appwrite and views-crafdapi, both with `ref: main` — without it actions/checkout takes the sibling's own default branch, which for views-appwrite is `development`, while two checks read the working tree and a third demands reachability from main. Latent today; contradictory the first time a bump lands on development first. tests/test_ci_sibling_coverage.py: eight rules, each a pure function of (workflow, siblings) so each runs against a synthetic broken world as well as the real file. G3 (public => no token) and G5 (no continue-on-error) look like fussiness and are not: `public` is verified by CI DOING the tokenless checkout, and either key silently turns it into an unchecked claim. A meta-test refuses a rule added to _RULES with no mutant proving it bites. Also fixed, found en route: broken_sibling_overrides treated a variable set to the EMPTY STRING as unset — which is exactly what a YAML interpolation resolving to nothing produces. Seven checks would skip on a green build. Path("") .exists() is True, so the obvious fix makes it worse; the strip is load-bearing. pyyaml declared and re-locked in the same commit. It already arrived transitively, which is precisely how this repo lost pytest; a stale lock makes poetry install refuse and takes the whole suite with it. test_no_coordinate_value_is_copied_into_this_repo changes character rather than merely un-skipping: the scan refusing registry VALUES in this public repo's markdown now runs on every PR rather than only on a maintainer's machine. Its comment said the opposite. ADR-016 rewritten around the public/private rule; the token guide deleted, since we are not issuing a token. The credential for views-faoapi — the one private sibling, worth one test — is deferred with a named trigger, pending a request to FAO to make it public. C-46's explicit recommendation ("do NOT add sibling checkouts to the per-PR workflow") is overridden and disposed of in the same change: its stated objection was coupling to a default branch, which `ref: main` addresses; the residual cost, that an upstream merge to main can block merges here, is accepted and stated rather than glossed. Deciders swept across ADRs 000-015: the PRIO MD&D Team no longer exists. Suite 404 passed / 40 xfailed; ruff clean; lock fresh. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/run_pytest.yml | 50 +- docs/ADRs/000_use_of_adrs.md | 2 +- .../001_ontology_of_views_postprocessing.md | 2 +- .../ADRs/002_topology_and_dependency_rules.md | 2 +- ...uthority_of_declarations_over_inference.md | 2 +- ...ng_as_mandatory_critical_infrastructure.md | 2 +- ...ntent_contracts_for_non_trivial_classes.md | 2 +- ..._based_agents_as_untrusted_contributors.md | 2 +- .../008_observability_and_explicit_failure.md | 2 +- ..._contracts_and_configuration_validation.md | 2 +- docs/ADRs/010_technical_risk_register.md | 2 +- ..._runtime_mapper_with_precomputed_lookup.md | 2 +- docs/ADRs/012_revised_ontology.md | 2 +- .../013_sampled_forecast_wire_contract.md | 2 +- ...4_claims_and_the_guards_that_carry_them.md | 2 +- .../015_the_pipeline_core_appwrite_import.md | 2 +- .../016_ci_read_access_to_private_siblings.md | 206 +++++++ docs/ADRs/README.md | 16 + poetry.lock | 4 +- pyproject.toml | 8 + reports/technical_risk_register.md | 26 +- tests/conftest.py | 122 ++++- tests/test_ci_sibling_coverage.py | 511 ++++++++++++++++++ tests/test_env_declaration.py | 12 +- tests/test_product.py | 6 +- 25 files changed, 929 insertions(+), 62 deletions(-) create mode 100644 docs/ADRs/016_ci_read_access_to_private_siblings.md create mode 100644 tests/test_ci_sibling_coverage.py diff --git a/.github/workflows/run_pytest.yml b/.github/workflows/run_pytest.yml index 1e2d99f..b548505 100644 --- a/.github/workflows/run_pytest.yml +++ b/.github/workflows/run_pytest.yml @@ -27,35 +27,46 @@ jobs: # is normal. The consequence was that CI verified strictly LESS than a developer's # laptop, and precisely on the guards that matter most across a repo boundary. # - # Measured 2026-08-03 (402 collected in all three runs, so the delta is entirely - # skips): - # local, all siblings present 362 passed / 40 xfailed / 0 skipped - # CI, bare checkout 347 passed / 38 xfailed / 17 skipped - # CI, with the checkout below 348 passed / 38 xfailed / 16 skipped + # WHICH siblings are checked out is NOT decided here. It is declared in + # `tests/conftest.py::SIBLINGS`, and `tests/test_ci_sibling_coverage.py` fails if + # this file and that declaration disagree — in either direction. ADR-016. # - # So this recovers exactly ONE test — the cross-seam consumer-name check for CRAF'd. - # That is worth having (it is the §4.1a pin, whose failure mode is a delivery nobody - # can find) but it is one, not seventeen. The other sixteen are below. + # The prose that used to live here said views-appwrite was PRIVATE. It went public + # on 2026-08-08 and the comment did not, so seven checks stayed dark for no reason. + # That is why the fact now lives in a declaration carrying the date it was checked, + # rather than in a paragraph. # - # Only views-crafdapi is checked out, and the choice is deliberate: + # `ref: main` is deliberate on every sibling and is not a default. Without it + # `actions/checkout` takes the sibling's OWN default branch — which for + # views-appwrite is `development` — while ADR-014 §3 says the authority for a claim + # about another repository is that repository's `main`. Two checks in + # test_env_declaration read the working tree and a third demands reachability from + # `main`; pointed at `development` they would eventually demand contradictory things. # - # views-crafdapi PUBLIC, and its check reads source text -> enabled here. - # views-datafactory PUBLIC, but its 8 tests need the producer's raw GAUL - # parquets (`data/raw/gaul_admin/*.parquet`), which are NOT in - # its git repository. Checking it out converts an honest skip - # into a FileNotFoundError. Measured, not assumed — it was tried. - # views-appwrite PRIVATE. The registry-drift detectors live here and are the - # views-faoapi most valuable of the lot; enabling them needs a token, which - # is an operator decision. Register C-46 and C-57 both carry - # that as their standing residual, and it is still open. # `actions/checkout` refuses a path outside $GITHUB_WORKSPACE, so the siblings go # inside it and are pointed at by the declared env vars instead of the conventional # `../` location. `sibling_repo` checks $VIEWS_ first, precisely so the - # location can be declared rather than assumed. + # location can be declared rather than assumed. `_siblings/` specifically: it is + # excluded from ruff in pyproject.toml, and the Lint step reported 745 findings in + # someone else's test suite the first time a sibling landed outside it. + # + # No `token:` on any step below, and that is load-bearing rather than an omission: + # the default GITHUB_TOKEN is scoped to THIS repository, so a tokenless checkout of + # a repo declared public FAILS if it is actually private. That is what verifies the + # `public` field — no test does, because nothing in this suite touches the network. + - name: Checkout views-appwrite (sibling) + uses: actions/checkout@v3 + with: + repository: views-platform/views-appwrite + ref: main + path: _siblings/views-appwrite + fetch-depth: 0 + - name: Checkout views-crafdapi (sibling) uses: actions/checkout@v3 with: repository: views-platform/views-crafdapi + ref: main path: _siblings/views-crafdapi fetch-depth: 0 @@ -80,6 +91,7 @@ jobs: - name: Run tests env: + VIEWS_APPWRITE: ${{ github.workspace }}/_siblings/views-appwrite VIEWS_CRAFDAPI: ${{ github.workspace }}/_siblings/views-crafdapi run: | set -e diff --git a/docs/ADRs/000_use_of_adrs.md b/docs/ADRs/000_use_of_adrs.md index ee9b9b3..e30bd47 100644 --- a/docs/ADRs/000_use_of_adrs.md +++ b/docs/ADRs/000_use_of_adrs.md @@ -3,7 +3,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase **Informed:** All contributors --- diff --git a/docs/ADRs/001_ontology_of_views_postprocessing.md b/docs/ADRs/001_ontology_of_views_postprocessing.md index 0a32b2e..df9a73d 100644 --- a/docs/ADRs/001_ontology_of_views_postprocessing.md +++ b/docs/ADRs/001_ontology_of_views_postprocessing.md @@ -3,7 +3,7 @@ **Status:** Superseded by [ADR-012](012_revised_ontology.md) (2026-06-27) **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/002_topology_and_dependency_rules.md b/docs/ADRs/002_topology_and_dependency_rules.md index ebf9a7a..83e4ff3 100644 --- a/docs/ADRs/002_topology_and_dependency_rules.md +++ b/docs/ADRs/002_topology_and_dependency_rules.md @@ -2,7 +2,7 @@ **Status:** Accepted — amended 2026-06-27 (see [Amendment](#amendment-2026-06-27)) **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/003_authority_of_declarations_over_inference.md b/docs/ADRs/003_authority_of_declarations_over_inference.md index cd2e868..ac664c3 100644 --- a/docs/ADRs/003_authority_of_declarations_over_inference.md +++ b/docs/ADRs/003_authority_of_declarations_over_inference.md @@ -3,7 +3,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/005_testing_as_mandatory_critical_infrastructure.md b/docs/ADRs/005_testing_as_mandatory_critical_infrastructure.md index 534673e..0bb32c9 100644 --- a/docs/ADRs/005_testing_as_mandatory_critical_infrastructure.md +++ b/docs/ADRs/005_testing_as_mandatory_critical_infrastructure.md @@ -3,7 +3,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/006_intent_contracts_for_non_trivial_classes.md b/docs/ADRs/006_intent_contracts_for_non_trivial_classes.md index 46827bc..5d11083 100644 --- a/docs/ADRs/006_intent_contracts_for_non_trivial_classes.md +++ b/docs/ADRs/006_intent_contracts_for_non_trivial_classes.md @@ -3,7 +3,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/007_silicon_based_agents_as_untrusted_contributors.md b/docs/ADRs/007_silicon_based_agents_as_untrusted_contributors.md index bbf45ab..1703917 100644 --- a/docs/ADRs/007_silicon_based_agents_as_untrusted_contributors.md +++ b/docs/ADRs/007_silicon_based_agents_as_untrusted_contributors.md @@ -3,7 +3,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/008_observability_and_explicit_failure.md b/docs/ADRs/008_observability_and_explicit_failure.md index c48583a..9d22e06 100644 --- a/docs/ADRs/008_observability_and_explicit_failure.md +++ b/docs/ADRs/008_observability_and_explicit_failure.md @@ -3,7 +3,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/009_boundary_contracts_and_configuration_validation.md b/docs/ADRs/009_boundary_contracts_and_configuration_validation.md index 6e7c0d7..3668251 100644 --- a/docs/ADRs/009_boundary_contracts_and_configuration_validation.md +++ b/docs/ADRs/009_boundary_contracts_and_configuration_validation.md @@ -2,7 +2,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/010_technical_risk_register.md b/docs/ADRs/010_technical_risk_register.md index 56d7bdf..993f9f3 100644 --- a/docs/ADRs/010_technical_risk_register.md +++ b/docs/ADRs/010_technical_risk_register.md @@ -2,7 +2,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase --- diff --git a/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md b/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md index e2cc9a7..ed789c5 100644 --- a/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md +++ b/docs/ADRs/011_replace_runtime_mapper_with_precomputed_lookup.md @@ -4,7 +4,7 @@ **Status:** Accepted **Date:** 2026-06-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase **Consulted:** FAO-FSFC (via Pre-release Note 02 and Release Note 02 confirmation) **Informed:** All contributors, UN FAO operational team diff --git a/docs/ADRs/012_revised_ontology.md b/docs/ADRs/012_revised_ontology.md index 9b05a14..846292f 100644 --- a/docs/ADRs/012_revised_ontology.md +++ b/docs/ADRs/012_revised_ontology.md @@ -2,7 +2,7 @@ **Status:** Accepted **Date:** 2026-06-27 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase **Supersedes:** [ADR-001](001_ontology_of_views_postprocessing.md) --- diff --git a/docs/ADRs/013_sampled_forecast_wire_contract.md b/docs/ADRs/013_sampled_forecast_wire_contract.md index 09e097d..6510bc8 100644 --- a/docs/ADRs/013_sampled_forecast_wire_contract.md +++ b/docs/ADRs/013_sampled_forecast_wire_contract.md @@ -2,7 +2,7 @@ **Status:** Accepted **Date:** 2026-07-15 -**Deciders:** Project maintainer (PRIO MD&D Team) — explicit sign-off, views-models#149 +**Decider:** Simon Polichinel von der Maase — explicit sign-off, views-models#149 **Supersedes:** the "platform ADR-046" that platform issues cited as the format authority — no such *format* document ever existed (a pipeline-core ADR-046 exists but covers storage infrastructure only; see Erratum E2 in the Post-adoption record) — and the v1 proposal comment on views-models#149 (2026-07-02). --- 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 index 547c9f3..afddd36 100644 --- a/docs/ADRs/014_claims_and_the_guards_that_carry_them.md +++ b/docs/ADRs/014_claims_and_the_guards_that_carry_them.md @@ -2,7 +2,7 @@ **Status:** Accepted **Date:** 2026-08-02 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase **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 diff --git a/docs/ADRs/015_the_pipeline_core_appwrite_import.md b/docs/ADRs/015_the_pipeline_core_appwrite_import.md index 27728c5..9648272 100644 --- a/docs/ADRs/015_the_pipeline_core_appwrite_import.md +++ b/docs/ADRs/015_the_pipeline_core_appwrite_import.md @@ -2,7 +2,7 @@ **Status:** Accepted **Date:** 2026-08-04 -**Deciders:** Project maintainers (PRIO MD&D Team) +**Decider:** Simon Polichinel von der Maase **Arises from:** issue [#146](https://github.com/views-platform/views-postprocessing/issues/146), recorded at the request of the þing-02 ratification **Related:** [ADR-002](002_topology_and_dependency_rules.md) (dependency direction), diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md new file mode 100644 index 0000000..e45d947 --- /dev/null +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -0,0 +1,206 @@ +# ADR-016: Cross-repository checks in CI, and how siblings declare their visibility + +**Status:** Accepted +**Date:** 2026-08-10 +**Decider:** Simon Polichinel von der Maase +**Arises from:** register **C-46**, **C-57**, **C-81**, which all carried the same standing +residual: the cross-repository drift detectors ran only on a maintainer's laptop +**Related:** [ADR-003](003_authority_of_declarations_over_inference.md) (declarations over +inference), [ADR-014](014_claims_and_the_guards_that_carry_them.md) §2 (a guard nobody has +watched fail is decoration) and §4 (a deferral names a trigger and an owner) + +--- + +## Context + +### §1 Some of this repository's tests are about other repositories + +Most tests here check our own code. A few check something else: whether **claims this code +makes about other repositories are still true**. + +The clearest case is the shared Appwrite coordinate registry. Each partner's +`appwrite_env.py` declares, in two constants, which edition of that registry it was written +against. That is a claim about a *different repository*. A test opens that repository, reads +the real edition, and compares. + +This is not theoretical maintenance. On 2026-08-05 the check failed: the registry had moved +from v1.4.1 to v1.4.4 while nobody here was looking. Two days earlier, the same family of +checks fired **twice in one day**. A claim about another repository goes stale at *that* +repository's pace, and nothing in this repository's own code can notice. + +### §2 They only run where the other repository is on disk + +`tests/conftest.py::sibling_repo` resolves a sibling by a declared environment variable and +falls back to the conventional `../` directory. When neither resolves it returns +`None` and the test **skips** — correctly, because on a laptop a missing sibling is normal. + +A GitHub Actions job gets a checkout of **one** repository. So in CI they skipped, and +**continuous integration verified strictly less than a laptop did, precisely on the checks +that cross a repository boundary**. + +### §3 Why they stayed dark, which is the more interesting failure + +Four siblings, and until now the decision about which ones CI could fetch lived in a +**comment** in the workflow file. That comment said `views-appwrite` was private. + +It is not. It was made public on **2026-08-08** — a deliberate, recorded act in that +repository (`views-appwrite@9d80b75`, *"docs: record going public"*). The comment did not +change with it, so seven checks went on skipping in CI for no reason whatsoever. + +The first draft of this very document, written on 2026-08-09, proposed issuing a credential +to reach those seven — **one day after the fact that justified it had ceased to be true**. +That is not an embarrassing footnote to the decision below; it *is* the argument for it. A +fact about another repository, recorded in prose, with no date and nothing able to check it, +will be wrong and nobody will find out. + +--- + +## Decision + +### §4 Every sibling is declared, and the declaration separates fact from decision + +`tests/conftest.py` declares each sibling repository with four fields: + +| field | kind | meaning | +|---|---|---| +| `env` | declaration | the environment variable that overrides its location | +| `public` | **fact** | its visibility on GitHub — not ours to decide | +| `public_checked` | **fact** | the ISO date that visibility was last verified | +| `ci_checkout` | **decision** | whether CI fetches it | +| `note` | reasoning | why not, required whenever `ci_checkout` is false | + +Separating `public` from `ci_checkout` is the point. One is a fact about the world, the +other is a choice we make; §3 is what happens when a single sentence tries to be both. And +`public_checked` is not decoration — every measured claim in this repository carries a date, +and a bare boolean is a fact with no expiry. + +### §5 CI fetches exactly what the declaration says, and a test enforces the agreement + +`.github/workflows/run_pytest.yml` checks out every sibling declared `ci_checkout=True`, into +`_siblings/`, pointed at by the declared environment variable. + +`tests/test_ci_sibling_coverage.py` fails when the workflow and the declaration disagree **in +either direction** — a declared checkout that is missing, or a checkout nobody declared. Each +rule is a pure function of `(workflow, siblings)` so it can be run against a synthetic broken +world; a rule that can only be demonstrated by editing CI is one nobody ever watches fail. + +Today that means `views-appwrite` and `views-crafdapi` are fetched. `views-datafactory` is +public but is **not** fetched: its checks need raw GAUL parquet files that are not in its git +repository, so checking it out replaces an honest skip with a crash — measured, tried and +reverted. `views-faoapi` is the one genuinely private sibling. + +### §6 A missing sibling that CI declared must turn the build red + +Skipping is right on a laptop and wrong in CI once we have declared the repository should be +there. If a checkout silently fails, the checks skip, the build stays green, and we are back +to §2's blindness while looking fixed. + +The mechanism already existed: +`tests/test_env_declaration.py::test_no_sibling_override_points_at_a_missing_path` fails when +a declared variable is set but resolves to nothing. Because the workflow sets those variables +unconditionally, a failed checkout is a **red build**. Naming it here so nobody deletes it +believing it to be tidiness. + +That guard had a hole, closed in the same change: it treated a variable set to the **empty +string** as unset, which is exactly what a YAML interpolation resolving to nothing produces — +so the likeliest CI misconfiguration was the one case it could not see. (`Path("").exists()` +is `True`, so the obvious fix makes it worse.) + +### §7 `public` is verified by CI doing it, not by a test — and two rules keep that true + +No test in this suite touches the network. That is a deliberate convention, not an oversight: +what these tests locate is a working copy on disk. + +So `public` is verified by **the checkout itself**. The default `GITHUB_TOKEN` is scoped to +this repository, so a tokenless fetch of a sibling declared public *fails the build* if it is +actually private. + +Two rules exist solely to keep that argument load-bearing, and both look like fussiness until +you see what they protect: + +- a checkout of a **public** sibling must **not** pass a credential — add one to dodge a rate + limit and the field silently becomes an unchecked claim; +- no sibling checkout may carry `continue-on-error` — one key and a failed fetch stops + failing the build. + +**The reverse case is not detected.** A private sibling that quietly becomes public will go on +being declared private, and nobody here will notice. That is stated rather than papered over; +it costs a stale `note` and an unnecessary skip, not a wrong result. + +### §8 The credential for the one private sibling is deferred + +`views-faoapi` is private, and reaching it needs a credential. That is **not** done here. + +The cost/benefit is thin: it is **one** test. It is, admittedly, the most valuable single one +— it checks that our delivery is filed under the name the consumer actually filters for, and +that failure produces *no error anywhere*: the upload succeeds, storage is paid for, and the +consumer's endpoint is simply empty. But one test does not justify a credential tied to one +person, with an expiry somebody must track, while a better answer is pending. + +**The better answer being pursued:** asking FAO to consent to that repository being made +public, which removes the need entirely. An audit of its full history found no credentials of +any kind and no partner staff email addresses; it already carries an MIT licence. Two items +remain open — internal storage identifiers appear in 21 tracked files, and whether GAUL 2024's +terms permit redistribution. + +**Named trigger (ADR-014 §4).** Issue the credential when **either**: FAO declines, or a +**second** private sibling appears. **Owner:** the maintainer. Should it ever be issued, the +scope floor is one repository, read-only, with a deliberately chosen expiry — the default of +30 days would put this repository back in the dark within a month, with a green build +throughout. + +--- + +## Consequences + +**What this buys.** Seven cross-repository checks move from *"run when a maintainer happens to +run them"* to *"run on every change"*. Two of them fired in anger in the week before this was +written. One test also changes character: the scan refusing registry **values** in this public +repository's markdown now runs on every pull request rather than only on a maintainer's +machine — worth having, since README.md carried four such values once already. + +**What it costs.** CI now depends on two other repositories being fetchable, so an outage or a +visibility change there turns this repository's build red. That is the intended trade: a red +build is the honest signal, and §6 exists to make sure it is what happens. + +**Where this will go wrong first.** Somebody debugging a red CI adds `continue-on-error` to a +sibling checkout, or a `token:` to make a rate limit go away. Either quietly dismantles §7. +Both are rules in `test_ci_sibling_coverage.py` for that reason. + +**Second most likely.** A sibling checkout is added to a different workflow file and escapes a +rule scoped to `run_pytest.yml`. The declaration check scans every workflow, not one. + +--- + +## Alternatives considered + +**Leave it, and rely on running the suite by hand.** The status quo, and what registers C-46 +and C-57 carried as an open residual for weeks. It works exactly as well as one person's +habits, which is not a property a safety check should have. + +**Vendor the facts instead of reading them.** Copy the registry edition into this repository +and check the copy. Rejected outright: copies of that registry were the platform's original +failure, and the standing rule is that it is referenced by pinned URL and never copied. A test +reading a local copy compares a thing to itself. + +**Check `public` against the GitHub API from a test.** Rejected. It would be the only network +call in the suite, would need a credential to answer for private repositories — the very thing +under discussion — and would be flaky in exactly the conditions where a green build matters. +Verification-by-doing (§7) is weaker but honest, and its limits are written down. + +**Issue the credential now and fetch all four siblings.** Rejected on the arithmetic in §8: it +buys one test, and a decision that may make it unnecessary is outstanding. + +--- + +## Appendix — reproducing the measurement + +Counts drift as tests are added; the command does not. From the repository root: + +``` +pytest -q +VIEWS_APPWRITE=/nonexistent pytest -q -rs +``` + +The first, with the sibling repositories present, is the full suite. The second is what CI +would see without the checkout — and it must **fail**, not merely skip. That is §6. diff --git a/docs/ADRs/README.md b/docs/ADRs/README.md index 48d7ab1..d866caf 100644 --- a/docs/ADRs/README.md +++ b/docs/ADRs/README.md @@ -4,6 +4,12 @@ This repository uses Architectural Decision Records (ADRs) to govern structural, semantic, and operational behavior. +> **On the `Deciders` field (2026-08-10).** ADRs 000–015 originally recorded +> *"Project maintainers (PRIO MD&D Team)"*. That team no longer exists, and a decider +> field naming a body that cannot be asked anything is worse than useless to a future +> reader. All of them now name the sole decider, Simon Polichinel von der Maase. +> ADR-013's line keeps its original sign-off reference alongside the name. + ADRs are divided into two categories: 1. **Constitutional ADRs (000–009)** @@ -88,6 +94,16 @@ These ADRs form the architectural constitution of the repository. demand and supply — under which it is revisited. Arises from #146 and the þing-02 ratification, which asked that the reasoning live here rather than in an issue. +- **ADR-016** — Cross-Repository Checks in CI, and How Siblings Declare Their Visibility + A handful of tests here verify claims this repo makes about *other* repos — chiefly that the + coordinate-registry edition we pinned is the one that exists. They need the sibling on disk, + so they skipped in CI and ran only on a laptop. Each sibling is now declared with its + visibility, the date that was checked, and whether CI fetches it; a test fails when the + workflow and the declaration disagree either way. Written after the workflow's comment + asserted a repository was private for two days after it went public — which is the argument + for a dated declaration rather than prose. The credential for the one genuinely private + sibling is deferred with a named trigger. + ADRs numbered 010 and above define: - Domain-specific decisions diff --git a/poetry.lock b/poetry.lock index 42a6e0f..bbc9180 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2641,7 +2641,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -3785,4 +3785,4 @@ viz = ["matplotlib", "nc-time-axis", "seaborn"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.15" -content-hash = "a51d9be49b3be1984501750e17be52d4efb46d4cb72e2f1decaaf3bf999b9bf2" +content-hash = "302cbc3aa750f7dacffe848c6141e6ba542ce30f2f646d99a859409acfd23b49" diff --git a/pyproject.toml b/pyproject.toml index 19b8f99..d76c633 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,14 @@ pyarrow = ">=16.1.0,<17.0.0" pytest = ">=8.0,<10.0" # the suite runs on 9.x today; 8.x still works ruff = ">=0.6,<1.0" +# Declared for the same reason the pytest comment above exists. `tests/test_ci_sibling_ +# coverage.py` parses `.github/workflows/*.yml` to check that CI checks out exactly the +# siblings `tests/conftest.py::SIBLINGS` declares. pyyaml already arrives transitively +# (it is in the lock as a main-group dependency), and relying on that is precisely how +# this repository lost pytest. The alternative — `pytest.importorskip("yaml")` — would +# make the guard skip itself whenever the parser is absent, which is decoration. +pyyaml = ">=6.0,<7.0" + # Ruff's default rule selection is NOT stable across ruff versions, and inheriting a # moving default is the inference ADR-003 forbids applied to tooling. Measured # 2026-08-03: ruff 0.14.13 reported 0 findings on this tree; 0.16.1 reported **815**, diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 0dcfc30..05a6236 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -4,7 +4,7 @@ |-------------------|--------------------------------------| | Project | views-postprocessing | | Owner | Dylan Pinheiro / PRIO MD&D Team | -| Last Updated | 2026-08-05 | +| Last Updated | 2026-08-10 | | Total Concerns | 85 | | Open Concerns | 12 | | Resolved Concerns | 73 | @@ -114,7 +114,7 @@ Plus, outside the register: #158's rename finished and the **broken URL it creat **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.** +2. **The CI question is decided in writing but not implemented.** Three gated cross-repo checks run nowhere automatic. *(Largely implemented 2026-08-10 — ADR-016. Two of the three groups now run in CI; the datafactory group still cannot, because it needs raw data absent from that repository's git, which no CI change can supply.)* 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. @@ -519,13 +519,27 @@ Nine of the seventeen are **new in this arc**, including both registry-drift det Where each sibling stands, after trying them: - **views-crafdapi** — public, its check reads source text. **Now checked out in CI**, recovering **one** test: the cross-seam consumer-document-name pin for CRAF'd. - **views-datafactory** — public, but its eight tests need the producer's raw GAUL parquets, which are **not in its git repository**. Checking it out converts an honest skip into a `FileNotFoundError`; tried and reverted. -- **views-appwrite**, **views-faoapi** — **private**. The most valuable checks live here. Closing this needs a token in CI. +- **views-appwrite** — was private when this was written; **made public 2026-08-08** (`views-appwrite@9d80b75`) and **now checked out in CI**, recovering **seven** tests including both registry-drift detectors. No credential was needed for any of them, and none was ever the obstacle after 2026-08-08 — the obstacle was that this line went on saying "private" for two days after it stopped being true. +- **views-faoapi** — **private**, and the only one. Its single check is dark. Closing it needs either a credential or FAO's consent to make that repository public; the second is being pursued, and ADR-016 §8 carries the trigger for falling back to the first. **Enforcement.** `main` is **not branch-protected**: `gh api .../branches/main/protection` returns `404 Branch not protected`, and `gh api .../rules/branches/main` returns `[]`. The `protect_main` ruleset exists and is `active`, but its `ref_name` include-list is **empty**, so it matches nothing — and it declares no `required_status_checks` rule in any case. **A red `Run Pytest` would not block a merge to `main`.** This repository's own `tests/test_falsification_campaign_4_1.py` carries the question as an unverifiable xfail probe; it is verifiable through the API, and the answer is no. The two compound: a suite that checks less than you think, and no requirement that even that much passes. Neither is caused by this sync — both are pre-existing — but this sync is the first time `main` receives an epic whose value is largely the guards themselves. Cross-refs: **C-46** and **C-57** (both RESOLVED; this is the residual each recorded as *"a CI-cost and cross-repo-coupling decision"* and *"worth deciding once for both"* — it now has a live home and a concrete answer per sibling), **C-80** (the other verification gap found in the same audit), #188. +**Partial mitigation 2026-08-10 (ADR-016) — the coverage half is mostly closed; the enforcement half is untouched.** + +The coverage half rested on a claim nobody could check. This entry, and the workflow comment it drew on, said `views-appwrite` was **private**, so its seven checks needed a credential. It went public on **2026-08-08** (`views-appwrite@9d80b75`, a deliberate and recorded act), and the claim here went on being made for two days afterwards. No credential was required, and none had been the obstacle since that date. + +CI now checks that repository out and those seven run on every pull request — including both registry-drift detectors, which is what this entry called *"the most valuable of the lot"*. One test also changes character rather than merely un-skipping: the scan refusing registry **values** in this public repository's markdown now runs on the merge rather than only on a maintainer's machine. + +**What remains, and it is two different things:** + +1. **One dark check.** `views-faoapi` is genuinely private — the consumer-name pin is still laptop-only. That is one test, not seven, and it is the one whose failure mode is invisible rather than loud. ADR-016 §8 defers the credential and names the trigger: FAO declining the request to make that repository public, or a second private sibling appearing. +2. **The enforcement half is entirely untouched.** `protect_main`'s ref-name include-list was empty; it now targets the default branch, but **no status check is required**, so a pull request with a red CI can still be merged to `main`. Since merging to `main` *is* the production release, this is the half that matters most and the half that has not moved. + +**The lesson this entry should carry.** The blocker was not a missing credential. It was a fact about another repository recorded in prose, with no date, that nothing could check — and it survived a console session, an ADR draft and a register entry, all of which repeated it. ADR-016 replaces the prose with a declaration carrying the date it was verified, and a test that fails when CI and the declaration disagree. + **Update 2026-08-05 — the operator session happened, and neither half of this entry moved.** Simon read the Appwrite console that morning (views-appwrite v1.4.4). It answered two *other* þing-02 questions definitively — there is **no non-production project**, and both platform keys expire 2026-11-17 (now **C-84**) — but the console read is a different action from issuing a token and a different console from GitHub's. So both halves stand: the two private siblings still have no CI credential, and `protect_main`'s ref-name include-list is still empty. Recorded rather than left implicit because "the operator did a console session" is exactly the kind of adjacent fact that gets mistaken for progress on this entry. It is not. What it does establish is that the session is a thing that happens, and these two items are small enough to ride along with the next one. @@ -1289,6 +1303,10 @@ Verified 2026-08-02: `grep -rn "/home/" tests/ scripts/ views_postprocessing/ -- | Trigger | When treating `test_datafactory_deploy_readiness` as a release gate (it never runs in CI), or when a contributor's local `pytest` fails on it — re-promote / re-pin the strict-xfail now that views-datafactory has advanced to `1.5.0`-dev past its `v1.4.0` tag | | Location | `tests/test_datafactory_deploy_readiness.py` (`_DF = Path("/home/simon/.../views-datafactory")`, `skipif(not _DF.exists())`) | +**Overridden 2026-08-10 (ADR-016), and the objection was designed around rather than dismissed.** Sibling checkouts *were* added to the per-PR workflow. The recommendation's argument was specific — *"it couples this repo's CI to another repo's **default branch**, so an unrelated upstream commit turns this repo red"* — and every sibling checkout declares **`ref: main`** for exactly that reason. A commit on someone's feature branch, or on a default branch that is not `main` (views-appwrite's default is `development`), cannot reach us. `test_ci_sibling_coverage.py` makes `ref: main` a rule rather than a habit. + +**What is genuinely accepted, and should not be glossed:** a change merged to a sibling's `main` — a registry edition bump, say — *can* turn this repository red and block merges here until someone re-pins. That is not a defect being tolerated; it is the drift detector working, and the alternative is the state this entry was open about, where the drift was noticed only when a maintainer happened to run the suite. The cost is real and the trade is deliberate. + The cross-repo deploy-readiness gates introduced under C-36 are guarded by `skipif` on a **hardcoded local datafactory checkout path**, so they are **skipped in CI** and only ever execute on one developer's machine. There, `test_version_bumped_past_latest_tag` is currently **failing**: it is an `xfail(strict)` that flipped to XPASS because datafactory moved to `1.5.0`-dev past its `v1.4.0` tag — exactly the auto-flip C-36's resolution anticipated, but because of the hardcoded path the flip surfaces as a **local red** rather than a CI signal, and breaks local `pytest` runs (the suite is run with this test deselected). No correctness/reliability impact on the delivery → **Tier 4** (test hygiene). C-36 (resolved) converted these gates to strict-xfail but did not capture the local-path / CI-skip dimension. See also C-36 (the resolved strict-xfail conversion this extends), C-44 (the datafactory version-state coupling). @@ -1326,7 +1344,7 @@ The right axis was **exact equality on string constants**, not statement shape. **Gated, and honestly so.** The checks need a views-appwrite checkout and skip without one, naming `VIEWS_APPWRITE` and the conventional sibling path so a contributor can run them rather than merely watch them skip. The would-catch-a-rename proof runs in CI with no checkout at all. Resolution helper shared with **C-46** (S7) in `tests/conftest.py` — the second incident, which is this repo's named trigger for extracting. -**Residual — now tracked as C-81.** The gated half does not run in CI, which needs a views-appwrite checkout in the workflow. That was recorded here and in **C-46** as *"a CI-cost and cross-repo-coupling decision, not a code fix … worth deciding once for both"*, and it sat as a residual on two RESOLVED entries, which is where residuals go to be forgotten. It now has a live entry with a measured cost (17 tests, 9 of them new in this arc), a per-sibling answer, and an owner: **C-81**. views-appwrite is private, so it needs a token — an operator decision. +**Residual — RESOLVED 2026-08-10 (ADR-016).** The gated half did not run in CI, which needed a views-appwrite checkout in the workflow. It has one: that repository went public on 2026-08-08 and the workflow now fetches it, so these checks run on every pull request. That was recorded here and in **C-46** as *"a CI-cost and cross-repo-coupling decision, not a code fix … worth deciding once for both"*, and it sat as a residual on two RESOLVED entries, which is where residuals go to be forgotten. It now has a live entry with a measured cost (17 tests, 9 of them new in this arc), a per-sibling answer, and an owner: **C-81**. views-appwrite is private, so it needs a token — an operator decision. A second, smaller instance of the same shape: these checks parse TOML with `tomllib`, stdlib from Python 3.11, and `pyproject` declares `>=3.11`. CI runs 3.11 and executes them. The maintainer's box runs **3.10**, below the declared floor, so they skip there — the local suite is quietly weaker than a green `pytest -q` suggests. Not a repo defect and not worth its own entry; recorded because "a gate that does not run" is exactly what C-46 is open for, and the CI decision should cover both. | | Tier | 3 | diff --git a/tests/conftest.py b/tests/conftest.py index 78493ac..f9ec51b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,7 @@ import os import subprocess +from dataclasses import dataclass from pathlib import Path import pytest @@ -60,15 +61,94 @@ #: either a partner or machinery, and the same test refuses to let it be neither. MACHINERY_PACKAGES = ("contract", "delivery") -#: repo name -> the environment variable that overrides its location. -#: Declared, never derived from the name: ``views-datafactory`` → ``VIEWS_DATAFACTORY`` -#: happens to be mechanical, but a future sibling need not follow the pattern and -#: guessing it would be the inference ADR-003 forbids. -SIBLING_ENV = { - "views-datafactory": "VIEWS_DATAFACTORY", - "views-appwrite": "VIEWS_APPWRITE", - "views-faoapi": "VIEWS_FAOAPI", - "views-crafdapi": "VIEWS_CRAFDAPI", +@dataclass(frozen=True, kw_only=True) +class Sibling: + """What this repository declares about one views-platform sibling (ADR-016). + + Two of these fields are different kinds of thing and the distinction is the point. + ``public`` is a **fact about the world** that this repository does not control. + ``ci_checkout`` is a **decision** this repository makes. Conflating them is how the + workflow ended up asserting, in a comment, that ``views-appwrite`` was private for + days after it went public — and how seven checks stayed dark in CI for no reason. + + ``public_checked`` is the date the fact was last verified, and it is not decoration: + every measured claim in this repository carries one. A bare boolean is a fact with no + expiry, which is precisely what went wrong. + + **Keyword-only and frozen, deliberately.** Two adjacent booleans are a one-token slip + between "public, not checked out" and "private, checked out" — the second being the + combination rule G3 exists to forbid. A plain dict would let a missing ``ci_checkout`` + read as ``None``, silently exempting that sibling from every guard; that is the + failure mode this repository has registered more often than any other (C-47, C-57, + #211). Here the omission is a ``TypeError`` at import. + + **No validation in ``__post_init__``.** See ``broken_sibling_overrides`` below for + what raising at import time costs: one typo became three collection errors and zero + tests run. The rules live in ``tests/test_ci_sibling_coverage.py``, where a violation + is one clean failure and the other four hundred tests still report. + """ + + #: The environment variable that overrides this sibling's location. Declared, never + #: derived: ``views-datafactory`` → ``VIEWS_DATAFACTORY`` happens to be mechanical, + #: but a future sibling need not follow the pattern and guessing it would be the + #: inference ADR-003 forbids. + env: str + #: Visibility on GitHub — a fact about the world, not a decision of ours. + public: bool + #: ISO date ``public`` was last verified. See the class docstring. + public_checked: str + #: Whether CI checks this sibling out. A decision, and the reason for it belongs in + #: ``note`` whenever the answer is no. + ci_checkout: bool + #: Why this sibling is not checked out. Required when ``ci_checkout`` is False, and + #: must name a record, so the non-coverage has an owner rather than a shrug. + note: str = "" + + +#: The views-platform repositories whose current state this repository's tests read. +#: +#: `views-appwrite` was private until **2026-08-08**, when it was deliberately made +#: public (`views-appwrite@9d80b75`, "docs: record going public"). The workflow comment +#: here went on saying PRIVATE afterwards, which is the whole argument for declaring the +#: fact with a date instead of narrating it in prose. +SIBLINGS = { + "views-datafactory": Sibling( + env="VIEWS_DATAFACTORY", + public=True, + public_checked="2026-08-10", + ci_checkout=False, + note=( + "public, but its checks need the producer's raw GAUL parquets " + "(data/raw/gaul_admin/*.parquet), which are NOT in its git repository. " + "Checking it out converts an honest skip into a FileNotFoundError — measured " + "2026-08-03, tried and reverted. Closing this needs the data published " + "somewhere fetchable, not an access grant. Register C-46." + ), + ), + "views-appwrite": Sibling( + env="VIEWS_APPWRITE", + public=True, + public_checked="2026-08-10", + ci_checkout=True, + ), + "views-faoapi": Sibling( + env="VIEWS_FAOAPI", + public=False, + public_checked="2026-08-10", + ci_checkout=False, + note=( + "the only private sibling. Checking it out needs a credential, which is an " + "operator decision deferred pending a request to FAO to make the repository " + "public. One check is dark meanwhile — the consumer-name pin, whose failure " + "mode is a delivery nobody can find. See ADR-016 and register C-81." + ), + ), + "views-crafdapi": Sibling( + env="VIEWS_CRAFDAPI", + public=True, + public_checked="2026-08-10", + ci_checkout=True, + ), } #: partner package -> the repository that CONSUMES its delivery. @@ -97,12 +177,12 @@ def sibling_repo(name: str) -> Path | None: Returns ``None`` rather than raising so callers can skip; a missing sibling is a normal condition in CI, where only this repo is checked out. """ - if name not in SIBLING_ENV: + if name not in SIBLINGS: raise KeyError( f"no environment variable declared for sibling {name!r}; add it to " - f"SIBLING_ENV rather than guessing one from the name" + f"SIBLINGS rather than guessing one from the name" ) - override = os.environ.get(SIBLING_ENV[name]) + override = os.environ.get(SIBLINGS[name].env) candidate = Path(override) if override else _REPO.parent / name return candidate if candidate.exists() else None @@ -122,12 +202,24 @@ def broken_sibling_overrides() -> dict[str, str]: ``Interrupted: 3 errors during collection`` and **zero tests run** — trading silent under-coverage for total loss of the suite. One clean failure says the same thing and lets the other 360 tests report. + + **An EMPTY variable is the case this missed, and it is the one CI produces.** The + filter was ``if value and ...``, so ``VIEWS_APPWRITE=""`` read as unset: + ``sibling_repo`` fell through to the conventional ``../views-appwrite``, which does + not exist in a CI workspace, and seven checks skipped on a green build. A YAML + interpolation that resolves to nothing — ``${{ env.TYPO }}`` — produces exactly that + empty string, so this is the *likely* misconfiguration in CI, not an exotic one. + + ``.strip()`` and not merely ``if value is not None`` because **``Path("").exists()`` + is ``True``** — it resolves to the current directory. Dropping the truthiness test + without the strip would report an empty override as a perfectly good checkout, which + is worse than the bug being fixed. """ return { var: value - for var in SIBLING_ENV.values() + for var in (s.env for s in SIBLINGS.values()) for value in [os.environ.get(var)] - if value and not Path(value).exists() + if value is not None and (not value.strip() or not Path(value).exists()) } @@ -141,7 +233,7 @@ def require_sibling(name: str) -> Path: path = sibling_repo(name) if path is None: pytest.skip( - f"{name} checkout not found — set {SIBLING_ENV[name]}=/path/to/{name}, " + f"{name} checkout not found — set {SIBLINGS[name].env}=/path/to/{name}, " f"or place it alongside this repo at {(_REPO.parent / name)}" ) return path diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py new file mode 100644 index 0000000..489e72f --- /dev/null +++ b/tests/test_ci_sibling_coverage.py @@ -0,0 +1,511 @@ +"""The workflow and the sibling declaration must agree — ADR-016. + +**The failure this exists to prevent has already happened.** A comment in +`run_pytest.yml` stated that `views-appwrite` was private. It went public on 2026-08-08 +and the comment did not, so seven cross-repository checks stayed dark in CI for no reason +at all — and the document written to justify a credential for them was drafted the next +day, against a fact that had already changed. + +The fix is not a better comment. It is that **which siblings CI checks out is declared +once**, in `tests/conftest.py::SIBLINGS`, and this file fails when the workflow and that +declaration disagree — in either direction. + +**What verifies the `public` field, since no test here does.** Nothing in this suite +touches the network; that is a hard convention (`conftest`'s docstring: what is located +is a working copy on disk). `public` is verified by CI *doing it*: the default +`GITHUB_TOKEN` is scoped to this repository, so a tokenless checkout of a sibling +declared public fails the build if it is actually private. Rules G3 and G5 exist to keep +that true — one `token:` or one `continue-on-error:` and the claim silently stops being +checked. The reverse case (a private sibling quietly becoming public) is **not** detected +here, and ADR-016 §7 says so rather than implying coverage it does not have. + +**Every rule is a pure function of (workflow, siblings)**, so each can be run against a +synthetic mutant rather than only against the real file. A guard that can only be +demonstrated by editing CI is a guard nobody ever watches fail (ADR-014 §2) — this repo +already answers that three times over (`test_the_drift_check_would_catch_a_rename`, +`test_the_guard_would_actually_catch_a_violation`, `test_the_ban_actually_fires_on_a_living_doc`). +""" + +from __future__ import annotations + +import datetime as dt +from pathlib import Path + +import pytest +import yaml + +from tests.conftest import SIBLINGS, Sibling + +_WORKFLOWS = Path(__file__).resolve().parent.parent / ".github" / "workflows" +_TEST_WORKFLOW = _WORKFLOWS / "run_pytest.yml" + +#: Where a sibling checkout must land. Not tidiness: `pyproject.toml` excludes +#: `_siblings` from ruff, and the Lint step reported 745 findings in views-crafdapi's own +#: test suite the first time a sibling was checked out anywhere else. +_SIBLING_PATH_PREFIX = "_siblings/" + +#: The org every sibling belongs to. A `repository:` outside it is not a sibling. +_ORG = "views-platform/" + + +# ── parsing ────────────────────────────────────────────────────────────────── + + +def _load(path: Path) -> dict: + return yaml.safe_load(path.read_text()) or {} + + +def _steps(workflow: dict) -> list[dict]: + """Every step of every job, flattened.""" + return [ + step + for job in (workflow.get("jobs") or {}).values() + for step in (job.get("steps") or []) + if isinstance(step, dict) + ] + + +def _sibling_steps(workflow: dict) -> list[dict]: + """Checkout steps that fetch ANOTHER repository. + + Keyed on the presence of ``repository:``, not on ``uses: actions/checkout``. This + repo's own checkout is an `actions/checkout` step with no ``repository:``, so a rule + scoped to the action would fire on line one and be deleted the same day (ADR-014 §3). + """ + return [ + step + for step in _steps(workflow) + if str(step.get("uses", "")).startswith("actions/checkout") + and isinstance(step.get("with"), dict) + and step["with"].get("repository") + ] + + +def _repo_name(step: dict) -> str: + """``views-platform/views-appwrite`` -> ``views-appwrite``.""" + return str(step["with"]["repository"]).removeprefix(_ORG) + + +def _pytest_step(workflow: dict) -> dict | None: + """The step that actually runs the suite — where the env vars must land. + + Found by what it runs, not by its name. An `env:` block on the Lint step satisfies a + naive "the variable is set somewhere" check while the tests see nothing. + """ + for step in _steps(workflow): + if "pytest" in str(step.get("run", "")): + return step + return None + + +# ── rules: pure functions, each returning the violations it found ──────────── + + +def _g1_declared_checkouts_are_present_and_pointed_at( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """Every ``ci_checkout=True`` sibling is checked out AND its env var points at it.""" + by_name = {_repo_name(s): s for s in _sibling_steps(workflow)} + pytest_step = _pytest_step(workflow) + env = (pytest_step or {}).get("env") or {} + + problems = [] + for name, sibling in siblings.items(): + if not sibling.ci_checkout: + continue + step = by_name.get(name) + if step is None: + problems.append(f"{name}: declared ci_checkout=True but no checkout step") + continue + expected = "${{ github.workspace }}/" + str(step["with"].get("path", "")) + actual = env.get(sibling.env) + if actual is None: + problems.append( + f"{name}: checked out, but {sibling.env} is not set on the step that " + "runs pytest — the tests would look in the conventional location, find " + "nothing, and skip" + ) + elif str(actual).strip() != expected: + problems.append( + f"{name}: {sibling.env}={actual!r} does not match the checkout path " + f"({expected!r})" + ) + return problems + + +def _g2_every_checkout_is_declared( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """No sibling is fetched in CI without being declared with ``ci_checkout=True``.""" + problems = [] + for step in _sibling_steps(workflow): + name = _repo_name(step) + sibling = siblings.get(name) + if sibling is None: + problems.append( + f"{name}: checked out in CI but absent from SIBLINGS. A sibling nobody " + "declared is one no rule below covers." + ) + elif not sibling.ci_checkout: + problems.append( + f"{name}: checked out in CI but declared ci_checkout=False. One of the " + "two is wrong, and the declaration is what the reader believes." + ) + return problems + + +def _g3_tokens_match_visibility( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """Private ⇒ must pass a token. Public ⇒ must NOT. + + The second half is the one that matters and the one that looks pointless. It is what + keeps ``public`` verified-by-doing: a tokenless checkout of a private repo fails the + build. Add a token to a public sibling — to dodge a rate limit, say — and the field + becomes an unchecked claim with nothing anywhere to catch it. + """ + problems = [] + for step in _sibling_steps(workflow): + name = _repo_name(step) + sibling = siblings.get(name) + if sibling is None: + continue # G2's business + has_token = "token" in step["with"] or "ssh-key" in step["with"] + if not sibling.public and not has_token: + problems.append( + f"{name}: declared private but checked out with no credential — the " + "step will fail. Either it is public now (update the declaration and its " + "date) or it needs a token." + ) + if sibling.public and has_token: + problems.append( + f"{name}: declared public but the checkout passes a credential. That " + "removes the only thing verifying the `public` field — a tokenless " + "checkout failing when a repo is private. Drop the token, or the claim " + "is unverified." + ) + return problems + + +def _g4_non_coverage_is_explained( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """Every sibling NOT checked out says why, and names a record. + + A deferral needs a trigger and an owner (ADR-014 §4). Requiring the note to cite a + `C-nn` or an `ADR-nnn` is what makes the citation non-optional, so declared + non-coverage stays attached to something a reader can follow. + """ + problems = [] + for name, sibling in siblings.items(): + if sibling.ci_checkout: + continue + if not sibling.note.strip(): + problems.append( + f"{name}: not checked out in CI and no note says why. Silent " + "non-coverage reads as 'nothing to see here'." + ) + elif not any( + token in sibling.note for token in ("C-", "ADR-") + ): + problems.append( + f"{name}: its note explains the non-coverage but names no record. Cite " + "the register entry or ADR that owns it, so the deferral has an owner." + ) + return problems + + +def _g5_no_step_swallows_its_own_failure( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """No sibling checkout may carry ``continue-on-error``. + + One key, and the entire verification-by-doing argument becomes false: the checkout of + a repo that turned private fails, the build stays green, and the checks it enables + skip. It is also exactly the key someone reaches for while debugging a red CI. + """ + return [ + f"{_repo_name(step)}: sibling checkout carries continue-on-error, so a failed " + "fetch would not fail the build — and the checks it enables would skip silently" + for step in _sibling_steps(workflow) + if step.get("continue-on-error") + ] + + +def _g6_siblings_land_under_the_excluded_path( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """Sibling checkouts go under ``_siblings/`` — a real coupling to the ruff config.""" + return [ + f"{_repo_name(step)}: checked out to {step['with'].get('path')!r}, outside " + f"{_SIBLING_PATH_PREFIX!r}. pyproject.toml excludes only that prefix from ruff, " + "so the Lint step would start reporting another repository's code (it once " + "reported 745 findings that way)." + for step in _sibling_steps(workflow) + if not str(step["with"].get("path", "")).startswith(_SIBLING_PATH_PREFIX) + ] + + +def _g7_siblings_are_taken_from_main( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """Every sibling checkout declares ``ref: main``. + + Without it `actions/checkout` takes the sibling's own default branch — which for + views-appwrite is `development`. ADR-014 §3 makes `main` the authority for a claim + about another repository, and `test_the_pinned_commit_is_reachable_from_the_contract_repos_main` + already enforces that. Reading the working tree from `development` while demanding + reachability from `main` is two guards in one file asking for different things. + """ + return [ + f"{_repo_name(step)}: checkout does not declare `ref: main`, so it takes that " + "repository's default branch — someone else's setting, and not the branch this " + "repo treats as authoritative." + for step in _sibling_steps(workflow) + if str(step["with"].get("ref", "")) != "main" + ] + + +def _g8_the_visibility_fact_carries_a_usable_date( + workflow: dict, siblings: dict[str, Sibling] +) -> list[str]: + """``public_checked`` parses, and is not in the future.""" + problems = [] + today = dt.date.today() + for name, sibling in siblings.items(): + try: + checked = dt.date.fromisoformat(sibling.public_checked) + except ValueError: + problems.append( + f"{name}: public_checked={sibling.public_checked!r} is not an ISO date. " + "A fact with an unreadable date is a fact with no date." + ) + continue + if checked > today: + problems.append( + f"{name}: public_checked={sibling.public_checked} is in the future." + ) + return problems + + +_RULES = { + "G1 declared checkouts are present and pointed at": _g1_declared_checkouts_are_present_and_pointed_at, + "G2 every checkout is declared": _g2_every_checkout_is_declared, + "G3 tokens match visibility": _g3_tokens_match_visibility, + "G4 non-coverage is explained": _g4_non_coverage_is_explained, + "G5 no step swallows its own failure": _g5_no_step_swallows_its_own_failure, + "G6 siblings land under the excluded path": _g6_siblings_land_under_the_excluded_path, + "G7 siblings are taken from main": _g7_siblings_are_taken_from_main, + "G8 the visibility fact carries a usable date": _g8_the_visibility_fact_carries_a_usable_date, +} + + +# ── the inputs are real (ADR-014 §2) ───────────────────────────────────────── + + +def test_the_workflow_this_file_scans_actually_parses(): + """A YAML restructure would empty every scan below and report success. + + This is by far the most likely way this file becomes decoration: rename the job, nest + `steps` differently, move the workflow, and eight rules find nothing to complain + about. So assert there is something to scan before trusting that there was nothing + wrong. + """ + assert _TEST_WORKFLOW.exists(), f"no workflow at {_TEST_WORKFLOW}" + workflow = _load(_TEST_WORKFLOW) + assert _steps(workflow), ( + "no steps parsed out of run_pytest.yml — the job or step structure moved, and " + "every rule in this file is now scanning an empty list." + ) + assert _pytest_step(workflow) is not None, ( + "no step in run_pytest.yml runs pytest. G1 checks the env vars on that step; " + "without it, it checks nothing." + ) + found = {_repo_name(s) for s in _sibling_steps(workflow)} + assert found, ( + "no sibling checkout steps found. Either they were all removed — in which case " + "the cross-repo checks are dark again — or the detection predicate no longer " + "matches how they are written." + ) + + +def test_every_workflow_file_is_scanned_for_smuggled_siblings(): + """G2 must see ALL workflows, not just this one. + + A sibling checkout added to `codeql.yml` would be invisible to a rule scoped to + `run_pytest.yml`. Nothing forbids one being added there; this makes it visible. + """ + workflows = sorted(_WORKFLOWS.glob("*.yml")) + assert workflows, ( + f"no workflow files under {_WORKFLOWS} — a glob over a moved directory finds " + "nothing and reports success (register C-74)." + ) + stray = [] + for path in workflows: + stray += [ + f"{path.name}: {_repo_name(step)}" + for step in _sibling_steps(_load(path)) + if _repo_name(step) not in SIBLINGS + ] + assert not stray, ( + f"sibling checkouts in workflow files that SIBLINGS does not declare: {stray}." + ) + + +def test_the_declared_siblings_are_the_ones_the_suite_resolves(): + """`SIBLINGS` is what every gated check resolves through; assert it is not empty.""" + assert SIBLINGS, "SIBLINGS is empty — every cross-repo check now skips permanently." + assert all(s.env.startswith("VIEWS_") for s in SIBLINGS.values()), ( + "a declared override variable does not follow the VIEWS_* convention; " + f"{ {n: s.env for n, s in SIBLINGS.items()} }" + ) + + +# ── the rules, against the real workflow ───────────────────────────────────── + + +@pytest.mark.parametrize("label", sorted(_RULES)) +def test_the_workflow_agrees_with_the_declaration(label): + """Each rule, run against the file that actually ships.""" + violations = _RULES[label](_load(_TEST_WORKFLOW), SIBLINGS) + assert not violations, f"[{label}]\n " + "\n ".join(violations) + + +# ── the rules, against synthetic mutants (ADR-014 §2) ──────────────────────── +# +# A rule that has only ever been run against a correct file has never been watched fail. +# Each case below is a minimal broken world; the rule must object AND name the sibling, +# because a violation nobody can locate is barely better than none. + + +def _workflow(**step_overrides) -> dict: + """A minimal well-formed workflow with one sibling checkout, then mutated.""" + with_block = { + "repository": "views-platform/views-appwrite", + "ref": "main", + "path": "_siblings/views-appwrite", + } + with_block.update(step_overrides.pop("with", {})) + step = {"uses": "actions/checkout@v3", "with": with_block} + step.update(step_overrides) + return { + "jobs": { + "test": { + "steps": [ + {"uses": "actions/checkout@v3"}, # this repo's own — no `repository:` + step, + { + "run": "poetry run pytest tests/", + "env": { + "VIEWS_APPWRITE": "${{ github.workspace }}/_siblings/views-appwrite" + }, + }, + ] + } + } + } + + +_ONE = {"views-appwrite": SIBLINGS["views-appwrite"]} + + +def _replace(name: str, **changes) -> dict[str, Sibling]: + from dataclasses import replace + + return {name: replace(SIBLINGS[name], **changes)} + + +_MUTANTS = [ + ("G1 declared checkouts are present and pointed at", + {"jobs": {"test": {"steps": [{"run": "poetry run pytest tests/", "env": {}}]}}}, + _ONE, "declared ci_checkout=True but the checkout step is gone"), + ("G2 every checkout is declared", _workflow(), {}, "checked out but undeclared"), + ("G3 tokens match visibility", + _workflow(**{"with": {"token": "${{ secrets.X }}"}}), _ONE, + "public sibling fetched with a credential — kills verification-by-doing"), + ("G4 non-coverage is explained", + _workflow(), _replace("views-appwrite", ci_checkout=False, note=""), + "not checked out and no note"), + ("G4 non-coverage is explained", + _workflow(), _replace("views-appwrite", ci_checkout=False, note="because reasons"), + "note explains but names no record"), + ("G5 no step swallows its own failure", + _workflow(**{"continue-on-error": True}), _ONE, + "a failed fetch would not fail the build"), + ("G6 siblings land under the excluded path", + _workflow(**{"with": {"path": "vendor/views-appwrite"}}), _ONE, + "outside _siblings/, so ruff would lint it"), + ("G7 siblings are taken from main", + _workflow(**{"with": {"ref": "development"}}), _ONE, + "takes a branch that is not the authority"), + ("G8 the visibility fact carries a usable date", + _workflow(), _replace("views-appwrite", public_checked="2099-01-01"), + "the fact was checked in the future"), + ("G8 the visibility fact carries a usable date", + _workflow(), _replace("views-appwrite", public_checked="last tuesday"), + "unparseable date"), +] + + +def test_every_rule_has_a_mutant_that_proves_it_bites(): + """ADR-014 §2, applied to this file's own contents. + + Adding a rule to `_RULES` without a broken world to try it against is adding a rule + nobody has watched fail. That is the definition of decoration, and it is easy to do + by accident — the rule reads fine, the real workflow satisfies it, and the suite goes + green forever. + + This is a cheap structural check, not a clever one: it cannot tell whether a mutant + is a *good* one. It only refuses the case where there is none at all. + """ + proven = {label for label, *_ in _MUTANTS} + unproven = sorted(set(_RULES) - proven) + assert not unproven, ( + f"rules with no mutant in _MUTANTS: {unproven}. Add a broken world that each one " + "must object to, or the rule is untested against anything but a correct file." + ) + + +@pytest.mark.parametrize("label, workflow, siblings, why", _MUTANTS) +def test_each_rule_bites_on_a_broken_world(label, workflow, siblings, why): + violations = _RULES[label](workflow, siblings) + assert violations, f"[{label}] did not object to: {why}" + assert any("views-appwrite" in v for v in violations), ( + f"[{label}] objected but did not name the offending sibling: {violations}" + ) + + +@pytest.mark.parametrize( + "env, why", + [ + ({}, "the env var is not set at all"), + ({"VIEWS_APPWRITE": ""}, "set to empty — the CI misconfiguration that skips silently"), + ({"VIEWS_APPWRITE": "${{ github.workspace }}/_siblings/appwrite"}, "points at the wrong path"), + ], +) +def test_g1_bites_on_every_way_the_env_var_can_be_wrong(env, why): + """The three shapes of "checked out, but the tests cannot see it".""" + workflow = _workflow() + workflow["jobs"]["test"]["steps"][2]["env"] = env + violations = _g1_declared_checkouts_are_present_and_pointed_at(workflow, _ONE) + assert violations, f"G1 did not object to: {why}" + + +def test_g1_looks_at_the_step_that_runs_pytest_and_not_merely_any_step(): + """Env on the Lint step satisfies "the variable is set somewhere" and nothing else.""" + workflow = _workflow() + workflow["jobs"]["test"]["steps"][2]["env"] = {} + workflow["jobs"]["test"]["steps"].insert( + 2, {"run": "poetry run ruff check .", + "env": {"VIEWS_APPWRITE": "${{ github.workspace }}/_siblings/views-appwrite"}} + ) + assert _g1_declared_checkouts_are_present_and_pointed_at(workflow, _ONE), ( + "G1 accepted an env var set on the Lint step — the tests would never see it" + ) + + +def test_the_repos_own_checkout_is_not_mistaken_for_a_sibling(): + """G2 must not fire on line one of every workflow ever written (ADR-014 §3).""" + own_checkout_only = {"jobs": {"test": {"steps": [{"uses": "actions/checkout@v3"}]}}} + assert not _g2_every_checkout_is_declared(own_checkout_only, SIBLINGS) + assert not _sibling_steps(own_checkout_only) diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 4f75f3f..74d3267 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -720,10 +720,14 @@ def test_no_coordinate_value_is_copied_into_this_repo(): # sibling repositories out into the workspace, and their documents are not this # repo's to police; an rglob would scan them and fail on someone else's prose. # - # **This half cannot run in CI**, and that is worth stating rather than discovering. - # The registry values come from the views-appwrite checkout, which is private and - # deliberately not checked out — so the whole test skips there. It guards a - # maintainer's commit, not the merge. Closing that is C-81's token decision. + # **This half runs in CI as of 2026-08-10** (ADR-016), and the change is worth + # noting because it inverts what this comment said for weeks. The registry values + # come from the views-appwrite checkout, which was private and deliberately absent — + # so the whole test skipped there, guarding a maintainer's commit but not the merge. + # views-appwrite went public on 2026-08-08 and CI now checks it out, so this scan + # runs on every pull request. It is the one test here whose CI behaviour went from + # "skip" to "runs a security-adjacent scan", and it is worth having: README.md + # carried four real coordinate values once already. tracked = subprocess.run( ["git", "-C", str(_REPO), "ls-files", "-z", "*.md"], capture_output=True, text=True, check=False, timeout=30, diff --git a/tests/test_product.py b/tests/test_product.py index 152fc0c..1fd606f 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -25,7 +25,7 @@ import pytest -from tests.conftest import CONSUMER_REPO, PARTNER_PACKAGES, SIBLING_ENV, require_sibling +from tests.conftest import CONSUMER_REPO, PARTNER_PACKAGES, SIBLINGS, require_sibling #: partner -> the document name its consumer filters on, DECLARED here rather than #: read back from the module under test. @@ -170,9 +170,9 @@ def test_every_partner_has_a_declared_consumer_repository(): f"{sorted(set(PARTNER_PACKAGES) - set(CONSUMER_REPO))}. Without one, that " "partner's consumer-name pin is never checked against the consumer." ) - undeclared = sorted(r for r in CONSUMER_REPO.values() if r not in SIBLING_ENV) + undeclared = sorted(r for r in CONSUMER_REPO.values() if r not in SIBLINGS) assert not undeclared, ( - f"consumer repositories with no SIBLING_ENV entry: {undeclared}. " + f"consumer repositories with no SIBLINGS entry: {undeclared}. " "require_sibling() raises KeyError rather than skipping for those, so the " "check would fail confusingly instead of skipping cleanly." ) From 124cf4789dffccfba12a22e09263908a6a9a2dbc Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 01:26:14 +0200 Subject: [PATCH 11/42] =?UTF-8?q?fix(tests):=20G8=20compared=20two=20diffe?= =?UTF-8?q?rent=20"today"s=20=E2=80=94=20one=20day=20of=20clock=20slack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught this on the first run, which is the point of the change it is part of: the guard could not fail locally, because locally there is only one clock. The dates were stamped from a maintainer's machine in CEST at 01:25 on 2026-08-10. The runner is UTC, where it was 23:25 on 2026-08-09. So a date recorded truthfully today read as tomorrow two hours away, and G8 called it a lie. "Today" is not something a bare ISO date determines — it depends on where the reader is — so comparing one against `date.today()` asks two different questions. A full day of tolerance covers every real offset, and a date more than a day ahead is still exactly what the rule is for: a fact nobody checked. Written down rather than fixed quietly, because the repair a hurried reader reaches for here is deleting the rule (ADR-014 §3). Added test_g8_tolerates_a_timezone_but_not_a_ fiction, pinning both sides of the boundary at 0/+1/+2/+400 days — a tolerance with no test becomes whatever made the last failure go away. Suite green under TZ=UTC too. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_ci_sibling_coverage.py | 51 ++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index 489e72f..ce8c31c 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -266,12 +266,32 @@ def _g7_siblings_are_taken_from_main( ] +#: How far ahead of "today" a check date may sit before it is an error rather than a +#: clock difference. See `_g8_the_visibility_fact_carries_a_usable_date`. +_CLOCK_SKEW = dt.timedelta(days=1) + + def _g8_the_visibility_fact_carries_a_usable_date( workflow: dict, siblings: dict[str, Sibling] ) -> list[str]: - """``public_checked`` parses, and is not in the future.""" + """``public_checked`` parses, and is not meaningfully in the future. + + **One day of slack, and it is not laziness — this rule failed CI on its first run.** + The dates were stamped from a maintainer's machine in CEST at 01:25 on 2026-08-10; + the runner was in UTC, where it was still 23:25 on 2026-08-09. A date recorded + truthfully today read as tomorrow two hours away, and the guard called it a lie. + + "Today" is not a fact a date alone determines — it depends on where the reader is — + so comparing a bare ISO date against `date.today()` is comparing two different + questions. A full day of tolerance covers every real timezone offset, and a date more + than a day ahead is still what this rule is for: a fact nobody actually checked. + + Recorded here rather than fixed silently, because the alternative repair a hurried + reader would reach for is deleting the rule (ADR-014 §3: when a guard cries wolf, + check the matching before the scope). + """ problems = [] - today = dt.date.today() + horizon = dt.date.today() + _CLOCK_SKEW for name, sibling in siblings.items(): try: checked = dt.date.fromisoformat(sibling.public_checked) @@ -281,9 +301,10 @@ def _g8_the_visibility_fact_carries_a_usable_date( "A fact with an unreadable date is a fact with no date." ) continue - if checked > today: + if checked > horizon: problems.append( - f"{name}: public_checked={sibling.public_checked} is in the future." + f"{name}: public_checked={sibling.public_checked} is more than a day " + "ahead of today — that is not clock skew, it is a date nobody checked." ) return problems @@ -440,7 +461,7 @@ def _replace(name: str, **changes) -> dict[str, Sibling]: "takes a branch that is not the authority"), ("G8 the visibility fact carries a usable date", _workflow(), _replace("views-appwrite", public_checked="2099-01-01"), - "the fact was checked in the future"), + "the fact was checked far in the future"), ("G8 the visibility fact carries a usable date", _workflow(), _replace("views-appwrite", public_checked="last tuesday"), "unparseable date"), @@ -509,3 +530,23 @@ def test_the_repos_own_checkout_is_not_mistaken_for_a_sibling(): own_checkout_only = {"jobs": {"test": {"steps": [{"uses": "actions/checkout@v3"}]}}} assert not _g2_every_checkout_is_declared(own_checkout_only, SIBLINGS) assert not _sibling_steps(own_checkout_only) + + +def test_g8_tolerates_a_timezone_but_not_a_fiction(): + """The boundary either side of `_CLOCK_SKEW`, pinned. + + A rule with a tolerance needs its tolerance tested, or the number drifts into being + whatever made the last failure go away. Tomorrow is a clock difference; the day after + is a claim about a check that has not happened. + """ + today = dt.date.today() + for offset, should_object in ((0, False), (1, False), (2, True), (400, True)): + siblings = _replace( + "views-appwrite", + public_checked=(today + dt.timedelta(days=offset)).isoformat(), + ) + violations = _g8_the_visibility_fact_carries_a_usable_date(_workflow(), siblings) + assert bool(violations) is should_object, ( + f"a check date {offset} day(s) ahead of today: expected " + f"{'an objection' if should_object else 'no objection'}, got {violations}" + ) From e902c1fc82cc1289a8805e13ad9a796aa5136075 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 01:28:21 +0200 Subject: [PATCH 12/42] =?UTF-8?q?docs(register):=20C-81=20=E2=80=94=20the?= =?UTF-8?q?=20measured=20CI=20figure,=2016=20skips=20to=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read off the merge run rather than derived. 398 passed / 9 skipped / 38 xfailed; the 9 are 8 views-datafactory (needs published data, not a credential) and 1 views-faoapi. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 05a6236..c97871e 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -531,7 +531,7 @@ Cross-refs: **C-46** and **C-57** (both RESOLVED; this is the residual each reco The coverage half rested on a claim nobody could check. This entry, and the workflow comment it drew on, said `views-appwrite` was **private**, so its seven checks needed a credential. It went public on **2026-08-08** (`views-appwrite@9d80b75`, a deliberate and recorded act), and the claim here went on being made for two days afterwards. No credential was required, and none had been the obstacle since that date. -CI now checks that repository out and those seven run on every pull request — including both registry-drift detectors, which is what this entry called *"the most valuable of the lot"*. One test also changes character rather than merely un-skipping: the scan refusing registry **values** in this public repository's markdown now runs on the merge rather than only on a maintainer's machine. +CI now checks that repository out and those seven run on every pull request — including both registry-drift detectors, which is what this entry called *"the most valuable of the lot"*. **Measured on the merge run, not derived:** CI went from 16 skips to **9** (`398 passed / 9 skipped / 38 xfailed`, PR #229), the remainder being 8 views-datafactory and 1 views-faoapi. One test also changes character rather than merely un-skipping: the scan refusing registry **values** in this public repository's markdown now runs on the merge rather than only on a maintainer's machine. **What remains, and it is two different things:** From 2ae1c73bff7221d543f199cd7f9d982798284919 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 02:12:38 +0200 Subject: [PATCH 13/42] refactor(tests): drop the circular half of the sibling declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expert-code-review of the design found two fields and two rules justifying each other and nothing else. Measured rather than argued: .public read in exactly one place — rule G3 .public_checked read in exactly one place — rule G8 G3's private-branch vacuous; no private sibling is checked out So `public` existed to be verified by G3, and G3 existed to protect `public`'s verifiability. Deleting both changes no behaviour. `public_checked` was worse than useless: nothing could confirm the check had happened, so it manufactured confidence instead of recording a fact — and G8, its only reader, cried wolf on its first CI run over a timezone, which is ADR-014 §3's own worked example. Visibility now lives in a sibling's `note`, as prose, because nothing here can verify it in any case. What survives without the flag needs no field: a repository CI cannot read fails to download, and G5 keeps that failure loud. guard file 552 -> 448 lines rules 8 -> 6 (G1, G2, G4, G5, G6, G7) Sibling 5 fields -> 3 (env, ci_checkout, note) Every surviving rule has an incident behind it, not a hypothesis: G6 for the 745 lint findings when a sibling landed outside `_siblings/`, G7 for views-appwrite's default branch being `development`. Re-proven against the real workflow after the cut — and the first G7 mutation was wrong rather than the guard, having hit a comment instead of a step. ADR-016 rewritten. The maintainer could not follow the previous version past §4, and the reason was the design rather than the prose: §5-§7 were second-order, rules protecting an argument rather than protecting the code. It now opens with the decision in four sentences, gives each rule the incident behind it, and states plainly in §7 that a broken sibling can block merging here — with the reasoning, which was previously asserted rather than argued. What was removed is recorded, because the removed design is more tempting than it looks and this document once argued for it. Suite 399 passed / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 302 +++++++++--------- tests/conftest.py | 57 ++-- tests/test_ci_sibling_coverage.py | 173 ++-------- 3 files changed, 215 insertions(+), 317 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index e45d947..35b824f 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -1,206 +1,222 @@ -# ADR-016: Cross-repository checks in CI, and how siblings declare their visibility +# ADR-016: CI checks out the sibling repositories our tests read **Status:** Accepted **Date:** 2026-08-10 **Decider:** Simon Polichinel von der Maase -**Arises from:** register **C-46**, **C-57**, **C-81**, which all carried the same standing -residual: the cross-repository drift detectors ran only on a maintainer's laptop -**Related:** [ADR-003](003_authority_of_declarations_over_inference.md) (declarations over -inference), [ADR-014](014_claims_and_the_guards_that_carry_them.md) §2 (a guard nobody has +**Related:** [ADR-014](014_claims_and_the_guards_that_carry_them.md) §2 (a guard nobody has watched fail is decoration) and §4 (a deferral names a trigger and an owner) --- -## Context - -### §1 Some of this repository's tests are about other repositories - -Most tests here check our own code. A few check something else: whether **claims this code -makes about other repositories are still true**. +## The decision in four sentences -The clearest case is the shared Appwrite coordinate registry. Each partner's -`appwrite_env.py` declares, in two constants, which edition of that registry it was written -against. That is a claim about a *different repository*. A test opens that repository, reads -the real edition, and compares. +A few of this repository's tests read **other** repositories to check that things we say +about them are still true. Those tests only work when the other repository is on disk, so +they ran on a developer's laptop and skipped in CI. -This is not theoretical maintenance. On 2026-08-05 the check failed: the registry had moved -from v1.4.1 to v1.4.4 while nobody here was looking. Two days earlier, the same family of -checks fired **twice in one day**. A claim about another repository goes stale at *that* -repository's pace, and nothing in this repository's own code can notice. +**CI now downloads the repositories those tests need**, and a test fails if CI stops +downloading one that the code says it should. The single repository we cannot download — +because it is private — is named, with the reason, and a decision about it is deferred. -### §2 They only run where the other repository is on disk - -`tests/conftest.py::sibling_repo` resolves a sibling by a declared environment variable and -falls back to the conventional `../` directory. When neither resolves it returns -`None` and the test **skips** — correctly, because on a laptop a missing sibling is normal. - -A GitHub Actions job gets a checkout of **one** repository. So in CI they skipped, and -**continuous integration verified strictly less than a laptop did, precisely on the checks -that cross a repository boundary**. +--- -### §3 Why they stayed dark, which is the more interesting failure +## Context -Four siblings, and until now the decision about which ones CI could fetch lived in a -**comment** in the workflow file. That comment said `views-appwrite` was private. +### §1 What these tests actually do -It is not. It was made public on **2026-08-08** — a deliberate, recorded act in that -repository (`views-appwrite@9d80b75`, *"docs: record going public"*). The comment did not -change with it, so seven checks went on skipping in CI for no reason whatsoever. +Most tests here check our own code. A few check something different: whether a **claim +this repository makes about a different repository** is still true. -The first draft of this very document, written on 2026-08-09, proposed issuing a credential -to reach those seven — **one day after the fact that justified it had ceased to be true**. -That is not an embarrassing footnote to the decision below; it *is* the argument for it. A -fact about another repository, recorded in prose, with no date and nothing able to check it, -will be wrong and nobody will find out. +The clearest example. Each partner's `appwrite_env.py` says, in effect, *"we were written +against edition 1.4.4 of the shared configuration registry."* That registry lives in +another repository. A test opens that repository and checks the edition is still 1.4.4. ---- +This is not hypothetical housekeeping. On 2026-08-05 that test failed — the registry had +moved to a new edition while nobody here was looking. Two days earlier, the same family of +checks fired **twice in one day**. -## Decision +### §2 Why they were not running -### §4 Every sibling is declared, and the declaration separates fact from decision +A test like that needs the other repository present. On a laptop all the platform +repositories sit in one folder, so it runs. A GitHub Actions job gets **one** repository, +so it skipped. -`tests/conftest.py` declares each sibling repository with four fields: +The result: **CI verified less than a laptop did, exactly on the checks that span two +repositories** — the ones no single repository can replace. -| field | kind | meaning | -|---|---|---| -| `env` | declaration | the environment variable that overrides its location | -| `public` | **fact** | its visibility on GitHub — not ours to decide | -| `public_checked` | **fact** | the ISO date that visibility was last verified | -| `ci_checkout` | **decision** | whether CI fetches it | -| `note` | reasoning | why not, required whenever `ci_checkout` is false | +### §3 Why nobody noticed for so long -Separating `public` from `ci_checkout` is the point. One is a fact about the world, the -other is a choice we make; §3 is what happens when a single sentence tries to be both. And -`public_checked` is not decoration — every measured claim in this repository carries a date, -and a bare boolean is a fact with no expiry. +Which repositories CI could download was recorded in a **comment** in the workflow file. +That comment said `views-appwrite` was private, so its seven checks were assumed to need +an access credential nobody had issued. -### §5 CI fetches exactly what the declaration says, and a test enforces the agreement +It had gone public on **2026-08-08**. The comment had not changed with it. Seven checks +stayed switched off for no reason at all, and the first draft of this very document +proposed issuing a credential to reach them — written one day after the thing that +justified it stopped being true. -`.github/workflows/run_pytest.yml` checks out every sibling declared `ci_checkout=True`, into -`_siblings/`, pointed at by the declared environment variable. +**That is the whole lesson.** A fact about another repository, written in prose, with +nothing able to check it, will eventually be wrong and nobody will find out. -`tests/test_ci_sibling_coverage.py` fails when the workflow and the declaration disagree **in -either direction** — a declared checkout that is missing, or a checkout nobody declared. Each -rule is a pure function of `(workflow, siblings)` so it can be run against a synthetic broken -world; a rule that can only be demonstrated by editing CI is one nobody ever watches fail. +--- -Today that means `views-appwrite` and `views-crafdapi` are fetched. `views-datafactory` is -public but is **not** fetched: its checks need raw GAUL parquet files that are not in its git -repository, so checking it out replaces an honest skip with a crash — measured, tried and -reverted. `views-faoapi` is the one genuinely private sibling. +## Decision -### §6 A missing sibling that CI declared must turn the build red +### §4 The list of siblings is code, not a comment -Skipping is right on a laptop and wrong in CI once we have declared the repository should be -there. If a checkout silently fails, the checks skip, the build stays green, and we are back -to §2's blindness while looking fixed. +`tests/conftest.py` holds one entry per sibling repository, and each entry says: -The mechanism already existed: -`tests/test_env_declaration.py::test_no_sibling_override_points_at_a_missing_path` fails when -a declared variable is set but resolves to nothing. Because the workflow sets those variables -unconditionally, a failed checkout is a **red build**. Naming it here so nobody deletes it -believing it to be tidiness. +- **`env`** — the environment variable that overrides where to find it; +- **`ci_checkout`** — whether CI downloads it; +- **`note`** — why not, when the answer is no. -That guard had a hole, closed in the same change: it treated a variable set to the **empty -string** as unset, which is exactly what a YAML interpolation resolving to nothing produces — -so the likeliest CI misconfiguration was the one case it could not see. (`Path("").exists()` -is `True`, so the obvious fix makes it worse.) +That is all. It replaces a comment with something a test can read. -### §7 `public` is verified by CI doing it, not by a test — and two rules keep that true +### §5 CI downloads exactly what that list says, and a test enforces it -No test in this suite touches the network. That is a deliberate convention, not an oversight: -what these tests locate is a working copy on disk. +The workflow downloads every sibling marked `ci_checkout=True`. +`tests/test_ci_sibling_coverage.py` fails if the workflow and the list disagree — in +either direction: a repository the list expects and CI does not fetch, or one CI fetches +that the list never mentions. -So `public` is verified by **the checkout itself**. The default `GITHUB_TOKEN` is scoped to -this repository, so a tokenless fetch of a sibling declared public *fails the build* if it is -actually private. +Six rules, and each exists because of something that has actually gone wrong: -Two rules exist solely to keep that argument load-bearing, and both look like fussiness until -you see what they protect: +| | rule | the incident behind it | +|---|---|---| +| G1 | a listed repository is downloaded, and the tests are pointed at it | the tests looked in the wrong place and skipped silently | +| G2 | anything CI downloads appears in the list | the list is the thing people read; CI is not | +| G4 | a repository we skip says why, naming a record | silent non-coverage reads as "nothing to see here" | +| G5 | a download step may not be marked "ignore failures" | one setting and a failed download stops failing the build | +| G6 | downloads land in `_siblings/` | a sibling put elsewhere made the linter report 745 errors in someone else's code | +| G7 | download the sibling's `main` branch | without it you get *their* default branch — which for `views-appwrite` is `development`, not `main` | + +Each rule is a plain function, so each is also run against a deliberately broken example +to prove it objects. A rule only ever tried against a correct file is a rule nobody has +watched fail. + +### §6 A repository CI expects but cannot find turns the build red + +Skipping is right on a laptop, where a missing sibling is normal. It is wrong in CI once +we have said the repository should be there — a silent skip puts us back in §2 while +looking fixed. So CI names the repositories explicitly, and a missing one is a failure. + +That guard existed already +(`tests/test_env_declaration.py::test_no_sibling_override_points_at_a_missing_path`) and +had a hole, closed here: it treated a variable set to an **empty string** as unset, which +is exactly what a mis-typed CI setting produces. So the likeliest misconfiguration was the +one case it could not see. + +### §7 A broken sibling can block merging here, and that is accepted + +This is the real cost, and it should not be buried. CI now depends on two other +repositories. If one of them changes in a way that fails a check — say the configuration +registry moves again — **this repository's builds go red and merges are blocked until +someone updates the pin.** Since merging to `main` here *is* the release to FAO, that +matters. + +It is accepted for three reasons: + +1. The problem being fixed was that these checks were **invisible**. A check that reports + but cannot block is invisible again, just more politely. +2. A red build in that situation is *correct*. It says "re-pin before you ship", and the + fix is minutes. +3. There is an escape. The maintainer administers this repository and can merge over a + failing check when something genuinely urgent is blocked. + +This overrides an earlier recommendation in the risk register (**C-46**) not to couple +per-PR CI to another repository. That recommendation's stated objection was coupling to +another repository's *default branch*, which G7 removes. The remaining coupling is real, +and is the trade above. + +### §8 One repository stays out, and the decision about it is deferred + +`views-faoapi` is private. Downloading it needs a credential, and that is **not** done +here. + +It buys **one** test. That one is admittedly the most valuable of the set — it checks our +delivery is filed under the name the consumer looks for, and when that is wrong nothing +raises an error anywhere: the upload succeeds, storage is paid for, and the consumer's +endpoint is simply empty. But one test does not justify a credential tied to one person, +with an expiry someone must remember, while a better answer is pending. -- a checkout of a **public** sibling must **not** pass a credential — add one to dodge a rate - limit and the field silently becomes an unchecked claim; -- no sibling checkout may carry `continue-on-error` — one key and a failed fetch stops - failing the build. +**The better answer being pursued:** asking FAO to consent to that repository being made +public, which removes the need entirely. Its full history has been examined — no +credentials of any kind, no partner staff email addresses, and an MIT licence already in +place. Two items remain open: internal storage identifiers appear in about twenty files, +and whether the GAUL 2024 boundary data included there may be redistributed. -**The reverse case is not detected.** A private sibling that quietly becomes public will go on -being declared private, and nobody here will notice. That is stated rather than papered over; -it costs a stale `note` and an unnecessary skip, not a wrong result. +**Trigger for revisiting (ADR-014 §4):** issue the credential if FAO declines, or if a +**second** private sibling appears. **Owner:** the maintainer. -### §8 The credential for the one private sibling is deferred +--- -`views-faoapi` is private, and reaching it needs a credential. That is **not** done here. +## Consequences -The cost/benefit is thin: it is **one** test. It is, admittedly, the most valuable single one -— it checks that our delivery is filed under the name the consumer actually filters for, and -that failure produces *no error anywhere*: the upload succeeds, storage is paid for, and the -consumer's endpoint is simply empty. But one test does not justify a credential tied to one -person, with an expiry somebody must track, while a better answer is pending. +**What this buys.** Seven cross-repository checks move from *"run when someone happens to +run them"* to *"run on every change"*. Two of them fired in earnest the week this was +written. Separately, the scan that refuses configuration **values** in this public +repository's documentation now runs on every pull request rather than only on a +maintainer's machine — worth having, since the README carried four such values once. -**The better answer being pursued:** asking FAO to consent to that repository being made -public, which removes the need entirely. An audit of its full history found no credentials of -any kind and no partner staff email addresses; it already carries an MIT licence. Two items -remain open — internal storage identifiers appear in 21 tracked files, and whether GAUL 2024's -terms permit redistribution. +**What it costs.** §7: another repository can block merging here. And the list of siblings +is now something a contributor must keep in step with the workflow, enforced by tests they +may not have read. -**Named trigger (ADR-014 §4).** Issue the credential when **either**: FAO declines, or a -**second** private sibling appears. **Owner:** the maintainer. Should it ever be issued, the -scope floor is one repository, read-only, with a deliberately chosen expiry — the default of -30 days would put this repository back in the dark within a month, with a green build -throughout. +**Where this will go wrong first.** Someone debugging a red build marks a download step +"ignore failures", or moves it out of `_siblings/`. Both are rules for that reason. --- -## Consequences +## What was tried and removed -**What this buys.** Seven cross-repository checks move from *"run when a maintainer happens to -run them"* to *"run on every change"*. Two of them fired in anger in the week before this was -written. One test also changes character: the scan refusing registry **values** in this public -repository's markdown now runs on every pull request rather than only on a maintainer's -machine — worth having, since README.md carried four such values once already. +An earlier version of this design also recorded, for each sibling, whether it was +**public** and the date that was last checked — with a rule pairing visibility against +whether the download used a credential. -**What it costs.** CI now depends on two other repositories being fetchable, so an outage or a -visibility change there turns this repository's build red. That is the intended trade: a red -build is the honest signal, and §6 exists to make sure it is what happens. +A review found the pair circular. The `public` field was read by exactly one rule, and +that rule existed to protect the `public` field's verifiability. Nothing else consulted +either. Both were deleted on 2026-08-10 and no behaviour changed. The date field was worse +than useless: nothing could confirm the check had happened, so it manufactured confidence +rather than recording a fact. -**Where this will go wrong first.** Somebody debugging a red CI adds `continue-on-error` to a -sibling checkout, or a `token:` to make a rate limit go away. Either quietly dismantles §7. -Both are rules in `test_ci_sibling_coverage.py` for that reason. +Visibility now lives in a sibling's `note` — prose, where it belongs, because no test here +can verify it in any case. What still holds without the flag is simpler and needs no +field: a repository CI cannot read fails to download, and G5 keeps that failure loud. -**Second most likely.** A sibling checkout is added to a different workflow file and escapes a -rule scoped to `run_pytest.yml`. The declaration check scans every workflow, not one. +Recorded because the removed design is more tempting than it looks, and because this +document once argued for it. --- ## Alternatives considered -**Leave it, and rely on running the suite by hand.** The status quo, and what registers C-46 -and C-57 carried as an open residual for weeks. It works exactly as well as one person's -habits, which is not a property a safety check should have. +**Leave it and rely on running the suite by hand.** The status quo for weeks. It works +exactly as well as one person's habits, which is not a property a safety check should have. -**Vendor the facts instead of reading them.** Copy the registry edition into this repository -and check the copy. Rejected outright: copies of that registry were the platform's original -failure, and the standing rule is that it is referenced by pinned URL and never copied. A test -reading a local copy compares a thing to itself. +**Copy the facts here instead of reading them.** Rejected outright: copies of that +configuration registry were the platform's original failure, and the standing rule is that +it is referenced and never copied. A test reading a local copy compares a thing to itself. -**Check `public` against the GitHub API from a test.** Rejected. It would be the only network -call in the suite, would need a credential to answer for private repositories — the very thing -under discussion — and would be flaky in exactly the conditions where a green build matters. -Verification-by-doing (§7) is weaker but honest, and its limits are written down. +**Ask GitHub whether a repository is public, from a test.** Rejected. It would be the only +network call in the suite, would need a credential to answer for private repositories — +the very thing in question — and would be unreliable exactly when a green build matters. -**Issue the credential now and fetch all four siblings.** Rejected on the arithmetic in §8: it -buys one test, and a decision that may make it unnecessary is outstanding. +**Issue the credential now and download all four siblings.** Rejected on §8's arithmetic: +it buys one test, and a decision that may make it unnecessary is outstanding. --- -## Appendix — reproducing the measurement - -Counts drift as tests are added; the command does not. From the repository root: +## Appendix — checking it yourself ``` pytest -q -VIEWS_APPWRITE=/nonexistent pytest -q -rs ``` -The first, with the sibling repositories present, is the full suite. The second is what CI -would see without the checkout — and it must **fail**, not merely skip. That is §6. +with the sibling repositories present is the full suite. Then: + +``` +VIEWS_APPWRITE=/nonexistent pytest -q +``` + +is what CI would see without the download — and it must **fail**, not merely skip. That is +§6. diff --git a/tests/conftest.py b/tests/conftest.py index f9ec51b..45db926 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -65,27 +65,24 @@ class Sibling: """What this repository declares about one views-platform sibling (ADR-016). - Two of these fields are different kinds of thing and the distinction is the point. - ``public`` is a **fact about the world** that this repository does not control. - ``ci_checkout`` is a **decision** this repository makes. Conflating them is how the - workflow ended up asserting, in a comment, that ``views-appwrite`` was private for - days after it went public — and how seven checks stayed dark in CI for no reason. - - ``public_checked`` is the date the fact was last verified, and it is not decoration: - every measured claim in this repository carries one. A bare boolean is a fact with no - expiry, which is precisely what went wrong. - - **Keyword-only and frozen, deliberately.** Two adjacent booleans are a one-token slip - between "public, not checked out" and "private, checked out" — the second being the - combination rule G3 exists to forbid. A plain dict would let a missing ``ci_checkout`` - read as ``None``, silently exempting that sibling from every guard; that is the - failure mode this repository has registered more often than any other (C-47, C-57, - #211). Here the omission is a ``TypeError`` at import. + Two fields, and the second is the one that matters. ``ci_checkout`` says whether CI + fetches this repository; ``note`` says why not, whenever the answer is no. + + **An earlier version also carried ``public`` and ``public_checked``.** They were + removed on 2026-08-10 after a review found them circular: ``public`` was read by + exactly one rule, and that rule existed to protect ``public``'s verifiability. + Nothing else consulted either field, and deleting both changed no behaviour. Whether + a sibling is public is now simply part of ``note`` — prose, where it belongs, because + nothing here could verify it anyway. + + **Keyword-only and frozen.** A plain dict would let a missing ``ci_checkout`` read as + ``None`` and silently exempt that sibling from every rule; that is the failure this + repository has registered more often than any other. Here the omission is a + ``TypeError`` at import. **No validation in ``__post_init__``.** See ``broken_sibling_overrides`` below for what raising at import time costs: one typo became three collection errors and zero - tests run. The rules live in ``tests/test_ci_sibling_coverage.py``, where a violation - is one clean failure and the other four hundred tests still report. + tests run. The rules live in ``tests/test_ci_sibling_coverage.py``. """ #: The environment variable that overrides this sibling's location. Declared, never @@ -93,15 +90,10 @@ class Sibling: #: but a future sibling need not follow the pattern and guessing it would be the #: inference ADR-003 forbids. env: str - #: Visibility on GitHub — a fact about the world, not a decision of ours. - public: bool - #: ISO date ``public`` was last verified. See the class docstring. - public_checked: str - #: Whether CI checks this sibling out. A decision, and the reason for it belongs in - #: ``note`` whenever the answer is no. + #: Whether CI fetches this sibling. ci_checkout: bool - #: Why this sibling is not checked out. Required when ``ci_checkout`` is False, and - #: must name a record, so the non-coverage has an owner rather than a shrug. + #: Why not, when it is not fetched. Required in that case, and must name a record so + #: the non-coverage has an owner rather than a shrug. note: str = "" @@ -114,11 +106,9 @@ class Sibling: SIBLINGS = { "views-datafactory": Sibling( env="VIEWS_DATAFACTORY", - public=True, - public_checked="2026-08-10", ci_checkout=False, note=( - "public, but its checks need the producer's raw GAUL parquets " + "PUBLIC, but its checks need the producer's raw GAUL parquets " "(data/raw/gaul_admin/*.parquet), which are NOT in its git repository. " "Checking it out converts an honest skip into a FileNotFoundError — measured " "2026-08-03, tried and reverted. Closing this needs the data published " @@ -127,17 +117,14 @@ class Sibling: ), "views-appwrite": Sibling( env="VIEWS_APPWRITE", - public=True, - public_checked="2026-08-10", ci_checkout=True, ), "views-faoapi": Sibling( env="VIEWS_FAOAPI", - public=False, - public_checked="2026-08-10", ci_checkout=False, note=( - "the only private sibling. Checking it out needs a credential, which is an " + "PRIVATE — the only one, and that is why it is not fetched. Checking it " + "out needs a credential, which is an " "operator decision deferred pending a request to FAO to make the repository " "public. One check is dark meanwhile — the consumer-name pin, whose failure " "mode is a delivery nobody can find. See ADR-016 and register C-81." @@ -145,8 +132,6 @@ class Sibling: ), "views-crafdapi": Sibling( env="VIEWS_CRAFDAPI", - public=True, - public_checked="2026-08-10", ci_checkout=True, ), } diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index ce8c31c..dd0538a 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -10,14 +10,17 @@ once**, in `tests/conftest.py::SIBLINGS`, and this file fails when the workflow and that declaration disagree — in either direction. -**What verifies the `public` field, since no test here does.** Nothing in this suite -touches the network; that is a hard convention (`conftest`'s docstring: what is located -is a working copy on disk). `public` is verified by CI *doing it*: the default -`GITHUB_TOKEN` is scoped to this repository, so a tokenless checkout of a sibling -declared public fails the build if it is actually private. Rules G3 and G5 exist to keep -that true — one `token:` or one `continue-on-error:` and the claim silently stops being -checked. The reverse case (a private sibling quietly becoming public) is **not** detected -here, and ADR-016 §7 says so rather than implying coverage it does not have. +**Whether a sibling is public is not modelled here, and that is deliberate.** An earlier +version carried a `public` flag and a rule pairing it with the presence of a checkout +token. A review found the pair circular — the field was read by exactly one rule, and +that rule existed to protect the field's verifiability — so both were removed on +2026-08-10 without any change in behaviour. Visibility now lives in a sibling's `note`, +as prose, because nothing here could verify it in any case: no test in this suite touches +the network. + +What still holds without the flag: a checkout of a repository CI cannot read simply fails +the build, because the default `GITHUB_TOKEN` is scoped to this repository. Rule G5 keeps +that failure loud. **Every rule is a pure function of (workflow, siblings)**, so each can be run against a synthetic mutant rather than only against the real file. A guard that can only be @@ -28,7 +31,6 @@ from __future__ import annotations -import datetime as dt from pathlib import Path import pytest @@ -154,39 +156,6 @@ def _g2_every_checkout_is_declared( return problems -def _g3_tokens_match_visibility( - workflow: dict, siblings: dict[str, Sibling] -) -> list[str]: - """Private ⇒ must pass a token. Public ⇒ must NOT. - - The second half is the one that matters and the one that looks pointless. It is what - keeps ``public`` verified-by-doing: a tokenless checkout of a private repo fails the - build. Add a token to a public sibling — to dodge a rate limit, say — and the field - becomes an unchecked claim with nothing anywhere to catch it. - """ - problems = [] - for step in _sibling_steps(workflow): - name = _repo_name(step) - sibling = siblings.get(name) - if sibling is None: - continue # G2's business - has_token = "token" in step["with"] or "ssh-key" in step["with"] - if not sibling.public and not has_token: - problems.append( - f"{name}: declared private but checked out with no credential — the " - "step will fail. Either it is public now (update the declaration and its " - "date) or it needs a token." - ) - if sibling.public and has_token: - problems.append( - f"{name}: declared public but the checkout passes a credential. That " - "removes the only thing verifying the `public` field — a tokenless " - "checkout failing when a repo is private. Drop the token, or the claim " - "is unverified." - ) - return problems - - def _g4_non_coverage_is_explained( workflow: dict, siblings: dict[str, Sibling] ) -> list[str]: @@ -220,9 +189,10 @@ def _g5_no_step_swallows_its_own_failure( ) -> list[str]: """No sibling checkout may carry ``continue-on-error``. - One key, and the entire verification-by-doing argument becomes false: the checkout of - a repo that turned private fails, the build stays green, and the checks it enables - skip. It is also exactly the key someone reaches for while debugging a red CI. + One key and a failed fetch stops failing the build: the checks that sibling enables + go back to skipping, silently, on a green run. That is the state this whole file + exists to leave behind, and it is exactly the key someone reaches for while debugging + a red CI. """ return [ f"{_repo_name(step)}: sibling checkout carries continue-on-error, so a failed " @@ -266,58 +236,13 @@ def _g7_siblings_are_taken_from_main( ] -#: How far ahead of "today" a check date may sit before it is an error rather than a -#: clock difference. See `_g8_the_visibility_fact_carries_a_usable_date`. -_CLOCK_SKEW = dt.timedelta(days=1) - - -def _g8_the_visibility_fact_carries_a_usable_date( - workflow: dict, siblings: dict[str, Sibling] -) -> list[str]: - """``public_checked`` parses, and is not meaningfully in the future. - - **One day of slack, and it is not laziness — this rule failed CI on its first run.** - The dates were stamped from a maintainer's machine in CEST at 01:25 on 2026-08-10; - the runner was in UTC, where it was still 23:25 on 2026-08-09. A date recorded - truthfully today read as tomorrow two hours away, and the guard called it a lie. - - "Today" is not a fact a date alone determines — it depends on where the reader is — - so comparing a bare ISO date against `date.today()` is comparing two different - questions. A full day of tolerance covers every real timezone offset, and a date more - than a day ahead is still what this rule is for: a fact nobody actually checked. - - Recorded here rather than fixed silently, because the alternative repair a hurried - reader would reach for is deleting the rule (ADR-014 §3: when a guard cries wolf, - check the matching before the scope). - """ - problems = [] - horizon = dt.date.today() + _CLOCK_SKEW - for name, sibling in siblings.items(): - try: - checked = dt.date.fromisoformat(sibling.public_checked) - except ValueError: - problems.append( - f"{name}: public_checked={sibling.public_checked!r} is not an ISO date. " - "A fact with an unreadable date is a fact with no date." - ) - continue - if checked > horizon: - problems.append( - f"{name}: public_checked={sibling.public_checked} is more than a day " - "ahead of today — that is not clock skew, it is a date nobody checked." - ) - return problems - - _RULES = { "G1 declared checkouts are present and pointed at": _g1_declared_checkouts_are_present_and_pointed_at, "G2 every checkout is declared": _g2_every_checkout_is_declared, - "G3 tokens match visibility": _g3_tokens_match_visibility, "G4 non-coverage is explained": _g4_non_coverage_is_explained, "G5 no step swallows its own failure": _g5_no_step_swallows_its_own_failure, "G6 siblings land under the excluded path": _g6_siblings_land_under_the_excluded_path, "G7 siblings are taken from main": _g7_siblings_are_taken_from_main, - "G8 the visibility fact carries a usable date": _g8_the_visibility_fact_carries_a_usable_date, } @@ -437,34 +362,26 @@ def _replace(name: str, **changes) -> dict[str, Sibling]: _MUTANTS = [ - ("G1 declared checkouts are present and pointed at", - {"jobs": {"test": {"steps": [{"run": "poetry run pytest tests/", "env": {}}]}}}, - _ONE, "declared ci_checkout=True but the checkout step is gone"), - ("G2 every checkout is declared", _workflow(), {}, "checked out but undeclared"), - ("G3 tokens match visibility", - _workflow(**{"with": {"token": "${{ secrets.X }}"}}), _ONE, - "public sibling fetched with a credential — kills verification-by-doing"), - ("G4 non-coverage is explained", - _workflow(), _replace("views-appwrite", ci_checkout=False, note=""), - "not checked out and no note"), - ("G4 non-coverage is explained", - _workflow(), _replace("views-appwrite", ci_checkout=False, note="because reasons"), - "note explains but names no record"), - ("G5 no step swallows its own failure", - _workflow(**{"continue-on-error": True}), _ONE, - "a failed fetch would not fail the build"), - ("G6 siblings land under the excluded path", - _workflow(**{"with": {"path": "vendor/views-appwrite"}}), _ONE, - "outside _siblings/, so ruff would lint it"), - ("G7 siblings are taken from main", - _workflow(**{"with": {"ref": "development"}}), _ONE, - "takes a branch that is not the authority"), - ("G8 the visibility fact carries a usable date", - _workflow(), _replace("views-appwrite", public_checked="2099-01-01"), - "the fact was checked far in the future"), - ("G8 the visibility fact carries a usable date", - _workflow(), _replace("views-appwrite", public_checked="last tuesday"), - "unparseable date"), + ("G1 declared checkouts are present and pointed at", + {"jobs": {"test": {"steps": [{"run": "poetry run pytest tests/", "env": {}}]}}}, + _ONE, "declared ci_checkout=True but the checkout step is gone"), + ("G2 every checkout is declared", + _workflow(), {}, "checked out but undeclared"), + ("G4 non-coverage is explained", + _workflow(), _replace("views-appwrite", ci_checkout=False, note=""), + "not checked out and no note"), + ("G4 non-coverage is explained", + _workflow(), _replace("views-appwrite", ci_checkout=False, note="because reasons"), + "note explains but names no record"), + ("G5 no step swallows its own failure", + _workflow(**{"continue-on-error": True}), _ONE, + "a failed fetch would not fail the build"), + ("G6 siblings land under the excluded path", + _workflow(**{"with": {"path": "vendor/views-appwrite"}}), _ONE, + "outside _siblings/, so ruff would lint it"), + ("G7 siblings are taken from main", + _workflow(**{"with": {"ref": "development"}}), _ONE, + "takes a branch that is not the authority"), ] @@ -530,23 +447,3 @@ def test_the_repos_own_checkout_is_not_mistaken_for_a_sibling(): own_checkout_only = {"jobs": {"test": {"steps": [{"uses": "actions/checkout@v3"}]}}} assert not _g2_every_checkout_is_declared(own_checkout_only, SIBLINGS) assert not _sibling_steps(own_checkout_only) - - -def test_g8_tolerates_a_timezone_but_not_a_fiction(): - """The boundary either side of `_CLOCK_SKEW`, pinned. - - A rule with a tolerance needs its tolerance tested, or the number drifts into being - whatever made the last failure go away. Tomorrow is a clock difference; the day after - is a claim about a check that has not happened. - """ - today = dt.date.today() - for offset, should_object in ((0, False), (1, False), (2, True), (400, True)): - siblings = _replace( - "views-appwrite", - public_checked=(today + dt.timedelta(days=offset)).isoformat(), - ) - violations = _g8_the_visibility_fact_carries_a_usable_date(_workflow(), siblings) - assert bool(violations) is should_object, ( - f"a check date {offset} day(s) ahead of today: expected " - f"{'an objection' if should_object else 'no objection'}, got {violations}" - ) From fb10cfee3d0e1c81965995877d688c9718c15145 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 03:20:04 +0200 Subject: [PATCH 14/42] docs(adr): ADR-017, and a falsification pass over both ADRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-017 decides what this repository does about a fact held in a repository it cannot read. The rule: the fact is declared in the public coordinate registry, each side verifies itself against it, neither reads the other's source. No credential, for any number of private APIs. Written for the category, per the maintainer's brief that private consumer APIs are permanent even if views-faoapi itself goes public. ADR-016 rescoped to say plainly that it only covers repositories CI can download, with the private case handed to ADR-017 rather than deferred. A falsification pass over every section of both documents found seven defects, all fixed: - the heading said 'four sentences' over a body of three, after the rescope removed one - ADR-016 still cited a private register ID in a document meant to stand alone - 'Rejected on §8's arithmetic' pointed at arithmetic the rewrite had removed - 'sibling' was used seventeen times and never defined - ADR-017 makes ADR-016's views-crafdapi download pointless — that fetch serves exactly one test, and ADR-017 replaces it. Neither document said so; both now do - §2's ownership claim is evidenced by ADR-013 for the FAO partner only; CRAF'd is not mentioned there at all. Now admitted, and folded into the registry work - ADR-016 rejects 'copy the facts here' while ADR-017 keeps a copy. Reconciled: what is forbidden is a copy that is ALSO the thing checked against The fix for the fourth broke the first by landing between two sentences; caught on re-run. Suite 399 passed / 40 xfailed; ruff clean. Nothing merged. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 74 +++-- .../017_facts_across_a_private_boundary.md | 288 ++++++++++++++++++ docs/ADRs/README.md | 12 +- tests/conftest.py | 4 +- 4 files changed, 348 insertions(+), 30 deletions(-) create mode 100644 docs/ADRs/017_facts_across_a_private_boundary.md diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index 35b824f..2071597 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -1,22 +1,36 @@ -# ADR-016: CI checks out the sibling repositories our tests read +# ADR-016: Which sibling repositories CI downloads **Status:** Accepted **Date:** 2026-08-10 **Decider:** Simon Polichinel von der Maase +**Scope:** repositories CI **can** download — that is, public ones. What to do when a +repository **cannot** be downloaded is a different decision and is +[ADR-017](017_facts_across_a_private_boundary.md). **Related:** [ADR-014](014_claims_and_the_guards_that_carry_them.md) §2 (a guard nobody has watched fail is decoration) and §4 (a deferral names a trigger and an owner) --- -## The decision in four sentences +## The decision in three sentences A few of this repository's tests read **other** repositories to check that things we say about them are still true. Those tests only work when the other repository is on disk, so -they ran on a developer's laptop and skipped in CI. +they ran on a developer's laptop and skipped in CI. **CI now downloads the repositories +those tests need**, and a test fails if CI stops downloading one that the code says it +should. -**CI now downloads the repositories those tests need**, and a test fails if CI stops -downloading one that the code says it should. The single repository we cannot download — -because it is private — is named, with the reason, and a decision about it is deferred. +*Throughout, a "sibling" is another repository in the views-platform organisation that sits +beside this one in a developer's folder — never a dependency we install.* + +## What this document does not cover + +Downloading a repository requires being able to read it, so **everything here applies only +to repositories that are public.** A private one cannot be downloaded by our CI at all, +and no amount of workflow configuration changes that. + +That is not a gap in this decision; it is a different problem with a different answer, and +that answer is [ADR-017](017_facts_across_a_private_boundary.md). This document stops at +the boundary and says so, rather than implying a coverage it does not have. --- @@ -94,6 +108,12 @@ Each rule is a plain function, so each is also run against a deliberately broken to prove it objects. A rule only ever tried against a correct file is a rule nobody has watched fail. +**One of these downloads is temporary, and it is worth knowing which.** `views-crafdapi` is +fetched for exactly one test. [ADR-017](017_facts_across_a_private_boundary.md) §7 replaces +that test with one that reads a public declaration instead — at which point this download +buys nothing and should go. That is a decision this document cannot make on its own, which +is why it is recorded there and cross-referenced here. + ### §6 A repository CI expects but cannot find turns the build red Skipping is right on a laptop, where a missing sibling is normal. It is wrong in CI once @@ -123,30 +143,26 @@ It is accepted for three reasons: 3. There is an escape. The maintainer administers this repository and can merge over a failing check when something genuinely urgent is blocked. -This overrides an earlier recommendation in the risk register (**C-46**) not to couple -per-PR CI to another repository. That recommendation's stated objection was coupling to -another repository's *default branch*, which G7 removes. The remaining coupling is real, -and is the trade above. - -### §8 One repository stays out, and the decision about it is deferred +This overrides an earlier internal recommendation not to couple per-pull-request CI to +another repository at all. That recommendation's stated objection was coupling to another +repository's *default branch* — which G7 removes, by naming `main` explicitly instead of +accepting whatever default the other repository happens to be set to. The coupling that +remains is real, and is the trade described above. -`views-faoapi` is private. Downloading it needs a credential, and that is **not** done -here. +### §8 One repository stays out, and why that is not this document's problem -It buys **one** test. That one is admittedly the most valuable of the set — it checks our -delivery is filed under the name the consumer looks for, and when that is wrong nothing -raises an error anywhere: the upload succeeds, storage is paid for, and the consumer's -endpoint is simply empty. But one test does not justify a credential tied to one person, -with an expiry someone must remember, while a better answer is pending. +`views-faoapi` is private. Our CI cannot download it, so the one check that reads it does +not run here. -**The better answer being pursued:** asking FAO to consent to that repository being made -public, which removes the need entirely. Its full history has been examined — no -credentials of any kind, no partner staff email addresses, and an MIT licence already in -place. Two items remain open: internal storage identifiers appear in about twenty files, -and whether the GAUL 2024 boundary data included there may be redistributed. +**No credential is issued to work around that**, and that is a decision rather than an +omission — see [ADR-017](017_facts_across_a_private_boundary.md), which settles what this +repository does about facts held in repositories it cannot read. In short: the fact gets +declared somewhere public that both sides can read, so neither side needs access to the +other. -**Trigger for revisiting (ADR-014 §4):** issue the credential if FAO declines, or if a -**second** private sibling appears. **Owner:** the maintainer. +Until that declaration exists, the affected check runs on a maintainer's machine and not +in CI. That is stated in the sibling's `note`, which rule G4 requires and which must name +the record that owns it. --- @@ -201,8 +217,10 @@ it is referenced and never copied. A test reading a local copy compares a thing network call in the suite, would need a credential to answer for private repositories — the very thing in question — and would be unreliable exactly when a green build matters. -**Issue the credential now and download all four siblings.** Rejected on §8's arithmetic: -it buys one test, and a decision that may make it unnecessary is outstanding. +**Issue an access credential and download the private one too.** Rejected, and the +reasoning is [ADR-017](017_facts_across_a_private_boundary.md) §9 rather than anything here: +a credential is the wrong shape of answer to a standing category, and there is a route that +needs no credential at all. --- diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md new file mode 100644 index 0000000..443dd2b --- /dev/null +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -0,0 +1,288 @@ +# ADR-017: Facts shared with a repository we cannot read + +**Status:** Proposed +**Date:** 2026-08-10 +**Decider:** Simon Polichinel von der Maase +**Scope:** what this repository does when it depends on a fact held in a repository it +cannot download — today because that repository is private, and in principle for any +reason. The companion decision, [ADR-016](016_ci_read_access_to_private_siblings.md), +covers repositories we *can* download. +**Related:** [ADR-003](003_authority_of_declarations_over_inference.md) (declarations over +inference), [ADR-013](013_sampled_forecast_wire_contract.md) §4.1a (the field in question), +[ADR-014](014_claims_and_the_guards_that_carry_them.md) §1 and §4 + +--- + +## The decision in one sentence + +**A fact shared across a boundary we cannot see through is declared in a public place both +sides read, and neither side reads the other's source code.** + +--- + +## Context + +### §1 The failure this is about + +When this repository delivers forecasts, it uploads each file with a label — a store +document field called `name`. The consuming API filters every one of its queries on that +label. + +If our label and their filter ever stop matching: + +- the upload **succeeds**; +- the file is stored, and paid for; +- the consumer's endpoint returns **empty**; +- **nothing, anywhere, raises an error.** + +ADR-013 §4.1a states it plainly: a document uploaded under the wrong name is *"invisible to +the consumer, not merely degraded."* Every system involved reports success while the +partner receives nothing. + +This is the failure mode that most deserves an automatic check, precisely because no human +or machine downstream will notice it. + +### §2 Who owns the label, which is not what you would guess + +ADR-013 §4.1a also says: the name is **"Config-owned by views-faoapi; changing it is a +contract amendment, not a deploy detail."** + +So the consuming API owns the value. This repository holds a **copy** of it — the constant +`CONSUMER_DOCUMENT_NAME` in each partner's `product.py`, which is what we actually write +onto every upload. + +That direction matters for everything below. The question is not *"can we see into their +repository"*. The question is **"they own a fact, we keep a copy, and how do we know the +copy is still right?"** + +**One honest limit on that claim.** ADR-013 states the ownership only for the FAO API — its +table names *"the literal faoapi model name"*, and the CRAF'd partner arrived later and is +not mentioned there at all. So consumer-ownership is written down for one partner and +merely assumed for the other. §7 treats them the same, which is almost certainly right, but +"almost certainly" is not a citation. **Settling that is part of the registry work**: a +declaration naming both partners makes the ownership explicit for both, which is a second, +smaller reason to do it. + +### §3 How we check it today, and why that stops working + +The existing check opens the consumer's source, finds the name their API filters on, and +compares it with our copy. + +That works when the consumer's code is on the same machine — true on a maintainer's laptop, +where all the platform repositories sit in one folder. It is impossible in continuous +integration when the consumer is private, because a CI job cannot download a private +repository without a credential. + +So the check runs when someone happens to run it, and not on the change that would break +it. + +### §4 Why this is a standing category and not a one-off + +Two consuming APIs exist. `views-crafdapi` is public; `views-faoapi` is private. Across the +whole organisation, **15 of 33 repositories are private** — private is ordinary, not +exceptional. + +The maintainer's instruction is explicit: `views-faoapi` may or may not become public one +day, but **there will be one or more private APIs at any given time**. So this document +must decide for the category, not for today's instance. + +--- + +## Decision + +### §5 The rule + +> **A fact shared across a public/private boundary is declared in the public contract +> surface. Each side verifies itself against that declaration. Neither side reads the +> other's source code.** + +Concretely, for the delivery label: + +1. The value is declared once, in the platform's public coordinate registry + (`views-appwrite`, `docs/ADRs/platform/coordinate_registry.toml`). +2. **This repository checks its copy against that declaration.** No access to the consumer + is required, so the check runs in CI on the change that could break it. +3. **The consuming API checks its own code against the same declaration.** It needs no + access to us either — this repository is public — so that half is free for it too. + +**"But you rejected copying."** [ADR-016](016_ci_read_access_to_private_siblings.md) turns +down keeping a local copy of the shared registry, on the grounds that a test reading a copy +compares a thing to itself. This is not that, and the difference is the whole point. + +We keep a copy of the label because we have to — it is written onto every upload, so it +must exist in our code. What ADR-016 forbids is a copy that is *also the thing we check +against*. Here the copy is checked against an authority held elsewhere, which is what makes +the check mean something. A copy nobody verifies is the failure; a copy verified against a +declared authority is just a value with a source. + +### §6 Why the coordinate registry, rather than somewhere new + +Four reasons, and the fourth is the one that makes this cheap: + +- **It is the right kind of place.** The registry already holds cross-repository facts + about the delivery seam, already distinguishes secret entries from non-secret ones, and + already records which repository consumes what. +- **It is public**, so a private consumer can read it without anyone granting anything. +- **It has a change process** and an amendment log, so a change to the label is a recorded + contract amendment — which is what ADR-013 §4.1a already says it should be. +- **This repository's CI already downloads and reads that file on every run.** Adding this + check costs no new machinery, no credential, and no new dependency. + +A label is not a secret. Knowing that documents are named `un_fao` grants no access to +anything; access is controlled by keys held elsewhere. So this belongs with the non-secret +coordinates and must never be placed among the secret entries. + +### §7 The rule applies to both partners, not only the private one + +`views-crafdapi` is public, so our check *can* read its source, and does today. It will +move to the registry anyway. + +The reason is not tidiness. If only the private case moves, the platform ends up with two +mechanisms doing one job — one reading a declaration, one reading source code — and a +future contributor has to know which partner uses which. One mechanism, applied uniformly, +is the point of having a rule at all. + +It also means this repository stops reading any consumer's source, which removes a +dependency on another repository's *file layout* — something we were never entitled to +depend on. + +**And it retires a download.** [ADR-016](016_ci_read_access_to_private_siblings.md) has CI +fetch `views-crafdapi`, and measurement shows that fetch serves **exactly one test** — this +one. Once the check reads the registry instead, that download buys nothing, and +`views-crafdapi` should be marked as not fetched, with a note saying why. Stated here +because a download that has quietly stopped earning its place is how the previous version +of all this went wrong, and because ADR-016 cannot know it: the decision that obsoletes it +is this one. + +### §8 What this does **not** verify, stated plainly + +This checks **our copy against the declaration**. It does **not** check the consumer's code +against the declaration. + +So if the registry says `un_fao`, and we write `un_fao`, and the consuming API quietly +starts filtering on something else — our check passes, and the delivery is still invisible. + +That half is genuinely the consumer's to verify, and cheap for it (see §5.3). But it is +outside this repository's control, and there is a case where it may never exist: **a +private API operated by a third party**, who has no obligation to run any test of ours. + +**In that case the fact is not verifiable from here, and must be recorded as such** — an +accepted blind spot with a named owner, not a check that quietly covers nothing. Pretending +otherwise would be the exact failure ADR-014 §1 exists to prevent. + +### §9 What is deliberately not built + +- **No access credential** — no personal access token, no machine account, no GitHub App. + A credential solves one repository at a time and must be maintained forever, for a + category that grows. It is the wrong shape of answer to a standing problem, and it puts a + rotation obligation on one person. +- **No moving our checks into another repository's test suite.** It looks attractive — + the private side can read us for free — but it fails on its own extension case: a + third-party-operated API will not run our tests, and we would have no lever. A rule that + breaks on its second instance is not a rule. +- **No new file, service, or endpoint.** If a fact does not fit the existing registry, that + is information about the fact, not a reason to build a second surface. +- **No abstraction over "how to reach a private repository."** There is one such + repository, and the decision above is that we do not reach it. + +### §10 When to revisit + +- **A shared fact that genuinely cannot go in the registry** — for instance one that is + itself sensitive. Then this rule has met a case it does not cover, and the case is the + evidence needed to decide the next thing. +- **A third-party-operated private API becomes a real consumer**, making §8's blind spot + concrete rather than hypothetical. +- **The registry stops being public**, which would invalidate §6 entirely. + +**Owner:** the maintainer. None of these is a deadline; each is an event. + +--- + +## Consequences + +**What this buys.** The check that guards the invisible failure runs automatically, on the +change that could cause it, for every partner — with no credential, now or ever, however +many private APIs appear. Adding partner number three costs one registry row. + +**What it costs.** A change to a delivery label now requires editing a file in a third +repository. That is slower than editing a constant here, and deliberately so: ADR-013 +already calls this a contract amendment rather than a deploy detail, and this makes the +process match the words. + +**A dependency moves rather than disappearing.** We stop depending on the consumer's file +layout and start depending on the registry. That is a better dependency — declared, +versioned, public, with a change process — but it is not nothing, and if the registry +becomes unreliable this check inherits that. + +**Where this will go wrong first.** Someone changes the label in the consuming API and not +in the registry. Our check keeps passing, because our copy still matches the declaration, +and the delivery goes invisible exactly as before. **That is §8, and it is the whole +residual risk of this design.** It is why the consumer-side check is part of the rule and +not an optional extra. + +--- + +## Alternatives considered + +### Issue an access credential so our CI can read the private repository + +**Rejected.** It works, and it keeps both halves of the check in one place — genuinely its +strongest property. + +But the brief is a permanent category. A token is tied to a person, expires, and must be +re-scoped for each new private repository; a GitHub App is a substantially larger piece of +infrastructure to own. Either way the cost recurs per repository and per rotation, forever, +to verify a handful of short strings. And when it lapses, the checks skip and the build +stays green — the exact failure the whole of ADR-016 was written to end. + +### Move our check into the private repository's test suite + +**Rejected**, though it was the leading candidate for some time. + +The private repository can read this public one for free, so the check would cost no +credential and would fire in a real pipeline. Attractive, and correct today, when one +person maintains both. + +It fails on the case that defines the category. A private API operated by a **third party** +will not run a conformance test on our behalf, and we would have no way to require it. The +rule would then hold only for repositories we happen to operate — which is not a rule about +private APIs, it is a rule about our own repositories wearing a general-sounding name. + +### Accept the gap and check nothing + +**Rejected as the destination, though it is the honest description of where we are until +the registry entry lands.** The failure it leaves uncovered is the invisible one, and this +repository has already delivered an artifact nobody could find once. + +Worth stating because it is the fallback if the registry route stalls: the position would +then be *"this is not verified, here is the failure mode, here is who owns it"* — an +accepted blind spot, recorded. Never a check that appears to cover it and does not. + +--- + +## Appendix A — the facts behind §4 + +Measured 2026-08-10. Counts drift; the commands do not. + +``` +gh repo list views-platform --limit 60 --json name,visibility +``` +returned 33 repositories, 15 of them private. + +Consuming APIs: `views-crafdapi` public, `views-faoapi` private. `views-productionapi` and +`views-publicapi` are named in platform records but do not exist yet, so no default +visibility can be inferred for future APIs. + +## Appendix B — the work this decision requires, and in what order + +1. **views-appwrite** — declare the label for each partner in the coordinate registry. + Filed as views-appwrite#75. Nothing here can proceed before it. +2. **views-postprocessing** — switch the check to read the registry rather than the + consumer's source, for both partners. Ours, blocked on step 1. **In the same change**, + stop fetching `views-crafdapi` in CI: §7 shows that fetch then serves nothing, and + ADR-016's rules require a sibling that is not fetched to say why. +3. **views-faoapi** — verify its own served label against the declaration; §8's other half. + Filed as views-faoapi#379. + +Step 3 is not a precondition for steps 1 and 2, and the sequence is safe to interrupt: the +existing check keeps running on a maintainer's machine throughout. diff --git a/docs/ADRs/README.md b/docs/ADRs/README.md index d866caf..b8fd52b 100644 --- a/docs/ADRs/README.md +++ b/docs/ADRs/README.md @@ -94,7 +94,7 @@ These ADRs form the architectural constitution of the repository. demand and supply — under which it is revisited. Arises from #146 and the þing-02 ratification, which asked that the reasoning live here rather than in an issue. -- **ADR-016** — Cross-Repository Checks in CI, and How Siblings Declare Their Visibility +- **ADR-016** — Which Sibling Repositories CI Downloads A handful of tests here verify claims this repo makes about *other* repos — chiefly that the coordinate-registry edition we pinned is the one that exists. They need the sibling on disk, so they skipped in CI and ran only on a laptop. Each sibling is now declared with its @@ -104,6 +104,16 @@ These ADRs form the architectural constitution of the repository. for a dated declaration rather than prose. The credential for the one genuinely private sibling is deferred with a named trigger. +- **ADR-017** — Facts Shared With a Repository We Cannot Read + The delivery label this repository writes is owned by the consuming API and mirrored here; + if the two drift the upload succeeds, the file is stored, and the consumer's endpoint is + empty with no error anywhere. Verifying the mirror currently means reading the consumer's + source, which is impossible in CI when that consumer is private — and private consumer APIs + are a standing category, not a one-off. Decides that such a fact is declared in the public + coordinate registry and each side verifies itself against it, so neither reads the other's + source. No credential, for any number of private APIs. States plainly the half it does not + cover: the consumer's own code against its own declaration. + ADRs numbered 010 and above define: - Domain-specific decisions diff --git a/tests/conftest.py b/tests/conftest.py index 45db926..b29d9e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -127,7 +127,9 @@ class Sibling: "out needs a credential, which is an " "operator decision deferred pending a request to FAO to make the repository " "public. One check is dark meanwhile — the consumer-name pin, whose failure " - "mode is a delivery nobody can find. See ADR-016 and register C-81." + "mode is a delivery nobody can find. ADR-017 decides what happens instead " + "of a credential: the fact moves to the public coordinate registry, and " + "neither side reads the other. See also register C-81." ), ), "views-crafdapi": Sibling( From 169b97696d71f3dc2f41e3e335a15e0c225bf932 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 03:48:17 +0200 Subject: [PATCH 15/42] =?UTF-8?q?docs(adr):=20second=20falsification=20pas?= =?UTF-8?q?s=20=E2=80=94=20three=20more=20defects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New probes, not a re-run of the first pass. Three findings, all fixed: - ADR-016 is Accepted and asserted the outcome of ADR-017, which is Proposed. It said ADR-017 'settles' the private case and that its §7 'replaces' a test. An accepted decision cannot depend on an unaccepted one. Softened to conditional throughout, and the scope line now states plainly that nothing in ADR-016 depends on the proposal being adopted — if it is rejected, the private case returns as an open question. - §5's rule table runs G1, G2, G4, G5, G6, G7. Two numbers are missing and nothing said why. The removed-design section now names G3 and G8 as the removed rules and records that survivors keep their original names rather than being renumbered. - '(see §5.3)' reads as a subsection that does not exist — the ADR's own convention is §N, and §4.1a elsewhere refers to a real ADR-013 subsection. Rewritten as prose. Probes that failed to falsify, recorded because a passed probe is evidence: every factual claim verified (15 of 33 repositories private; the ADR-013 quote is verbatim; the registry does carry consumer fields; CI does read it), and the label/name/CONSUMER_DOCUMENT_NAME terminology is defined on first use and consistent thereafter. All ten findings from both passes re-checked together; no regressions. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 28 ++++++++++++------- .../017_facts_across_a_private_boundary.md | 2 +- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index 2071597..4e90b15 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -4,8 +4,10 @@ **Date:** 2026-08-10 **Decider:** Simon Polichinel von der Maase **Scope:** repositories CI **can** download — that is, public ones. What to do when a -repository **cannot** be downloaded is a different decision and is -[ADR-017](017_facts_across_a_private_boundary.md). +repository **cannot** be downloaded is a different decision, taken in +[ADR-017](017_facts_across_a_private_boundary.md) — **which is Proposed, not yet accepted.** +Nothing below depends on that proposal being adopted: if it is rejected, the private case +returns here as an open question, and the only thing this document loses is a pointer. **Related:** [ADR-014](014_claims_and_the_guards_that_carry_them.md) §2 (a guard nobody has watched fail is decoration) and §4 (a deferral names a trigger and an owner) @@ -109,9 +111,9 @@ to prove it objects. A rule only ever tried against a correct file is a rule nob watched fail. **One of these downloads is temporary, and it is worth knowing which.** `views-crafdapi` is -fetched for exactly one test. [ADR-017](017_facts_across_a_private_boundary.md) §7 replaces -that test with one that reads a public declaration instead — at which point this download -buys nothing and should go. That is a decision this document cannot make on its own, which +fetched for exactly one test. [ADR-017](017_facts_across_a_private_boundary.md) §7 proposes +replacing that test with one that reads a public declaration instead — at which point this +download would buy nothing and should go. That is a decision this document cannot make on its own, which is why it is recorded there and cross-referenced here. ### §6 A repository CI expects but cannot find turns the build red @@ -155,10 +157,11 @@ remains is real, and is the trade described above. not run here. **No credential is issued to work around that**, and that is a decision rather than an -omission — see [ADR-017](017_facts_across_a_private_boundary.md), which settles what this -repository does about facts held in repositories it cannot read. In short: the fact gets -declared somewhere public that both sides can read, so neither side needs access to the -other. +omission. Where that question *is* decided is +[ADR-017](017_facts_across_a_private_boundary.md), currently proposed: it argues the fact +should be declared somewhere public that both sides can read, so that neither needs access +to the other. Should that proposal not be adopted, the position here reverts to a stated +gap with a named owner — never a check that appears to cover it and does not. Until that declaration exists, the affected check runs on a maintainer's machine and not in CI. That is stated in the sibling's `note`, which rule G4 requires and which must name @@ -190,7 +193,12 @@ An earlier version of this design also recorded, for each sibling, whether it wa whether the download used a credential. A review found the pair circular. The `public` field was read by exactly one rule, and -that rule existed to protect the `public` field's verifiability. Nothing else consulted +that rule existed to protect the `public` field's verifiability. + +**This is why §5's table runs G1, G2, G4, G5, G6, G7 with two numbers missing.** The +removed rules were **G3** (visibility must match whether a credential was used) and **G8** +(the date must be a plausible one). The remaining rules keep their original names rather +than being renumbered, so that anything written about "G6" still means G6. Nothing else consulted either. Both were deleted on 2026-08-10 and no behaviour changed. The date field was worse than useless: nothing could confirm the check had happened, so it manufactured confidence rather than recording a fact. diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index 443dd2b..6b1d7a7 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -162,7 +162,7 @@ against the declaration. So if the registry says `un_fao`, and we write `un_fao`, and the consuming API quietly starts filtering on something else — our check passes, and the delivery is still invisible. -That half is genuinely the consumer's to verify, and cheap for it (see §5.3). But it is +That half is genuinely the consumer's to verify, and cheap for it — it is the third item in §5's list, and needs no access to us because this repository is public. But it is outside this repository's control, and there is a case where it may never exist: **a private API operated by a third party**, who has no obligation to run any test of ours. From a837c634c5f74440d8b7a022cade8da86c9942d7 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 03:56:43 +0200 Subject: [PATCH 16/42] docs(adr): accept 016 and 017, and fix two findings from the diff review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ADRs accepted. ADR-017 moves Proposed -> Accepted, and ADR-016's conditional language about it resolves with it: the scope line no longer hedges on whether the proposal is adopted, and §8 now says the decision is accepted but not yet implemented, because it waits on a declaration landing in another repository's registry. /review-diff over the branch found two, both prose, both this repo's named defect class: - a docstring in test_ci_sibling_coverage said "eight rules find nothing to complain about" after the cut left six. Removed the count rather than correcting it — a number nobody checks is a claim with a half-life, which is C-82's whole finding. - ADR-017 §5 stated in the present tense that this repository checks its copy against the registry declaration. It does not: test_product.py still reads the consumer's source and will until the declaration exists. Appendix B and ADR-016 §8 both recorded it as pending, so the documents were honest taken together, but §5 read alone stated unbuilt work as fact. Now says plainly that none of the three steps is in place and that the present tense describes the decided end state. Probes that found nothing, recorded because a passed probe is evidence: no orphaned reference to the deleted `public`, `public_checked`, G3, G8 or the clock-skew constant anywhere in tests, docs or workflows; broken_sibling_overrides verified against unset, empty, whitespace-only, bad-path and valid inputs with the docstring matching each; all six rules carry a mutant and each mutant fires its own rule. Suite 399 passed / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 21 ++++++++++--------- .../017_facts_across_a_private_boundary.md | 8 ++++++- tests/test_ci_sibling_coverage.py | 2 +- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index 4e90b15..83dcf4d 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -5,9 +5,7 @@ **Decider:** Simon Polichinel von der Maase **Scope:** repositories CI **can** download — that is, public ones. What to do when a repository **cannot** be downloaded is a different decision, taken in -[ADR-017](017_facts_across_a_private_boundary.md) — **which is Proposed, not yet accepted.** -Nothing below depends on that proposal being adopted: if it is rejected, the private case -returns here as an open question, and the only thing this document loses is a pointer. +[ADR-017](017_facts_across_a_private_boundary.md) and accepted alongside this one. **Related:** [ADR-014](014_claims_and_the_guards_that_carry_them.md) §2 (a guard nobody has watched fail is decoration) and §4 (a deferral names a trigger and an owner) @@ -111,9 +109,10 @@ to prove it objects. A rule only ever tried against a correct file is a rule nob watched fail. **One of these downloads is temporary, and it is worth knowing which.** `views-crafdapi` is -fetched for exactly one test. [ADR-017](017_facts_across_a_private_boundary.md) §7 proposes -replacing that test with one that reads a public declaration instead — at which point this -download would buy nothing and should go. That is a decision this document cannot make on its own, which +fetched for exactly one test. [ADR-017](017_facts_across_a_private_boundary.md) §7 replaces +that test with one that reads a public declaration instead — at which point this download +buys nothing and goes. That has been decided and is not yet done; it waits on the +declaration landing in the registry. That is a decision this document cannot make on its own, which is why it is recorded there and cross-referenced here. ### §6 A repository CI expects but cannot find turns the build red @@ -158,10 +157,12 @@ not run here. **No credential is issued to work around that**, and that is a decision rather than an omission. Where that question *is* decided is -[ADR-017](017_facts_across_a_private_boundary.md), currently proposed: it argues the fact -should be declared somewhere public that both sides can read, so that neither needs access -to the other. Should that proposal not be adopted, the position here reverts to a stated -gap with a named owner — never a check that appears to cover it and does not. +[ADR-017](017_facts_across_a_private_boundary.md): the fact gets declared somewhere public +that both sides can read, so that neither needs access to the other. + +**That decision is accepted but not yet implemented** — it depends on a declaration being +added to the platform registry, which is another repository's work. Until then the affected +check runs on a maintainer's machine and not in CI, and the sibling's `note` says so. Until that declaration exists, the affected check runs on a maintainer's machine and not in CI. That is stated in the sibling's `note`, which rule G4 requires and which must name diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index 6b1d7a7..e7d8bb0 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -1,6 +1,6 @@ # ADR-017: Facts shared with a repository we cannot read -**Status:** Proposed +**Status:** Accepted **Date:** 2026-08-10 **Decider:** Simon Polichinel von der Maase **Scope:** what this repository does when it depends on a fact held in a repository it @@ -105,6 +105,12 @@ Concretely, for the delivery label: 3. **The consuming API checks its own code against the same declaration.** It needs no access to us either — this repository is public — so that half is free for it too. +**None of those three is in place yet**, and the present tense above describes the decided +end state rather than today's behaviour. Today the check still reads the consumer's source +and still skips in CI. The order the three land in, and what is blocked on what, is +Appendix B. Said here because a decision record that reads as a description of the code is +how this repository has repeatedly ended up believing work was done. + **"But you rejected copying."** [ADR-016](016_ci_read_access_to_private_siblings.md) turns down keeping a local copy of the shared registry, on the grounds that a test reading a copy compares a thing to itself. This is not that, and the difference is the whole point. diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index dd0538a..10b2785 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -253,7 +253,7 @@ def test_the_workflow_this_file_scans_actually_parses(): """A YAML restructure would empty every scan below and report success. This is by far the most likely way this file becomes decoration: rename the job, nest - `steps` differently, move the workflow, and eight rules find nothing to complain + `steps` differently, move the workflow, and every rule below finds nothing to complain about. So assert there is something to scan before trusting that there was nothing wrong. """ From 0a71c054b456843f42dbcd88e26487f651894ff7 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 04:18:27 +0200 Subject: [PATCH 17/42] docs(adr): address four external reviews of ADR-016 and ADR-017 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviews arrived from the views-appwrite seat (#231, #232) and the views-faoapi/crafdapi seat (#233, #234). Both accepted; both found real defects. Two independently found the same one. VERIFIED BEFORE ACTING, since two reviews turned on it: protect_main [active] bypass_actors: NONE branches/main/protection -> 404 Branch not protected So ADR-016 §7's third justification — "the maintainer can merge over a failing check" — was false. A ruleset applies to everyone except the actors it names, and it names none. Withdrawn in place rather than deleted: the section now says there is no escape hatch, that adding a bypass actor is a console change nobody has made, and that such an override would in any case be one person's judgement available only while that person is. The coupling stands on the two reasons that survive. ADR-016: - §7a: the coupling RATE, from views-appwrite's own measurement — five registry editions in four days, four observation-only, each of which would have blocked a release. "The fix is minutes" reads differently at once a quarter than once a day. - §7b: drift tripwires (movement is the signal) versus reachability checks (movement is noise), so a reader knows which coupling they are accepting where. - G7's row is now a dated observation rather than a standing fact about someone else's default branch — a rule that stays right while its explanation rots is this document's own diagnosis. CODE, because one finding was a hole rather than a wording problem: - G4 fired only on ci_checkout=False, so the note recording that the crafdapi fetch is TEMPORARY was enforced by nothing — prose about another repo that nothing can check, which is precisely §3's failure, reintroduced for the one download the ADR calls temporary. G4 now follows the note, not the flag: any note must name a record. - Both fetched siblings gained one. crafdapi's records its temporariness and names ADR-017 §7; appwrite's records that its fetch is load-bearing and that pruning it would silently disable two checks. Mutation-proven both ways. ADR-017: - The transitional window, and the sharpest finding in the set: after the registry row lands and we switch to reading it, but before the consumer-side check exists, the green build proves only that two values this platform authored agree with each other — and the check that consulted real source would by then be deleted. Sequence now constrained: adding the registry check does not remove the source-reading one; that goes only when the consumer-side check lands. - crafd had no third leg filed and its label value is not decided yet. views-crafdapi#39 filed; Appendix B gains it as step 4, records that the CRAF'd registry row is blocked on that partner's data contract, and asks for the FAO half to land independently. - §5 no longer pre-claims views-appwrite's assent to hosting the declaration. - The consumer's check is free because the REGISTRY is public, not because this repository is — the consumer never reads us at all, which is the symmetry the rule is about. - Edition skew stated rather than assumed. - §9 scoped to declarable facts; behavioural agreement needs declared semantics instead, with views-models#327 / D-05 as the live instance. DECLINED: an interim tripwire failing CI if the manual check goes stale. The sequencing fix removes its premise — the source-reading check now survives until the consumer-side one exists — and a dated assertion needing periodic refresh is a guard whose likely first firing is a non-event, which is what G8 did in this very file before being deleted. Suite 399 passed / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 64 +++++++++++++-- .../017_facts_across_a_private_boundary.md | 78 +++++++++++++++++-- tests/conftest.py | 13 ++++ tests/test_ci_sibling_coverage.py | 36 ++++++--- 4 files changed, 164 insertions(+), 27 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index 83dcf4d..ef27514 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -99,10 +99,10 @@ Six rules, and each exists because of something that has actually gone wrong: |---|---|---| | G1 | a listed repository is downloaded, and the tests are pointed at it | the tests looked in the wrong place and skipped silently | | G2 | anything CI downloads appears in the list | the list is the thing people read; CI is not | -| G4 | a repository we skip says why, naming a record | silent non-coverage reads as "nothing to see here" | +| G4 | a repository we skip says why, and **every** note names a record | silent non-coverage reads as "nothing to see here" — and a note nothing points at is the prose this file replaces, whether it explains an exclusion or a temporary inclusion | | G5 | a download step may not be marked "ignore failures" | one setting and a failed download stops failing the build | | G6 | downloads land in `_siblings/` | a sibling put elsewhere made the linter report 745 errors in someone else's code | -| G7 | download the sibling's `main` branch | without it you get *their* default branch — which for `views-appwrite` is `development`, not `main` | +| G7 | download the sibling's `main` branch | a sibling's default branch is not ours to rely on — views-appwrite's was `development` as of 2026-08-10, and is theirs to change again | Each rule is a plain function, so each is also run against a deliberately broken example to prove it objects. A rule only ever tried against a correct file is a rule nobody has @@ -135,17 +135,65 @@ registry moves again — **this repository's builds go red and merges are blocke someone updates the pin.** Since merging to `main` here *is* the release to FAO, that matters. -It is accepted for three reasons: +It is accepted for two reasons: 1. The problem being fixed was that these checks were **invisible**. A check that reports but cannot block is invisible again, just more politely. 2. A red build in that situation is *correct*. It says "re-pin before you ship", and the - fix is minutes. -3. There is an escape. The maintainer administers this repository and can merge over a - failing check when something genuinely urgent is blocked. + fix is a small edit rather than an investigation. + +**A third reason was offered and withdrawn, because it was not true.** An earlier draft +said the maintainer administers this repository and can therefore merge over a failing +check when something is genuinely urgent. Two external reviewers challenged it, and it does +not survive measurement: the `protect_main` ruleset lists **zero bypass actors**, and a +ruleset applies to everyone except the actors named there — so administrator status confers +no exemption. There is no classic branch protection either, hence no `enforce_admins` route. + +So **there is currently no escape hatch**, and the coupling here is accepted without one. +That is defensible — the two reasons above stand on their own — but it should be a chosen +position rather than a surprise on the day it matters. Adding a bypass actor is a console +change and would restore the third reason; it has not been made. And an override of that +kind would be one person's judgement, available only while that person is — the same +habit-dependence this document criticises in its own alternatives. + +### §7a How often this actually bites + +*"A small edit"* reads differently at once a quarter than at once a day, so the rate +belongs here rather than in a reader's imagination. + +views-appwrite reports its registry moved through **five editions in four days** — v1.4.0 +on 2026-08-02 through v1.4.4 on 2026-08-05. **Four of the five were observation-driven**, +recording what a console showed or correcting a key's scopes, and carried no obligation for +any consumer. Under this section each would have reddened this repository and blocked a +release until someone re-pinned. + +That is not an argument against the decision; it is the honest size of it. It also points +at a better shape, which that repository has volunteered to make usable: pin against the +contract **version** and treat observation-only bumps as non-blocking, rather than pinning +a commit and blocking on every edit — the upstream amendment log already marks which bumps +carry obligations. **Not adopted here**, because it needs the upstream side first and this +document should not decide another repository's format. + +### §7b Two kinds of coupling, and only one is a tripwire + +A reader could take G7 as *"always track the moving tip of `main`"*, which would sit oddly +beside the rest of the platform, where consumers pin the seam contract by tag and never by +branch. Both live here, and they answer different questions: + +- **Drift tripwires** read the sibling's `main` *because* movement is the signal. The + registry-edition check is one: if the registry moved and our pin did not, we want to know, + and a red build is the entire point. +- **Reachability checks** verify that a *pinned* commit is an ancestor of the sibling's + `main`. Here movement is noise; what matters is that what we pinned was ratified rather + than taken from someone's unmerged branch. + +G7 makes both read `main` rather than a default branch. It does not make either of them +track the tip for its own sake. -This overrides an earlier internal recommendation not to couple per-pull-request CI to -another repository at all. That recommendation's stated objection was coupling to another +--- + +This section overrides an earlier internal recommendation not to couple per-pull-request CI +to another repository at all. That recommendation's stated objection was coupling to another repository's *default branch* — which G7 removes, by naming `main` explicitly instead of accepting whatever default the other repository happens to be set to. The coupling that remains is real, and is the trade described above. diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index e7d8bb0..fd85af5 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -99,14 +99,47 @@ must decide for the category, not for today's instance. Concretely, for the delivery label: 1. The value is declared once, in the platform's public coordinate registry - (`views-appwrite`, `docs/ADRs/platform/coordinate_registry.toml`). + (`views-appwrite`, `docs/ADRs/platform/coordinate_registry.toml`) — **subject to + views-appwrite accepting the registry as the home; the request is views-appwrite#75.** + A store-document name is a contract fact rather than a coordinate, so admitting it + widens that registry's charter, and that is not this repository's call to make. (That + seat has since reviewed this document and agreed to implement it. The wording stands + anyway: the decision should rest on its own reasoning, not on assent presumed in + advance.) 2. **This repository checks its copy against that declaration.** No access to the consumer is required, so the check runs in CI on the change that could break it. 3. **The consuming API checks its own code against the same declaration.** It needs no - access to us either — this repository is public — so that half is free for it too. + access to us either — **because the registry is public**, not because this repository + is. The consumer never reads us at all, which is the symmetry the rule is really about: + neither side reads the other; both read a public third place. + +### The order these land in is part of the decision, not an afterthought **None of those three is in place yet**, and the present tense above describes the decided -end state rather than today's behaviour. Today the check still reads the consumer's source +end state rather than today's behaviour. + +**The obvious sequence has a hole, and it is green.** If step 1 lands, then step 2 replaces +the source-reading check with a registry read, and step 3 has not happened yet, the state +is: the registry declares a string a human typed; we check our copy against that string and +pass; the consumer checks nothing; and the check that *did* consult the consumer's real +source has been deleted. The build is green and what it proves is that **two values this +platform authored agree with each other.** That is strictly weaker than today, and it is +precisely the objection ADR-016 raises against copies — during that window the registry is +not an authority, it is a third copy, and nothing closes the loop. + +So the sequence is constrained: **step 2 adds the registry check but does not remove the +source-reading one. The source-reading check is removed only when step 3 lands**, and until +then it keeps running wherever it can — in CI for the public partner, on a maintainer's +machine for the private one. Slightly redundant for a while, and redundancy is the correct +price for not having a window where the only thing verifying a delivery label is our own +typing. + +**One assumption worth stating rather than relying on.** The registry is versioned, so the +two sides could in principle read it at different editions and both pass while disagreeing. +That is not a live risk here because the label is contract-immutable — changing it is an +amendment, which produces a new edition both sides re-pin to, and our pin's reachability is +already checked. Recorded because an unstated assumption carrying a silent-failure mode is +what §1 is about. Today the check still reads the consumer's source and still skips in CI. The order the three land in, and what is blocked on what, is Appendix B. Said here because a decision record that reads as a description of the code is how this repository has repeatedly ended up believing work was done. @@ -191,6 +224,19 @@ otherwise would be the exact failure ADR-014 §1 exists to prevent. - **No abstraction over "how to reach a private repository."** There is one such repository, and the decision above is that we do not reach it. +**Scope of the prohibition, narrowed after review.** Everything above is about **declarable +facts** — short values that can sit in a registry row. There is a second kind of shared +thing this rule does not reach: agreement about **behaviour**, such as whether two +independently written readers interpret a reserved entry the same way. You cannot put a +program's behaviour in a row, so §5's mechanism does not transfer, and a blanket "never a +credential" would leave that case with no answer at all. + +The rule does extend, but one level up: **declare the semantics rather than the code** — +state in the contract what a reader must do, and each repository tests its own reader +against that statement. Still no repository reads another's source, and still no +credential. That question is live elsewhere as views-models#327 / D-05, and this document is +an argument for settling it there rather than here. + ### §10 When to revisit - **A shared fact that genuinely cannot go in the registry** — for instance one that is @@ -283,12 +329,30 @@ visibility can be inferred for future APIs. 1. **views-appwrite** — declare the label for each partner in the coordinate registry. Filed as views-appwrite#75. Nothing here can proceed before it. + + **The two partners are not equally ready, and the halves should land separately.** The + FAO label is fully specified — `un_fao`, in force, evidenced in ADR-013 §4.1a. The + CRAF'd label is **not a value anyone has decided yet**: that partner serves a different + dataset whose targets, columns and entity model are still an open data-contract question + on the consumer side. Bundling both into one edit stalls the ready half behind the + blocked one, so **the FAO row should land on its own** and the CRAF'd row should follow + its data contract. 2. **views-postprocessing** — switch the check to read the registry rather than the consumer's source, for both partners. Ours, blocked on step 1. **In the same change**, stop fetching `views-crafdapi` in CI: §7 shows that fetch then serves nothing, and ADR-016's rules require a sibling that is not fetched to say why. 3. **views-faoapi** — verify its own served label against the declaration; §8's other half. - Filed as views-faoapi#379. - -Step 3 is not a precondition for steps 1 and 2, and the sequence is safe to interrupt: the -existing check keeps running on a maintainer's machine throughout. + Filed as views-faoapi#379, and the consumer-side maintainer has accepted it. + +4. **views-crafdapi** — the same self-check for the other partner. **Not yet filed at the + time of writing, and that gap matters**: §7 retires the crafdapi fetch, so without this + leg the CRAF'd label would end up checked against a registry row that nobody checks + against CRAF'd's actual code — no verification against reality at all, which is worse + than the status quo. §7 argues the rule must apply uniformly; uniform application means + uniformly filing the third leg. Sequenced after that partner's data contract settles, + per step 1. + +**Step 3 is a precondition for removing the old check**, though not for adding the new one +— see the sequencing note in §5. The order is safe to interrupt at any point: until step 3 +lands for a partner, that partner's source-reading check stays, so no window exists in which +the label is verified only against our own typing. diff --git a/tests/conftest.py b/tests/conftest.py index b29d9e3..2fe5ccd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,13 @@ class Sibling: "views-appwrite": Sibling( env="VIEWS_APPWRITE", ci_checkout=True, + note=( + "PUBLIC. Fetched, and the fetch is load-bearing rather than convenient: it " + "carries the coordinate-registry drift checks, and under ADR-017 it becomes " + "the authority source for the delivery-label check too. Pruning it as an " + "unused sibling would disable both and leave a green build — the invisible " + "skip ADR-016 §6 exists to prevent." + ), ), "views-faoapi": Sibling( env="VIEWS_FAOAPI", @@ -135,6 +142,12 @@ class Sibling: "views-crafdapi": Sibling( env="VIEWS_CRAFDAPI", ci_checkout=True, + note=( + "PUBLIC, and fetched **temporarily**. Measured: this fetch serves exactly one " + "test, the consumer-name pin. ADR-017 §7 replaces that test with a read of " + "the public registry, at which point this fetch buys nothing and should be " + "removed along with it. Until then it is real coverage, not decoration." + ), ), } diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index 10b2785..6952307 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -156,30 +156,42 @@ def _g2_every_checkout_is_declared( return problems -def _g4_non_coverage_is_explained( +def _g4_every_note_names_a_record( workflow: dict, siblings: dict[str, Sibling] ) -> list[str]: - """Every sibling NOT checked out says why, and names a record. + """A sibling NOT checked out must say why, and **every** note must name a record. A deferral needs a trigger and an owner (ADR-014 §4). Requiring the note to cite a `C-nn` or an `ADR-nnn` is what makes the citation non-optional, so declared non-coverage stays attached to something a reader can follow. + + **Widened 2026-08-10 after external review.** The rule used to look only at siblings + with ``ci_checkout=False``, which left a hole in exactly the place ADR-016 calls out: + `views-crafdapi` is fetched *temporarily*, until the check it serves moves to a + registry read, and that fact lived in a note **no rule examined**. A claim about + another repository, in prose, that nothing can check — which is the failure ADR-016 + §3 diagnoses, reintroduced for the one download the document itself calls temporary. + + So the note requirement now follows the *note*, not the flag: whenever a sibling + carries one, it must cite the record that owns it, whether it explains an exclusion + or a temporary inclusion. Siblings with no note and `ci_checkout=True` are unaffected + — a permanent, unremarkable fetch needs no justification. """ problems = [] for name, sibling in siblings.items(): - if sibling.ci_checkout: - continue - if not sibling.note.strip(): + if not sibling.ci_checkout and not sibling.note.strip(): problems.append( f"{name}: not checked out in CI and no note says why. Silent " "non-coverage reads as 'nothing to see here'." ) - elif not any( - token in sibling.note for token in ("C-", "ADR-") + continue + if sibling.note.strip() and not any( + token in sibling.note for token in ("C-", "ADR-", "#") ): problems.append( - f"{name}: its note explains the non-coverage but names no record. Cite " - "the register entry or ADR that owns it, so the deferral has an owner." + f"{name}: its note explains something but names no record. Cite the " + "entry, ADR or issue that owns it — a note nothing points at is the " + "prose this file exists to replace." ) return problems @@ -239,7 +251,7 @@ def _g7_siblings_are_taken_from_main( _RULES = { "G1 declared checkouts are present and pointed at": _g1_declared_checkouts_are_present_and_pointed_at, "G2 every checkout is declared": _g2_every_checkout_is_declared, - "G4 non-coverage is explained": _g4_non_coverage_is_explained, + "G4 every note names a record": _g4_every_note_names_a_record, "G5 no step swallows its own failure": _g5_no_step_swallows_its_own_failure, "G6 siblings land under the excluded path": _g6_siblings_land_under_the_excluded_path, "G7 siblings are taken from main": _g7_siblings_are_taken_from_main, @@ -367,10 +379,10 @@ def _replace(name: str, **changes) -> dict[str, Sibling]: _ONE, "declared ci_checkout=True but the checkout step is gone"), ("G2 every checkout is declared", _workflow(), {}, "checked out but undeclared"), - ("G4 non-coverage is explained", + ("G4 every note names a record", _workflow(), _replace("views-appwrite", ci_checkout=False, note=""), "not checked out and no note"), - ("G4 non-coverage is explained", + ("G4 every note names a record", _workflow(), _replace("views-appwrite", ci_checkout=False, note="because reasons"), "note explains but names no record"), ("G5 no step swallows its own failure", From 60908d1fc9423f929425a3c6b1559c6e7b2a84ac Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 08:31:23 +0200 Subject: [PATCH 18/42] =?UTF-8?q?docs(register):=20C-86=20and=20C-87=20?= =?UTF-8?q?=E2=80=94=20the=20two=20residuals=20ADR-016=20and=20ADR-017=20l?= =?UTF-8?q?eave=20behind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were described in the ADRs and tracked nowhere. A decision record states a position; the register is what carries a live risk, and these are live. C-86 — the release path now depends on two other repositories with no way past a red build. ADR-016 accepted that coupling partly on a justification that measurement killed: protect_main lists ZERO bypass actors, so administrator status confers no exemption, and there is no classic branch protection either. The interesting part is that it is LATENT: no status check is required today, so a red build blocks nothing. The moment C-81's enforcement half is fixed — correctly, and it should be — this becomes live and unbypassable in the same change. Two open items that each look sensible alone combine into something neither of them says, so C-81 now says it. Rate is not hypothetical: views-appwrite reports five registry editions in four days, four of them observation-only, each of which would have blocked a release. views-appwrite#76 filed to make the obligation-carrying distinction machine-readable, which is the preferred resolution and which that seat volunteered. C-87 — the delivery label will be verified against a declaration, and nothing will verify the declaration against the consumer's code. ADR-017 §8 states it; the residual belongs here because the second half is work in repositories this project does not control (views-faoapi#379 has an owner, views-crafdapi#39 is blocked on a data contract), and because for a third-party-operated API it may never exist at all. Register 85/12/73 -> 87/14/73. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 54 ++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index c97871e..035ab40 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -5,8 +5,8 @@ | Project | views-postprocessing | | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-10 | -| Total Concerns | 85 | -| Open Concerns | 12 | +| Total Concerns | 87 | +| Open Concerns | 14 | | Resolved Concerns | 73 | --- @@ -161,6 +161,54 @@ that indexes only deleted code is noise. ## Open Concerns +### C-86: The release path now depends on two other repositories, and there is no way past a red build + +| Field | Value | +|-------|-------| +| ID | C-86 | +| Tier | 2 — no wrong data ships and nothing is silent. What is at risk is the ability to *ship at all* on a day when someone else's repository has moved, on the path that is this project's production release. Latent today, live the moment a status check becomes required. | +| Source | External review of ADR-016 by the views-appwrite and views-faoapi seats (#231, #233), 2026-08-10 | +| Trigger | **Two, and the first is the one to watch.** (a) A status check becomes required on `main` — at that moment this stops being latent. (b) An upstream merge to a sibling's `main` reddens this repository while a delivery fix is waiting. | +| Owner | Simon. Both available responses are console actions: add a bypass actor to `protect_main`, or accept the coupling as written. | +| Location | `.github/workflows/run_pytest.yml` — the two sibling checkout steps; ADR-016 §7, §7a, §7b. | + +ADR-016 has CI check out `views-appwrite` and `views-crafdapi` so that cross-repository checks run on every change rather than on a maintainer's habits. That is the right trade and the entry does not dispute it. What it records is the cost, which was accepted in the ADR on a justification that turned out to be false. + +**The false justification.** ADR-016 §7 originally said the maintainer could merge over a failing check when something was urgent. Two reviewers challenged it independently. Measured 2026-08-10: `protect_main` lists **zero bypass actors**, and a GitHub ruleset applies to everyone except the actors it names — so administrator status confers no exemption. There is no classic branch protection either, so no `enforce_admins` route. The claim is withdrawn in the ADR; the risk it papered over is this entry. + +**Why this is latent rather than live.** `protect_main` currently requires **no status check at all** (C-81's enforcement half). So today a red build blocks nothing and this coupling costs nothing. The instant a required check is added — which C-81 asks for, correctly — the coupling becomes real and unbypassable in the same change. **Two open items that each look independently sensible combine into something neither of them says.** + +**The rate is not hypothetical.** views-appwrite reports five registry editions in four days (v1.4.0 2026-08-02 through v1.4.4 2026-08-05), **four of them observation-driven** — recording console facts, correcting a key's scopes — carrying no obligation for any consumer. Each would have reddened this repository and blocked a release. + +**What would resolve it, in order of preference:** views-appwrite#76 makes the obligation-carrying distinction machine-readable, so observation-only bumps stop firing the check at all — filed, and that seat volunteered it. Failing that, a bypass actor restores the escape. Failing both, the coupling stands as ADR-016 §7 describes, which is defensible but should be chosen rather than discovered. + +Cross-refs: **C-81** (the enforcement half, whose fix activates this), **C-46** (whose recommendation against per-PR sibling checkouts this overrode, with the reasoning recorded there), ADR-016 §7/§7a/§7b, views-appwrite#76. + +--- + +### C-87: The delivery label will be checked against a declaration, and nothing will check the declaration against the consumer + +| Field | Value | +|-------|-------| +| ID | C-87 | +| Tier | 2 — the failure mode is invisible by construction. The upload succeeds, the storage is paid for, the consumer's endpoint returns empty, and nothing anywhere raises. That is the shape ADR-013 §4.1a calls *"invisible to the consumer, not merely degraded"*. | +| Source | ADR-017 §8, sharpened by external review (#232, #234), 2026-08-10 | +| Trigger | **Either half going missing.** (a) `views-faoapi#379` or `views-crafdapi#39` is closed without the check being written. (b) A private API operated by a **third party** becomes a consumer — at which point the second half cannot be required at all and this becomes permanent. | +| Owner | The consumer-side seats own the check; this repository owns noticing that it exists. | +| Location | `views_postprocessing//product.py::CONSUMER_DOCUMENT_NAME`; the check in `tests/test_product.py`; ADR-017 §5, §8, Appendix B. | + +ADR-017 decides that the delivery label is declared in the public coordinate registry and that each side verifies **itself** against that declaration, so neither repository reads the other's source. That is the right rule and this entry does not dispute it. + +Its residual is stated plainly in §8 and belongs here rather than only in a document: **we will verify our copy against the declaration, not the consumer's code against it.** If a consumer quietly starts filtering on something else, our check passes and the delivery is invisible exactly as before. + +**Why this is a risk and not merely a note.** The second half is real work in repositories this project does not control. `views-faoapi#379` has a willing owner. `views-crafdapi#39` is blocked on that partner's data contract and could sit for a long time. Until both land, the label's agreement with reality rests on the source-reading check — which ADR-017's sequencing deliberately keeps alive for exactly this reason, and which someone could remove believing the registry check replaced it. + +**What was already prevented.** A reviewer caught that the obvious sequence created a window where the source-reading check was deleted before the consumer-side check existed, leaving a green build proving only that two values this platform authored agreed with each other. ADR-017 now forbids that ordering. This entry exists so the ordering constraint has a home outside the document that states it. + +Cross-refs: **C-77** (the same field's producer-side half, resolved), ADR-013 §4.1a, ADR-017 §5/§8/Appendix B, views-appwrite#75, views-faoapi#379, views-crafdapi#39. + +--- + ### C-85: A cross-repo ask is adopted on its stated terms without anyone measuring the current state | Field | Value | @@ -538,6 +586,8 @@ CI now checks that repository out and those seven run on every pull request — 1. **One dark check.** `views-faoapi` is genuinely private — the consumer-name pin is still laptop-only. That is one test, not seven, and it is the one whose failure mode is invisible rather than loud. ADR-016 §8 defers the credential and names the trigger: FAO declining the request to make that repository public, or a second private sibling appearing. 2. **The enforcement half is entirely untouched.** `protect_main`'s ref-name include-list was empty; it now targets the default branch, but **no status check is required**, so a pull request with a red CI can still be merged to `main`. Since merging to `main` *is* the production release, this is the half that matters most and the half that has not moved. +**And it no longer stands alone — read C-86 before closing this.** Measured 2026-08-10: `protect_main` also lists **zero bypass actors**, so once a status check *is* required, nobody can merge past it, administrator or otherwise. Meanwhile ADR-016 made this repository's CI depend on two other repositories. Adding the required check therefore does two things at once: it closes this entry, and it makes an unbypassable external dependency live on the release path. Both are defensible; doing them in one unremarked step is not. If the escape is wanted, a bypass actor is the same console session. + **The lesson this entry should carry.** The blocker was not a missing credential. It was a fact about another repository recorded in prose, with no date, that nothing could check — and it survived a console session, an ADR draft and a register entry, all of which repeated it. ADR-016 replaces the prose with a declaration carrying the date it was verified, and a test that fails when CI and the declaration disagree. **Update 2026-08-05 — the operator session happened, and neither half of this entry moved.** Simon read the Appwrite console that morning (views-appwrite v1.4.4). It answered two *other* þing-02 questions definitively — there is **no non-production project**, and both platform keys expire 2026-11-17 (now **C-84**) — but the console read is a different action from issuing a token and a different console from GitHub's. So both halves stand: the two private siblings still have no CI credential, and `protect_main`'s ref-name include-list is still empty. From 1a874bd456df8725d74e37f54f173899da01c0db Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 10 Aug 2026 09:58:34 +0200 Subject: [PATCH 19/42] =?UTF-8?q?docs(adr-013):=20Erratum=20E3=20=E2=80=94?= =?UTF-8?q?=20lift=20the=20pipeline=5Fcore=5Fversion=20caveat,=20in=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #228 asked for this: §2.2 declared the field unreliable until pipeline-core cut a real release, and 3.0.0 shipped to PyPI on 2026-08-03. Condition met. But the lift is narrower than the request's suggested wording, and the difference is a released-versus-merged distinction worth getting right. authoritative when the producer ran a RELEASED distribution — a wheel's recorded version is written by the release that built it, so it cannot disagree with the code beside it. In force now. "unknown" when the producer ran an EDITABLE install — NOT yet in any released version. pipeline-core#403 makes that case report "unknown" instead of a stale number, and it merged 2026-08-04 13:58, a day and a half AFTER 3.0.0 was uploaded (2026-08-03 02:06). #228's suggested note states this half as current; it is not, and becomes true at pipeline-core's next release. Verified here rather than taken on trust: in this repo's environment importlib.metadata reports pipeline-core 2.3.0 while the source beside it is 3.0.0 — a full major version stale, exactly the value that would have been stamped into published provenance. With the fix present, _pipeline_core_version() returns "unknown". The operational point, which #228 got right: consumers must treat "unknown" as "do not infer the producing version", never as an error — and the set producing it will WIDEN at the next release, because every developer run joins it. Nothing in this repository changes. Hop-B re-embeds the Hop-A header untouched, so we forward this value and never compose it. contract_version stays 1.5. E3's claim that the fix is unreleased will expire, so it carries a check: test_e3s_claim_about_unreleased_behaviour_is_still_true fails if the installed pipeline-core is a released distribution past 3.0.0. Inert in an editable environment and live in CI, which installs from PyPI — stated in the docstring rather than left to be discovered. Mutation-proven against a synthetic 3.1.0 wheel. Suite 399 passed / 1 skipped / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../013_sampled_forecast_wire_contract.md | 44 +++++++++++++++--- tests/test_falsify_adr013_s2.py | 45 +++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/docs/ADRs/013_sampled_forecast_wire_contract.md b/docs/ADRs/013_sampled_forecast_wire_contract.md index 6510bc8..35099b5 100644 --- a/docs/ADRs/013_sampled_forecast_wire_contract.md +++ b/docs/ADRs/013_sampled_forecast_wire_contract.md @@ -352,11 +352,10 @@ identifier arrays mean (`time` is the VIEWS month-id; `unit` is the `priogrid_id this platform has already paid once for leaving id vocabulary implicit (the gid/id epic: a past platform-wide cleanup needed just to disambiguate what its integer identifier columns meant). `provenance` is exactly the three keys shown (strings/bool). **Caveat:** -`pipeline_core_version` is self-reported and will be unreliable until pipeline-core's -release train (their #261) cuts real releases (status at adoption, 2026-07-15: none -yet) — consumers must not treat it as authoritative before then. The lift of this -caveat is tracked as a reminder in pipeline-core: their issue #279 (filed -2026-07-19) fires when the first real release ships. +`pipeline_core_version` is self-reported. It was declared unreliable at adoption +(2026-07-15) because pipeline-core's release train had cut no real releases yet, and +consumers were told not to treat it as authoritative. **That caveat is lifted in part — +see Erratum E3 below, which also explains why "in part" is the accurate word.** **§2.2a Amendment A2 — run maturity, drafted 2026-08-05, NOT YET IN FORCE.** @@ -933,6 +932,41 @@ runs can therefore never touch the live bucket by accident. Dated events after adoption. Errata correct errors in this document; other entries record execution progress against it. +- **2026-08-10 — Erratum E3 (§2.2, `pipeline_core_version`; `contract_version` stays + 1.5):** the adoption-time caveat said this field would be unreliable until pipeline-core + cut a real release. **views-pipeline-core 3.0.0 shipped to PyPI on 2026-08-03**, so the + condition is met — but the honest lift is narrower than "the field is now reliable", and + splits by how the *producing* pipeline-core was installed. + + **Authoritative when the producer ran a released distribution.** A wheel's recorded + version is written by the release that built it, so it cannot disagree with the code + beside it. In force now, for 3.0.0 onward. + + **`"unknown"` when the producer ran an editable install** — and this half is **not yet in + any released version**, which is the part the originating request (#228) stated too + strongly. An editable install's recorded version is fixed at the moment `pip install -e` + last ran and never tracks the source afterwards; pipeline-core#403 makes that case report + `"unknown"` instead of a stale number. That fix merged **2026-08-04**, a day and a half + *after* 3.0.0 was uploaded (2026-08-03 02:06 UTC), so **no released version contains it.** + It becomes true of producers at pipeline-core's next release. + + Verified here rather than taken on trust, 2026-08-10: in this repository's development + environment `importlib.metadata` reports pipeline-core **2.3.0** while the source beside + it is **3.0.0** — a full major version stale, exactly the value that would have been + stamped into published provenance. With the fix present, `_pipeline_core_version()` + returns `"unknown"` instead. + + **What consumers must do, and it is the operational point.** Treat `"unknown"` as *"do + not infer the producing version"*, never as an error. The set of runs producing + `"unknown"` will **widen** at pipeline-core's next release, because every developer run + joins it. A consumer that starts rejecting `"unknown"` on the strength of this lift would + break exactly those runs. + + This repository does not produce the value — Hop-B re-embeds the Hop-A header untouched + (`contract/wire/sink.py`) — so nothing here changes. The field is declared by this + contract, which is why the lift is recorded here. Arises from #228; pipeline-core #279 + (closed), #403, and their register C-280. + - **2026-08-05 — Amendment A2 drafted, not in force (§2.2a; `contract_version` stays 1.5):** views-postprocessing #133 asked for three declared fields on the run manifest — `maturity`, `source`, and a required schema version. Measured against what is actually diff --git a/tests/test_falsify_adr013_s2.py b/tests/test_falsify_adr013_s2.py index 7a751d2..7ce8cc4 100644 --- a/tests/test_falsify_adr013_s2.py +++ b/tests/test_falsify_adr013_s2.py @@ -79,3 +79,48 @@ def test_s2_2a_is_not_secretly_in_force(): "The field shipped and the amendment was never marked adopted — which leaves the " "§10 golden fixture, three repos' vendored copies, and this document disagreeing." ) + + +def test_e3s_claim_about_unreleased_behaviour_is_still_true(): + """Erratum E3 says a pipeline-core fix is in no released version. That expires. + + E3 lifts §2.2's `pipeline_core_version` caveat in two halves. The second — that an + editable install reports ``"unknown"`` rather than a stale number — landed in + pipeline-core on 2026-08-04, a day and a half *after* 3.0.0 was uploaded to PyPI. So + the erratum states plainly that **no released version contains it**, and that it + becomes true of producers at the next release. + + That is a dated claim about someone else's release history, in the document that + punishes those hardest. It stops being true the moment pipeline-core publishes again, + and nothing about this repository would change to signal it. + + So: if the pipeline-core we are running is a **released distribution** (not an + editable checkout) and its version is past 3.0.0, the next release has happened and + E3's wording is stale. CI installs from PyPI, so this is live there even though a + maintainer's editable environment leaves it inert — which is stated rather than + discovered, because a guard that only ever runs in one place is half a guard. + """ + pytest.importorskip("views_pipeline_core", reason="a declared dependency") + from importlib.metadata import PackageNotFoundError, version as dist_version + + import views_pipeline_core + + source = Path(views_pipeline_core.__file__).resolve() + if "site-packages" not in str(source): + pytest.skip( + "pipeline-core is an editable checkout here, so its recorded version says " + "nothing about what has been released. This check is live in CI, which " + "installs from PyPI." + ) + try: + installed = dist_version("views-pipeline-core") + except PackageNotFoundError: # pragma: no cover - not a distribution at all + pytest.skip("pipeline-core is not installed as a distribution") + + parts = tuple(int(p) for p in installed.split(".")[:3] if p.isdigit()) + assert parts <= (3, 0, 0), ( + f"pipeline-core {installed} is released and past 3.0.0, so Erratum E3's claim " + "that the editable-install fix is 'not yet in any released version' is out of " + "date. Re-read E3 against that release: the second half of the lift is probably " + "now in force, and the sentence saying it is not must go." + ) From 2d3b2c528bdd3ad6d1231cc3bcd29d90f52072ef Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 13:25:35 +0200 Subject: [PATCH 20/42] docs(adr): the picture, the tables, and where declarations belong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, the last found by falsifying the ADRs against the architecture rules rather than against themselves. THE PICTURE. Neither ADR contained one, and neither had a table saying which repository does what — you had to assemble that from prose that said "this repository" throughout. The maintainer read both and was lost three times; that was data about the documents. ADR-017 §5 gains a three-party diagram and a role table. The diagram's point is the arrow that is not there: both sides point at the registry, neither points at the other. The table's "needs access to" column is what shows why no credential is required. ADR-016 §4 gains the role table. Its diagram was drafted, rendered badly, and was DELETED rather than fixed — it duplicated the table beside it, and the table is better. For that document the table was the picture. RENDERING, kept out of the source. Fenced blocks split across pages in PDF. Fixed with a pandoc Lua filter that makes each block an unbreakable minipage, living beside the PDFs rather than as raw LaTeX in the markdown — the markdown is the artifact, the PDF a view. The ADR-017 diagram was also rebuilt with its columns computed rather than eyeballed, so the arrows sit over the boxes they rise from. WHERE DECLARATIONS BELONG — new C-88, and the one substantive finding. tests/conftest.py is pytest's fixture file and now holds four unrelated groups: the package taxonomy, the sibling repositories, the consumer mapping, and two git helpers. None is a fixture. ADR-016 §4 asserted "the list of siblings is code, not a comment" and named that file without ever asking whether it was the right home — which is exactly the question the repository's own architecture rules are about. The cost is already paid, not hypothetical: scripts/build_gaul_lookup.py needs the same sibling-location fact and cannot import it, because a script importing from tests/ is the dependency direction backwards. So it declares "VIEWS_DATAFACTORY" a second time. That duplication is defended on WET grounds and the defence is sound — but it was not a free choice, and a structural constraint and a design decision reached the same answer with only one of them examined. §4 now says where the declaration lives, what that costs, and the trigger for moving it. Not fixed here: moving it touches a dozen imports for no immediate gain, and the second incident is what tells you the right shape. Probes that found nothing: hedging language (three hits, all legitimate prose); OCP — adding a sibling is data plus a workflow block, no rule changes; ISP — the record is down to three fields and its two audiences are coherent. Register 87/14/73 -> 88/15/73. Pre-existing on development and NOT from this branch: three failures — two stale registry pins and the consumer-name check, which broke when views-faoapi tidied the file it reads. That is #238's work and is awaiting the maintainer's discussion. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 31 +++++++++++++++++ .../017_facts_across_a_private_boundary.md | 27 +++++++++++++++ reports/technical_risk_register.md | 33 +++++++++++++++++-- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index ef27514..44debfd 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -86,6 +86,37 @@ nothing able to check it, will eventually be wrong and nobody will find out. That is all. It replaces a comment with something a test can read. +**Where that code lives, and why the question is worth asking.** The declaration sits in +`tests/conftest.py`, because that is where this repository already keeps facts several +test modules share. It is not obviously the right home: `conftest.py` is pytest's fixture +file, and none of these declarations is a fixture — they are statements about the platform +that tests happen to read. + +The cost is already visible. `scripts/build_gaul_lookup.py` needs the same +sibling-location fact and cannot import it, because a script importing from `tests/` is +the dependency direction backwards. So it declares the variable name a second time. That +duplication is defensible on its own merits — the two contracts genuinely differ — but it +was not a free choice, and a reader should know that a structural constraint and a design +decision happened to agree. + +**Named trigger for moving it (ADR-014 §4):** a second non-test consumer needing one of +these declarations, or a fifth declaration arriving in that file. The shape it would move +to is a small module owning the platform declarations, importable by tests and scripts +alike. Not committed to here, because the second incident is what tells you whether that +shape is right. Tracked as register **C-88**. + +Who is who, once and in one table: + +| repository | public? | fetched by CI? | why not, if not | +|---|---|---|---| +| **views-appwrite** | yes | **yes** | — carries the registry drift checks | +| **views-crafdapi** | yes | **yes**, temporarily | — retires when ADR-017 §7 lands | +| **views-datafactory** | yes | no | its checks need raw data that is not in its git repository; fetching it turns an honest skip into a crash | +| **views-faoapi** | **no** | no | private — no amount of workflow configuration reaches it. That case is [ADR-017](017_facts_across_a_private_boundary.md) | + +The table is not decoration: it is `tests/conftest.py::SIBLINGS` in prose, and a test fails +if the two disagree. + ### §5 CI downloads exactly what that list says, and a test enforces it The workflow downloads every sibling marked `ci_checkout=True`. diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index fd85af5..c419527 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -96,6 +96,33 @@ must decide for the category, not for today's instance. > surface. Each side verifies itself against that declaration. Neither side reads the > other's source code.** +The same thing as a picture: + +``` + views-appwrite + the registry (public) + "un_fao" + + ▲ ▲ + │ │ + views-postprocessing views-faoapi + writes the name reads the name + + these two never read each other +``` + +**The arrow that is missing is the point.** Neither repository reads the other, so it does +not matter that one of them is private, and no credential is needed by anyone. Both arrows +point at a third place that is public to everybody. + +Who does what, once and in one table: + +| repository | its role | what it checks | needs access to | +|---|---|---|---| +| **views-appwrite** | holds the fact | nothing — it is the authority | — | +| **views-postprocessing** (here) | writes the name onto every upload | its own copy against the registry | the registry only | +| **views-faoapi** (private) | opens files with that name | its own served name against the registry | the registry only | + Concretely, for the delivery label: 1. The value is declared once, in the platform's public coordinate registry diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 035ab40..f0594c4 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -4,9 +4,9 @@ |-------------------|--------------------------------------| | Project | views-postprocessing | | Owner | Dylan Pinheiro / PRIO MD&D Team | -| Last Updated | 2026-08-10 | -| Total Concerns | 87 | -| Open Concerns | 14 | +| Last Updated | 2026-08-11 | +| Total Concerns | 88 | +| Open Concerns | 15 | | Resolved Concerns | 73 | --- @@ -161,6 +161,33 @@ that indexes only deleted code is noise. ## Open Concerns +### C-88: The platform declarations live in pytest's fixture file, so nothing outside the test tree can reach them + +| Field | Value | +|-------|-------| +| ID | C-88 | +| Tier | 3 — no correctness risk and nothing silent. It is a boundary that has already forced one duplication for structural rather than design reasons, and it will force the next one the same way. | +| Source | `falsify` against the SOLID / component-principle lens, 2026-08-11 | +| Trigger | **Either.** (a) A second non-test consumer needs one of these declarations and has to copy it. (b) A fifth declaration is added to `tests/conftest.py` — the file is at four, and the threshold for "dumping ground" is not a number but the moment nobody can say in one sentence what the file is for. | +| Owner | Whoever adds the next declaration, or the next non-test consumer. Not urgent; it gets more expensive slowly. | +| Location | `tests/conftest.py` (271 lines); `scripts/build_gaul_lookup.py:73`; ADR-016 §4. | + +`tests/conftest.py` is pytest's fixture file. It currently holds **four unrelated groups**: the package taxonomy (`PARTNER_PACKAGES`, `MACHINERY_PACKAGES`), the sibling repositories (`Sibling`, `SIBLINGS`, and three resolver functions), the consumer mapping (`CONSUMER_REPO`), and two git helpers (`git_output`, `commit_is_on_main`). None of those is a test fixture. They are declarations about the platform that happen to be consumed by tests. + +**The concrete cost, which has already been paid once.** `scripts/build_gaul_lookup.py` needs the same sibling-location fact and cannot have it: *"a script must not import from `tests/` — that is the dependency direction backwards."* So it hardcodes `"VIEWS_DATAFACTORY"` at `:73` while `SIBLINGS` declares the same string in `conftest.py`. + +That duplication is **defended on WET grounds and the defence is sound** — the two contracts genuinely differ (the script returns a `Path` even when the checkout is absent so it can raise its own message; the test helper returns `None` because a missing sibling is a normal skip), and a guard asserts the two resolve to the same place. This entry does not ask for that to be merged. + +**What it records is that the choice was not free.** The script could not have reused the declaration even if reuse had been right, because of where the declaration lives. A structural constraint and a design decision reached the same answer, and only one of them was examined. + +**Why this is Tier 3 and not higher.** Nothing is wrong today. Every guard works, the duplication is guarded, and moving the declarations would touch a dozen imports for no immediate gain. The risk is the slope: `conftest.py` is where a declaration goes when nobody asks where it belongs, and each addition makes the next one more natural. + +**What "fixed" would look like**, when the trigger fires: a small module that owns the platform declarations — importable by tests, scripts and, if ever needed, package code — with `conftest.py` reduced to what pytest actually needs from it. That is the shape, not a commitment; the point of the trigger is that the second incident tells you whether it is right. + +Cross-refs: **C-46** (which records the builder's separate resolver and the guard that they agree), ADR-016 §4, ADR-002 (dependency direction). + +--- + ### C-86: The release path now depends on two other repositories, and there is no way past a red build | Field | Value | From bcdf5225192d7d4bb0cddd7dd98c6a95040db57a Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 13:32:49 +0200 Subject: [PATCH 21/42] test(readability): four things a maintainer new to this decision could not have known MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Falsified the TESTS against "readable by someone unfamiliar with the decision" — a different lens from the previous passes, which checked the documents against themselves. Four findings, all real, two of them contradictions with things written days earlier. THE NUMBERING GAP WAS FIXED IN THE ADR AND LEFT IN THE CODE. test_ci_sibling_coverage runs G1, G2, G4, G5, G6, G7. ADR-016 now explains why two numbers are missing; the test file did not, and the test file is what someone opens when a build goes red. It says so now: G3 and G8 were removed as circular, survivors keep their names, the gap is deliberate. INVERTED FAILURE SEMANTICS, UNSIGNPOSTED — the sharpest of the four. Half this file fails when the workflow is wrong; the other half fails when the RULE is wrong, because those tests feed a deliberately broken world to a rule and demand it complain. Nothing said so. A maintainer seeing "[G6] did not object to: outside _siblings/" would go and stare at run_pytest.yml, which is fine. The module docstring now names the inversion, the assertion says "Fix the rule, not run_pytest.yml", and test_each_rule_bites_on_a_broken_world has its docstring back — it lost one in the refactor that moved _MUTANTS out. Mutation-proven: breaking G6 produces that message. "TWO FIELDS" OVER A THREE-FIELD RECORD. Sibling's docstring counted ci_checkout and note and forgot env, the field two other modules actually use. CONFTEST'S FIRST LINE SAID IT HOLDS ONE THING; C-88 SAYS FOUR. Registered that yesterday and left the docstring claiming "Shared test fixtures — currently just one". It now opens by listing the four groups, defines "sibling" where a newcomer first meets the word, and points at C-88 rather than defending the arrangement. Its second line also opened with four private identifiers (S6 / #187 / C-46 / C-57) — the same thing the maintainer objected to in the ADRs, in the file a newcomer opens first. Not registered, consistently with the previous three passes: each is a defect fixed in the same change, and the only candidate trigger — "whenever a test is edited" — is perpetual, which the register's own quality gate rejects. Say so if you want them tracked anyway. Pre-existing and NOT from this change: three failures on development — two stale registry pins and the consumer-name check, which broke when views-faoapi tidied the file it reads. That is #238, awaiting discussion. Co-Authored-By: Claude Opus 5 (1M context) --- tests/conftest.py | 26 +++++++++++++++++----- tests/test_ci_sibling_coverage.py | 36 +++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2fe5ccd..18c1d6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,23 @@ -"""Shared test fixtures — currently just one: how to find a sibling repository. +"""Facts several test modules share, and the helpers that read them. -**Why this exists (S6 / #187, S7 / #188; register C-46, C-57).** Three places needed a -views-platform sibling checkout and each found it differently: +**Read this first if you are new.** Despite the name, almost nothing here is a pytest +fixture. The file holds four separate things: + +1. **which packages exist** — ``PARTNER_PACKAGES`` and ``MACHINERY_PACKAGES``, the + two kinds of top-level package under ``views_postprocessing/``; +2. **the sibling repositories** — ``Sibling``/``SIBLINGS`` plus three functions for + locating one on disk. A *sibling* is another repository in the views-platform + organisation that sits beside this one in a developer's folder — never something we + install; +3. **which repository consumes which partner's delivery** — ``CONSUMER_REPO``; +4. **two read-only git helpers** used by cross-repository checks. + +Those four are not obviously one file's worth of responsibility, and that is recorded +rather than defended: register **C-88**, with the trigger for splitting it. Read it +before adding a fifth thing here. + +**Why the sibling helpers exist.** Three places needed to locate a views-platform +sibling checkout and each did it differently: - ``scripts/build_gaul_lookup.py`` resolved ``$VIEWS_DATAFACTORY``, then the sibling directory, with a ``--datafactory`` override and a fail-loud message naming both — @@ -65,8 +81,8 @@ class Sibling: """What this repository declares about one views-platform sibling (ADR-016). - Two fields, and the second is the one that matters. ``ci_checkout`` says whether CI - fetches this repository; ``note`` says why not, whenever the answer is no. + Three fields. ``env`` says where to find this repository on disk; ``ci_checkout`` + says whether CI fetches it; ``note`` says why not, whenever the answer is no. **An earlier version also carried ``public`` and ``public_checked``.** They were removed on 2026-08-10 after a review found them circular: ``public`` was read by diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index 6952307..82de91f 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -22,6 +22,20 @@ the build, because the default `GITHUB_TOKEN` is scoped to this repository. Rule G5 keeps that failure loud. +**Why the rules run G1, G2, G4, G5, G6, G7 with two numbers missing.** G3 paired a +sibling's visibility against whether its checkout used a credential; G8 checked that a +recorded date was plausible. Both were deleted on 2026-08-10 after a review found them +circular — each existed to protect a field that existed to feed it. The survivors keep +their original names rather than being renumbered, so anything written about "G6" +elsewhere still means this G6. The gap is deliberate; nothing is missing. + +**Two sections of this file have inverted failure semantics, and that trips people.** +Everything above `── the rules, against the real workflow ──` fails when *the workflow or +the declaration* is wrong. Everything below `── the rules, against synthetic mutants ──` +fails when **the rule itself** is wrong — those tests feed a deliberately broken world to +a rule and demand it complain. If one of those fails, do not go looking at +`run_pytest.yml`; the workflow is fine and the guard has stopped guarding. + **Every rule is a pure function of (workflow, siblings)**, so each can be run against a synthetic mutant rather than only against the real file. A guard that can only be demonstrated by editing CI is a guard nobody ever watches fail (ADR-014 §2) — this repo @@ -418,10 +432,28 @@ def test_every_rule_has_a_mutant_that_proves_it_bites(): @pytest.mark.parametrize("label, workflow, siblings, why", _MUTANTS) def test_each_rule_bites_on_a_broken_world(label, workflow, siblings, why): + """Feed a rule a deliberately broken world and demand that it complains. + + **A failure here means the RULE is broken, not the workflow.** That inversion is the + one thing about this file worth knowing before you debug it: `run_pytest.yml` is not + involved, the world being fed in is a synthetic dict defined below, and a green real + workflow is entirely consistent with this failing. A guard that no longer objects to + a violation is a guard that has silently stopped working, which is the failure this + whole file exists to make loud (ADR-014 §2). + + The second assertion is not pedantry: a rule that objects without naming which + sibling is at fault leaves a maintainer grepping a workflow file by hand. + """ violations = _RULES[label](workflow, siblings) - assert violations, f"[{label}] did not object to: {why}" + assert violations, ( + f"[{label}] did not object to a world that is deliberately broken: {why}. " + "This means the RULE has stopped working — the real workflow is not involved " + "and is probably fine. Fix the rule, not run_pytest.yml." + ) assert any("views-appwrite" in v for v in violations), ( - f"[{label}] objected but did not name the offending sibling: {violations}" + f"[{label}] objected, but its message does not name the offending sibling: " + f"{violations}. A maintainer reading only the failure would not know which " + "repository to look at." ) From 2d414741c5e6c3d5e28d78043d9c469eba32dd5f Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 15:04:09 +0200 Subject: [PATCH 22/42] test(registry): match on the rows we read, not on the edition label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry check compared version strings, so it fired on every upstream edition — five in four days, four of them recording console observations that carried no obligation here (C-86). Two of those failures were red on development. The diagnosis is ADR-014 §3: when a guard fires on something legitimate, ask whether the MATCHING is wrong before narrowing the scope. It matched on meta.version, a label meaning "anything at all changed". What this package depends on is a set of rows. Two earlier proposals of mine were wrong, and the reasons are in the ADR erratum. The second — read everything at the pinned commit — would have frozen both sides of every comparison, so upstream could rename every coordinate and the suite would stay green. ADR-016 §7b already adjudicated that fork, in a document I wrote days ago. Three checks replace one: the pinned commit declares the pinned version. Both sides frozen, so upstream can never redden it. This closes a hole that was live: "Do not bump one alone — the pair is the claim" existed only inside an error-message string, and nothing compared the two. fcf32c9 does declare 1.4.4 — true by luck. every top-level table upstream is classified here. _declared_classes parses three and was silently ignoring four. `[contract.*]` arrived in v1.5.0 carrying a live obligation and the ONLY thing that noticed was the noisy version check. Directional on purpose: a new table fails, a vanished IGNORED table does not. no row we declare differs between the pinned edition and the current one. Catches ROTATION, which no other check here can see — a rotated bucket id keeps its name and its class, so names-and-classes passes while every delivery goes elsewhere. The no-copy rule forbids keeping expected values in our source, so the pinned edition is the only lawful baseline for that comparison. The reader is deliberately NOT built on conftest.git_output, which turns any failure into "" — and tomllib.loads("") is {}, so an unreadable pin would produce an empty baseline compared against an empty projection, passing forever. That is the shape ADR-014 §2 is about, and building it into a drift detector's own fix would be the joke writing itself. It distinguishes a non-zero exit from an empty file and refuses a parse lacking anchors. Module-local, not conftest: C-88's trigger is a fifth declaration there and this has one consumer. Seven mutations proven, three of them now permanent tests that need no sibling: a bogus sha must raise rather than return {}; a new table must fire while a vanished IGNORED one must not; a rotation must fire where names-and-classes cannot. ADR-016 gains an erratum. §7a's "not adopted here — needs the upstream side first" was wrong: fixing the matching removes the false alarms without upstream doing anything. §7b's two categories become three — a differential tripwire reads a pinned baseline AND current, and only that shape can see a rotation. C-86 amended with the measured rate change and explicitly NOT closed: the coupling, the checkout and the zero bypass actors are untouched. Its trigger is still a console action. Suite 401 passed / 1 skipped / 40 xfailed. The one remaining failure is the faoapi source-read, which is Change 2 (#238 D4). Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 29 ++ reports/technical_risk_register.md | 20 ++ tests/test_env_declaration.py | 285 +++++++++++++++++- 3 files changed, 321 insertions(+), 13 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index 44debfd..e764885 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -221,6 +221,35 @@ branch. Both live here, and they answer different questions: G7 makes both read `main` rather than a default branch. It does not make either of them track the tip for its own sake. +**Erratum, 2026-08-11 — there is a third kind, and §7a's "not adopted here" is stale.** + +§7a recorded views-appwrite's suggestion — pin against the contract *version* and treat +observation-only bumps as non-blocking — and marked it *not adopted here, because it needs +the upstream side first*. That framing was wrong in a way worth correcting rather than +quietly editing: it treated the noise as something only upstream could fix. + +The registry-edition check was matching on `meta.version`, a label meaning *"anything at +all changed"*. What this repository actually depends on is a set of rows. Matching on the +rows instead removes the false alarms **without needing anything from upstream** — which +is ADR-014 §3 applied properly: when a guard fires on something legitimate, the first +question is whether the matching is wrong, not whether the scope is too wide. The matching +was wrong. + +So the two categories above become three: + +- **Drift tripwires** read the sibling's `main` because movement is the signal. +- **Reachability checks** read a pinned commit; movement is noise. +- **Differential tripwires** read **both** — a pinned edition as the baseline, `main` as + the comparison — and fire only when something *we declare* differs between them. This is + the shape the registry check now has. It catches a rotation, which is invisible to the + other two, and it is silent through prose edits and version bumps. + +The third kind exists because the no-copy rule forbids writing expected coordinate values +into this repository. A pinned edition is the only lawful place to keep a baseline for +comparing them. + +views-appwrite#76 is still worth doing; it is no longer a precondition for anything here. + --- This section overrides an earlier internal recommendation not to couple per-pull-request CI diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index f0594c4..40bc65a 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -210,6 +210,26 @@ ADR-016 has CI check out `views-appwrite` and `views-crafdapi` so that cross-rep **What would resolve it, in order of preference:** views-appwrite#76 makes the obligation-carrying distinction machine-readable, so observation-only bumps stop firing the check at all — filed, and that seat volunteered it. Failing that, a bypass actor restores the escape. Failing both, the coupling stands as ADR-016 §7 describes, which is defensible but should be chosen rather than discovered. Cross-refs: **C-81** (the enforcement half, whose fix activates this), **C-46** (whose recommendation against per-PR sibling checkouts this overrode, with the reasoning recorded there), ADR-016 §7/§7a/§7b, views-appwrite#76. +**Partial mitigation 2026-08-11 — the false-alarm rate, not the coupling.** The check that +kept firing compared registry *version strings*. It has been replaced by three that match +on the facts this repository actually declares: the pinned commit must declare the pinned +version; every top-level table upstream must be classified here; and no row we read may +differ between the pinned edition and the current one. + +Measured across the window that prompted this entry — v1.4.4 → v1.5.2, three editions in a +week — the new checks are **green**, because none of those editions touched a row this +package reads. Under the old check every one of them was a red build blocking a release. + +**This does not close the entry, and the distinction matters.** C-86 is about CI depending +on two other repositories with no way past a red build. That dependency is untouched: the +sibling checkout still happens, `protect_main` still has zero bypass actors, and a change +upstream that *does* touch a row we read will still redden this repository and block a +merge — correctly, and that is the point. What changed is that it now fires for reasons +that carry an obligation. + +The entry's trigger is unchanged and remains a console action: **a status check becomes +required on `main`**, at which moment the coupling stops being latent. + --- diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 74d3267..49a3d60 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -485,6 +485,96 @@ def _load_registry(repo: Path) -> dict: return tomllib.loads((repo / _REGISTRY_RELPATH).read_text()) +#: Every top-level table the registry may carry, and what this repository does with it. +#: +#: **Declared, and asserted against the live registry** — a table appearing upstream that +#: nobody here has classified is a change this repository has not looked at. That is not +#: hypothetical: `[contract.*]` arrived in v1.5.0 carrying a live obligation (the +#: ADR-017 delivery-label mirror), and the ONLY mechanism here that noticed was a +#: version-string comparison that fired for the wrong reason and was deleted with this +#: change. Four tables were being ignored silently at the time. +_TABLE_ROLE = { + "connection": "CONSUMED", # parsed by _declared_classes; coordinates we read + "target": "CONSUMED", # parsed by _declared_classes; coordinates we read + "secret": "CONSUMED", # parsed by _declared_classes; the operator's slots + "contract": "MIRRORED", # values legitimately live in our source — ADR-017; see below + "excluded": "IGNORED", # names the registry records as deliberately NOT coordinates + "test_environment": "IGNORED", # a fact about the platform, not about this package + "meta": "METADATA", # the edition and its amendment log +} + +#: Tables whose absence is our problem. An IGNORED table vanishing is not — that +#: direction is deliberately unchecked, which is why v1.5.1 dropping `[unmodelled]` is +#: silent while v1.5.0 adding `[contract]` is a red build with something to do. +_TABLES_WE_DEPEND_ON = tuple( + name for name, role in _TABLE_ROLE.items() if role in ("CONSUMED", "MIRRORED") +) + + +class _RegistryReadError(RuntimeError): + """The registry could not be read at a pinned commit — never a silent empty dict.""" + + +def _registry_at(repo: Path, ref: str) -> dict: + """The registry as it stood at ``ref``, or raise. Never returns ``{}`` quietly. + + **Why not ``conftest.git_output``.** That helper turns any failure into ``""``, and + ``tomllib.loads("")`` returns ``{}``. An empty registry compared against an empty + projection passes — forever, silently. That is the vanished-directory shape ADR-014 + §2 exists for, and building it into the fix for a drift detector would be the joke + writing itself. So this distinguishes a non-zero exit from an empty file, and + refuses a parse that lacks the anchors every real edition has. + + Module-local rather than in ``conftest.py``: register C-88 records that file already + holding four unrelated groups, and its trigger is a fifth. There is one consumer. + """ + tomllib = pytest.importorskip("tomllib", reason="stdlib from 3.11; pyproject requires it") + result = subprocess.run( + ["git", "-C", str(repo), "show", f"{ref}:{_REGISTRY_RELPATH.as_posix()}"], + capture_output=True, text=True, check=False, timeout=30, + ) + if result.returncode != 0: + raise _RegistryReadError( + f"cannot read the registry at {ref!r} in {repo}: git exited " + f"{result.returncode} ({result.stderr.strip()[:200]}). A pinned commit that " + "cannot be read is not a baseline — run `git fetch` in that checkout." + ) + if not result.stdout.strip(): + raise _RegistryReadError( + f"the registry is EMPTY at {ref!r} in {repo}. Treating that as a registry " + "with no coordinates would make every comparison below pass." + ) + registry = tomllib.loads(result.stdout) + if not registry.get("meta", {}).get("version") or not registry.get("connection"): + raise _RegistryReadError( + f"the registry at {ref!r} parsed but carries no meta.version or no " + "connection rows. Every real edition has both, so this is not the file we " + "think it is — refusing rather than comparing against it." + ) + return registry + + +def _projection(registry: dict, names: set[str]) -> dict[str, tuple]: + """``name -> (section, class, value-or-absent)`` for the names we declare. + + The unit of comparison between two editions. Restricted to our own surface, so an + unrelated row moving is invisible here — that is the whole point, and it is what + stops an observation-only bump from reddening this repository (register C-86). + + ``value`` is included because **rotation is the change no other check can see**: a + bucket id whose value changes keeps its name and its class, so the names-and-classes + check passes while every delivery goes to the wrong place. The no-copy rule forbids + writing the expected values into this repository, so the pinned edition is the only + lawful place a baseline for that comparison can live. + """ + out = {} + for section in ("connection", "target", "secret"): + for name, body in (registry.get(section) or {}).items(): + if name in names: + out[name] = (section, body.get("class"), body.get("value", "")) + return out + + def _declared_classes(registry: dict) -> dict[str, str]: """name -> the class the registry DECLARES for it (never inferred from the name).""" return { @@ -520,24 +610,116 @@ def test_every_declared_name_exists_in_the_registry_with_the_class_we_treat_it_a @pytest.mark.parametrize("partner", _PARTNERS) -def test_the_pinned_contract_edition_still_matches_the_registry(partner): - """The check that catches everything the other one cannot — including additions. +def test_the_pinned_commit_declares_the_pinned_version(partner): + """The pair is a claim, and until now nothing checked it. + + ``appwrite_env.py`` declares a version AND a commit, and the old drift check's own + message said *"Do not bump one alone — the pair is the claim."* That sentence lived + only inside an error string: no test compared the two. Someone could bump either + alone and the whole suite stayed green, leaving a pin that names an edition nobody + published. - Names and classes catch a rename. The edition catches **any** other change: a new - target this repo ought to adopt, a retired secret slot, a reworded rule. It fails - loudly and tells you what to do rather than what broke. + Verified 2026-08-11: ``fcf32c9`` does declare ``1.4.4`` — true by luck, not by check. - It is also the check that would have caught #211's stale crafd pin on the day it - was written, had it been looking at crafd. It was not. It is now. + Both sides of this comparison are frozen, so it is the one registry check in this + file that upstream activity can never redden. It fails only when *we* are wrong. """ repo = require_sibling("views-appwrite") module = _PARTNER_ENV[partner][0] - actual = _load_registry(repo)["meta"]["version"] - assert actual == module.SEAM_CONTRACT_VERSION, ( - f"the Appwrite Seam Contract's registry moved to v{actual}; " - f"{partner}/appwrite_env.py declares v{module.SEAM_CONTRACT_VERSION}. Re-verify " - f"that module's declaration against v{actual}, then bump SEAM_CONTRACT_VERSION " - "and SEAM_CONTRACT_COMMIT together. Do not bump one alone — the pair is the claim." + try: + pinned = _registry_at(repo, module.SEAM_CONTRACT_COMMIT) + except _RegistryReadError as exc: + pytest.fail(str(exc)) + assert pinned["meta"]["version"] == module.SEAM_CONTRACT_VERSION, ( + f"[{partner}] the pin is internally inconsistent: commit " + f"{module.SEAM_CONTRACT_COMMIT} declares registry v{pinned['meta']['version']}, " + f"but SEAM_CONTRACT_VERSION says v{module.SEAM_CONTRACT_VERSION}. One of the two " + "was bumped alone. The pair is the claim — fix whichever is wrong." + ) + + +def test_every_table_in_the_registry_is_classified_here(): + """A table nobody classified is an upstream change nobody looked at. + + This replaces the old edition-equality check, and it is worth being precise about + what that check was really doing. It compared version *strings*, so it fired on every + edition — five in four days, four of them recording console observations that carried + no obligation for anyone here (register C-86). A guard that cries wolf gets bumped + reflexively, which is how its one real firing goes unread. + + But it did have a real job underneath the noise, and this is that job. On 2026-08-10 + the registry grew a ``[contract.*]`` table carrying a live obligation for this + repository — the ADR-017 delivery-label mirror — and the edition check was the *only* + mechanism here that noticed, because ``_declared_classes`` parses three tables and + was silently ignoring four. + + **Directional on purpose.** Every table upstream must be classified; only the tables + we depend on must exist. An IGNORED table disappearing is not our business, which is + why v1.5.1 dropping ``[unmodelled]`` is silent while v1.5.0 adding ``[contract]`` is + a red build with something to do. Two events in the registry's life, both of which + this repository needed to see. + """ + repo = require_sibling("views-appwrite") + registry = _load_registry(repo) + + unclassified = sorted(set(registry) - set(_TABLE_ROLE)) + assert not unclassified, ( + f"the registry has table(s) this repository has never classified: " + f"{unclassified}. Decide what each one is — CONSUMED (we read it), MIRRORED (its " + "values live in our source), or IGNORED (with a reason) — and add it to " + "_TABLE_ROLE. A new table is how an obligation arrives; `[contract.*]` arrived " + "exactly this way and only a noisy version check happened to catch it." + ) + vanished = sorted(n for n in _TABLES_WE_DEPEND_ON if n not in registry) + assert not vanished, ( + f"table(s) this repository depends on are gone from the registry: {vanished}. " + "Either they were retired upstream and this package must stop reading them, or " + "the file being read is not the registry." + ) + + +@pytest.mark.parametrize("partner", _PARTNERS) +def test_nothing_this_repo_reads_has_changed_since_the_pin(partner): + """The drift check, matching on the facts rather than on the edition label. + + ADR-014 §3: when a guard fires on something legitimate, ask whether the *matching* is + wrong before you narrow the scope. The old check matched on ``meta.version``, which + is a label for "anything at all changed". This matches on the rows this package + actually declares, compared between the edition we pinned and the edition upstream + has now. + + **Fires on:** a rename, a reclassification, a removal, a valueless row gaining a + value — and on **rotation**, which is the one no other check in this file can see. A + rotated bucket id keeps its name and its class, so names-and-classes passes while + every delivery goes somewhere else. The no-copy rule forbids writing the expected + values into this repository, so the pinned edition is the only lawful baseline for + that comparison. + + **Silent through:** prose edits, ``[meta]`` bumps, and rows belonging to anyone else. + Measured across the real v1.4.4 -> v1.5.2 window (three editions, one week): green. + """ + repo = require_sibling("views-appwrite") + module, _, expected_class = _PARTNER_ENV[partner] + try: + pinned = _registry_at(repo, module.SEAM_CONTRACT_COMMIT) + except _RegistryReadError as exc: + pytest.fail(str(exc)) + + names = set(expected_class) + then, now = _projection(pinned, names), _projection(_load_registry(repo), names) + + changed = { + name: {"at the pin": then.get(name, ""), "now": now.get(name, "")} + for name in sorted(names) + if then.get(name) != now.get(name) + } + assert not changed, ( + f"[{partner}] the registry changed something this package reads, since the " + f"edition it was verified against (v{module.SEAM_CONTRACT_VERSION}, commit " + f"{module.SEAM_CONTRACT_COMMIT}): {changed}. Re-verify this module's declaration " + "against the current edition, then move SEAM_CONTRACT_VERSION and " + "SEAM_CONTRACT_COMMIT together. If a value rotated, the delivery is pointing " + "somewhere else until the launcher's environment is updated too." ) @@ -593,6 +775,83 @@ def test_the_pinned_commit_is_reachable_from_the_contract_repos_main(partner): ) +def test_the_pinned_reader_refuses_rather_than_returning_an_empty_registry(): + """The trap this reader exists to avoid, proven rather than asserted in prose. + + ``conftest.git_output`` turns any git failure into ``""``, and ``tomllib.loads("")`` + is ``{}``. Had this reader been built on it, an unreadable pin would have produced an + empty baseline, an empty projection, and a comparison that passes forever — the + vanished-directory shape ADR-014 §2 exists for, rebuilt inside the fix for a drift + detector. + + Runs everywhere: it needs a git repository, not the sibling, and this repository is + one. + """ + with pytest.raises(_RegistryReadError, match="cannot read the registry"): + _registry_at(_REPO, "0000000000000000000000000000000000000000") + + +def test_the_table_partition_would_catch_a_new_table_and_a_vanished_one(): + """Both directions, against synthetic registries — no sibling needed. + + The asymmetry is the design: an *unclassified* table fails (an obligation may have + arrived), a *missing IGNORED* table does not (not our business). Getting that + backwards would either miss the `[contract.*]` case or cry wolf on v1.5.1's removal + of `[unmodelled]`, and both have actually happened upstream. + """ + base = {name: {} for name in _TABLE_ROLE} + + arrived = dict(base) | {"brand_new_table": {}} + assert sorted(set(arrived) - set(_TABLE_ROLE)) == ["brand_new_table"], ( + "a table nobody classified went unnoticed — that is how `[contract.*]` arrived" + ) + + lost_consumed = {k: v for k, v in base.items() if k != "target"} + assert [n for n in _TABLES_WE_DEPEND_ON if n not in lost_consumed] == ["target"], ( + "a table this package reads vanished and the check did not object" + ) + + lost_ignored = {k: v for k, v in base.items() if k != "excluded"} + assert not [n for n in _TABLES_WE_DEPEND_ON if n not in lost_ignored], ( + "an IGNORED table disappearing must NOT fire — v1.5.1 removed `[unmodelled]` " + "and that carried no obligation for anyone here (ADR-014 §3: prefer the false " + "negative to the false alarm)" + ) + + +@pytest.mark.parametrize("partner", _PARTNERS) +def test_the_drift_check_would_catch_a_rotation_that_names_and_classes_cannot(partner): + """Rotation is the whole reason the projection carries ``value``. + + A rotated bucket id keeps its name and its class, so + ``test_every_declared_name_exists_in_the_registry_with_the_class_we_treat_it_as`` + passes while every delivery goes somewhere else. This proves the differential sees + what that check structurally cannot — and proves it without a sibling checkout, so + it runs on every machine rather than only where views-appwrite happens to be. + + No real coordinate value appears here: both editions are synthetic, and the point is + that the two *differ*, not what either says. + """ + _, own_store, expected_class = _PARTNER_ENV[partner] + canary = own_store[0] + names = set(expected_class) + + def edition(value: str) -> dict: + return {"target": {canary: {"class": "target", "value": value}}} + + then = _projection(edition("value-at-the-pin"), names) + now = _projection(edition("value-after-rotation"), names) + + assert then[canary][:2] == now[canary][:2], ( + "this fixture is meant to change ONLY the value — if the name or class differs " + "it is not proving what it claims" + ) + assert then != now, ( + f"[{partner}] a rotated value went unnoticed. Names and classes are identical " + "on both sides, so nothing else in this file can see it." + ) + + @pytest.mark.parametrize("partner", _PARTNERS) def test_the_drift_check_would_catch_a_rename(partner): """A gated test that cannot fail is decoration — so prove this one bites in CI. From 3f3f5709baab26ec9b526966de2ad2cc31fb3d20 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 15:07:22 +0200 Subject: [PATCH 23/42] =?UTF-8?q?test(product):=20#238=20D3+D4=20=E2=80=94?= =?UTF-8?q?=20the=20delivery=20label=20reads=20the=20registry,=20not=20the?= =?UTF-8?q?=20consumer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D3. The consumer-name check now reads the public coordinate registry instead of the consumer's source, for BOTH partners. No credential, and the private partner is reachable for the first time — that is ADR-017 §5 in one assertion. The check it replaces broke on 2026-08-11, and how it broke is the argument: views-faoapi tidied managers/api.py into a named constant, so our regex went looking for an APIPathManager(...) construction that had moved. Their code got better and our check went red. ADR-017 §7 — we were never entitled to depend on another repository's file layout. D4 is PER PARTNER, and I got this wrong once before catching it. My first pass deleted the source-read for both, which violates ADR-017 §5's own sequencing constraint: a partner's source-read goes only when THAT partner's consumer-side check exists. Otherwise the registry row is a string a human typed and a green build proves only that two values this platform authored agree. views-faoapi#379 merged 2026-08-11 -> the FAO source-read is gone views-crafdapi#53 open -> the crafd source-read stays The survivor is scoped by a declared, deliberately SHRINKING map (_CONSUMER_SELF_CHECK_PENDING) naming the issue that retires each entry, and its docstring says plainly that the test is meant to be deleted. The crafd sibling fetch lives or dies with it, which conftest's note already records. BOTH PARTNERS, correcting #238. That issue says CRAF'd's label "is not a decided value" and defers it. views-appwrite declared both rows in v1.5.0 and the CRAF'd row corrects this itself: "Both sides already use this value in code today — this row DECLARES a standing fact, it does not decide a new one", citing both sides' source. ADR-017 Appendix B updated. ADR-017 Appendix B also carried a contradiction, fixed before scoping rather than discovered mid-PR: step 2 said "in the same change, stop fetching views-crafdapi", while §5 forbids retiring that partner's check until #53 lands — and that fetch's sole consumer IS that check. The two could not both be followed. The no-copy scan is now scoped BY THE PARTITION rather than an inline tuple, so `contract` cannot be swept in by a one-word edit. It must not be: ADR-017 §5 REQUIRES those values in our source, so banning them would forbid what the contract obliges. Not an exception to "never copy a coordinate" — a different class, declared upstream, whose values no reader ever emits into a process environment. Proven: reclassifying contract as CONSUMED reddens on our own product.py, so the widening cannot be quiet. Suite 409 passed / 1 skipped / 40 xfailed — green for the first time in this arc. Co-Authored-By: Claude Opus 5 (1M context) --- .../017_facts_across_a_private_boundary.md | 32 ++-- tests/test_env_declaration.py | 18 ++- tests/test_product.py | 147 +++++++++++++++--- 3 files changed, 163 insertions(+), 34 deletions(-) diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index c419527..d327e68 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -357,17 +357,29 @@ visibility can be inferred for future APIs. 1. **views-appwrite** — declare the label for each partner in the coordinate registry. Filed as views-appwrite#75. Nothing here can proceed before it. - **The two partners are not equally ready, and the halves should land separately.** The - FAO label is fully specified — `un_fao`, in force, evidenced in ADR-013 §4.1a. The - CRAF'd label is **not a value anyone has decided yet**: that partner serves a different - dataset whose targets, columns and entity model are still an open data-contract question - on the consumer side. Bundling both into one edit stalls the ready half behind the - blocked one, so **the FAO row should land on its own** and the CRAF'd row should follow - its data contract. + **Superseded 2026-08-11 — both rows now exist and both are in force.** This step used + to say the CRAF'd label was "not a value anyone has decided yet" and should follow its + data contract. views-appwrite declared both rows in v1.5.0, and the CRAF'd row states + the correction itself: *"Both sides already use this value in code today — this row + DECLARES a standing fact, it does not decide a new one."* It cites both sides' source. + + So step 1 is **done for both partners**, and step 2 covers both. What remains split is + step 3, which is per-partner and is what step 2's retirement half waits on. 2. **views-postprocessing** — switch the check to read the registry rather than the - consumer's source, for both partners. Ours, blocked on step 1. **In the same change**, - stop fetching `views-crafdapi` in CI: §7 shows that fetch then serves nothing, and - ADR-016's rules require a sibling that is not fetched to say why. + consumer's source, for both partners. Ours, blocked on step 1. + + **Erratum, 2026-08-11 — this step used to say "in the same change, stop fetching + `views-crafdapi` in CI", and that contradicted §5.** §5 constrains the order: a + partner's source-reading check is removed only once *that partner's* consumer-side + check exists. The crafd source-read is the sole consumer of the crafd fetch, so + retiring the fetch retires the check — before views-crafdapi#53 has landed. The + instruction and the constraint could not both be followed. + + Corrected: **retirement is per partner, and follows that partner's step 3.** For FAO + that is now met (views-faoapi#379 merged 2026-08-11), so the FAO source-read goes. For + CRAF'd it is not (views-crafdapi#53 open), so the crafd source-read stays — and with + it the crafd fetch, whose `note` in `tests/conftest.py` already records that it lives + or dies with that one check. 3. **views-faoapi** — verify its own served label against the declaration; §8's other half. Filed as views-faoapi#379, and the consumer-side maintainer has accepted it. diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 49a3d60..e9bcc7a 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -497,7 +497,8 @@ def _load_registry(repo: Path) -> dict: "connection": "CONSUMED", # parsed by _declared_classes; coordinates we read "target": "CONSUMED", # parsed by _declared_classes; coordinates we read "secret": "CONSUMED", # parsed by _declared_classes; the operator's slots - "contract": "MIRRORED", # values legitimately live in our source — ADR-017; see below + "contract": "MIRRORED", # values live in our source by design (ADR-017 §5); + # checked by tests/test_product.py, not by _declared_classes "excluded": "IGNORED", # names the registry records as deliberately NOT coordinates "test_environment": "IGNORED", # a fact about the platform, not about this package "meta": "METADATA", # the edition and its amendment log @@ -937,9 +938,22 @@ def test_no_coordinate_value_is_copied_into_this_repo(): if repo is None: pytest.skip("views-appwrite checkout not found — set VIEWS_APPWRITE") registry = _load_registry(repo) + # Scoped by the declared partition, NOT by an inline tuple — so a new table cannot + # be swept in by a one-word edit, and `contract` cannot be swept in at all. + # + # `[contract.*]` values are MIRRORED: ADR-017 §5 requires them to appear in this + # package's source, because we write them onto every upload. Banning them here would + # forbid the thing the contract obliges. That is not an exception to "never copy a + # coordinate" — it is a different class, declared upstream: the registry's own header + # says no reader scans `[contract.*]`, so no value there ever reaches a process + # environment. The no-copy rule protects values the launcher supplies; a mirror is + # the inverse by construction. + scanned_sections = tuple( + name for name, role in _TABLE_ROLE.items() if role == "CONSUMED" and name != "secret" + ) values = { body["value"] - for section in ("connection", "target") + for section in scanned_sections for body in registry.get(section, {}).values() if isinstance(body.get("value"), str) and len(body["value"]) > 6 } diff --git a/tests/test_product.py b/tests/test_product.py index 1fd606f..aa2281f 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -178,17 +178,125 @@ def test_every_partner_has_a_declared_consumer_repository(): ) +#: The platform registry, relative to a views-appwrite checkout. +#: +#: Loaded locally rather than imported from ``tests/test_env_declaration.py``: a test +#: module importing another test module is a dependency nobody declared, and the two read +#: different things — that file reads coordinate sections, this one reads one contract +#: row. Two four-line readers that are understood beat one shared one that has to serve +#: both (WET before DRY). +_REGISTRY_RELPATH = Path("docs") / "ADRs" / "platform" / "coordinate_registry.toml" + + +def _registry(repo: Path) -> dict: + tomllib = pytest.importorskip("tomllib", reason="stdlib from 3.11; pyproject requires it") + return tomllib.loads((repo / _REGISTRY_RELPATH).read_text()) + + +#: partner -> the registry row that is the AUTHORITY for its delivery label. +#: +#: Declared, never derived from the partner name: `unfao` -> `UNFAO_...` is mechanical, +#: `crafd` -> `UNCRAFD_...` is not, and guessing it would be the inference ADR-003 forbids. +_CONTRACT_ROW = { + "unfao": "UNFAO_CONSUMER_DOCUMENT_NAME", + "crafd": "UNCRAFD_CONSUMER_DOCUMENT_NAME", +} + + +def test_every_partner_has_a_declared_contract_row(): + """Assert this file's declared scope is the real one (ADR-014 §2). + + A partner missing from ``_CONTRACT_ROW`` is a partner whose delivery label is checked + against nothing, and the suite stays green — which is exactly how ``crafd`` arrived + the first time. + """ + assert set(_CONTRACT_ROW) == set(PARTNER_PACKAGES), ( + f"partners with no declared contract row: " + f"{sorted(set(PARTNER_PACKAGES) - set(_CONTRACT_ROW))}; declared but no longer a " + f"partner: {sorted(set(_CONTRACT_ROW) - set(PARTNER_PACKAGES))}." + ) + + @pytest.mark.parametrize("partner", PARTNER_PACKAGES) -def test_the_declared_consumer_name_still_matches_the_consumer(partner): - """§4.1a across the seam — the half a local literal cannot carry. +def test_the_declared_consumer_name_matches_the_registry(partner): + """ADR-017 §5, the producer half: our mirror against the public declaration. + + **What this replaces, and why.** Until 2026-08-11 this check read the *consumer's + source* — a regex over `managers/api.py` looking for the argument to an + `APIPathManager(...)` construction. That worked on a laptop and never in CI for the + private partner, and it broke on 2026-08-11 when views-faoapi tidied that file into a + named constant. Their code got better and our check went looking for something that + had moved. ADR-017 §7: we were never entitled to depend on another repository's file + layout. + + Now both sides read one public declaration and neither reads the other. The registry + lives in views-appwrite, which is public, so this needs no credential even for the + private partner — that is the whole of ADR-017 §5 in one assertion. + + The value it guards is the one whose failure is silent: a delivery filed under a name + the consumer does not ask for is uploaded, stored, billed, and invisible — no error + anywhere (ADR-013 §4.1a). + """ + repo = require_sibling("views-appwrite") + registry = _registry(repo) + + contract = registry.get("contract") or {} + row = _CONTRACT_ROW[partner] + assert row in contract, ( + f"[{partner}] the registry has no `[contract.{row}]` row. That row is the " + "authority this package's CONSUMER_DOCUMENT_NAME mirrors (ADR-017 §5); without " + "it there is nothing to check the mirror against. Either it was retired upstream " + "or this file names the wrong row." + ) + declared = contract[row].get("value") + ours = _product(partner).CONSUMER_DOCUMENT_NAME + assert declared == ours, ( + f"[{partner}] this package writes every delivery under {ours!r}, but the platform " + f"registry declares {declared!r} in `[contract.{row}]`. The registry is the " + "authority — the consumer owns this name and changing it is a contract amendment. " + "A delivery under a name the consumer does not ask for is uploaded, stored, and " + "invisible (ADR-013 §4.1a)." + ) + assert declared == _CONSUMER_DOCUMENT_NAME[partner], ( + f"[{partner}] the pin in this file disagrees with the registry — the local " + "always-on test above should have caught a mismatch with the module first." + ) + + +#: Partners whose consumer-side self-check has NOT landed yet, so this repository still +#: reads their source as well as the registry (ADR-017 §5's sequencing constraint). +#: +#: Declared rather than inferred, and deliberately a *shrinking* list: an entry leaves it +#: when that partner's own check lands, and the day it empties this whole mechanism goes. +_CONSUMER_SELF_CHECK_PENDING = { + "crafd": "views-crafdapi#53", +} + + +def test_the_pending_list_names_only_real_partners(): + """A stale name here would keep a retired mechanism alive against nothing.""" + unknown = sorted(set(_CONSUMER_SELF_CHECK_PENDING) - set(PARTNER_PACKAGES)) + assert not unknown, f"not partners: {unknown}" + - The consumer decides what it filters on. This repository declares what it writes. - Agreement between the two is the whole of §4.1a, and it is not a fact this - repository owns — so it is checked against the consumer's checkout, exactly as the - coordinate registry is checked against views-appwrite's. +@pytest.mark.parametrize("partner", sorted(_CONSUMER_SELF_CHECK_PENDING)) +def test_the_consumer_still_filters_on_the_name_until_it_checks_itself(partner): + """The source-read, kept ONLY for partners whose own check does not exist yet. - Gated: skips without the sibling, naming the variable to set. The always-on pin - above still runs everywhere, so CI is never left with nothing. + ADR-017 §5 constrains the order: this repository stops reading a consumer's source + when *that consumer* starts checking itself against the registry. Otherwise there is + a window where the registry row is a string a human typed, our check compares our + copy to it, and nothing anywhere consults what the consumer actually does — a green + build proving only that two values this platform authored agree. + + views-faoapi#379 landed on 2026-08-11, so the FAO half of this check is gone. + views-crafdapi#53 has not, so CRAF'd's stays — and with it the crafd sibling fetch, + whose ``note`` in ``tests/conftest.py`` records that it lives or dies with this one + check. + + **This test is meant to be deleted.** When #53 lands, remove that partner from + ``_CONSUMER_SELF_CHECK_PENDING``; when the map empties, remove this test, the regexes + it uses, and the fetch. """ repo = require_sibling(CONSUMER_REPO[partner]) pkg = _consumer_package(repo) @@ -197,25 +305,20 @@ def test_the_declared_consumer_name_still_matches_the_consumer(partner): found = _CONSUMER_PATH_MANAGER.findall(api) assert len(found) == 1, ( f"expected exactly one APIPathManager(...) construction in " - f"{CONSUMER_REPO[partner]}'s managers/api.py, found {found}. More than one " - "means the consumer serves several document names and this check no longer " - "knows which one is ours." + f"{CONSUMER_REPO[partner]}'s managers/api.py, found {found}. This is the fragile " + "half ADR-017 retires — a layout change there breaks a check here, which is what " + "happened to the FAO half on 2026-08-11. It survives only until " + f"{_CONSUMER_SELF_CHECK_PENDING[partner]} lands." ) assert found[0] == _CONSUMER_DOCUMENT_NAME[partner], ( f"{CONSUMER_REPO[partner]} filters on {found[0]!r}; this repository declares " - f"{_CONSUMER_DOCUMENT_NAME[partner]!r} and writes it as " - f"{partner}/product.py's CONSUMER_DOCUMENT_NAME. A delivery under a name the " - "consumer does not ask for is uploaded, stored, and invisible (ADR-013 §4.1a)." - ) - assert found[0] == _product(partner).CONSUMER_DOCUMENT_NAME, ( - "the declared pin in this file and the module's constant disagree — the " - "always-on test above should have caught this first" + f"{_CONSUMER_DOCUMENT_NAME[partner]!r}. A delivery under a name the consumer does " + "not ask for is uploaded, stored, and invisible (ADR-013 §4.1a)." ) manager = (pkg / "managers" / "prediction" / "manager.py").read_text() assert _CONSUMER_FILTER in manager, ( - f"{CONSUMER_REPO[partner]} no longer selects by " - f"{_CONSUMER_FILTER!r}. The name may still match while the consumer filters on " - "something else entirely — same invisibility, different cause. Re-read its " - "selection path before assuming this repository's deliveries are reachable." + f"{CONSUMER_REPO[partner]} no longer selects by {_CONSUMER_FILTER!r}. The name " + "may still match while the consumer filters on something else entirely — same " + "invisibility, different cause." ) From 8e1b874b68e3272bf635bc7cd1b9b7d9601cdbe1 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 15:15:58 +0200 Subject: [PATCH 24/42] chore(seam): re-pin both partners to registry v1.5.2 / b703cab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested by the views-appwrite seat on #238. Their three facts all check out: b703cab declares 1.5.2, it is reachable from their main, and it is the tag appwrite-seam-v1.5.2. ONE CORRECTION TO THEIR PREMISE. The issue is titled "we are breaking your build" and names test_the_pinned_contract_edition_still_matches_the_registry. That test no longer exists — the preceding commit deleted it, because comparing version STRINGS is what made three observation-only editions look like three obligations. So nothing was broken and no push was going to break. Worth telling them, since they filed it as an outage they had caused. Their advice survives the correction, and is the better half of the message: "actually read the diff rather than just moving the number." Done, both ways. By hand — git diff appwrite-seam-v1.4.4..v1.5.2 on the registry is, in full: + [contract.UNFAO_CONSUMER_DOCUMENT_NAME] value = "un_fao" + [contract.UNCRAFD_CONSUMER_DOCUMENT_NAME] value = "un_crafd" - [unmodelled.session_auth_email_password] ... and prose Every element is already handled by the checks that replaced the string comparison: the two contract rows are classified MIRRORED and asserted in test_product; the unmodelled removal is the IGNORED direction that is deliberately silent; and nothing in connection, target or secret moved. Mechanically — test_nothing_this_repo_reads_has_changed_since_the_pin was green across the whole 1.4.4 -> 1.5.2 window before this commit. That is the re-verification the ratchet asks for, done by a check rather than by a promise, and it is what makes moving the baseline safe rather than merely tidy. Note what re-pinning costs: the differential now spans nothing until upstream moves again. That is correct — a baseline that has been verified moves forward — but it means the evidence for this window lives in this message rather than in a test from here on. Docstring version and the pinned blob URL moved with the constants; the pin-pair check confirms b703cab declares 1.5.2 rather than taking it on trust. Suite 409 passed. Co-Authored-By: Claude Opus 5 (1M context) --- views_postprocessing/crafd/appwrite_env.py | 8 ++++---- views_postprocessing/unfao/appwrite_env.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/views_postprocessing/crafd/appwrite_env.py b/views_postprocessing/crafd/appwrite_env.py index 57ab560..9a379c6 100644 --- a/views_postprocessing/crafd/appwrite_env.py +++ b/views_postprocessing/crafd/appwrite_env.py @@ -6,9 +6,9 @@ views-appwrite and referenced by pinned URL, never copied — copies were the platform's original failure (þing-01 sáttmál S6): - https://github.com/views-platform/views-appwrite/blob/fcf32c9/docs/ADRs/platform/coordinate_registry.toml + https://github.com/views-platform/views-appwrite/blob/b703cab/docs/ADRs/platform/coordinate_registry.toml -That pin is registry **v1.4.4** — declared below as ``SEAM_CONTRACT_VERSION`` / +That pin is registry **v1.5.2** — declared below as ``SEAM_CONTRACT_VERSION`` / ``SEAM_CONTRACT_COMMIT`` so the pin is a value a test can check rather than a fact buried in prose. A pinned URL does not rot, but it does go stale, and nothing in this repository could previously tell you it had (register C-57). @@ -47,8 +47,8 @@ #: declaration against that edition of the registry. ``tests/test_env_declaration.py`` #: enforces the pair — for this package and for ``unfao`` alike — against a local #: views-appwrite checkout when one is present. -SEAM_CONTRACT_VERSION = "1.4.4" -SEAM_CONTRACT_COMMIT = "fcf32c9" +SEAM_CONTRACT_VERSION = "1.5.2" +SEAM_CONTRACT_COMMIT = "b703cab" CONNECTION_ENV = ( "APPWRITE_ENDPOINT", diff --git a/views_postprocessing/unfao/appwrite_env.py b/views_postprocessing/unfao/appwrite_env.py index ffd6169..b13fade 100644 --- a/views_postprocessing/unfao/appwrite_env.py +++ b/views_postprocessing/unfao/appwrite_env.py @@ -5,9 +5,9 @@ views-appwrite and referenced by pinned URL, never copied — copies were the platform's original failure (þing-01 sáttmál S6): - https://github.com/views-platform/views-appwrite/blob/fcf32c9/docs/ADRs/platform/coordinate_registry.toml + https://github.com/views-platform/views-appwrite/blob/b703cab/docs/ADRs/platform/coordinate_registry.toml -That pin is registry **v1.4.4** — declared below as +That pin is registry **v1.5.2** — declared below as ``SEAM_CONTRACT_VERSION`` / ``SEAM_CONTRACT_COMMIT`` so the pin is a value a test can check rather than a fact buried in prose. A pinned URL does not rot, but it does go stale, and nothing in this repository could previously tell you it had (register C-57). @@ -44,8 +44,8 @@ #: Bumping these is not bookkeeping: it asserts that someone re-checked this module's #: declaration against that edition of the registry. ``tests/test_env_declaration.py`` #: enforces the pair against a local views-appwrite checkout when one is present. -SEAM_CONTRACT_VERSION = "1.4.4" -SEAM_CONTRACT_COMMIT = "fcf32c9" +SEAM_CONTRACT_VERSION = "1.5.2" +SEAM_CONTRACT_COMMIT = "b703cab" CONNECTION_ENV = ( "APPWRITE_ENDPOINT", From 898a0aa05a9cd91062b7d61b235bea0183054277 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 16:22:01 +0200 Subject: [PATCH 25/42] fix(tests): fifteen findings from /code-review max, one of them shipped-and-dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design had been reviewed before implementation and the ADRs falsified four times. The ~470 lines of test code had been reviewed by nobody. Fifteen findings; most were mutation-proven by the reviewer against my own code. THE ONE THAT WAS ALREADY LIVE. SEAM_CONTRACT_COMMIT = "b703cab" is an annotated TAG OBJECT, not a commit. git cat-file -t says `tag`; it peels to c7b597e. So the pinned blob URL both PUBLIC partner modules publish returned HTTP 404 — verified over the network — three lines above the sentence "A pinned URL does not rot." Every test passed because git peels tags silently in show, merge-base and cat-file alike. Pinned c7b597e. SECURITY. The drift check's failure message interpolated projection tuples, which carry coordinate VALUES, into a PUBLIC repository's world-readable CI log — on the one event the check exists to fire on. Values are now digested (sha256 prefix); names and fields are reported in the clear. BOTH "PERMANENT PROOFS" WERE TAUTOLOGIES. They re-typed the production expressions instead of calling them. Proven: replacing the real predicates with `return []` left the table proof green; making the drift comparison ignore `value` — literally the thing the rotation proof exists to disprove — left that one green too. So the guards this PR sells as mutation-proven had no detector at all. Predicates extracted; both proofs now call production code; both re-proven by breaking the real thing. _TABLE_ROLE WAS DECORATIVE. Two of its three consumers kept hardcoded copies of the section tuple, so classifying a table CONSUMED satisfied the guard demanding it and added zero coverage — C-74's shape arriving through a guard's own remediation advice. One _rows() reader now drives all three. Role strings are a closed vocabulary, because a typo silently narrowed the security scan from twelve values to two. THE DELETED CHECK'S REAL JOB WAS NOT FULLY REPLACED. A new upstream row in a table we read was invisible, and nothing forced a re-pin: restoring the pin to 1.4.4 against the current registry passed everything. Added: coordinates ARRIVING in a table we read now fail with "decide whether this package must adopt them" — the half worth keeping, since `[contract.*]` arrived exactly that way. "MAIN" WAS THE WORKING TREE. The differential compared against whatever branch the sibling clone sat on — #196's shape, grading this repo against unreviewed content. Reads origin/main now, which is what the ADR already claimed. Also: an unvalidated ref meant a blanked or branch-valued pin compared the registry to itself and passed forever; three exception types escaped a reader whose whole job is failing legibly; the reader's proof exercised one branch for the wrong reason (five branches now, against a purpose-built scratch repo); an emptied _CONSUMER_SELF_CHECK_ PENDING silently SKIPPED rather than prompting deletion. Nine prose claims corrected, including three repetitions of an [unmodelled] worked example that was simply wrong — it was never in the partition, so it would have been a red build, not the silent pass it was cited as. And an ADR table asserting machine enforcement that does not exist, in the document whose thesis is that unbacked claims go wrong unnoticed. Suite 410 passed / 1 skipped / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 37 +- reports/technical_risk_register.md | 4 +- tests/conftest.py | 9 +- tests/test_env_declaration.py | 356 ++++++++++++++---- tests/test_product.py | 45 ++- views_postprocessing/crafd/appwrite_env.py | 4 +- views_postprocessing/unfao/appwrite_env.py | 4 +- 7 files changed, 355 insertions(+), 104 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index e764885..a67b249 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -41,9 +41,12 @@ the boundary and says so, rather than implying a coverage it does not have. Most tests here check our own code. A few check something different: whether a **claim this repository makes about a different repository** is still true. -The clearest example. Each partner's `appwrite_env.py` says, in effect, *"we were written -against edition 1.4.4 of the shared configuration registry."* That registry lives in -another repository. A test opens that repository and checks the edition is still 1.4.4. +The clearest example. Each partner's `appwrite_env.py` records which edition of the +shared configuration registry it was verified against, by naming a commit in another +repository. Tests open that repository at that commit and ask whether anything this +package reads has moved since — a rename, a reclassification, a rotated value, or a new +coordinate that has arrived. *(Until 2026-08-11 they asked a cruder question — whether the +edition **label** still matched — which fired on every upstream edit. See §7b's erratum.)* This is not hypothetical housekeeping. On 2026-08-05 that test failed — the registry had moved to a new edition while nobody here was looking. Two days earlier, the same family of @@ -110,12 +113,16 @@ Who is who, once and in one table: | repository | public? | fetched by CI? | why not, if not | |---|---|---|---| | **views-appwrite** | yes | **yes** | — carries the registry drift checks | -| **views-crafdapi** | yes | **yes**, temporarily | — retires when ADR-017 §7 lands | +| **views-crafdapi** | yes | **yes**, temporarily | — retires when views-crafdapi#53 lands, not before (ADR-017 §5 is per partner) | | **views-datafactory** | yes | no | its checks need raw data that is not in its git repository; fetching it turns an honest skip into a crash | | **views-faoapi** | **no** | no | private — no amount of workflow configuration reaches it. That case is [ADR-017](017_facts_across_a_private_boundary.md) | -The table is not decoration: it is `tests/conftest.py::SIBLINGS` in prose, and a test fails -if the two disagree. +**What of this table is enforced, and what is not.** `ci_checkout` is: the workflow and +`SIBLINGS` are compared by `tests/test_ci_sibling_coverage.py`, and disagreement fails. +The **`public?` column is not, and cannot be** — which is why `public` was deleted from the +`Sibling` record on 2026-08-10, after a review found nothing could verify it. It is here +because a reader needs it to follow the argument. Nothing reads this table; if it drifts +from `SIBLINGS`, only a human will notice. ### §5 CI downloads exactly what that list says, and a test enforces it @@ -239,10 +246,14 @@ So the two categories above become three: - **Drift tripwires** read the sibling's `main` because movement is the signal. - **Reachability checks** read a pinned commit; movement is noise. -- **Differential tripwires** read **both** — a pinned edition as the baseline, `main` as - the comparison — and fire only when something *we declare* differs between them. This is - the shape the registry check now has. It catches a rotation, which is invisible to the - other two, and it is silent through prose edits and version bumps. +- **Differential tripwires** read **both** — a pinned edition as the baseline, the + sibling's `main` as the comparison — and fire when something *we declare* differs between + them, or when a coordinate arrives in a table we read. This is the shape the registry + check now has. It catches a rotation, which is invisible to the other two, and it is + silent through prose edits and version bumps. *(Corrected 2026-08-11: the first + implementation compared against the sibling's **working tree**, so a developer whose + clone sat on a feature branch was grading this repository against unreviewed content — + #196's shape. It reads `origin/main` now.)* The third kind exists because the no-copy rule forbids writing expected coordinate values into this repository. A pinned edition is the only lawful place to keep a baseline for @@ -276,6 +287,12 @@ Until that declaration exists, the affected check runs on a maintainer's machine in CI. That is stated in the sibling's `note`, which rule G4 requires and which must name the record that owns it. +**Update 2026-08-11.** The declaration now exists (views-appwrite#75), views-faoapi +checks itself against it (their #379), and this repository's check reads the registry +rather than that repository's source. So the FAO half is no longer laptop-only, and no +credential was issued. What remains is CRAF'd's consumer-side check (views-crafdapi#53); +until it lands, that partner's source-read stays, and with it that partner's fetch. + --- ## Consequences diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 40bc65a..d3c72bf 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -170,7 +170,7 @@ that indexes only deleted code is noise. | Source | `falsify` against the SOLID / component-principle lens, 2026-08-11 | | Trigger | **Either.** (a) A second non-test consumer needs one of these declarations and has to copy it. (b) A fifth declaration is added to `tests/conftest.py` — the file is at four, and the threshold for "dumping ground" is not a number but the moment nobody can say in one sentence what the file is for. | | Owner | Whoever adds the next declaration, or the next non-test consumer. Not urgent; it gets more expensive slowly. | -| Location | `tests/conftest.py` (271 lines); `scripts/build_gaul_lookup.py:73`; ADR-016 §4. | +| Location | `tests/conftest.py` (grew again this week; `wc -l` for the number, which moves); `scripts/build_gaul_lookup.py:73`; ADR-016 §4. | `tests/conftest.py` is pytest's fixture file. It currently holds **four unrelated groups**: the package taxonomy (`PARTNER_PACKAGES`, `MACHINERY_PACKAGES`), the sibling repositories (`Sibling`, `SIBLINGS`, and three resolver functions), the consumer mapping (`CONSUMER_REPO`), and two git helpers (`git_output`, `commit_is_on_main`). None of those is a test fixture. They are declarations about the platform that happen to be consumed by tests. @@ -630,7 +630,7 @@ CI now checks that repository out and those seven run on every pull request — **What remains, and it is two different things:** -1. **One dark check.** `views-faoapi` is genuinely private — the consumer-name pin is still laptop-only. That is one test, not seven, and it is the one whose failure mode is invisible rather than loud. ADR-016 §8 defers the credential and names the trigger: FAO declining the request to make that repository public, or a second private sibling appearing. +1. **~~One dark check.~~ CLOSED 2026-08-11, and not by a credential.** This read: *"views-faoapi is genuinely private — the consumer-name pin is still laptop-only. That is one test, and it is the one whose failure mode is invisible rather than loud."* ADR-017 moved that check onto the public coordinate registry, so it runs in CI for every partner and needs no access to any private repository. The credential ADR-016 §8 deferred was never issued and is no longer the route. What remains of this half is CRAF'd's consumer-side check (views-crafdapi#53), which is the other repository's work, not a dark check here. 2. **The enforcement half is entirely untouched.** `protect_main`'s ref-name include-list was empty; it now targets the default branch, but **no status check is required**, so a pull request with a red CI can still be merged to `main`. Since merging to `main` *is* the production release, this is the half that matters most and the half that has not moved. **And it no longer stands alone — read C-86 before closing this.** Measured 2026-08-10: `protect_main` also lists **zero bypass actors**, so once a status check *is* required, nobody can merge past it, administrator or otherwise. Meanwhile ADR-016 made this repository's CI depend on two other repositories. Adding the required check therefore does two things at once: it closes this entry, and it makes an unbypassable external dependency live on the release path. Both are defensible; doing them in one unremarked step is not. If the escape is wanted, a bypass actor is the same console session. diff --git a/tests/conftest.py b/tests/conftest.py index 18c1d6f..839acaa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -149,10 +149,11 @@ class Sibling: "PRIVATE — the only one, and that is why it is not fetched. Checking it " "out needs a credential, which is an " "operator decision deferred pending a request to FAO to make the repository " - "public. One check is dark meanwhile — the consumer-name pin, whose failure " - "mode is a delivery nobody can find. ADR-017 decides what happens instead " - "of a credential: the fact moves to the public coordinate registry, and " - "neither side reads the other. See also register C-81." + "public. **Nothing is dark because of this any more** (2026-08-11): ADR-017 " + "moved the delivery-label check onto the public coordinate registry, which " + "needs no credential, so neither side reads the other. This entry stays " + "PRIVATE because the fact is still true, not because anything is blocked on " + "it. See ADR-017 and register C-81." ), ), "views-crafdapi": Sibling( diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index e9bcc7a..b96a944 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -24,6 +24,7 @@ """ import ast +import hashlib import logging import re import subprocess @@ -485,6 +486,10 @@ def _load_registry(repo: Path) -> dict: return tomllib.loads((repo / _REGISTRY_RELPATH).read_text()) +#: Sentinel for "this row carries no value", distinct from any string a registry +#: could legitimately hold. +_ABSENT = object() + #: Every top-level table the registry may carry, and what this repository does with it. #: #: **Declared, and asserted against the live registry** — a table appearing upstream that @@ -504,14 +509,59 @@ def _load_registry(repo: Path) -> dict: "meta": "METADATA", # the edition and its amendment log } -#: Tables whose absence is our problem. An IGNORED table vanishing is not — that -#: direction is deliberately unchecked, which is why v1.5.1 dropping `[unmodelled]` is +#: direction is deliberately unchecked. (An earlier version of this comment offered +#: v1.5.1's removal of `[unmodelled]` as the worked example of a silent case. That was +#: WRONG: `unmodelled` was never in this partition, so against v1.4.4 it would have +#: been a RED build demanding classification. The rule is right; the illustration +#: was not, and it had been repeated in three places.) #: silent while v1.5.0 adding `[contract]` is a red build with something to do. +#: The only roles that mean anything. A typo in `_TABLE_ROLE` used to be silent, and it +#: silently narrowed a security scan: mistyping "CONSUMED" dropped `target` from the +#: no-copy check's sections, taking it from twelve values to two, with no test objecting. +_ROLES = ("CONSUMED", "MIRRORED", "IGNORED", "METADATA") + +#: Tables this package reads rows out of. **One source, three consumers** — the class +#: check, the drift projection and the no-copy scan all derive from here rather than each +#: keeping a copy of the tuple. They did keep copies, which made classifying a table as +#: CONSUMED add exactly zero coverage while satisfying the guard that demanded it — the +#: guard's own remediation advice producing register C-74's shape. +_CONSUMED_TABLES = tuple(n for n, role in _TABLE_ROLE.items() if role == "CONSUMED") + _TABLES_WE_DEPEND_ON = tuple( name for name, role in _TABLE_ROLE.items() if role in ("CONSUMED", "MIRRORED") ) +def _unclassified_tables(registry: dict) -> list[str]: + """Tables upstream that nobody here has classified. THE production predicate. + + Extracted so the proof can call it instead of re-typing it. The previous proof + re-typed the set arithmetic, so neutering this to ``return []`` left it green — a + detector for the detector that detected nothing. + """ + return sorted(set(registry) - set(_TABLE_ROLE)) + + +def _missing_dependencies(registry: dict) -> list[str]: + """Tables we depend on that are absent upstream. THE production predicate.""" + return sorted(n for n in _TABLES_WE_DEPEND_ON if n not in registry) + + +def _rows(registry: dict, sections: tuple) -> dict[str, tuple]: + """``name -> (section, class, value-or-absent)`` across ``sections``. + + The single reader the class check, the drift projection and the no-copy scan share. + Two hand-copied versions had already diverged on null handling: one used + ``registry.get(s, {})`` and raised ``AttributeError`` on a null section, the other + used ``(registry.get(s) or {})`` and did not. + """ + return { + name: (section, body.get("class"), body.get("value", _ABSENT)) + for section in sections + for name, body in (registry.get(section) or {}).items() + } + + class _RegistryReadError(RuntimeError): """The registry could not be read at a pinned commit — never a silent empty dict.""" @@ -530,6 +580,37 @@ def _registry_at(repo: Path, ref: str) -> dict: holding four unrelated groups, and its trigger is a fifth. There is one consumer. """ tomllib = pytest.importorskip("tomllib", reason="stdlib from 3.11; pyproject requires it") + + # The ref must name a FROZEN commit, and must be the one we asked for. + # + # Without this, `git show ":"` reads the INDEX and `git show "main:"` + # reads a branch — both exit 0, both parse, both pass every anchor below. A pin + # blanked by a bad edit or set to "main" by someone who thinks that is what a pin + # means would make the differential compare the current registry against itself and + # report green against every future edition. That is the same silent-baseline trap + # this reader exists to close, wearing a different hat. + # + # ``^{commit}`` also peels an annotated TAG. That matters here: a tag object is not + # a commit, is reachable only through refs/tags, and — measured 2026-08-11 — yields + # a 404 from the pinned blob URL these modules publish. So this refuses one. + resolved = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + capture_output=True, text=True, check=False, timeout=30, + ) + if resolved.returncode != 0 or not resolved.stdout.strip(): + raise _RegistryReadError( + f"{ref!r} does not resolve to a commit in {repo}. A pin must name a frozen " + "commit: an empty ref reads the index, a branch name reads a moving tip, and " + "either would make the comparison below compare the registry to itself." + ) + if ref not in ("origin/main", "main") and not resolved.stdout.strip().startswith(ref): + raise _RegistryReadError( + f"{ref!r} resolves to commit {resolved.stdout.strip()[:12]}, which does not " + f"start with it. That happens when the pin names an annotated TAG rather than " + "a commit — git peels it silently, every check here passes, and the blob URL " + "these modules publish returns 404. Pin the commit." + ) + result = subprocess.run( ["git", "-C", str(repo), "show", f"{ref}:{_REGISTRY_RELPATH.as_posix()}"], capture_output=True, text=True, check=False, timeout=30, @@ -545,7 +626,15 @@ def _registry_at(repo: Path, ref: str) -> dict: f"the registry is EMPTY at {ref!r} in {repo}. Treating that as a registry " "with no coordinates would make every comparison below pass." ) - registry = tomllib.loads(result.stdout) + try: + registry = tomllib.loads(result.stdout) + except Exception as exc: # TOMLDecodeError, and whatever a non-TOML blob raises + raise _RegistryReadError( + f"the registry at {ref!r} in {repo} did not parse as TOML: {exc}. A raw " + "traceback from a helper whose justification is failing legibly would be its " + "own defeat — this happens when the path names a directory, or the blob is " + "not the file we think it is." + ) from exc if not registry.get("meta", {}).get("version") or not registry.get("connection"): raise _RegistryReadError( f"the registry at {ref!r} parsed but carries no meta.version or no " @@ -568,21 +657,70 @@ def _projection(registry: dict, names: set[str]) -> dict[str, tuple]: writing the expected values into this repository, so the pinned edition is the only lawful place a baseline for that comparison can live. """ + return {n: row for n, row in _rows(registry, _CONSUMED_TABLES).items() if n in names} + + +def _registry_current(repo: Path) -> dict: + """The registry as the sibling's ``main`` has it — not its working tree. + + A sibling clone normally sits on whatever branch its own agent last worked on. + Comparing against that grades us on unreviewed content, or reddens on a branch nobody + merged — #196 verbatim, the case that cost this platform a withdrawn pull request. + The ``ref: main`` discipline lived only inside the CI workflow, so a developer running + the suite locally was comparing against something else entirely, and ADR-016's erratum + claimed otherwise. + """ + for ref in ("origin/main", "main"): + try: + return _registry_at(repo, ref) + except _RegistryReadError: + continue + raise _RegistryReadError( + f"neither origin/main nor main resolves in {repo}. The comparison is against that " + "repository's ratified content, so there is nothing to compare against — run " + "`git fetch` there." + ) + + +def _describe_changes(then: dict, now: dict, names: set[str]) -> dict[str, str]: + """What differs, said WITHOUT printing a coordinate value. + + This repository is public and its CI logs are world-readable. The first version of + this comparison interpolated the projection tuples straight into the assertion + message — so the single event this check exists to fire on, a rotated coordinate, + would have printed the old and new values into a public log. Longer than the + six-character threshold the no-copy check itself uses to define a leak, and the + outcome ``tests/test_redaction_guard.py`` exists to prevent. + + So: name the field that moved, and for a value give a short digest — enough to see + that two editions disagree and to match against a rotation you performed, never + enough to be the value. + """ + def digest(value) -> str: + if value is _ABSENT: + return "" + return f"" + out = {} - for section in ("connection", "target", "secret"): - for name, body in (registry.get(section) or {}).items(): - if name in names: - out[name] = (section, body.get("class"), body.get("value", "")) + for name in sorted(names): + was, is_ = then.get(name), now.get(name) + if was == is_: + continue + if was is None or is_ is None: + out[name] = "absent at the pin" if was is None else "removed upstream" + continue + fields = [ + f"{label}: {a!r} -> {b!r}" if label != "value" else f"value: {digest(a)} -> {digest(b)}" + for label, a, b in zip(("section", "class", "value"), was, is_) + if a != b + ] + out[name] = "; ".join(fields) return out def _declared_classes(registry: dict) -> dict[str, str]: """name -> the class the registry DECLARES for it (never inferred from the name).""" - return { - name: body.get("class") - for section in ("connection", "target", "secret") - for name, body in registry.get(section, {}).items() - } + return {n: row[1] for n, row in _rows(registry, _CONSUMED_TABLES).items()} @pytest.mark.parametrize("partner", _PARTNERS) @@ -622,15 +760,14 @@ def test_the_pinned_commit_declares_the_pinned_version(partner): Verified 2026-08-11: ``fcf32c9`` does declare ``1.4.4`` — true by luck, not by check. - Both sides of this comparison are frozen, so it is the one registry check in this - file that upstream activity can never redden. It fails only when *we* are wrong. + Both sides of the *comparison* are frozen, so upstream editing the registry cannot + change its verdict. It still needs the sibling checkout to read the pinned blob, so a + missing or degraded clone reddens it — the check that runs with no sibling at all is + ``test_the_docstring_states_the_same_edition_the_constants_declare``. """ repo = require_sibling("views-appwrite") module = _PARTNER_ENV[partner][0] - try: - pinned = _registry_at(repo, module.SEAM_CONTRACT_COMMIT) - except _RegistryReadError as exc: - pytest.fail(str(exc)) + pinned = _registry_at(repo, module.SEAM_CONTRACT_COMMIT) assert pinned["meta"]["version"] == module.SEAM_CONTRACT_VERSION, ( f"[{partner}] the pin is internally inconsistent: commit " f"{module.SEAM_CONTRACT_COMMIT} declares registry v{pinned['meta']['version']}, " @@ -656,14 +793,18 @@ def test_every_table_in_the_registry_is_classified_here(): **Directional on purpose.** Every table upstream must be classified; only the tables we depend on must exist. An IGNORED table disappearing is not our business, which is - why v1.5.1 dropping ``[unmodelled]`` is silent while v1.5.0 adding ``[contract]`` is - a red build with something to do. Two events in the registry's life, both of which - this repository needed to see. + an IGNORED table disappearing is silent while a new, unclassified one is a red build + with something to do. Two such events in the registry's life so far, and this + repository needed to see both. + + *(An earlier draft illustrated the silent case with v1.5.1's removal of + ``[unmodelled]``. That was wrong — ``unmodelled`` was never in the partition, so it + would have been a red build demanding classification, not a silent pass.)* """ repo = require_sibling("views-appwrite") registry = _load_registry(repo) - unclassified = sorted(set(registry) - set(_TABLE_ROLE)) + unclassified = _unclassified_tables(registry) assert not unclassified, ( f"the registry has table(s) this repository has never classified: " f"{unclassified}. Decide what each one is — CONSUMED (we read it), MIRRORED (its " @@ -671,7 +812,7 @@ def test_every_table_in_the_registry_is_classified_here(): "_TABLE_ROLE. A new table is how an obligation arrives; `[contract.*]` arrived " "exactly this way and only a noisy version check happened to catch it." ) - vanished = sorted(n for n in _TABLES_WE_DEPEND_ON if n not in registry) + vanished = _missing_dependencies(registry) assert not vanished, ( f"table(s) this repository depends on are gone from the registry: {vanished}. " "Either they were retired upstream and this package must stop reading them, or " @@ -701,19 +842,26 @@ def test_nothing_this_repo_reads_has_changed_since_the_pin(partner): """ repo = require_sibling("views-appwrite") module, _, expected_class = _PARTNER_ENV[partner] - try: - pinned = _registry_at(repo, module.SEAM_CONTRACT_COMMIT) - except _RegistryReadError as exc: - pytest.fail(str(exc)) + pinned = _registry_at(repo, module.SEAM_CONTRACT_COMMIT) + current = _registry_current(repo) names = set(expected_class) - then, now = _projection(pinned, names), _projection(_load_registry(repo), names) + then, now = _projection(pinned, names), _projection(current, names) - changed = { - name: {"at the pin": then.get(name, ""), "now": now.get(name, "")} - for name in sorted(names) - if then.get(name) != now.get(name) - } + arrived = sorted( + set(_rows(current, _TABLES_WE_DEPEND_ON)) - set(_rows(pinned, _TABLES_WE_DEPEND_ON)) + ) + assert not arrived, ( + f"[{partner}] the registry gained coordinate(s) {arrived} in a table this package " + f"reads, since the edition it was verified against " + f"(v{module.SEAM_CONTRACT_VERSION}). Decide whether this package must adopt them " + "— that is a human read, which is why this reports rather than guesses — then " + "move SEAM_CONTRACT_VERSION and SEAM_CONTRACT_COMMIT together. This is the half " + "of the deleted edition check that was worth keeping: `[contract.*]` arrived " + "exactly this way, and nothing else here would have seen it." + ) + + changed = _describe_changes(then, now, names) assert not changed, ( f"[{partner}] the registry changed something this package reads, since the " f"edition it was verified against (v{module.SEAM_CONTRACT_VERSION}, commit " @@ -776,80 +924,138 @@ def test_the_pinned_commit_is_reachable_from_the_contract_repos_main(partner): ) -def test_the_pinned_reader_refuses_rather_than_returning_an_empty_registry(): - """The trap this reader exists to avoid, proven rather than asserted in prose. +def test_the_pinned_reader_refuses_every_way_a_baseline_can_be_wrong(tmp_path): + """Each refusal branch, against a scratch repository built to trigger it. - ``conftest.git_output`` turns any git failure into ``""``, and ``tomllib.loads("")`` - is ``{}``. Had this reader been built on it, an unreadable pin would have produced an - empty baseline, an empty projection, and a comparison that passes forever — the - vanished-directory shape ADR-014 §2 exists for, rebuilt inside the fix for a drift - detector. + The first version of this test used a bogus sha against *this* repository — where the + registry path does not exist at any ref, so a nonsense sha and ``HEAD`` failed + identically. It proved the helper rejects *something*, not that it rejects an + unreadable **pin**, and deleting the empty-file and anchor branches left it green. - Runs everywhere: it needs a git repository, not the sibling, and this repository is - one. + Every branch here is genuinely reachable, and each is the shape of a real accident: + a pin blanked by a bad edit, a pin pointing at a branch, a pin naming an annotated + tag, a file emptied upstream, a file that parses but is not the registry. """ - with pytest.raises(_RegistryReadError, match="cannot read the registry"): - _registry_at(_REPO, "0000000000000000000000000000000000000000") + import subprocess as sp + + def git(*args): + return sp.run(["git", "-C", str(tmp_path), *args], capture_output=True, text=True, check=True) + + git("init", "-q") + git("config", "user.email", "t@t") + git("config", "user.name", "t") + target = tmp_path / _REGISTRY_RELPATH + target.parent.mkdir(parents=True) + + def commit(text: str, message: str) -> str: + target.write_text(text) + git("add", "-A") + git("commit", "-q", "-m", message) + return git("rev-parse", "--short", "HEAD").stdout.strip() + + good = commit('[meta]\nversion = "9.9.9"\n\n[connection.X]\nclass = "connection"\n', "good") + empty = commit("", "empty") + anchorless = commit('[target.X]\nclass = "target"\n', "no meta, no connection") + git("tag", "-a", "v1", "-m", "annotated") + + assert _registry_at(tmp_path, good)["meta"]["version"] == "9.9.9", ( + "the reader must accept a well-formed registry, or the refusals below prove nothing" + ) + + with pytest.raises(_RegistryReadError, match="does not resolve to a commit"): + _registry_at(tmp_path, "") # a blanked pin reads the INDEX + with pytest.raises(_RegistryReadError, match="annotated TAG|does not start with it"): + _registry_at(tmp_path, "v1") # a tag object: git peels it, the URL 404s + with pytest.raises(_RegistryReadError, match="is EMPTY"): + _registry_at(tmp_path, empty) # exits 0, stdout empty + with pytest.raises(_RegistryReadError, match="no meta.version or no"): + _registry_at(tmp_path, anchorless) # parses, but is not the registry + with pytest.raises(_RegistryReadError, match="does not resolve to a commit"): + _registry_at(tmp_path, "0" * 40) # a pin that names nothing + + +def test_the_role_vocabulary_is_closed(): + """A typo in a role string used to silently narrow a security scan. + + ``_TABLE_ROLE``'s values drive which sections the no-copy check reads. Mistyping + ``"CONSUMED"`` dropped `target` from that scan — twelve coordinate values to two — + with nothing objecting, while also narrowing what counts as a dependency. Roles are + now a closed set, asserted. + """ + unknown = sorted({r for r in _TABLE_ROLE.values() if r not in _ROLES}) + assert not unknown, ( + f"unknown role(s) in _TABLE_ROLE: {unknown}. Roles drive which sections the " + f"no-copy scan reads; a typo here silently narrows it. Use one of {_ROLES}." + ) + assert _CONSUMED_TABLES, "no table is classified CONSUMED — every row check reads nothing" def test_the_table_partition_would_catch_a_new_table_and_a_vanished_one(): - """Both directions, against synthetic registries — no sibling needed. + """Calls the production predicates, so neutering them fails here. - The asymmetry is the design: an *unclassified* table fails (an obligation may have - arrived), a *missing IGNORED* table does not (not our business). Getting that - backwards would either miss the `[contract.*]` case or cry wolf on v1.5.1's removal - of `[unmodelled]`, and both have actually happened upstream. + The first version of this test re-typed the set arithmetic inline. Mutation-proven + afterwards: replacing the real computations with ``[]`` left the guard accepting any + upstream registry forever, and this proof still passed. It detected nothing, which is + ADR-014 §2 exactly — and it was the ONLY thing claiming the table check bites. + + The asymmetry is the design and is asserted in both directions: an unclassified table + fires, a vanished IGNORED table does not. """ base = {name: {} for name in _TABLE_ROLE} - arrived = dict(base) | {"brand_new_table": {}} - assert sorted(set(arrived) - set(_TABLE_ROLE)) == ["brand_new_table"], ( + assert _unclassified_tables(base | {"brand_new_table": {}}) == ["brand_new_table"], ( "a table nobody classified went unnoticed — that is how `[contract.*]` arrived" ) + assert not _unclassified_tables(base), "the real registry's tables must all classify" - lost_consumed = {k: v for k, v in base.items() if k != "target"} - assert [n for n in _TABLES_WE_DEPEND_ON if n not in lost_consumed] == ["target"], ( - "a table this package reads vanished and the check did not object" + assert _missing_dependencies({k: v for k, v in base.items() if k != "target"}) == ["target"], ( + "a table this package reads rows out of vanished and the check did not object" ) - - lost_ignored = {k: v for k, v in base.items() if k != "excluded"} - assert not [n for n in _TABLES_WE_DEPEND_ON if n not in lost_ignored], ( - "an IGNORED table disappearing must NOT fire — v1.5.1 removed `[unmodelled]` " - "and that carried no obligation for anyone here (ADR-014 §3: prefer the false " + assert not _missing_dependencies({k: v for k, v in base.items() if k != "excluded"}), ( + "an IGNORED table disappearing must NOT fire (ADR-014 §3: prefer the false " "negative to the false alarm)" ) @pytest.mark.parametrize("partner", _PARTNERS) def test_the_drift_check_would_catch_a_rotation_that_names_and_classes_cannot(partner): - """Rotation is the whole reason the projection carries ``value``. + """Rotation is why the projection carries ``value`` — proven through the real code. - A rotated bucket id keeps its name and its class, so - ``test_every_declared_name_exists_in_the_registry_with_the_class_we_treat_it_as`` - passes while every delivery goes somewhere else. This proves the differential sees - what that check structurally cannot — and proves it without a sibling checkout, so - it runs on every machine rather than only where views-appwrite happens to be. + The first version of this test asserted ``then != now`` on dicts it built itself. + Mutation-proven afterwards: making the production comparison ignore ``value`` — which + is *literally* "names and classes cannot see a rotation", the thing this test exists + to disprove — left both the proof and the check green, while a rotated bucket id + shipped every delivery to the wrong place. So it now calls ``_describe_changes``, the + same function the check calls. - No real coordinate value appears here: both editions are synthetic, and the point is - that the two *differ*, not what either says. + Runs without a sibling, so it holds on every machine rather than only where + views-appwrite happens to be checked out. """ _, own_store, expected_class = _PARTNER_ENV[partner] canary = own_store[0] names = set(expected_class) def edition(value: str) -> dict: - return {"target": {canary: {"class": "target", "value": value}}} + return { + "meta": {"version": "0.0.0-fixture"}, + "connection": {"APPWRITE_ENDPOINT": {"class": "connection", "value": "e"}}, + "target": {canary: {"class": "target", "value": value}}, + } - then = _projection(edition("value-at-the-pin"), names) - now = _projection(edition("value-after-rotation"), names) + then = _projection(edition("at-the-pin"), names) + now = _projection(edition("after-rotation"), names) assert then[canary][:2] == now[canary][:2], ( - "this fixture is meant to change ONLY the value — if the name or class differs " - "it is not proving what it claims" + "this fixture must differ ONLY in the value, or it is not proving what it claims" + ) + changed = _describe_changes(then, now, names) + assert canary in changed, ( + f"[{partner}] a rotated value went unnoticed. Name and class are identical on " + "both sides, so nothing else in this file can see it." ) - assert then != now, ( - f"[{partner}] a rotated value went unnoticed. Names and classes are identical " - "on both sides, so nothing else in this file can see it." + assert "value:" in changed[canary] and "at-the-pin" not in changed[canary], ( + "the report must name the field that moved and must NOT print the value — this " + "repository is public and its CI logs are world-readable" ) diff --git a/tests/test_product.py b/tests/test_product.py index aa2281f..5e8440c 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -41,10 +41,13 @@ #: ADR-013 §4.1a, the defect that stranded six `orange_ensemble` documents in #: `unfao_bucket` while FAO's forecast serving read empty for months. #: -#: ``test_the_declared_consumer_name_still_matches_the_consumer`` closes that half -#: against the sibling checkout, following the pattern -#: ``tests/test_env_declaration.py`` already uses for the coordinate registry: declared -#: locally, verified across the seam when the other repo is on disk. +#: ``test_the_declared_consumer_name_matches_the_registry`` closes that half against the +#: **public coordinate registry** rather than the consumer's source (ADR-017 §5). It runs +#: for every partner and needs no credential, because the registry is public even when the +#: consumer is not — which is why it reaches the FAO partner and its predecessor could not. +#: +#: One partner is still ALSO read at the source, and only one: +#: ``_CONSUMER_SELF_CHECK_PENDING`` below names it and the issue that retires it. _CONSUMER_DOCUMENT_NAME = { "unfao": "un_fao", "crafd": "un_crafd", @@ -229,9 +232,12 @@ def test_the_declared_consumer_name_matches_the_registry(partner): had moved. ADR-017 §7: we were never entitled to depend on another repository's file layout. - Now both sides read one public declaration and neither reads the other. The registry - lives in views-appwrite, which is public, so this needs no credential even for the - private partner — that is the whole of ADR-017 §5 in one assertion. + Now both sides read one public declaration. The registry lives in views-appwrite, + which is public, so this needs no credential even for the private partner — that is + the whole of ADR-017 §5 in one assertion. + + *"Neither reads the other" is the end state, not yet the present one:* CRAF'd's source + is still read by the check below, until views-crafdapi#53 lands. The value it guards is the one whose failure is silent: a delivery filed under a name the consumer does not ask for is uploaded, stored, billed, and invisible — no error @@ -273,10 +279,31 @@ def test_the_declared_consumer_name_matches_the_registry(partner): } -def test_the_pending_list_names_only_real_partners(): - """A stale name here would keep a retired mechanism alive against nothing.""" +def test_the_pending_list_is_not_empty_and_names_only_real_partners(): + """An empty map must be a prompt to delete code, not a silent skip. + + ``parametrize`` over an empty collection is a **skip** under pytest's default + ``empty_parameter_set_mark``, and this repository declares no pytest configuration at + all. So emptying this map — by a premature cleanup, or an edit made before + views-crafdapi#53 lands — would retire the only check that reads what a consumer + actually does, and report ``1 skipped`` on a green build. The registry check would + then be comparing two values this platform authored to each other, which is the exact + failure the retained test's docstring warns about. + + So the map is asserted non-empty. When it legitimately empties, this assertion is + what tells you to delete the test, its regexes, and the sibling fetch that serves it. + """ unknown = sorted(set(_CONSUMER_SELF_CHECK_PENDING) - set(PARTNER_PACKAGES)) assert not unknown, f"not partners: {unknown}" + assert _CONSUMER_SELF_CHECK_PENDING, ( + "_CONSUMER_SELF_CHECK_PENDING is empty, which means every consumer now checks " + "itself against the registry. That is the end state ADR-017 §5 describes — so " + "finish it: delete test_the_consumer_still_filters_on_the_name_until_it_checks_" + "itself, the _CONSUMER_PATH_MANAGER and _CONSUMER_FILTER patterns, and the " + "views-crafdapi entry in tests/conftest.py::SIBLINGS whose note says that fetch " + "serves exactly this one test. Leaving them costs a clone on every pull request " + "and asserts nothing." + ) @pytest.mark.parametrize("partner", sorted(_CONSUMER_SELF_CHECK_PENDING)) diff --git a/views_postprocessing/crafd/appwrite_env.py b/views_postprocessing/crafd/appwrite_env.py index 9a379c6..fcf2917 100644 --- a/views_postprocessing/crafd/appwrite_env.py +++ b/views_postprocessing/crafd/appwrite_env.py @@ -6,7 +6,7 @@ views-appwrite and referenced by pinned URL, never copied — copies were the platform's original failure (þing-01 sáttmál S6): - https://github.com/views-platform/views-appwrite/blob/b703cab/docs/ADRs/platform/coordinate_registry.toml + https://github.com/views-platform/views-appwrite/blob/c7b597e/docs/ADRs/platform/coordinate_registry.toml That pin is registry **v1.5.2** — declared below as ``SEAM_CONTRACT_VERSION`` / ``SEAM_CONTRACT_COMMIT`` so the pin is a value a test can check rather than a fact @@ -48,7 +48,7 @@ #: enforces the pair — for this package and for ``unfao`` alike — against a local #: views-appwrite checkout when one is present. SEAM_CONTRACT_VERSION = "1.5.2" -SEAM_CONTRACT_COMMIT = "b703cab" +SEAM_CONTRACT_COMMIT = "c7b597e" CONNECTION_ENV = ( "APPWRITE_ENDPOINT", diff --git a/views_postprocessing/unfao/appwrite_env.py b/views_postprocessing/unfao/appwrite_env.py index b13fade..3e7122d 100644 --- a/views_postprocessing/unfao/appwrite_env.py +++ b/views_postprocessing/unfao/appwrite_env.py @@ -5,7 +5,7 @@ views-appwrite and referenced by pinned URL, never copied — copies were the platform's original failure (þing-01 sáttmál S6): - https://github.com/views-platform/views-appwrite/blob/b703cab/docs/ADRs/platform/coordinate_registry.toml + https://github.com/views-platform/views-appwrite/blob/c7b597e/docs/ADRs/platform/coordinate_registry.toml That pin is registry **v1.5.2** — declared below as ``SEAM_CONTRACT_VERSION`` / ``SEAM_CONTRACT_COMMIT`` so the pin is a value a test can @@ -45,7 +45,7 @@ #: declaration against that edition of the registry. ``tests/test_env_declaration.py`` #: enforces the pair against a local views-appwrite checkout when one is present. SEAM_CONTRACT_VERSION = "1.5.2" -SEAM_CONTRACT_COMMIT = "b703cab" +SEAM_CONTRACT_COMMIT = "c7b597e" CONNECTION_ENV = ( "APPWRITE_ENDPOINT", From 3d5a24433557dd61967de523ae90c29dd8f7acd8 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 16:27:57 +0200 Subject: [PATCH 26/42] =?UTF-8?q?fix(tests):=20four=20findings=20from=20/r?= =?UTF-8?q?eview-diff=20=E2=80=94=20the=20sixteenth,=20and=20three=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of the loop, aimed at the remediation commit itself, because fixing fifteen things at once is when the next one gets introduced. It did. THE BYPASS REOPENED THE HOLE IT WAS ADDED BESIDE. The frozen-commit guard existed because a pin set to "" reads the git index and a pin set to "main" reads a moving tip — either makes the differential compare the registry to itself and pass forever. Then I exempted "main"/"origin/main" from that guard so _registry_current could call _registry_at, which meant a pin literally set to "main" was accepted. Verified before and after. _registry_current now resolves the branch to a SHA first, so the guard has no exceptions. I FIXED THE WORKING-TREE PROBLEM IN ONE PLACE OUT OF FOUR. The differential moved onto origin/main; the names-and-classes check, the table partition and the no-copy scan were all still reading whatever branch the sibling clone happened to sit on — which is #196's shape, the case that cost this platform a withdrawn PR. All four read origin/main now, and _load_registry, which read the working tree, is deleted rather than left as a loaded gun. Four registry readers become three. A REAL DIAGNOSIS WAS SWALLOWED. _registry_current caught _RegistryReadError per ref and then raised its own, so an anchorless or corrupt registry ON MAIN was reported as "neither origin/main nor main resolves" — a message pointing at the wrong problem. Read errors now propagate; only genuine ref-resolution failure produces that text. AND THE PROSE WAS ONE FIX AHEAD OF THE CODE. ADR-016 §7b's erratum said differential tripwires read the sibling's main. That was true of one check and false of three at the moment I wrote it. Corrected, and the correction records its own overclaim, because a sentence written to describe a fix that outran the fix is the same defect §3 diagnoses. Also caught in passing: my first attempt at deleting the dead reader cut from its `def` to the next `def`, swallowing the _TABLE_ROLE block. The suite caught it; the file was restored and the removal redone against the function's own bounds. Suite 410 passed / 1 skipped / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 13 +++-- tests/test_env_declaration.py | 49 ++++++++++--------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index a67b249..ddd6024 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -250,10 +250,15 @@ So the two categories above become three: sibling's `main` as the comparison — and fire when something *we declare* differs between them, or when a coordinate arrives in a table we read. This is the shape the registry check now has. It catches a rotation, which is invisible to the other two, and it is - silent through prose edits and version bumps. *(Corrected 2026-08-11: the first - implementation compared against the sibling's **working tree**, so a developer whose - clone sat on a feature branch was grading this repository against unreviewed content — - #196's shape. It reads `origin/main` now.)* + silent through prose edits and version bumps. + +*(Corrected twice on 2026-08-11, and the second time is the instructive one. The first +implementation compared against the sibling's **working tree** — so a developer whose clone +sat on a feature branch graded this repository against unreviewed content, which is #196's +shape. The first correction moved **one** check onto `origin/main` and this paragraph then +claimed all of them did; three others were still reading the working tree. All four read +`origin/main` now. A sentence written to describe a fix, one fix ahead of the code, is the +same defect §3 diagnoses.)* The third kind exists because the no-copy rule forbids writing expected coordinate values into this repository. A pinned edition is the only lawful place to keep a baseline for diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index b96a944..eb30777 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -477,15 +477,6 @@ def test_every_declared_name_is_classified_here(partner): ) -def _load_registry(repo: Path) -> dict: - tomllib = pytest.importorskip( - "tomllib", - reason="tomllib is stdlib from Python 3.11; pyproject declares >=3.11, so a " - "conforming environment has it. CI runs 3.11.", - ) - return tomllib.loads((repo / _REGISTRY_RELPATH).read_text()) - - #: Sentinel for "this row carries no value", distinct from any string a registry #: could legitimately hold. _ABSENT = object() @@ -603,7 +594,7 @@ def _registry_at(repo: Path, ref: str) -> dict: "commit: an empty ref reads the index, a branch name reads a moving tip, and " "either would make the comparison below compare the registry to itself." ) - if ref not in ("origin/main", "main") and not resolved.stdout.strip().startswith(ref): + if not resolved.stdout.strip().startswith(ref): raise _RegistryReadError( f"{ref!r} resolves to commit {resolved.stdout.strip()[:12]}, which does not " f"start with it. That happens when the pin names an annotated TAG rather than " @@ -670,16 +661,30 @@ def _registry_current(repo: Path) -> dict: the suite locally was comparing against something else entirely, and ADR-016's erratum claimed otherwise. """ + head = "" for ref in ("origin/main", "main"): - try: - return _registry_at(repo, ref) - except _RegistryReadError: - continue - raise _RegistryReadError( - f"neither origin/main nor main resolves in {repo}. The comparison is against that " - "repository's ratified content, so there is nothing to compare against — run " - "`git fetch` there." - ) + resolved = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + capture_output=True, text=True, check=False, timeout=30, + ) + if resolved.returncode == 0 and resolved.stdout.strip(): + head = resolved.stdout.strip() + break + if not head: + raise _RegistryReadError( + f"neither origin/main nor main resolves in {repo}. The comparison is against " + "that repository's ratified content, so there is nothing to compare against — " + "run `git fetch` there." + ) + # Resolved to a SHA before reading, deliberately. `_registry_at` refuses any ref that + # is not a frozen commit, and that guard must have no exceptions: an earlier version + # exempted "main"/"origin/main" so this function could call it — which meant a pin + # literally set to ``"main"`` was accepted, reopening the hole the guard exists for. + # Resolving here keeps the guard absolute at the cost of one extra rev-parse. + # + # Read errors propagate unchanged: a registry that is corrupt or anchorless ON MAIN + # must say so, not be reported as "neither ref resolves". + return _registry_at(repo, head) def _describe_changes(then: dict, now: dict, names: set[str]) -> dict[str, str]: @@ -727,7 +732,7 @@ def _declared_classes(registry: dict) -> dict[str, str]: def test_every_declared_name_exists_in_the_registry_with_the_class_we_treat_it_as(partner): """C-57: a rename or reclassification upstream must not be silent here.""" repo = require_sibling("views-appwrite") - declared = _declared_classes(_load_registry(repo)) + declared = _declared_classes(_registry_current(repo)) _, _, expected_class = _PARTNER_ENV[partner] missing = sorted(n for n in expected_class if n not in declared) @@ -802,7 +807,7 @@ def test_every_table_in_the_registry_is_classified_here(): would have been a red build demanding classification, not a silent pass.)* """ repo = require_sibling("views-appwrite") - registry = _load_registry(repo) + registry = _registry_current(repo) unclassified = _unclassified_tables(registry) assert not unclassified, ( @@ -1143,7 +1148,7 @@ def test_no_coordinate_value_is_copied_into_this_repo(): repo = sibling_repo("views-appwrite") if repo is None: pytest.skip("views-appwrite checkout not found — set VIEWS_APPWRITE") - registry = _load_registry(repo) + registry = _registry_current(repo) # Scoped by the declared partition, NOT by an inline tuple — so a new table cannot # be swept in by a one-word edit, and `contract` cannot be swept in at all. # From 89fc5552a6fa536caac3a0fd2d904fec5caf9f01 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 17:05:28 +0200 Subject: [PATCH 27/42] =?UTF-8?q?fix(tests):=20iteration=202=20=E2=80=94?= =?UTF-8?q?=20a=20scan=20blind=20in=20its=20own=20house=20style,=20and=20o?= =?UTF-8?q?ne=20shared=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loop iteration 2. Five findings plus one of mine, and the first is the serious one. THE NO-COPY SCAN WAS BLIND IN THE STYLE OF THE DOCUMENT IT WAS WRITTEN FOR. Proven by injection: real registry values written into README.md as `NAME=value # comment` passed the guard, because `(.+?)\s*$` swallowed the comment into the captured value and .strip('"\'') could not recover it. README's own Configuration block is written in exactly that form. On a PUBLIC repository that is the guard's whole purpose defeated by its own house style. Now: quoted values matched whole (a `#` inside quotes is legitimate), unquoted values truncated at a comment. The `len > 6` floor is gone too — it excluded two five-character coordinates and, measured, removing it keeps the suite green, so it was narrowing a security check for nothing. THE FOUNDING INCIDENT REAPPEARED IN THE FOUNDING FILE. run_pytest.yml still said a tokenless checkout "is what verifies the `public` field — no test does". `public` was deleted on 2026-08-10 because nothing could verify it, and ADR-016 §4 — added by THIS branch — says so explicitly. A comment in that file asserting an unverifiable fact about another repository is ADR-016 §3's founding incident, recurring in the file it happened in. C-87 WATCHED THE WRONG ISSUE. Its trigger, body and cross-refs still named views-crafdapi#39 while everything else moved to #53. Closing #39 would have fired it spuriously; #53 landing would not have cleared it. Mine, found while checking their claims: test_product.py was still reading the sibling's WORKING TREE. I moved four checks onto origin/main last iteration and left the fifth — the same partial-fix pattern review-diff had just caught. That is the second copy of the same rule disagreeing with itself, which is this repo's stated trigger for extracting, so the reader now lives in tests/seam_registry.py: one module, named for what it does, out of conftest (C-88), with every refusal branch and the reason each exists. Also: the mutant assertion hardcoded "views-appwrite" under a docstring framing it generically — correct only by coincidence of today's fixtures; and two register blocks were appended straight after a `Cross-refs:` line, rendering as run-on paragraphs below the line every other entry treats as terminal. AND THE PARTITION EARNED ITS KEEP ON LIVE DATA. Mid-fix it went red: views-appwrite had added an `[edition]` table at v1.6.0 — which is views-appwrite#76 delivered, each edition marked `obliges_consumers = true|false`. Exactly the mechanism ADR-016 §7a described and I filed for. The guard caught its arrival unprompted, on its first real encounter, and the row-level drift check stayed correctly silent because nothing we read moved. Classified IGNORED for now; adopting it is recorded as the follow-up, not folded into a remediation. Suite 410 passed / 1 skipped / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/run_pytest.yml | 11 +- reports/technical_risk_register.md | 8 +- tests/seam_registry.py | 153 +++++++++++++++++++++++++ tests/test_ci_sibling_coverage.py | 13 ++- tests/test_env_declaration.py | 175 ++++++----------------------- tests/test_product.py | 23 ++-- 6 files changed, 219 insertions(+), 164 deletions(-) create mode 100644 tests/seam_registry.py diff --git a/.github/workflows/run_pytest.yml b/.github/workflows/run_pytest.yml index b548505..94773d6 100644 --- a/.github/workflows/run_pytest.yml +++ b/.github/workflows/run_pytest.yml @@ -51,9 +51,14 @@ jobs: # someone else's test suite the first time a sibling landed outside it. # # No `token:` on any step below, and that is load-bearing rather than an omission: - # the default GITHUB_TOKEN is scoped to THIS repository, so a tokenless checkout of - # a repo declared public FAILS if it is actually private. That is what verifies the - # `public` field — no test does, because nothing in this suite touches the network. + # the default GITHUB_TOKEN is scoped to THIS repository, so a tokenless checkout of a + # private repository simply fails, loudly, at this step. + # + # An earlier version of this comment said that "verifies the `public` field". There is + # no such field: `public` was deleted from `SIBLINGS` on 2026-08-10 because nothing + # could verify it, and ADR-016 §4 now says so explicitly. A comment in THIS file + # asserting a fact about another repository that nothing checks is the founding + # incident of ADR-016 §3, and it had reappeared here. - name: Checkout views-appwrite (sibling) uses: actions/checkout@v3 with: diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index d3c72bf..6aa7687 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -210,6 +210,7 @@ ADR-016 has CI check out `views-appwrite` and `views-crafdapi` so that cross-rep **What would resolve it, in order of preference:** views-appwrite#76 makes the obligation-carrying distinction machine-readable, so observation-only bumps stop firing the check at all — filed, and that seat volunteered it. Failing that, a bypass actor restores the escape. Failing both, the coupling stands as ADR-016 §7 describes, which is defensible but should be chosen rather than discovered. Cross-refs: **C-81** (the enforcement half, whose fix activates this), **C-46** (whose recommendation against per-PR sibling checkouts this overrode, with the reasoning recorded there), ADR-016 §7/§7a/§7b, views-appwrite#76. + **Partial mitigation 2026-08-11 — the false-alarm rate, not the coupling.** The check that kept firing compared registry *version strings*. It has been replaced by three that match on the facts this repository actually declares: the pinned commit must declare the pinned @@ -240,7 +241,7 @@ required on `main`**, at which moment the coupling stops being latent. | ID | C-87 | | Tier | 2 — the failure mode is invisible by construction. The upload succeeds, the storage is paid for, the consumer's endpoint returns empty, and nothing anywhere raises. That is the shape ADR-013 §4.1a calls *"invisible to the consumer, not merely degraded"*. | | Source | ADR-017 §8, sharpened by external review (#232, #234), 2026-08-10 | -| Trigger | **Either half going missing.** (a) `views-faoapi#379` or `views-crafdapi#39` is closed without the check being written. (b) A private API operated by a **third party** becomes a consumer — at which point the second half cannot be required at all and this becomes permanent. | +| Trigger | **Either half going missing.** (a) `views-faoapi#379` or `views-crafdapi#53` is closed without the check being written. (b) A private API operated by a **third party** becomes a consumer — at which point the second half cannot be required at all and this becomes permanent. | | Owner | The consumer-side seats own the check; this repository owns noticing that it exists. | | Location | `views_postprocessing//product.py::CONSUMER_DOCUMENT_NAME`; the check in `tests/test_product.py`; ADR-017 §5, §8, Appendix B. | @@ -248,11 +249,11 @@ ADR-017 decides that the delivery label is declared in the public coordinate reg Its residual is stated plainly in §8 and belongs here rather than only in a document: **we will verify our copy against the declaration, not the consumer's code against it.** If a consumer quietly starts filtering on something else, our check passes and the delivery is invisible exactly as before. -**Why this is a risk and not merely a note.** The second half is real work in repositories this project does not control. `views-faoapi#379` has a willing owner. `views-crafdapi#39` is blocked on that partner's data contract and could sit for a long time. Until both land, the label's agreement with reality rests on the source-reading check — which ADR-017's sequencing deliberately keeps alive for exactly this reason, and which someone could remove believing the registry check replaced it. +**Why this is a risk and not merely a note.** The second half is real work in repositories this project does not control. `views-faoapi#379` has a willing owner. `views-crafdapi#53` is blocked on that partner's data contract and could sit for a long time. Until both land, the label's agreement with reality rests on the source-reading check — which ADR-017's sequencing deliberately keeps alive for exactly this reason, and which someone could remove believing the registry check replaced it. **What was already prevented.** A reviewer caught that the obvious sequence created a window where the source-reading check was deleted before the consumer-side check existed, leaving a green build proving only that two values this platform authored agreed with each other. ADR-017 now forbids that ordering. This entry exists so the ordering constraint has a home outside the document that states it. -Cross-refs: **C-77** (the same field's producer-side half, resolved), ADR-013 §4.1a, ADR-017 §5/§8/Appendix B, views-appwrite#75, views-faoapi#379, views-crafdapi#39. +Cross-refs: **C-77** (the same field's producer-side half, resolved), ADR-013 §4.1a, ADR-017 §5/§8/Appendix B, views-appwrite#75, views-faoapi#379, views-crafdapi#53. --- @@ -622,6 +623,7 @@ Where each sibling stands, after trying them: The two compound: a suite that checks less than you think, and no requirement that even that much passes. Neither is caused by this sync — both are pre-existing — but this sync is the first time `main` receives an epic whose value is largely the guards themselves. Cross-refs: **C-46** and **C-57** (both RESOLVED; this is the residual each recorded as *"a CI-cost and cross-repo-coupling decision"* and *"worth deciding once for both"* — it now has a live home and a concrete answer per sibling), **C-80** (the other verification gap found in the same audit), #188. + **Partial mitigation 2026-08-10 (ADR-016) — the coverage half is mostly closed; the enforcement half is untouched.** The coverage half rested on a claim nobody could check. This entry, and the workflow comment it drew on, said `views-appwrite` was **private**, so its seven checks needed a credential. It went public on **2026-08-08** (`views-appwrite@9d80b75`, a deliberate and recorded act), and the claim here went on being made for two days afterwards. No credential was required, and none had been the obstacle since that date. diff --git a/tests/seam_registry.py b/tests/seam_registry.py new file mode 100644 index 0000000..d6a4f90 --- /dev/null +++ b/tests/seam_registry.py @@ -0,0 +1,153 @@ +"""Reading the platform coordinate registry, and refusing to read it badly. + +The registry is a TOML file in **views-appwrite** declaring every cross-repository +coordinate on the delivery seam. This repository never copies it — it is referenced, and +read from a sibling checkout when one is present. + +**Why this is its own module.** Two test modules now need it: `test_env_declaration.py` +for the coordinate sections, `test_product.py` for the `[contract.*]` delivery labels. +It began as one reader, was hand-copied into the second, and the copies immediately +disagreed — one read the sibling's `main`, the other whatever branch the clone sat on, +which is the very defect a review had just caught in the first. That is the second +incident, which is this repository's stated trigger for extracting (WET before DRY: two +copies that are understood beat one abstraction that is guessed — until the second copy +proves the shape). + +It is deliberately **not** in `conftest.py`. That file already holds four unrelated +groups and register **C-88**'s trigger is a fifth arriving. + +**What this module refuses, and why each refusal exists.** Every one is a real accident +that a silent read would have turned into a permanently green test: + +- a ref that does not resolve, or resolves to something that is not a commit; +- a ref whose resolved sha does not begin with it — an annotated **tag**, which git peels + silently and which yields a 404 from the blob URL these modules publish; +- an **empty** file: `tomllib.loads("")` is `{}`, and an empty registry compared against + an empty projection passes forever; +- a file that parses but carries no `meta.version` and no `connection` rows, i.e. is not + the registry; +- a parse error, wrapped rather than raised raw — a helper whose justification is failing + legibly must not hand back a bare traceback. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +#: The registry, relative to a views-appwrite checkout. +REGISTRY_RELPATH = Path("docs") / "ADRs" / "platform" / "coordinate_registry.toml" + +#: A row that carries no value at all — distinct from any string a registry could hold. +ABSENT = object() + + +class RegistryReadError(RuntimeError): + """The registry could not be read — never a silently empty dict.""" + + +def registry_at(repo: Path, ref: str) -> dict: + """The registry as it stood at ``ref``. Raises rather than returning ``{}``. + + ``ref`` must name a **frozen commit**, and that has no exceptions. An empty ref reads + the git index; a branch name reads a moving tip. Either makes a pin-versus-current + comparison compare the registry to itself and report green against every future + edition — which is the trap this module exists for, so an exemption "just for the + caller that needs a branch" reopens it. Callers wanting `main` resolve it to a sha + first; :func:`registry_current` does exactly that. + """ + tomllib = pytest.importorskip("tomllib", reason="stdlib from 3.11; pyproject requires it") + + resolved = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + capture_output=True, text=True, check=False, timeout=30, + ) + if resolved.returncode != 0 or not resolved.stdout.strip(): + raise RegistryReadError( + f"{ref!r} does not resolve to a commit in {repo}. A pin must name a frozen " + "commit: an empty ref reads the index and a branch reads a moving tip, and " + "either would make the comparison compare the registry to itself." + ) + if not resolved.stdout.strip().startswith(ref): + raise RegistryReadError( + f"{ref!r} resolves to commit {resolved.stdout.strip()[:12]}, which does not " + "start with it — that is what an annotated TAG looks like. git peels tags " + "silently, so every check here would pass while the blob URL these modules " + "publish returns 404. Pin the commit." + ) + + result = subprocess.run( + ["git", "-C", str(repo), "show", f"{ref}:{REGISTRY_RELPATH.as_posix()}"], + capture_output=True, text=True, check=False, timeout=30, + ) + if result.returncode != 0: + raise RegistryReadError( + f"cannot read the registry at {ref!r} in {repo}: git exited " + f"{result.returncode} ({result.stderr.strip()[:200]}). A pinned commit that " + "cannot be read is not a baseline — run `git fetch` in that checkout." + ) + if not result.stdout.strip(): + raise RegistryReadError( + f"the registry is EMPTY at {ref!r} in {repo}. Treating that as a registry " + "with no coordinates would make every comparison against it pass." + ) + try: + registry = tomllib.loads(result.stdout) + except Exception as exc: + raise RegistryReadError( + f"the registry at {ref!r} in {repo} did not parse as TOML: {exc}. This is " + "what happens when the path names a directory, or the blob is not the file " + "we think it is." + ) from exc + if not registry.get("meta", {}).get("version") or not registry.get("connection"): + raise RegistryReadError( + f"the registry at {ref!r} parsed but carries no meta.version or no connection " + "rows. Every real edition has both, so this is not the file we think it is — " + "refusing rather than comparing against it." + ) + return registry + + +def registry_current(repo: Path) -> dict: + """The registry as the sibling's ``main`` has it — never its working tree. + + A sibling clone normally sits on whatever branch its own agent last worked on. + Comparing against that grades this repository on unreviewed content, or reddens it on + a branch nobody merged — issue #196 verbatim, the case that cost this platform a + withdrawn pull request. The `ref: main` discipline lived only inside the CI workflow; + a developer running the suite locally had no such thing. + + Read errors propagate unchanged, so a registry that is corrupt or anchorless **on + main** says so, rather than being reported as "neither ref resolves". + """ + head = "" + for ref in ("origin/main", "main"): + resolved = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + capture_output=True, text=True, check=False, timeout=30, + ) + if resolved.returncode == 0 and resolved.stdout.strip(): + head = resolved.stdout.strip() + break + if not head: + raise RegistryReadError( + f"neither origin/main nor main resolves in {repo}. The comparison is against " + "that repository's ratified content, so there is nothing to compare against " + "— run `git fetch` there." + ) + return registry_at(repo, head) + + +def rows(registry: dict, sections: tuple) -> dict[str, tuple]: + """``name -> (section, class, value-or-ABSENT)`` across ``sections``. + + The one shared projection. Two hand-copied versions had already diverged on null + handling — one raised ``AttributeError`` on a null section, the other did not. + """ + return { + name: (section, body.get("class"), body.get("value", ABSENT)) + for section in sections + for name, body in (registry.get(section) or {}).items() + } diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index 82de91f..51a8898 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -450,10 +450,15 @@ def test_each_rule_bites_on_a_broken_world(label, workflow, siblings, why): "This means the RULE has stopped working — the real workflow is not involved " "and is probably fine. Fix the rule, not run_pytest.yml." ) - assert any("views-appwrite" in v for v in violations), ( - f"[{label}] objected, but its message does not name the offending sibling: " - f"{violations}. A maintainer reading only the failure would not know which " - "repository to look at." + offender = next(iter(siblings), None) or "views-appwrite" + assert any(offender in v for v in violations), ( + f"[{label}] objected, but its message does not name the offending sibling " + f"({offender!r}): {violations}. A maintainer reading only the failure would not " + "know which repository to look at.\n\n" + "The name is taken from the mutant's own sibling map rather than hardcoded — a " + "fixed \"views-appwrite\" was correct only by coincidence of today's fixtures, " + "and would have made a working rule fail for the next mutant about a different " + "repository, under this file's inverted semantics." ) diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index eb30777..871e653 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -32,6 +32,14 @@ import pytest +from tests.seam_registry import ( + ABSENT as _ABSENT, + REGISTRY_RELPATH as _REGISTRY_RELPATH, + RegistryReadError as _RegistryReadError, + registry_at as _registry_at, + registry_current as _registry_current, + rows as _rows, +) from tests.conftest import ( PARTNER_PACKAGES, broken_sibling_overrides, @@ -433,7 +441,7 @@ def test_secret_env_names_follow_the_seam_contract_naming_rule(): # and copying one into a test is the same violation as copying it into code. These # compare names, declared classes, and the edition — nothing else. -_REGISTRY_RELPATH = Path("docs") / "ADRs" / "platform" / "coordinate_registry.toml" + #: How this module treats each declared name, vs. the registry's own `class` field. #: @@ -479,8 +487,6 @@ def test_every_declared_name_is_classified_here(partner): #: Sentinel for "this row carries no value", distinct from any string a registry #: could legitimately hold. -_ABSENT = object() - #: Every top-level table the registry may carry, and what this repository does with it. #: #: **Declared, and asserted against the live registry** — a table appearing upstream that @@ -497,6 +503,13 @@ def test_every_declared_name_is_classified_here(partner): # checked by tests/test_product.py, not by _declared_classes "excluded": "IGNORED", # names the registry records as deliberately NOT coordinates "test_environment": "IGNORED", # a fact about the platform, not about this package + #: Arrived at registry v1.6.0, and it is views-appwrite#76 delivered — each edition + #: marked ``obliges_consumers = true|false``, so a consumer can tell a console + #: observation from a change it must act on. IGNORED only because nothing here reads + #: it YET: adopting it is the follow-up that lets the drift check below stop caring + #: about editions that oblige nobody. Caught by this very partition on its first + #: encounter, which is what the partition is for. + "edition": "IGNORED", "meta": "METADATA", # the edition and its amendment log } @@ -538,103 +551,6 @@ def _missing_dependencies(registry: dict) -> list[str]: return sorted(n for n in _TABLES_WE_DEPEND_ON if n not in registry) -def _rows(registry: dict, sections: tuple) -> dict[str, tuple]: - """``name -> (section, class, value-or-absent)`` across ``sections``. - - The single reader the class check, the drift projection and the no-copy scan share. - Two hand-copied versions had already diverged on null handling: one used - ``registry.get(s, {})`` and raised ``AttributeError`` on a null section, the other - used ``(registry.get(s) or {})`` and did not. - """ - return { - name: (section, body.get("class"), body.get("value", _ABSENT)) - for section in sections - for name, body in (registry.get(section) or {}).items() - } - - -class _RegistryReadError(RuntimeError): - """The registry could not be read at a pinned commit — never a silent empty dict.""" - - -def _registry_at(repo: Path, ref: str) -> dict: - """The registry as it stood at ``ref``, or raise. Never returns ``{}`` quietly. - - **Why not ``conftest.git_output``.** That helper turns any failure into ``""``, and - ``tomllib.loads("")`` returns ``{}``. An empty registry compared against an empty - projection passes — forever, silently. That is the vanished-directory shape ADR-014 - §2 exists for, and building it into the fix for a drift detector would be the joke - writing itself. So this distinguishes a non-zero exit from an empty file, and - refuses a parse that lacks the anchors every real edition has. - - Module-local rather than in ``conftest.py``: register C-88 records that file already - holding four unrelated groups, and its trigger is a fifth. There is one consumer. - """ - tomllib = pytest.importorskip("tomllib", reason="stdlib from 3.11; pyproject requires it") - - # The ref must name a FROZEN commit, and must be the one we asked for. - # - # Without this, `git show ":"` reads the INDEX and `git show "main:"` - # reads a branch — both exit 0, both parse, both pass every anchor below. A pin - # blanked by a bad edit or set to "main" by someone who thinks that is what a pin - # means would make the differential compare the current registry against itself and - # report green against every future edition. That is the same silent-baseline trap - # this reader exists to close, wearing a different hat. - # - # ``^{commit}`` also peels an annotated TAG. That matters here: a tag object is not - # a commit, is reachable only through refs/tags, and — measured 2026-08-11 — yields - # a 404 from the pinned blob URL these modules publish. So this refuses one. - resolved = subprocess.run( - ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], - capture_output=True, text=True, check=False, timeout=30, - ) - if resolved.returncode != 0 or not resolved.stdout.strip(): - raise _RegistryReadError( - f"{ref!r} does not resolve to a commit in {repo}. A pin must name a frozen " - "commit: an empty ref reads the index, a branch name reads a moving tip, and " - "either would make the comparison below compare the registry to itself." - ) - if not resolved.stdout.strip().startswith(ref): - raise _RegistryReadError( - f"{ref!r} resolves to commit {resolved.stdout.strip()[:12]}, which does not " - f"start with it. That happens when the pin names an annotated TAG rather than " - "a commit — git peels it silently, every check here passes, and the blob URL " - "these modules publish returns 404. Pin the commit." - ) - - result = subprocess.run( - ["git", "-C", str(repo), "show", f"{ref}:{_REGISTRY_RELPATH.as_posix()}"], - capture_output=True, text=True, check=False, timeout=30, - ) - if result.returncode != 0: - raise _RegistryReadError( - f"cannot read the registry at {ref!r} in {repo}: git exited " - f"{result.returncode} ({result.stderr.strip()[:200]}). A pinned commit that " - "cannot be read is not a baseline — run `git fetch` in that checkout." - ) - if not result.stdout.strip(): - raise _RegistryReadError( - f"the registry is EMPTY at {ref!r} in {repo}. Treating that as a registry " - "with no coordinates would make every comparison below pass." - ) - try: - registry = tomllib.loads(result.stdout) - except Exception as exc: # TOMLDecodeError, and whatever a non-TOML blob raises - raise _RegistryReadError( - f"the registry at {ref!r} in {repo} did not parse as TOML: {exc}. A raw " - "traceback from a helper whose justification is failing legibly would be its " - "own defeat — this happens when the path names a directory, or the blob is " - "not the file we think it is." - ) from exc - if not registry.get("meta", {}).get("version") or not registry.get("connection"): - raise _RegistryReadError( - f"the registry at {ref!r} parsed but carries no meta.version or no " - "connection rows. Every real edition has both, so this is not the file we " - "think it is — refusing rather than comparing against it." - ) - return registry - - def _projection(registry: dict, names: set[str]) -> dict[str, tuple]: """``name -> (section, class, value-or-absent)`` for the names we declare. @@ -651,42 +567,6 @@ def _projection(registry: dict, names: set[str]) -> dict[str, tuple]: return {n: row for n, row in _rows(registry, _CONSUMED_TABLES).items() if n in names} -def _registry_current(repo: Path) -> dict: - """The registry as the sibling's ``main`` has it — not its working tree. - - A sibling clone normally sits on whatever branch its own agent last worked on. - Comparing against that grades us on unreviewed content, or reddens on a branch nobody - merged — #196 verbatim, the case that cost this platform a withdrawn pull request. - The ``ref: main`` discipline lived only inside the CI workflow, so a developer running - the suite locally was comparing against something else entirely, and ADR-016's erratum - claimed otherwise. - """ - head = "" - for ref in ("origin/main", "main"): - resolved = subprocess.run( - ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], - capture_output=True, text=True, check=False, timeout=30, - ) - if resolved.returncode == 0 and resolved.stdout.strip(): - head = resolved.stdout.strip() - break - if not head: - raise _RegistryReadError( - f"neither origin/main nor main resolves in {repo}. The comparison is against " - "that repository's ratified content, so there is nothing to compare against — " - "run `git fetch` there." - ) - # Resolved to a SHA before reading, deliberately. `_registry_at` refuses any ref that - # is not a frozen commit, and that guard must have no exceptions: an earlier version - # exempted "main"/"origin/main" so this function could call it — which meant a pin - # literally set to ``"main"`` was accepted, reopening the hole the guard exists for. - # Resolving here keeps the guard absolute at the cost of one extra rev-parse. - # - # Read errors propagate unchanged: a registry that is corrupt or anchorless ON MAIN - # must say so, not be reported as "neither ref resolves". - return _registry_at(repo, head) - - def _describe_changes(then: dict, now: dict, names: set[str]) -> dict[str, str]: """What differs, said WITHOUT printing a coordinate value. @@ -1166,7 +1046,10 @@ def test_no_coordinate_value_is_copied_into_this_repo(): body["value"] for section in scanned_sections for body in registry.get(section, {}).values() - if isinstance(body.get("value"), str) and len(body["value"]) > 6 + # No length floor: it excluded two five-character coordinates and, + # measured, removing it keeps the suite green — so it was buying nothing + # while narrowing a security check. + if isinstance(body.get("value"), str) and body["value"].strip() } copied = [] @@ -1197,8 +1080,17 @@ def test_no_coordinate_value_is_copied_into_this_repo(): # The copy is a value **assigned to its own coordinate name** — `APPWRITE_X=value` — # which is a reader's instruction to configure with that literal. That is precise # enough to have caught README.md and to ignore every legitimate mention. + # `(.+?)\s*$` used to swallow an inline comment into the captured value, so + # `NAME=value # note` compared `'value # note'` against the registry and matched + # nothing. README.md's own Configuration block is written in exactly that style, so + # the guard was blind in the house style of the document it was written for — proven + # by injecting two real coordinate values with trailing comments, both invisible. + # + # A `#` inside a quoted value is legitimate, so the quoted form is matched first and + # taken whole; only an unquoted value is truncated at a comment. assignment = re.compile( - r"^\s*(?:export\s+)?(" + "|".join(sorted(_EXPECTED_NAMES)) + r")\s*=\s*(.+?)\s*$" + r"^\s*(?:export\s+)?(" + "|".join(sorted(_EXPECTED_NAMES)) + r")\s*=" + r"""\s*(?:"([^"]*)"|'([^']*)'|([^#]*?))\s*(?:#.*)?$""" ) # This repository's OWN tracked markdown — `git ls-files`, not `rglob`. CI checks # sibling repositories out into the workspace, and their documents are not this @@ -1225,10 +1117,11 @@ def test_no_coordinate_value_is_copied_into_this_repo(): for doc in sorted(scanned): for number, line in enumerate(doc.read_text().splitlines(), 1): match = assignment.match(line) - if match and match.group(2).strip('"\'') in values: + captured = next((g for g in match.groups()[1:] if g is not None), None) if match else None + if captured is not None and captured.strip() in values: copied.append( - f"{doc.relative_to(_REPO)}:{number} = {match.group(2)!r} " - f"(assigned to {match.group(1)})" + f"{doc.relative_to(_REPO)}:{number} assigns a registry value to " + f"{match.group(1)}" ) assert not copied, ( diff --git a/tests/test_product.py b/tests/test_product.py index 5e8440c..633f0e1 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -26,6 +26,7 @@ import pytest from tests.conftest import CONSUMER_REPO, PARTNER_PACKAGES, SIBLINGS, require_sibling +from tests.seam_registry import registry_current #: partner -> the document name its consumer filters on, DECLARED here rather than #: read back from the module under test. @@ -181,19 +182,15 @@ def test_every_partner_has_a_declared_consumer_repository(): ) -#: The platform registry, relative to a views-appwrite checkout. +#: The registry reader is shared with ``tests/test_env_declaration.py`` — see +#: ``tests/seam_registry.py``. #: -#: Loaded locally rather than imported from ``tests/test_env_declaration.py``: a test -#: module importing another test module is a dependency nobody declared, and the two read -#: different things — that file reads coordinate sections, this one reads one contract -#: row. Two four-line readers that are understood beat one shared one that has to serve -#: both (WET before DRY). -_REGISTRY_RELPATH = Path("docs") / "ADRs" / "platform" / "coordinate_registry.toml" - - -def _registry(repo: Path) -> dict: - tomllib = pytest.importorskip("tomllib", reason="stdlib from 3.11; pyproject requires it") - return tomllib.loads((repo / _REGISTRY_RELPATH).read_text()) +#: It began here as a local four-line copy, defended as WET. The copies immediately +#: disagreed: that module was moved to read the sibling's ``main`` after a review found +#: reading its working tree meant grading this repository against unreviewed content +#: (#196's shape), and this copy was left behind still reading the working tree. Two +#: copies of a rule are fine; two copies that answer the same question differently are +#: the second incident, which is this repository's trigger for extracting. #: partner -> the registry row that is the AUTHORITY for its delivery label. @@ -244,7 +241,7 @@ def test_the_declared_consumer_name_matches_the_registry(partner): anywhere (ADR-013 §4.1a). """ repo = require_sibling("views-appwrite") - registry = _registry(repo) + registry = registry_current(repo) contract = registry.get("contract") or {} row = _CONTRACT_ROW[partner] From f7ec27168d8d087bda835443db0ee0ffa2adfd14 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Tue, 11 Aug 2026 21:13:40 +0200 Subject: [PATCH 28/42] =?UTF-8?q?fix(tests):=20iteration=203=20=E2=80=94?= =?UTF-8?q?=20the=20scan=20was=20blind=20in=20a=20second=20house=20style,?= =?UTF-8?q?=20and=20#76=20landed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from /review-diff on iteration 2. Two are worth reading. THE NO-COPY SCAN WAS BLIND IN THE HOUSE STYLE AGAIN, IN A DIFFERENT WAY. Iteration 2 fixed `NAME=value # comment`. The pattern still anchored the coordinate name at `^\s*`, so it saw only an assignment that STARTS A LINE. Proven by injecting three real registry values into README.md — a markdown bullet, a table cell, and an assignment quoted mid-sentence — all three invisible, all three now caught. Thirteen forms proven caught, four legitimate mentions proven silent. The shared cause of both blindnesses is not a regex bug: the pattern described one way of writing markdown, the way this repository happens to write it today. A guard against publishing a coordinate value from a PUBLIC repository has to survive the next contributor writing a bullet instead of a fenced block. C-57 amended with both, and with the residual that stays deliberate — a value merely NAMED in a sentence is not a copy, and an earlier draft that matched those fired on a dozen documents (ADR-014 §3). VIEWS-APPWRITE#76 HAS LANDED AND TWO RECORDS SAID IT WAS "FILED". Registry v1.6.0 ships `[edition."x.y.z"].obliges_consumers` — C-86's stated first-preference resolution. C-86 still called it filed; ADR-016 §7b still called it "still worth doing"; the code comment added in the previous commit already called it delivered. Three artifacts in one branch, two of them behind. Both records amended, and the re-pin deferral that lived only in a pull-request description now has a written trigger and an owner — both halves of that trigger have already fired (ADR-014 §4). THE MUTANT OFFENDER INFERRED WHAT IT SHOULD DECLARE. `next(iter(siblings), None) or "views-appwrite"` reads as principled and is wrong for G2, whose premise is an EMPTY declaration: there is nothing to derive from, so it fell back to the hardcoded name while its own message claimed it had not. Each mutant now declares the offender its rule must name (ADR-003), proven load-bearing by feeding one a wrong name. Also: `_ABSENT`'s doc-comment survived `_ABSENT` — the block moved to seam_registry.py and left its `#:` lines attached to `_TABLE_ROLE`, so the partition was documented as a sentinel for a valueless row. And the no-copy scan was the last of seven readers still hand-rolling its skip instead of calling `require_sibling`, whose whole justification is that the hand-rolled message tells a contributor too little to run the test. Suite 410 passed / 1 skipped / 40 xfailed; ruff clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 11 +++- reports/technical_risk_register.md | 53 ++++++++++++++++++- tests/test_ci_sibling_coverage.py | 35 +++++++----- tests/test_env_declaration.py | 47 ++++++++++------ 4 files changed, 112 insertions(+), 34 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index ddd6024..ae5cf56 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -264,7 +264,16 @@ The third kind exists because the no-copy rule forbids writing expected coordina into this repository. A pinned edition is the only lawful place to keep a baseline for comparing them. -views-appwrite#76 is still worth doing; it is no longer a precondition for anything here. +views-appwrite#76 has since been done. It shipped on 2026-08-11 as registry version 1.6.0, +which adds a table naming each edition and saying whether that edition obliges the +repositories that read the registry. This repository does not read it yet, and does not have +to: the third kind of check above already tells us whether anything we depend on moved. What +the new table would add is upstream's own answer to the same question, which is cheaper and +does not require a pinned baseline at all. That is worth adopting, and it is not urgent. + +It is worth saying how we learned it had shipped. Nobody told us. The check that requires +every table upstream to be classified here went red the first time it met the new one, which +is what that check is for. --- diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 6aa7687..20bc1a0 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -207,7 +207,7 @@ ADR-016 has CI check out `views-appwrite` and `views-crafdapi` so that cross-rep **The rate is not hypothetical.** views-appwrite reports five registry editions in four days (v1.4.0 2026-08-02 through v1.4.4 2026-08-05), **four of them observation-driven** — recording console facts, correcting a key's scopes — carrying no obligation for any consumer. Each would have reddened this repository and blocked a release. -**What would resolve it, in order of preference:** views-appwrite#76 makes the obligation-carrying distinction machine-readable, so observation-only bumps stop firing the check at all — filed, and that seat volunteered it. Failing that, a bypass actor restores the escape. Failing both, the coupling stands as ADR-016 §7 describes, which is defensible but should be chosen rather than discovered. +**What would resolve it, in order of preference:** views-appwrite#76 makes the obligation-carrying distinction machine-readable, so observation-only bumps stop firing the check at all — filed, and that seat volunteered it. Failing that, a bypass actor restores the escape. Failing both, the coupling stands as ADR-016 §7 describes, which is defensible but should be chosen rather than discovered. *(⚠ "filed" is stale as of 2026-08-11 — #76 has **landed**, as registry v1.6.0. See the amendment at the end of this entry; the sentence is left as written because what it asked for and what arrived are worth comparing.)* Cross-refs: **C-81** (the enforcement half, whose fix activates this), **C-46** (whose recommendation against per-PR sibling checkouts this overrode, with the reasoning recorded there), ADR-016 §7/§7a/§7b, views-appwrite#76. @@ -231,6 +231,40 @@ that carry an obligation. The entry's trigger is unchanged and remains a console action: **a status check becomes required on `main`**, at which moment the coupling stops being latent. +**Amended 2026-08-11 — the first-preference resolution has LANDED, and this entry found out +from a guard rather than from a notification.** views-appwrite#76 shipped as registry +**v1.6.0**: a new `[edition."x.y.z"]` table marking each edition `obliges_consumers = +true|false`, which is exactly the machine-readable distinction the paragraph above asks for. +The sentence *"filed, and that seat volunteered it"* was true when written and is now stale. + +Two things are worth recording about how it arrived. The partition check added the same day +went red on it unprompted, on its first live encounter with an upstream table nobody here +had classified — which is the whole reason that check is directional. And the row-level +drift check stayed **correctly silent**, because a new table this package does not read is +not drift in anything it depends on. The two behaved exactly as designed on data neither +was tested against. + +**Adoption is a follow-up, not part of the change that noticed it.** `[edition]` is +classified `IGNORED` in `tests/test_env_declaration.py::_TABLE_ROLE` — declared, not +silently unseen. Nothing here reads `obliges_consumers` yet, so the false-alarm rate is +still carried by the row-level differential rather than by upstream's own flag. + +**Deferral, with the trigger ADR-014 §4 requires** — this was carried in a pull-request +description, where deferrals go to be forgotten: + +- **What:** re-pin `SEAM_CONTRACT_VERSION` / `SEAM_CONTRACT_COMMIT` in both + `views_postprocessing/{unfao,crafd}/appwrite_env.py` (currently v1.5.2 / `c7b597e`), and + read `[edition].obliges_consumers` so an observation-only edition cannot fire anything. +- **Trigger — both halves have now FIRED:** the partition fired on v1.6.0 (2026-08-11), and + views-appwrite#76 landed. The re-pin is therefore **due**, not deferred; what remains + deferred is reading the new flag. +- **Owner:** whoever next touches an `appwrite_env.py`. Re-pinning is hygiene with no + safety consequence now that the differential exists — which is precisely why it needs a + written trigger rather than a good intention. + +Cross-refs for this amendment: **C-57** (the drift detector this rides on), ADR-016 §7a/§7b, +views-appwrite#76 (**delivered**, registry v1.6.0). + --- @@ -1470,7 +1504,22 @@ This is ADR-014 §2 in its narrow form: a guard's *scope* is part of what has to PR #211 re-pins crafd to `1.4.1` / `90fc105` and parameterises **three** of the four checks over both partner declarations — names-and-class, pinned edition, commit reachability. The fourth, the value-copy scan, was never partner-scoped: it walks `_PKG.rglob("*.py")` and so covered `crafd/` from the day it landed. A third partner is now a one-line addition, and an unguarded one is a failure. This entry stays RESOLVED — the mechanism was right, its reach was not — but the residual below now has a companion: a detector that names its subject is a detector that will miss the next subject. -Cross-refs: C-74 (the guard this paragraph vouched for), C-33 (store identity still hardcoded per store — the same env surface, different concern), C-58 (what happens when a coordinate is wrong rather than missing), C-44 (the pipeline-core version coupling that would carry a registry change), issues #134/#135/#138 (this repo's discharged þing-01 obligations), #104 (README env block placeholders). +**⚠ AMENDED 2026-08-11 — the value-copy scan was blind in this repository's own house style, twice, and the second time is the general lesson.** + +The scan was extended to tracked markdown during the development→main sync, because README.md had carried four real coordinate values. Two reviews later, it was still missing two whole classes of copy — both of them ordinary markdown, both proven by injecting real registry values and watching the guard stay green: + +| form | why it was invisible | fixed | +|---|---|---| +| `NAME=value # comment` | `(.+?)\s*$` swallowed the comment into the captured value, so nothing compared equal. **README.md's own Configuration block is written in exactly this form.** | 2026-08-11 (iteration 2) | +| `` - `NAME=value` ``, `` \| NAME=value \| ``, `` set `NAME=value` before … `` | the pattern anchored the coordinate name at `^\s*`, so it saw only an assignment that *starts a line* | 2026-08-11 (iteration 3) | + +The shared cause is not a regex bug. **The pattern described one way of writing markdown — the way this repository happens to write it today — and a guard against publishing a value has to survive the next contributor writing a bullet instead of a fenced block.** The name may now be preceded by anything that is not part of an identifier, and the value ends at whatever terminates it in prose: a comment, a closing backtick, or a table pipe. Thirteen forms are proven caught; the four legitimate mentions that must stay silent are proven silent. + +**The residual, which is deliberate and should not be "fixed".** This remains a *syntax* match. A value merely named in a sentence — "the six stranded documents in ``" — is not a copy and does not fire. An earlier draft that matched any occurrence fired on a dozen documents, and the lesson recorded above applies to itself: a guard that cries wolf gets deleted, after which the real rule is unguarded (ADR-014 §3). The class this cannot catch is a value pasted into prose with no assignment syntax anywhere near it. That is accepted, because the alternative has been tried and was worse. + +**Why this belongs on C-57 rather than in a new entry.** It is the same guard, the same failure direction, and the same lesson this entry already records one layer down: the earlier amendment found the scan's *scope* was never mutation-proven (it named `unfao` and missed `crafd`); this one finds its *matching* was never proven against the file formats it scans. Scope, matching, and now syntax-variant — three ways for a mutation-proven guard to be proven against the wrong thing. + +Cross-refs: C-74 (the guard this paragraph vouched for), C-33 (store identity still hardcoded per store — the same env surface, different concern), C-58 (what happens when a coordinate is wrong rather than missing), C-44 (the pipeline-core version coupling that would carry a registry change), **C-86** (the drift checks this scan sits beside), issues #134/#135/#138 (this repo's discharged þing-01 obligations), #104 (README env block placeholders). --- diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index 51a8898..0572e2e 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -387,27 +387,36 @@ def _replace(name: str, **changes) -> dict[str, Sibling]: return {name: replace(SIBLINGS[name], **changes)} +#: ``(rule, broken workflow, broken declaration, the sibling the message must name, why)`` +#: +#: **The offender is declared, not inferred.** It was briefly derived from the mutant's +#: own sibling map — which reads as principled and is wrong for `G2`, whose whole premise +#: is an EMPTY declaration: there is nothing in the map to derive from, so it fell back to +#: a hardcoded ``"views-appwrite"`` while the assertion message claimed it had not. For G2 +#: the offender is a name in the *workflow*, and no amount of looking at the declaration +#: will find it. Writing it out is this repository's own rule (ADR-003): declare the fact, +#: do not let a helper guess it. _MUTANTS = [ ("G1 declared checkouts are present and pointed at", {"jobs": {"test": {"steps": [{"run": "poetry run pytest tests/", "env": {}}]}}}, - _ONE, "declared ci_checkout=True but the checkout step is gone"), + _ONE, "views-appwrite", "declared ci_checkout=True but the checkout step is gone"), ("G2 every checkout is declared", - _workflow(), {}, "checked out but undeclared"), + _workflow(), {}, "views-appwrite", "checked out but undeclared"), ("G4 every note names a record", _workflow(), _replace("views-appwrite", ci_checkout=False, note=""), - "not checked out and no note"), + "views-appwrite", "not checked out and no note"), ("G4 every note names a record", _workflow(), _replace("views-appwrite", ci_checkout=False, note="because reasons"), - "note explains but names no record"), + "views-appwrite", "note explains but names no record"), ("G5 no step swallows its own failure", _workflow(**{"continue-on-error": True}), _ONE, - "a failed fetch would not fail the build"), + "views-appwrite", "a failed fetch would not fail the build"), ("G6 siblings land under the excluded path", _workflow(**{"with": {"path": "vendor/views-appwrite"}}), _ONE, - "outside _siblings/, so ruff would lint it"), + "views-appwrite", "outside _siblings/, so ruff would lint it"), ("G7 siblings are taken from main", _workflow(**{"with": {"ref": "development"}}), _ONE, - "takes a branch that is not the authority"), + "views-appwrite", "takes a branch that is not the authority"), ] @@ -430,8 +439,8 @@ def test_every_rule_has_a_mutant_that_proves_it_bites(): ) -@pytest.mark.parametrize("label, workflow, siblings, why", _MUTANTS) -def test_each_rule_bites_on_a_broken_world(label, workflow, siblings, why): +@pytest.mark.parametrize("label, workflow, siblings, offender, why", _MUTANTS) +def test_each_rule_bites_on_a_broken_world(label, workflow, siblings, offender, why): """Feed a rule a deliberately broken world and demand that it complains. **A failure here means the RULE is broken, not the workflow.** That inversion is the @@ -450,15 +459,13 @@ def test_each_rule_bites_on_a_broken_world(label, workflow, siblings, why): "This means the RULE has stopped working — the real workflow is not involved " "and is probably fine. Fix the rule, not run_pytest.yml." ) - offender = next(iter(siblings), None) or "views-appwrite" assert any(offender in v for v in violations), ( f"[{label}] objected, but its message does not name the offending sibling " f"({offender!r}): {violations}. A maintainer reading only the failure would not " "know which repository to look at.\n\n" - "The name is taken from the mutant's own sibling map rather than hardcoded — a " - "fixed \"views-appwrite\" was correct only by coincidence of today's fixtures, " - "and would have made a working rule fail for the next mutant about a different " - "repository, under this file's inverted semantics." + "Each mutant declares the name its rule must produce, in _MUTANTS. If you have " + "changed which sibling a mutant breaks, change its declared offender in the same " + "edit — under this file's inverted semantics, a stale one fails a rule that works." ) diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 871e653..0db95f8 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -46,7 +46,6 @@ commit_is_on_main, git_output, require_sibling, - sibling_repo, ) from views_postprocessing.contract import launch_config from views_postprocessing.crafd import appwrite_env as crafd_env @@ -442,7 +441,6 @@ def test_secret_env_names_follow_the_seam_contract_naming_rule(): # compare names, declared classes, and the edition — nothing else. - #: How this module treats each declared name, vs. the registry's own `class` field. #: #: **Written out, not derived.** An earlier draft built this by suffix — anything ending @@ -485,8 +483,6 @@ def test_every_declared_name_is_classified_here(partner): ) -#: Sentinel for "this row carries no value", distinct from any string a registry -#: could legitimately hold. #: Every top-level table the registry may carry, and what this repository does with it. #: #: **Declared, and asserted against the live registry** — a table appearing upstream that @@ -1025,9 +1021,12 @@ def test_no_coordinate_value_is_copied_into_this_repo(): their own: a function name is not a ``Constant``; ``"unfao_bucket datastore"`` is not equal to ``"unfao_bucket"``; docstrings are excluded outright. """ - repo = sibling_repo("views-appwrite") - if repo is None: - pytest.skip("views-appwrite checkout not found — set VIEWS_APPWRITE") + # `require_sibling`, like every other reader in this file. The hand-rolled skip that + # stood here said only "checkout not found — set VIEWS_APPWRITE", which is the + # degraded message `require_sibling` exists to replace: it names the variable but not + # the conventional path, so a contributor can watch it skip and not run it. Being the + # last call site off the shared path is how a rule ends up with two behaviours. + repo = require_sibling("views-appwrite") registry = _registry_current(repo) # Scoped by the declared partition, NOT by an inline tuple — so a new table cannot # be swept in by a one-word edit, and `contract` cannot be swept in at all. @@ -1080,17 +1079,30 @@ def test_no_coordinate_value_is_copied_into_this_repo(): # The copy is a value **assigned to its own coordinate name** — `APPWRITE_X=value` — # which is a reader's instruction to configure with that literal. That is precise # enough to have caught README.md and to ignore every legitimate mention. - # `(.+?)\s*$` used to swallow an inline comment into the captured value, so - # `NAME=value # note` compared `'value # note'` against the registry and matched - # nothing. README.md's own Configuration block is written in exactly that style, so - # the guard was blind in the house style of the document it was written for — proven - # by injecting two real coordinate values with trailing comments, both invisible. # - # A `#` inside a quoted value is legitimate, so the quoted form is matched first and - # taken whole; only an unquoted value is truncated at a comment. + # **It has been blind in this repository's own house style twice, and both blindnesses + # had the same cause: the pattern described one way of writing markdown.** + # + # 1. `(.+?)\s*$` swallowed an inline comment into the captured value, so + # `NAME=value # note` compared `'value # note'` against the registry and + # matched nothing. README.md's Configuration block is written in exactly that form. + # 2. Anchoring the name at `^\s*` saw only an assignment that *starts a line*. A + # markdown bullet — ``- `NAME=value` `` — a table cell, or an assignment quoted + # mid-sentence were all invisible, and all three are ordinary ways to document + # configuration. The fenced-block form the guard was written against is the one + # form this repository happens to use today. + # + # So the name may be preceded by anything that is not part of an identifier, and the + # value ends at whatever terminates it in prose: a comment, a closing backtick, or a + # table pipe. A `#` inside a QUOTED value is legitimate, so the quoted forms are tried + # first and taken whole; only an unquoted value is truncated. + # + # This stays a syntax match, deliberately. A value merely *named* in a sentence is not + # a copy — C-57 recorded a draft that fired on a dozen such documents, and a guard that + # cries wolf gets deleted, after which the real rule is unguarded (ADR-014 §3). assignment = re.compile( - r"^\s*(?:export\s+)?(" + "|".join(sorted(_EXPECTED_NAMES)) + r")\s*=" - r"""\s*(?:"([^"]*)"|'([^']*)'|([^#]*?))\s*(?:#.*)?$""" + r"(?:^|(?<=[\s`|>*-]))(?:export\s+)?(" + "|".join(sorted(_EXPECTED_NAMES)) + r")\s*=" + r"""\s*(?:"([^"]*)"|'([^']*)'|([^#`|]*?))\s*(?:[#`|].*)?$""" ) # This repository's OWN tracked markdown — `git ls-files`, not `rglob`. CI checks # sibling repositories out into the workspace, and their documents are not this @@ -1116,7 +1128,8 @@ def test_no_coordinate_value_is_copied_into_this_repo(): ) for doc in sorted(scanned): for number, line in enumerate(doc.read_text().splitlines(), 1): - match = assignment.match(line) + # `search`, not `match`: the assignment no longer has to start the line. + match = assignment.search(line) captured = next((g for g in match.groups()[1:] if g is not None), None) if match else None if captured is not None and captured.strip() in values: copied.append( From 9f735439cdc2376993260122071dc4c720467ae0 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 12:38:25 +0200 Subject: [PATCH 29/42] =?UTF-8?q?docs(register):=20C-89..C-96=20=E2=80=94?= =?UTF-8?q?=20the=20post-merge=20review=20of=20#239,=20and=20the=20decisio?= =?UTF-8?q?ns=20it=20forced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registered before any code moves, so the reasoning survives the fixes. FIVE NEW FROM /code-review max ON THE MERGED #239 (all reproduced here, none taken on trust): C-89 the no-copy guard prints the value it forbids, in one branch of three and proven on one side of two; C-90 the replacement drift checks assert less than their docstrings say, and one mutation proof is a tautology; C-91 the git plumbing turns ordinary developer states into hard errors with the wrong diagnosis; C-92 this repository lost its only check that the FAO consumer still SELECTS by the delivery label; C-93 a mutation proof written by whoever wrote the guard tests that author's imagination — 13 forms proposed by the author, 13 caught; 29 proposed independently, 15 missed. THREE MORE FROM THE EXPERT REVIEW OF THE STANDING DECISIONS: C-94 nothing anywhere detects an invisible delivery at the time it happens — every mechanism aimed at it is a CI-time proxy — deferred with a trigger and an owner rather than omitted; C-95 the integration-test prohibition is cited to þing-02 D2 in three places when it is þing-01 D2, and the real ruling is CONDITIONAL and GRANTS read-only preflight; C-96 the registry table carrying that permission is classified "not about this package". C-95 and C-96 compound into the finding that matters most for planning: a whole class of mechanism was believed closed for weeks because a citation pointed at the wrong verdict and the table stating the permission was declared none of our business. AMENDED: C-57 (my own amendment overclaimed — it cited a backticked example as proof the mid-sentence class was covered; it passes only because of the backticks, and a fourth failure direction was found: removing the length floor admitted this repo's own package names into a security guard's ban-set); C-86 (its partial-mitigation paragraph overstates what shipped — one of the three replacement checks does not match on the facts this repository declares); C-87 (one partner's residual got worse, not better). Header 93/20/73 -> 96/23/73. Epic #241, tracking #251. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 212 ++++++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 3 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 20bc1a0..6c16d4c 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -4,9 +4,9 @@ |-------------------|--------------------------------------| | Project | views-postprocessing | | Owner | Dylan Pinheiro / PRIO MD&D Team | -| Last Updated | 2026-08-11 | -| Total Concerns | 88 | -| Open Concerns | 15 | +| Last Updated | 2026-08-12 | +| Total Concerns | 96 | +| Open Concerns | 23 | | Resolved Concerns | 73 | --- @@ -161,6 +161,186 @@ that indexes only deleted code is noise. ## Open Concerns +### C-96: The registry table that says which live checks this package may build is classified as none of our business + +| Field | Value | +|-------|-------| +| ID | C-96 | +| Tier | 4 — no defect follows from it directly. It is here because it concealed an available mechanism for weeks, which is a cheap mistake to make again. | +| Source | `/expert-code-review` of the standing decisions, 2026-08-12 | +| Trigger | Anyone asks whether a live check against the production Appwrite project is permitted. | +| Owner | This repository. | +| Location | `tests/test_env_declaration.py:509` (`_TABLE_ROLE["test_environment"] = "IGNORED"`). | + +`[test_environment]` is classified `IGNORED` with the reason *"a fact about the platform, not about this package"*. It is in fact the clause that governs **what live checks this package is permitted to build**: + +> `status = "none"` — *"No non-production Appwrite project exists (þing-01 S23). Until the operator creates one: integration tests against the production project are FORBIDDEN by the seam contract; **read-only preflight validation is the only permitted live check**."* + +The second half is a **permission**, and this repository spent weeks believing the whole clause was a prohibition. Reclassify and read it, or record why a permission that changes what we may build is not a fact we depend on. + +Cross-refs: **C-94** (the mechanism this permission authorises), **C-95** (the mis-citation that compounded it), issue #249. + +--- + +### C-95: The integration-test prohibition is cited to the wrong verdict, in three places + +| Field | Value | +|-------|-------| +| ID | C-95 | +| Tier | 3 — a citation error, but one that closed off a design option. Namespacing across six repositories and two þings is a known hazard here; this is it landing. | +| Source | `/expert-code-review` of the standing decisions, 2026-08-12 | +| Trigger | Anyone reasons from the integration-test prohibition — for a preflight, a drill, or a new ADR. | +| Owner | This repository. | +| Location | `reports/technical_risk_register.md` (~:475, ~:1628, ~:1717); anywhere else citing þing-02 D2 for this. | + +This register cites **þing-02 D2** for the ruling that integration tests against the production Appwrite project are forbidden. þing-02 D2 is about identity and key separation. The ruling is **þing-01 D2** (`þingit/01_identity_secrets_config/orð_dómr.md:53-61`), and it differs from the paraphrase in two ways that matter: it is **conditional** (*"until the operator creates one"*), and it **grants** read-only preflight validation as the permitted live check. It also records that creating a test project is **assigned to the operator** and gates the provisioning-path drill — an open assignment, not a closed door. + +Cross-refs: **C-94**, **C-96**, þing-01 `orð_dómr.md` D2, issue #249. + +--- + +### C-94: No mechanism anywhere detects an invisible delivery at the time it happens + +| Field | Value | +|-------|-------| +| ID | C-94 | +| Tier | 2 — the failure mode is invisible by construction and lands on the live FAO path: upload succeeds, storage is billed, the consumer's endpoint returns empty, nothing raises anywhere. ADR-013 §4.1a's *"invisible to the consumer, not merely degraded."* | +| Source | `/expert-code-review` of the standing decisions, 2026-08-12 | +| Trigger | **Either.** (a) A delivery is reported empty by a consumer or by FAO. (b) `APPWRITE_READ_API_KEY` is provisioned for the launcher — at which point the deferral below has no remaining cost. | +| Owner | This repository, for the mechanism. The credential is the operator's. | +| Location | `views_postprocessing/contract/wire/sink.py` (the upload path, where nothing verifies); `views_postprocessing/delivery/`. | + +Every mechanism this platform has aimed at invisible delivery is a **CI-time proxy** for it: we check our label against the registry, the consumer checks their constant against the registry, and — until this week — we parsed their source. None of them observes the outcome. Grepping the sink and the delivery package finds no read-back, no findability check, no assertion that what was uploaded can be retrieved by the name the consumer will query. + +**The mechanism that would close it is known and is legal.** A producer-side **read-only findability preflight**: after upload, query the store read-only for a document whose `name` equals the declared `CONSUMER_DOCUMENT_NAME`; assert non-empty; log at ERROR and raise (ADR-008); remedy is the existing operator quarantine. It is authorised by the seam contract (see **C-96**), the `APPWRITE_READ_API_KEY` slot is already declared, and it is the only mechanism that survives a **third-party-operated private consumer**, because it asks nothing of them. + +**Why it is deferred, stated honestly.** It needs a read credential wired into the launcher — an operator action, not a code change — and it adds a live network call to the delivery path. Delivery works today. Building it now would be building the right thing at the wrong time. That is a deferral with a trigger and an owner (ADR-014 §4), not an omission. + +**What it would not cover, so nobody over-reads it later:** it proves the document is findable by that name in the store. It does not prove the consumer's code queries by that name. That last link is theirs, and issues asking each consumer to bind their *query* to their constant are filed under #248. + +Cross-refs: **C-92** (the check we deleted rather than replaced), **C-87** (the broader residual), **C-96** (the permission), ADR-013 §4.1a, ADR-017 §5/§8, issue #248. + +--- + +### C-93: A mutation proof written by whoever wrote the guard tests that author's imagination, not the guard + +| Field | Value | +|-------|-------| +| ID | C-93 | +| Tier | 3 — no defect of its own. It is the reason several of the defects below survived three reviews, and it changes what "mutation-proven" is allowed to mean in this codebase. | +| Source | `/code-review max` on PR #239 post-merge, 2026-08-11, corroborated by measurement | +| Trigger | The next time a guard is defended in a pull-request description as "mutation-proven" against a list of cases the same change authored. | +| Owner | Whoever writes the next guard; the standard belongs in ADR-014 §2. | +| Location | ADR-014 §2; `tests/test_env_declaration.py::test_no_coordinate_value_is_copied_into_this_repo`; every `_MUTANTS`-style proof in `tests/`. | + +ADR-014 §2 says a guard is mutation-proven or it is decoration. That is right, and it is not sufficient. **A mutation proof is only as good as the mutant list, and a mutant list written by the author of the guard covers the cases the author already had in mind — which are, by construction, the cases the guard already handles.** + +Measured, on the guard that protects a public repository from publishing a coordinate value. Its author (this assistant) proposed thirteen input forms, proved all thirteen caught, and wrote that result into C-57 and into a commit message. An independent review then proposed twenty-nine forms. **Fifteen missed.** The thirteen were not a sample of how people write markdown; they were a sample of what the author had thought of, and every one of them happened to share the property the guard depended on. + +This is not the same as ADR-014 §2 failing. The proof was real, it was executed, and every case in it genuinely passed. The gap is that "proven against N mutations" reads as a statement about the guard when it is a statement about N. + +**What would change.** Two candidates, and they are not exclusive: (a) for a guard whose failure is silent — a leak, an invisible delivery — the mutant list is written or extended by something other than the change that wrote the guard; (b) the mutant list is a *declared, named* artifact in the test file rather than a paragraph in a commit message, so the next contributor can see what was and was not tried. `tests/test_ci_sibling_coverage.py::_MUTANTS` is already (b) and is the pattern to copy. + +Cross-refs: **C-57** and **C-89** (the guard this was measured on), **C-90** (a proof that proved nothing at all), ADR-014 §2. + +--- + +### C-89: The guard against publishing a coordinate value publishes it — in one branch of three, proven on one side of two + +| Field | Value | +|-------|-------| +| ID | C-89 | +| Tier | 2 — a confidentiality exposure on a public repository with no signal that it happened. It fires on exactly the event the guard exists to catch, and CI logs are world-readable and are not retroactively redactable. | +| Source | `/code-review max` on PR #239 post-merge, 2026-08-11; every element verified in this repository | +| Trigger | A literal coordinate value is committed into a `.py` under `views_postprocessing/` — the violation the scan exists for — on any branch whose CI runs. | +| Owner | This repository. | +| Location | `tests/test_env_declaration.py:1065` (the AST branch), `:1042` (the `secret` exemption), `:937` (the half-sided rotation proof). | + +The no-copy scan exists because README.md once carried four real coordinate values, two lines below the sentence promising they are never copied, in a public repository. It has three parts and they do not agree with each other about the one rule that matters. + +**The Python branch prints the value.** `copied.append(f"{source}:{node.lineno} = {node.value!r}")`. The markdown branch sixty-eight lines below was rewritten on 2026-08-11 to stop doing precisely this, and `_describe_changes` was added in the same change with a docstring whose entire subject is that a rotated coordinate must never reach a public log. The invariant was stated, applied to one branch, and left off the other. When this guard fires it does the thing it was built to prevent. + +**The `secret` table is exempted by an inline literal.** `scanned_sections` filters `_TABLE_ROLE` for `CONSUMED` and then subtracts `secret` by name, two lines under a comment claiming the scope comes from the declared partition and not from an inline list, and with no reason recorded anywhere. Measured against views-appwrite's current registry: `[secret]` has seven rows and none carries a `value`, so the clause removes zero entries today and the `isinstance(..., str)` filter on the next line already does its work. The day a secret row gains any value, the one table class whose leak matters most is silently outside the scan. + +**The rotation proof checks one side.** `assert "value:" in changed[canary] and "at-the-pin" not in changed[canary]` asserts the *pinned* value is absent. The freshly rotated value — the more damaging one, and the one the fixture already names — is never asserted absent. A regression that digests one side and interpolates the other passes this proof. + +The three share a cause: the no-print rule lives in prose and in one implementation, and nothing asserts it about the guard as a whole. + +Cross-refs: **C-57** (the scan's own entry and its history), **C-93** (why the author's own proof did not find this), **C-90** (the sibling defect in the drift checks), ADR-014 §1. + +--- + +### C-90: The replacement drift checks assert less than their docstrings say, and one of their mutation proofs is a tautology + +| Field | Value | +|-------|-------| +| ID | C-90 | +| Tier | 2 — this is the entry PR #239 was written to close, reopened by the code that closed it. It reinstates release-blocking false alarms on the path that is this project's production release, and it does so under a docstring saying the opposite. | +| Source | `/code-review max` on PR #239 post-merge, 2026-08-11; verified by direct measurement against views-appwrite `origin/main` | +| Trigger | views-appwrite declares any new coordinate in `connection`, `target`, `secret` or `contract` — including one belonging to another repository entirely. On the current registry that is 8 of 25 rows' worth of surface. | +| Owner | This repository. | +| Location | `tests/test_env_declaration.py:732` (`arrived`), `:890` (the tautology), `tests/seam_registry.py:113` (`registry_current`). | + +**`arrived` is not filtered by the names this package reads.** `changed` is; `arrived` is computed over every row of every table this package depends on. Measured on the live registry: **25 rows, 8 of which this package never reads** — six of them keys and callers belonging to other repositories. So views-appwrite issuing one more key for an unrelated repo turns both partner parametrizations red here, with a message demanding a `SEAM_CONTRACT` re-pin for a coordinate this package cannot use. + +That is the exact failure class C-86 records and that PR #239 was written to remove, and the same test's docstring seven lines above says **"Silent through: prose edits, `[meta]` bumps, and rows belonging to anyone else."** The prose describes the check that was designed; the code implements a wider one. + +There is a real question underneath, and it should be decided rather than inherited: a *new* coordinate in a table we read may be one we must adopt — `[contract.*]` arrived exactly that way and nothing else here would have seen it. That argues for table-granularity on arrival and row-granularity on change. If that is the intent it is defensible, and then the docstring is wrong; if the docstring is right, the code is. **They cannot both stand.** + +**The partition's mutation proof cannot fail.** `assert not _unclassified_tables(base)` where `base = {name: {} for name in _TABLE_ROLE}` reduces to `set(_TABLE_ROLE) - set(_TABLE_ROLE)`, empty for every possible input. Its message — *"the real registry's tables must all classify"* — asserts a fact about a file this test never opens. It is decoration inside the test whose own docstring is about removing decoration. + +**`registry_current` has no test.** The module `tests/seam_registry.py` was extracted for one reason: two copies of the reader disagreed about whether to read the sibling's `main` or its working tree, and reading the working tree is issue #196 verbatim. The function that settles it is called by five tests and is the subject of none. Replacing its body with `rev-parse HEAD` — the defect it exists to prevent — leaves the suite at its exact baseline. Three of its error branches are executed by nothing. + +Cross-refs: **C-86** (whose partial-mitigation paragraph this falsifies), **C-89** (the sibling defect in the no-copy scan), **C-93**, **C-91**, ADR-014 §1/§2, issue #196. + +--- + +### C-91: The git plumbing this arc added turns ordinary developer states into hard errors, bare tracebacks, and one possible hang + +| Field | Value | +|-------|-------| +| ID | C-91 | +| Tier | 3 — no wrong data and nothing silent. It taxes every contributor who does not already have the exact sibling checkout this repository assumes, and it does so with diagnoses that point at the wrong cause. | +| Source | `/code-review max` on PR #239 post-merge, 2026-08-11 | +| Trigger | Any of: a contributor clones views-appwrite shallow, single-branch, or before the pinned commit; a table classified `CONSUMED` upstream is written as flat keys rather than sub-tables; a contributor has `commit.gpgsign` or a global `core.hooksPath` set. | +| Owner | This repository. | +| Location | `tests/seam_registry.py:71` (refusal diagnoses), `:150` (`rows`), `tests/test_env_declaration.py:820` (the scratch repo). | + +**A stale clone produces four errors carrying the wrong explanation.** The reader the extraction replaced read the file off disk, so an older checkout simply read an older file. Now a clone that predates the pinned commit — or is shallow, or was made `--single-branch`, which matters because views-appwrite's default branch is not `main` — raises *"does not resolve to a commit … an empty ref reads the index and a branch reads a moving tip"*. That names neither cause and does not say `git fetch`. Meanwhile `test_the_pinned_commit_is_reachable_from_the_contract_repos_main` detects the identical root cause and *skips* with the right remedy. One condition, one skip, four errors, three explanations. + +**`rows()` raises a bare `AttributeError` on a shape the live registry already has.** It guards a null section and not a scalar row. Verified: views-appwrite's `[test_environment]` holds `status` and `fact` as top-level strings. That table is `IGNORED`, so nothing breaks today — but when the partition check fires on a new upstream table, its own message instructs the maintainer to classify it `CONSUMED` or `MIRRORED`, and doing so for a table written that way returns a traceback pointing into a dict comprehension. From the module whose docstring says a helper justified by failing legibly must not hand back a bare traceback. + +**The scratch repo inherits the developer's global git config and has no timeout.** `test_the_pinned_reader_refuses_every_way_a_baseline_can_be_wrong` sets `user.name` and `user.email` and stops. With `commit.gpgsign = true` it fails with a bare `CalledProcessError` — `capture_output=True` swallows git's explanation. With a passphrase-protected key it blocks on pinentry with no `timeout`, hanging the whole run; `conftest.git_output`, which this helper bypasses, caps at 30 seconds. The leak was anticipated for identity and not for the setting that blocks. + +Cross-refs: **C-90** (the same module's untested core), **C-88** (why the module exists outside `conftest.py`), ADR-008 (explicit failure), issue #196. + +--- + +### C-92: This repository lost its only check that the FAO consumer still SELECTS by the delivery label + +| Field | Value | +|-------|-------| +| ID | C-92 | +| Tier | 2 — the failure mode is invisible by construction and is on the live FAO path. Upload succeeds, storage is billed, the consumer's endpoint returns empty, nothing raises anywhere. | +| Source | `/code-review max` on PR #239 post-merge, 2026-08-11 | +| Trigger | views-faoapi changes how `managers/prediction/manager.py` selects — to a category, a metadata field, a query builder — without touching its served-name constant. | +| Owner | Shared: views-faoapi owns the check; this repository owns noticing it does not exist. | +| Location | `tests/test_product.py:63` (`_CONSUMER_FILTER`), `:274` (`_CONSUMER_SELF_CHECK_PENDING`), `:344`; `tests/conftest.py:159-167`; ADR-017 §5. | + +PR #239 retired the FAO half of the source-reading check on the strength of views-faoapi#379. That was the right sequencing — §5 requires the consumer-side check to land first, and it had. **But the two checks are not the same check.** + +views-faoapi#379 binds their *served-name constant* to the registry row. The assertion this repository deleted was `'filters["name"] = self.model_path.model_name'` — that they still *query* on it. So views-faoapi can refactor its selection mechanism, leave its name constant untouched, pass #379, pass our registry comparison, and serve an empty endpoint. The deleted assertion carried that exact sentence: *"the name may still match while the consumer filters on something else entirely — same invisibility, different cause."* Nothing carries it now. + +**Verified 2026-08-12, and the state is good — which is why this is a risk and not an incident.** Read at `views-faoapi@origin/development`: `_CONSUMER_DOCUMENT_NAME = "un_fao"` (`src/views_faoapi/managers/api.py:59`) reaches `APIPathManager(...)` at `:1282`, and `managers/prediction/manager.py` still filters on it at `:117` and `:435`. Their D2 test — views-faoapi's `tests/test_seam_contract_binding.py` — imports the constant from the production module rather than re-typing it, which is better than it had to be. **The composition is what nothing asserts**: constant↔registry is checked by them, query↔constant was checked by us and is not any more. Also worth recording: their D2 check is on `development`; their `main` is 22 commits behind at PR #357, so a reader taking "#379 merged" to mean "live on their default branch" is over-reading it. + +**C-87 is not this.** C-87 records that we verify our copy against the declaration rather than the consumer's code against it. This is narrower and worse: for one partner we briefly had the second check and gave it up for something that does not cover the same failure. + +**A second, structural half.** `_CONSUMER_SELF_CHECK_PENDING` is asserted non-empty and asserted to name only real partners — never asserted to *cover* them. Every other partner map here is two-sided against `PARTNER_PACKAGES`; this one is not, so a third partner is silently exempt from the source read the day it lands. And `SIBLINGS["views-crafdapi"].note` says the fetch "buys nothing and should be removed" once the registry read exists — which PR #239 landed — while ADR-017 §5 forbids retiring that partner's source read until views-crafdapi#53 lands. A maintainer following the note does the thing the ADR forbids, and `test_the_pending_list_is_not_empty` does not object because the map stays non-empty. + +Cross-refs: **C-87** (the broader residual this sharpens, not duplicates), ADR-017 §5/§8/Appendix B, views-faoapi#379, views-crafdapi#53. + +--- + ### C-88: The platform declarations live in pytest's fixture file, so nothing outside the test tree can reach them | Field | Value | @@ -265,6 +445,19 @@ description, where deferrals go to be forgotten: Cross-refs for this amendment: **C-57** (the drift detector this rides on), ADR-016 §7a/§7b, views-appwrite#76 (**delivered**, registry v1.6.0). +**⚠ CORRECTED 2026-08-12 — the partial mitigation above overstates what shipped.** The +paragraph beginning *"Partial mitigation 2026-08-11"* says the replacement checks "match on +the facts this repository actually declares". **One of the three does not.** The arrival +half of the drift check is computed over every row of every table this package depends on, +with no filter for the names it reads — 8 of 25 rows on the live registry belong to other +repositories. So the false-alarm class this entry records is *narrowed*, not removed: +observation-only edits no longer fire it, but any new coordinate for any repo still does. + +The measurement quoted above (v1.4.4 → v1.5.2, three editions, green) remains true and was +not the wrong measurement — none of those editions added a row. It was simply not a +measurement of the case that now fires. Registered as **C-90**, and this entry's rate claim +should be re-stated once that is fixed rather than before. + --- @@ -289,6 +482,8 @@ Its residual is stated plainly in §8 and belongs here rather than only in a doc Cross-refs: **C-77** (the same field's producer-side half, resolved), ADR-013 §4.1a, ADR-017 §5/§8/Appendix B, views-appwrite#75, views-faoapi#379, views-crafdapi#53. +**⚠ AMENDED 2026-08-12 — one partner's residual got worse, not better.** This entry says the label's agreement with reality "rests on the source-reading check … which someone could remove believing the registry check replaced it." PR #239 removed the FAO half on the strength of views-faoapi#379 — correct sequencing under ADR-017 §5, and still the wrong outcome, because **#379 and the deleted check do not cover the same failure.** #379 binds their served-name constant to the registry; the deleted assertion was that they still *query* on it. Registered separately as **C-92**, because it is narrower and more acute than the residual recorded here: not "we never checked the consumer's code" but "we checked it, for one partner, and stopped." + --- ### C-85: A cross-repo ask is adopted on its stated terms without anyone measuring the current state @@ -1519,6 +1714,17 @@ The shared cause is not a regex bug. **The pattern described one way of writing **Why this belongs on C-57 rather than in a new entry.** It is the same guard, the same failure direction, and the same lesson this entry already records one layer down: the earlier amendment found the scan's *scope* was never mutation-proven (it named `unfao` and missed `crafd`); this one finds its *matching* was never proven against the file formats it scans. Scope, matching, and now syntax-variant — three ways for a mutation-proven guard to be proven against the wrong thing. +**⚠ CORRECTED 2026-08-12, and the correction is the same mistake one level up.** The amendment immediately above claims "thirteen forms are proven caught" and cites `` set `NAME=value` before … `` as evidence the mid-sentence class is covered. An independent review supplied twenty-nine forms and **fifteen missed**, including the *unbackticked* form of that very example: `set NAME=value before running` captures `'value before running'`. The cited case passes only because of its backticks. Measured, not argued. + +Two distinct defects sit under those fifteen, and both are the pattern-describes-one-dialect cause this entry already names: + +1. **The unquoted value has no whitespace terminator.** It ends only at `#`, a backtick or `|`, so any value followed by prose escapes. And `search` is called once per line, so on a two-variable table row every assignment after the first is invisible. +2. **The boundary is an allow-list, not a predicate.** The comment above it says the name may be preceded by "anything that is not part of an identifier"; the code is a six-character lookbehind. A quoted, parenthesised or colon-prefixed assignment produces no match at all — so the pattern can *parse* a quoted value but cannot *see* a quoted assignment. + +**And a fourth failure direction, opposite to the other three.** Removing the six-character floor admitted the registry's two shortest values into the ban-set. Measured: **they are this repository's own package directory names — the exact contents of `PARTNER_PACKAGES`** — matched by exact string equality against every non-docstring string constant under `views_postprocessing/`. The justification given, "measured, removing it keeps the suite green", measured today's source and not the next commit's. The next `logging.getLogger()` or `Literal[, …]` fails a security guard for code that copied nothing, and this entry's own doctrine says what happens next. It also hands views-appwrite a way to redden this repository by declaring any short ordinary word as a value — which is **C-86**'s coupling, arriving through the guard that was supposed to reduce it. + +Registered as **C-89** (the leak half) and tracked with this entry's remediation. **C-93** records why the author's own proof did not find any of it. + Cross-refs: C-74 (the guard this paragraph vouched for), C-33 (store identity still hardcoded per store — the same env surface, different concern), C-58 (what happens when a coordinate is wrong rather than missing), C-44 (the pipeline-core version coupling that would carry a registry change), **C-86** (the drift checks this scan sits beside), issues #134/#135/#138 (this repo's discharged þing-01 obligations), #104 (README env block placeholders). --- From 6b175a7e4277b442e16ccfc3c4ad3e9294a31167 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 12:42:07 +0200 Subject: [PATCH 30/42] =?UTF-8?q?fix(tests):=20#242=20=E2=80=94=20the=20no?= =?UTF-8?q?-copy=20guard=20must=20not=20publish=20what=20it=20forbids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-89's first two thirds. The guard that exists to stop a coordinate value being published was publishing one, into a world-readable CI log, on precisely the event it exists to catch. THE VALUE IS NO LONGER A PARAMETER. Both branches build their finding through `_report_a_copy(where, coordinates)`, which is never handed the value — so a future call site cannot print one however it is written. That is the difference between a rule and a design: the markdown branch was taught not to print on 2026-08-11, the AST branch was not, and the two drifted for a day because each read fine on its own. It names ALL coordinates sharing the value, not one. Measured against the live registry: two pairs share a value (the prod-forecasts bucket and collection share both their id and their name), so a `value -> name` dict would keep the last and name the wrong coordinate half the time — in the one message a maintainer uses to find the copy. THE ROTATION PROOF CHECKED ONE SIDE OF TWO. It asserted the pinned value was absent and said nothing about the freshly rotated one, which is the more damaging half. Mutation- proven: leaking the post-rotation side while keeping the pinned side digested fails now and passed before. AND THE RULE NOW HAS A CHECK, which is the part that matters. `test_every_finding_goes_ through_the_one_reporter` asserts by AST that every `copied.append` in the scan calls the one reporter. Nothing compared the two branches, which is why prose saying "never print a value" coexisted with a branch printing one. Read as source rather than run, because the leaking branch only executes when a coordinate has actually been copied — a state this repository must never be in, so a behavioural test of it would have nothing to observe. Three mutations, each reverted: (1) restore the original AST f-string -> the new guard fails, naming the line; (2) leak the post-rotation side -> the rotation proof fails; (3) inject a real value declared by TWO coordinates into a package module -> caught, both coordinates named, no value in the message. Deliberately NOT in this change: the `secret` exemption and the ban-set's package-name collision. Those are the scan's SCOPE, not its reporting, and belong with the matcher rewrite in #243. C-89 stays open rather than being closed on two thirds of its content. Suite 411 passed / 1 skipped / 40 xfailed, ruff clean. The one pre-existing failure (`test_the_consumer_still_filters_on_the_name_until_it_checks_itself[crafd]`) is #248's, red since views-crafdapi refactored a literal into a named constant on 2026-08-12. Closes #242. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 12 ++- tests/test_env_declaration.py | 125 +++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 6c16d4c..dc1dc67 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -266,7 +266,17 @@ The no-copy scan exists because README.md once carried four real coordinate valu The three share a cause: the no-print rule lives in prose and in one implementation, and nothing asserts it about the guard as a whole. -Cross-refs: **C-57** (the scan's own entry and its history), **C-93** (why the author's own proof did not find this), **C-90** (the sibling defect in the drift checks), ADR-014 §1. +**Mitigated 2026-08-12 (#242) — the leak is closed and the rule now has a check.** + +Two of the three are fixed, and the third moved: + +1. **The AST branch no longer receives the value.** Both branches build their finding through `_report_a_copy(where, coordinates)`, whose signature *has no value parameter* — so a future call site cannot print one however it is written. The report names the coordinate instead, and names **all** coordinates sharing that value: measured against the live registry, two pairs share one (the prod-forecasts bucket and collection share both id and name), so a `value -> name` map would have named the wrong coordinate half the time in the message a maintainer uses to find the copy. +2. **The rotation proof asserts both sides absent.** Mutation-proven: leaking the post-rotation side while keeping the pinned side digested fails now and **passed before** — which is exactly the half the old assertion missed, and the more damaging half. +3. **The rule is asserted about the scan as a whole**, by `test_every_finding_goes_through_the_one_reporter` — an AST check that every `copied.append` calls the one reporter. This is the guard whose absence let the two branches drift for a day; mutation-proven by restoring the original defect and watching it fail. + +**Deliberately still open, and moved rather than closed:** the `secret` exemption at `:1042` and the ban-set's package-name collision are the *scope* of the scan, not its reporting, and belong with the matcher rewrite in **#243**. This entry stays open until they land, because closing it now would close a Tier 2 on two-thirds of its content. + +Cross-refs: **C-57** (the scan's own entry and its history), **C-93** (why the author's own proof did not find this), **C-90** (the sibling defect in the drift checks), ADR-014 §1, issues #242, #243. --- diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 0db95f8..ccfa01c 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -934,10 +934,21 @@ def edition(value: str) -> dict: f"[{partner}] a rotated value went unnoticed. Name and class are identical on " "both sides, so nothing else in this file can see it." ) - assert "value:" in changed[canary] and "at-the-pin" not in changed[canary], ( - "the report must name the field that moved and must NOT print the value — this " - "repository is public and its CI logs are world-readable" + assert "value:" in changed[canary], ( + f"[{partner}] the report must name the FIELD that moved, or a maintainer reading " + "the failure cannot tell a rotation from a reclassification." ) + # BOTH sides, and the second one is the one that matters. An earlier version asserted + # only that the pinned value was absent, so a regression that digested one side and + # interpolated the other passed — while printing the freshly rotated coordinate, which + # is the more damaging of the two, into a public CI log on the single event this check + # exists to fire on (register C-89). + for side in ("at-the-pin", "after-rotation"): + assert side not in changed[canary], ( + f"[{partner}] the report printed the {side!r} value. It must name the field " + "and never the value: this repository is public, its CI logs are " + "world-readable, and they cannot be redacted afterwards." + ) @pytest.mark.parametrize("partner", _PARTNERS) @@ -998,6 +1009,77 @@ def _docstring_nodes(tree: ast.AST) -> set[int]: return out +def _report_a_copy(where: str, coordinates: tuple[str, ...]) -> str: + """One finding from the no-copy scan: WHERE the copy is and WHICH coordinate it is. + + **The value itself is not a parameter.** That is the whole design: a helper that is + never handed the value cannot print it, whatever a future call site does. + + **Why this is a function and not two f-strings — it was two f-strings.** The markdown + branch was taught not to print the value on 2026-08-11; the AST branch was not, and + went on interpolating it for a further day. So the guard against publishing a + coordinate published one itself, into a world-readable CI log, on precisely the event + it exists to catch (register **C-89**). A rule applied at two call sites is a rule + only until someone writes a third, which is why + ``test_every_finding_goes_through_the_one_reporter`` exists below. + + ``coordinates`` is a tuple because two coordinates may legitimately declare the same + value — measured against the live registry, two pairs do — and naming one of them + would be wrong half the time in the message a maintainer uses to find the copy. + """ + return f"{where} carries the value declared for {', '.join(coordinates)}" + + +def test_every_finding_goes_through_the_one_reporter(): + """The no-print rule, asserted about the scan as a whole rather than branch by branch. + + This is the guard whose absence let **C-89** happen. The rule "never print a + coordinate value" was stated in prose, applied to one of two branches, and nothing + compared the branches — so they drifted for a day and the drift was invisible because + both branches were individually plausible. + + So: every finding the scan appends must be built by :func:`_report_a_copy`, which + cannot be handed a value. Adding a third branch that formats its own message fails + here, whether or not that message happens to be safe today. + + Read as source rather than run, because the branch that leaked only executes when a + coordinate has actually been copied — a state this repository is never in, and must + never be in, so a behavioural test of it would have nothing to observe. + """ + scan = next( + node for node in ast.walk(ast.parse(Path(__file__).read_text())) + if isinstance(node, ast.FunctionDef) + and node.name == "test_no_coordinate_value_is_copied_into_this_repo" + ) + + homemade = [] + for node in ast.walk(scan): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "append" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "copied" + ): + continue + built_by_the_reporter = ( + len(node.args) == 1 + and isinstance(node.args[0], ast.Call) + and isinstance(node.args[0].func, ast.Name) + and node.args[0].func.id == "_report_a_copy" + ) + if not built_by_the_reporter: + homemade.append(node.lineno) + + assert not homemade, ( + f"the no-copy scan builds a finding without _report_a_copy at line(s) {homemade}. " + "Every finding must go through that helper: it is not given the value, so it " + "cannot print one. This repository is public and its CI logs are world-readable " + "and are not retroactively redactable — a branch that formats its own message is " + "how C-89 happened, and it stayed invisible because each branch read fine alone." + ) + + def test_no_coordinate_value_is_copied_into_this_repo(): """The registry's own rule: *"never bake a value into code, an example, or a dataclass default."* Consumers READ and VALIDATE; the launcher supplies values. @@ -1041,15 +1123,20 @@ def test_no_coordinate_value_is_copied_into_this_repo(): scanned_sections = tuple( name for name, role in _TABLE_ROLE.items() if role == "CONSUMED" and name != "secret" ) - values = { - body["value"] - for section in scanned_sections - for body in registry.get(section, {}).values() - # No length floor: it excluded two five-character coordinates and, - # measured, removing it keeps the suite green — so it was buying nothing - # while narrowing a security check. - if isinstance(body.get("value"), str) and body["value"].strip() - } + # value -> every coordinate declaring it. A LIST, not a single name: two coordinates + # may legitimately share a value, and measured against the live registry two pairs do + # (the prod-forecasts bucket and collection share both their id and their name). A + # `value -> name` dict would silently keep the last and name the wrong coordinate half + # the time, in the message a maintainer uses to find the copy. + declared_by_value: dict[str, list[str]] = {} + for section in scanned_sections: + for name, body in registry.get(section, {}).items(): + # No length floor: it excluded two five-character coordinates and, + # measured, removing it keeps the suite green — so it was buying nothing + # while narrowing a security check. + if isinstance(body.get("value"), str) and body["value"].strip(): + declared_by_value.setdefault(body["value"], []).append(name) + values = set(declared_by_value) copied = [] for source in sorted(_PKG.rglob("*.py")): @@ -1062,7 +1149,10 @@ def test_no_coordinate_value_is_copied_into_this_repo(): and node.value in values and id(node) not in docstrings ): - copied.append(f"{source.relative_to(_PKG)}:{node.lineno} = {node.value!r}") + copied.append(_report_a_copy( + f"{source.relative_to(_PKG)}:{node.lineno}", + tuple(sorted(declared_by_value[node.value])), + )) # Markdown too — the AST half cannot see a fenced ``bash`` block, and that is exactly # where four production-forecasts values sat: in README.md's Configuration section, # two lines below the sentence promising they are never copied, in a PUBLIC @@ -1132,10 +1222,11 @@ def test_no_coordinate_value_is_copied_into_this_repo(): match = assignment.search(line) captured = next((g for g in match.groups()[1:] if g is not None), None) if match else None if captured is not None and captured.strip() in values: - copied.append( - f"{doc.relative_to(_REPO)}:{number} assigns a registry value to " - f"{match.group(1)}" - ) + # The assignment names its own coordinate, so report THAT one rather than + # everything sharing the value — it is the more useful of the two answers. + copied.append(_report_a_copy( + f"{doc.relative_to(_REPO)}:{number}", (match.group(1),) + )) assert not copied, ( f"coordinate value(s) from the registry are copied into this repo: {copied}. The " From 4917610a8f0507eb463b2d719d057457f2e7d290 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 12:57:38 +0200 Subject: [PATCH 31/42] =?UTF-8?q?fix(tests):=20#242=20iteration=202=20?= =?UTF-8?q?=E2=80=94=20routing=20is=20not=20safety,=20so=20read=20the=20me?= =?UTF-8?q?ssage=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five independent reviewers were run against the first attempt. They agreed, and they were right: the guard I wrote was worse than nothing. IT MISSED SIX OF EIGHT MUTATIONS AND FIRED ON A LEGITIMATE REFACTOR. It asserted that every finding was ROUTED through `_report_a_copy` — an AST walk over the scan's own source. But routing is not safety. The value could be smuggled through either of that helper's two parameters, appended with `extend` or `+=`, or reported from a renamed accumulator, and in every case the guard stayed green while a coordinate reached the log. Meanwhile `finding = _report_a_copy(...); copied.append(finding)` — an ordinary refactor that leaks nothing — failed it. A guard that misses the thing and cries wolf on the innocent is C-82's shape and ADR-014 §3's deletion criterion at once, and my docstring's claim that a caller "cannot print one however it is written" was false on the day I wrote it. SO READ THE FINISHED MESSAGE, NOT THE SYNTAX THAT PRODUCED IT. `test_the_scan_reports_ a_copy_without_reprinting_it` plants a value in a fixture tree, runs the real scan through both halves, and asserts the message names the coordinate and never the value. It does not matter how a finding is built or what the final assertion interpolates. All five surviving mutations are now caught — including one that leaked the entire ban-set through the assertion message rather than through any finding. That is also the answer to C-93: not a longer list of mutants I thought of, but a test whose shape makes the class unreachable. AND THE SCAN'S OWN DOCSTRING CARRIED THREE REGISTRY VALUES. pytest prints the failing function's source, so the guard would have published them on precisely the event it exists to catch — message clean, traceback not. Now it names coordinates. The wider measurement is registered as C-97: 33 standalone values in docstrings and comments across nine files, production modules included, excluded from the scan by a deliberate C-57 decision taken before anyone counted them. Three of them cannot be told from ordinary code at all — two are this repo's package names, one is a function name. Also fixed, both found by review and both real: - the markdown half named the coordinate the ASSIGNMENT names, not the one that DECLARES the value. Those differ exactly when a document pastes one coordinate's value beside another's name — the ordinary copy-paste slip — so it asserted a false fact and hid the coordinate actually leaked. - the rotation loop hardcoded the fixture's two literals a dozen lines from where the fixture builds them. Renaming the fixture strings turned the assertion into "two dead strings are absent". Now taken from the fixture's own variables. Register: C-89 amended with what was tried and what failed; C-97 opened; header 96/23 -> 97/24. The earlier "Mitigated" heading corrected to "Partial mitigation", which is one of the two phrasings this register declares — inventing a third passed the integrity guard by evading its string rather than complying, the identical escape ADR-014 §5 records C-15 making. C-89's Location line numbers repointed; the fix had moved all three and the amendment cited the old ones. The file is +112 lines against `development`, which is the wrong direction for this epic. This story is inherently additive — it adds a guarantee's check where there was none. #245 and #248 are deletions and are where the budget comes back. Suite 411 (410 passed / 1 skipped / 40 xfailed), ruff clean. The one failure is #248's, red since views-crafdapi refactored on 2026-08-12. Closes #242. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 47 ++++++- tests/test_env_declaration.py | 197 ++++++++++++++++------------- 2 files changed, 149 insertions(+), 95 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index dc1dc67..e387ff3 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -5,8 +5,8 @@ | Project | views-postprocessing | | Owner | Dylan Pinheiro / PRIO MD&D Team | | Last Updated | 2026-08-12 | -| Total Concerns | 96 | -| Open Concerns | 23 | +| Total Concerns | 97 | +| Open Concerns | 24 | | Resolved Concerns | 73 | --- @@ -161,6 +161,31 @@ that indexes only deleted code is noise. ## Open Concerns +### C-97: Thirty-three coordinate values sit in docstrings and comments, where the scan deliberately does not look + +| Field | Value | +|-------|-------| +| ID | C-97 | +| Tier | 3 — a decided position, not an accident, and the decision is defensible. It is registered because the decision was taken before anyone counted, and because a docstring is printed by pytest in a way a code constant is not. | +| Source | `/code-review max` on #242, 2026-08-12; count reproduced here | +| Trigger | **Either.** (a) A check whose failing function contains one of these values starts firing in CI. (b) Someone proposes widening the no-copy scan to docstrings — at which point this entry is the measurement that says what that would cost. | +| Owner | This repository. | +| Location | Measured across `git ls-files '*.py'`: 33 standalone occurrences in 9 files, including `views_postprocessing/{unfao,crafd}/managers/`, `contract/store_metadata.py`, `contract/wire/sink.py`. | + +The no-copy scan compares string **constants** and excludes docstrings outright. That exclusion is C-57's recorded lesson: an early draft fired on refusal labels and on docstrings naming which store a function serves, and *"a guard that fails on `def file_metadata(record)` gets deleted — after which the real rule is unguarded"* (ADR-014 §3). + +The position is still right. What was not known when it was taken is the count, and one consequence: + +**A value in a docstring is printed by pytest that a value in a constant is not.** When any test fails, `--tb=auto` prints the failing function's source. So a registry value in the docstring of a *test* reaches a world-readable CI log on that test's next failure — which is how #242 found that the no-copy scan's own docstring carried three. That one is fixed. The others sit in functions that fail less predictably. + +**Three of the values cannot be distinguished from ordinary code at all.** Measured: two are this repository's own package directory names, and one is the name of a function in `contract/store_metadata.py`. Exact-string matching cannot separate a copy from a coincidence for these, which is the same measurement #243 needs for the ban-set — recorded here once rather than twice. + +**What is deliberately not proposed:** scanning docstrings. It would fire on every sentence naming a store, which is the false-alarm class that gets guards deleted. The honest options are to leave it (current), to scan only *test* docstrings (where the traceback amplification is), or to stop writing values in prose going forward without rewriting history. None is urgent. + +Cross-refs: **C-57** (the exclusion and why), **C-89** (the traceback amplification, found here), **C-86**, issues #242, #243. + +--- + ### C-96: The registry table that says which live checks this package may build is classified as none of our business | Field | Value | @@ -254,7 +279,7 @@ Cross-refs: **C-57** and **C-89** (the guard this was measured on), **C-90** (a | Source | `/code-review max` on PR #239 post-merge, 2026-08-11; every element verified in this repository | | Trigger | A literal coordinate value is committed into a `.py` under `views_postprocessing/` — the violation the scan exists for — on any branch whose CI runs. | | Owner | This repository. | -| Location | `tests/test_env_declaration.py:1065` (the AST branch), `:1042` (the `secret` exemption), `:937` (the half-sided rotation proof). | +| Location | `tests/test_env_declaration.py:1152` (the AST branch), `:1124` (the `secret` exemption), `:946` (the rotation proof). Line numbers as of 2026-08-12 — the 2026-08-11 filing cited `:1065`/`:1042`/`:937`, which the fix itself moved. | The no-copy scan exists because README.md once carried four real coordinate values, two lines below the sentence promising they are never copied, in a public repository. It has three parts and they do not agree with each other about the one rule that matters. @@ -266,13 +291,21 @@ The no-copy scan exists because README.md once carried four real coordinate valu The three share a cause: the no-print rule lives in prose and in one implementation, and nothing asserts it about the guard as a whole. -**Mitigated 2026-08-12 (#242) — the leak is closed and the rule now has a check.** +**Partial mitigation 2026-08-12 (#242) — the leak is closed; the scan's scope is not.** + +*(The heading matters. An earlier draft of this paragraph said "Mitigated", which is neither of the two phrasings this register declares — `Mitigation — landed` and `Partial mitigation`, enforced by `tests/test_register_integrity.py`. Inventing a third phrasing passes that guard by evading its string rather than by complying, which is the identical escape ADR-014 §5 records C-15 making. This entry is partial by its own next sentence.)* Two of the three are fixed, and the third moved: -1. **The AST branch no longer receives the value.** Both branches build their finding through `_report_a_copy(where, coordinates)`, whose signature *has no value parameter* — so a future call site cannot print one however it is written. The report names the coordinate instead, and names **all** coordinates sharing that value: measured against the live registry, two pairs share one (the prod-forecasts bucket and collection share both id and name), so a `value -> name` map would have named the wrong coordinate half the time in the message a maintainer uses to find the copy. -2. **The rotation proof asserts both sides absent.** Mutation-proven: leaking the post-rotation side while keeping the pinned side digested fails now and **passed before** — which is exactly the half the old assertion missed, and the more damaging half. -3. **The rule is asserted about the scan as a whole**, by `test_every_finding_goes_through_the_one_reporter` — an AST check that every `copied.append` calls the one reporter. This is the guard whose absence let the two branches drift for a day; mutation-proven by restoring the original defect and watching it fail. +1. **The AST branch no longer receives the value.** Both branches build their finding through `_report_a_copy(where, coordinates)`, which takes no value. The report names the coordinate instead, and names **all** coordinates sharing that value: measured, two pairs share one (the prod-forecasts bucket and collection share both id and name), so a `value -> name` map would have named the wrong coordinate half the time in the message a maintainer uses to find the copy. +2. **The rotation proof asserts both sides absent**, taken from the fixture's own variables rather than from two literals a rename would quietly orphan. Mutation-proven: leaking the post-rotation side while keeping the pinned side digested fails now and **passed before** — the more damaging half, unchecked. +3. **The rule is asserted behaviourally**, by `test_the_scan_reports_a_copy_without_reprinting_it`: plant a value in a fixture tree, run the real scan, read the finished message. + +**And the third one took two attempts, which is the part worth recording.** The first version asserted that every finding was *routed* through `_report_a_copy` — an AST walk over the scan's own source. Five independent reviewers were run against it and **routing turned out not to be safety**: the value could be smuggled through either of that helper's two parameters, appended with `extend` or `+=`, or reported from a renamed accumulator. Six of eight mutations survived, and one legitimate refactor *failed* it — a guard that misses the thing and fires on the innocent, which is C-82's shape and ADR-014 §3's deletion criterion at once. The docstring's claim that a caller "cannot print one however it is written" was false when written. + +Reading the finished message instead makes the whole class unreachable: it does not matter how a finding is built, which branch builds it, or what the final assertion interpolates. All five surviving mutations are now caught, including one that leaked the entire ban-set through the assertion message rather than through a finding. + +4. **The scan's own docstring carried three registry values**, and pytest prints the failing function's source — so the guard would have published them on exactly the event it exists to catch. The message was clean; the traceback was not. Now it names coordinates. **The wider finding is registered separately as C-97**: 33 standalone values sit in docstrings and comments across nine files, production modules included, and the AST scan excludes docstrings by a deliberate C-57 decision taken before anyone counted them. **Deliberately still open, and moved rather than closed:** the `secret` exemption at `:1042` and the ban-set's package-name collision are the *scope* of the scan, not its reporting, and belong with the matcher rewrite in **#243**. This entry stays open until they land, because closing it now would close a Tier 2 on two-thirds of its content. diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index ccfa01c..011b3cf 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -28,6 +28,7 @@ import logging import re import subprocess +import sys from pathlib import Path import pytest @@ -923,8 +924,9 @@ def edition(value: str) -> dict: "target": {canary: {"class": "target", "value": value}}, } - then = _projection(edition("at-the-pin"), names) - now = _projection(edition("after-rotation"), names) + was, rotated = "at-the-pin", "after-rotation" + then = _projection(edition(was), names) + now = _projection(edition(rotated), names) assert then[canary][:2] == now[canary][:2], ( "this fixture must differ ONLY in the value, or it is not proving what it claims" @@ -938,12 +940,10 @@ def edition(value: str) -> dict: f"[{partner}] the report must name the FIELD that moved, or a maintainer reading " "the failure cannot tell a rotation from a reclassification." ) - # BOTH sides, and the second one is the one that matters. An earlier version asserted - # only that the pinned value was absent, so a regression that digested one side and - # interpolated the other passed — while printing the freshly rotated coordinate, which - # is the more damaging of the two, into a public CI log on the single event this check - # exists to fire on (register C-89). - for side in ("at-the-pin", "after-rotation"): + # Both sides, taken from the fixture's own variables. Asserting the two literals + # instead let a fixture rename turn this into "two dead strings are absent" — and the + # rotated side is the one that matters, which an earlier version never checked. + for side in (was, rotated): assert side not in changed[canary], ( f"[{partner}] the report printed the {side!r} value. It must name the field " "and never the value: this repository is public, its CI logs are " @@ -1012,16 +1012,13 @@ def _docstring_nodes(tree: ast.AST) -> set[int]: def _report_a_copy(where: str, coordinates: tuple[str, ...]) -> str: """One finding from the no-copy scan: WHERE the copy is and WHICH coordinate it is. - **The value itself is not a parameter.** That is the whole design: a helper that is - never handed the value cannot print it, whatever a future call site does. - - **Why this is a function and not two f-strings — it was two f-strings.** The markdown - branch was taught not to print the value on 2026-08-11; the AST branch was not, and - went on interpolating it for a further day. So the guard against publishing a - coordinate published one itself, into a world-readable CI log, on precisely the event - it exists to catch (register **C-89**). A rule applied at two call sites is a rule - only until someone writes a third, which is why - ``test_every_finding_goes_through_the_one_reporter`` exists below. + The value is not a parameter, so the ordinary way of writing a finding cannot print + one. That is a convention with a shape, **not a guarantee** — an earlier version of + this docstring claimed a caller "cannot print one however it is written", and an + independent review falsified it in two lines by smuggling the value through ``where`` + and again through ``coordinates``. What actually holds the rule is + :func:`test_the_scan_reports_a_copy_without_reprinting_it`, which reads the finished + message rather than the syntax that produced it (register **C-89**, **C-93**). ``coordinates`` is a tuple because two coordinates may legitimately declare the same value — measured against the live registry, two pairs do — and naming one of them @@ -1030,78 +1027,37 @@ def _report_a_copy(where: str, coordinates: tuple[str, ...]) -> str: return f"{where} carries the value declared for {', '.join(coordinates)}" -def test_every_finding_goes_through_the_one_reporter(): - """The no-print rule, asserted about the scan as a whole rather than branch by branch. - - This is the guard whose absence let **C-89** happen. The rule "never print a - coordinate value" was stated in prose, applied to one of two branches, and nothing - compared the branches — so they drifted for a day and the drift was invisible because - both branches were individually plausible. - - So: every finding the scan appends must be built by :func:`_report_a_copy`, which - cannot be handed a value. Adding a third branch that formats its own message fails - here, whether or not that message happens to be safe today. - - Read as source rather than run, because the branch that leaked only executes when a - coordinate has actually been copied — a state this repository is never in, and must - never be in, so a behavioural test of it would have nothing to observe. - """ - scan = next( - node for node in ast.walk(ast.parse(Path(__file__).read_text())) - if isinstance(node, ast.FunctionDef) - and node.name == "test_no_coordinate_value_is_copied_into_this_repo" - ) - - homemade = [] - for node in ast.walk(scan): - if not ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "append" - and isinstance(node.func.value, ast.Name) - and node.func.value.id == "copied" - ): - continue - built_by_the_reporter = ( - len(node.args) == 1 - and isinstance(node.args[0], ast.Call) - and isinstance(node.args[0].func, ast.Name) - and node.args[0].func.id == "_report_a_copy" - ) - if not built_by_the_reporter: - homemade.append(node.lineno) - - assert not homemade, ( - f"the no-copy scan builds a finding without _report_a_copy at line(s) {homemade}. " - "Every finding must go through that helper: it is not given the value, so it " - "cannot print one. This repository is public and its CI logs are world-readable " - "and are not retroactively redactable — a branch that formats its own message is " - "how C-89 happened, and it stayed invisible because each branch read fine alone." - ) - def test_no_coordinate_value_is_copied_into_this_repo(): """The registry's own rule: *"never bake a value into code, an example, or a dataclass default."* Consumers READ and VALIDATE; the launcher supplies values. + **This docstring names coordinates and never values, and that is not fussiness.** + An earlier version quoted three real registry values as worked examples. When this + check fails, pytest prints the failing function's own source — so the guard against + publishing a coordinate would have published three, into a world-readable CI log, on + exactly the event it exists to catch. The message was clean and the traceback was not + (register **C-89**). + **Two wrong narrowings before this one, both instructive.** - A substring text scan over every ``.py`` flagged three "leaks": ``file_metadata`` - (a **function name** in ``contract/store_metadata.py``), ``production_forecasts`` - and ``unfao_bucket`` (only in refusal labels and docstrings naming which store a - function serves). None was a copy, and a guard that fails on - ``def file_metadata(record)`` gets deleted — after which the real rule is unguarded. + A substring text scan over every ``.py`` flagged three "leaks": the function name + ``file_metadata`` in ``contract/store_metadata.py``, and the values of + ``APPWRITE_PROD_FORECASTS_BUCKET_ID`` and ``APPWRITE_UNFAO_BUCKET_ID`` where they + appear only in refusal labels and in docstrings naming which store a function serves. + None was a copy, and a guard that fails on ``def file_metadata(record)`` gets deleted + — after which the real rule is unguarded. - Narrowing to *assignments and default arguments* then went too far in the other - direction: it caught neither a dict value nor a keyword argument, and the keyword - argument is the shape this repo would actually produce — - ``AppwriteConfig(bucket_id=...)`` is how every store is configured, and swapping one - ``os.getenv`` for a literal there is the violation. + Narrowing to *assignments and default arguments* then went too far the other way: it + caught neither a dict value nor a keyword argument, and the keyword argument is the + shape this repo would actually produce — ``AppwriteConfig(bucket_id=...)`` is how + every store is configured, and swapping one ``os.getenv`` for a literal there is the + violation. The right axis was **exact equality on string constants**, not statement shape. It catches dict values and kwargs, while all three original false positives fall out on - their own: a function name is not a ``Constant``; ``"unfao_bucket datastore"`` is not - equal to ``"unfao_bucket"``; docstrings are excluded outright. + their own: a function name is not a ``Constant``; a value with a word appended is not + equal to the value; docstrings are excluded outright. """ # `require_sibling`, like every other reader in this file. The hand-rolled skip that # stood here said only "checkout not found — set VIEWS_APPWRITE", which is the @@ -1123,11 +1079,8 @@ def test_no_coordinate_value_is_copied_into_this_repo(): scanned_sections = tuple( name for name, role in _TABLE_ROLE.items() if role == "CONSUMED" and name != "secret" ) - # value -> every coordinate declaring it. A LIST, not a single name: two coordinates - # may legitimately share a value, and measured against the live registry two pairs do - # (the prod-forecasts bucket and collection share both their id and their name). A - # `value -> name` dict would silently keep the last and name the wrong coordinate half - # the time, in the message a maintainer uses to find the copy. + # value -> every coordinate declaring it. A list, because two coordinates may share + # a value and measured against the live registry two pairs do. declared_by_value: dict[str, list[str]] = {} for section in scanned_sections: for name, body in registry.get(section, {}).items(): @@ -1161,7 +1114,8 @@ def test_no_coordinate_value_is_copied_into_this_repo(): # # **What counts as a copy, and what does not.** A first draft flagged any line # containing a registry value and immediately fired on a dozen documents that merely - # *name* a store in prose — "six stranded documents in unfao_bucket". That is not a + # *name* a store in prose — a sentence counting stranded documents in the UNFAO + # bucket, written with the bucket's declared value. That is not a # copy; it is a sentence. C-57 recorded the identical false-positive class over `.py` # and the identical lesson: when a guard cries wolf, the matching is wrong before the # scope is (ADR-014 §3). @@ -1222,10 +1176,14 @@ def test_no_coordinate_value_is_copied_into_this_repo(): match = assignment.search(line) captured = next((g for g in match.groups()[1:] if g is not None), None) if match else None if captured is not None and captured.strip() in values: - # The assignment names its own coordinate, so report THAT one rather than - # everything sharing the value — it is the more useful of the two answers. + # Report the coordinate(s) that DECLARE the value, not the one the + # assignment happens to name. Those differ exactly when a document + # pastes one coordinate's value beside another's name — the ordinary + # copy-paste slip — and naming the assignment would then assert a false + # fact and hide the coordinate actually leaked. copied.append(_report_a_copy( - f"{doc.relative_to(_REPO)}:{number}", (match.group(1),) + f"{doc.relative_to(_REPO)}:{number}", + tuple(sorted(declared_by_value[captured.strip()])), )) assert not copied, ( @@ -1234,3 +1192,66 @@ def test_no_coordinate_value_is_copied_into_this_repo(): "environment the launcher assembles, validated by assert_env_declared. In a " "document, write the NAME and leave the value to the launcher." ) + + +def test_the_scan_reports_a_copy_without_reprinting_it(tmp_path, monkeypatch): + """Feed the real scan a planted copy and read what it says. **The rule, behaviourally.** + + This replaces a source-reading guard that asserted every finding was *routed* through + ``_report_a_copy``. Routing is not safety, and an independent review proved it: the + value could be smuggled through either of that helper's two parameters, or appended + with ``extend``/``+=``, or reported from a renamed accumulator — **six of eight + mutations survived**, while one legitimate refactor failed it. A guard that misses the + thing and fires on the innocent is decoration with a proof attached (register C-82). + + Reading the finished message instead makes the whole class unreachable. It does not + matter how a finding is built, which branch builds it, or what the final assertion + interpolates — if a value reaches the text, this fails. That is the difference between + enumerating the mutations you thought of (register **C-93**) and making them moot. + + Both halves are exercised, because C-89 was one half being taught a rule the other + half was not. + """ + planted, coordinate = "fixture-value-2f9c-not-a-real-coordinate", "APPWRITE_UNFAO_BUCKET_ID" + + pkg = tmp_path / "views_postprocessing" + pkg.mkdir() + (pkg / "leaky.py").write_text(f'BUCKET_ID = "{planted}"\n') + (tmp_path / "README.md").write_text(f"{coordinate}={planted}\n") + for args in (("init", "-q"), ("add", "-A")): + subprocess.run( + ["git", "-C", str(tmp_path), "-c", "commit.gpgsign=false", *args], + capture_output=True, text=True, check=True, timeout=30, + ) + + here = sys.modules[__name__] + monkeypatch.setattr(here, "_PKG", pkg) + monkeypatch.setattr(here, "_REPO", tmp_path) + monkeypatch.setattr(here, "require_sibling", lambda name: tmp_path) + monkeypatch.setattr(here, "_registry_current", lambda repo: { + "meta": {"version": "0.0.0-fixture"}, + "connection": {"APPWRITE_ENDPOINT": {"class": "connection", "value": "e"}}, + "target": {coordinate: {"class": "target", "value": planted}}, + }) + + with pytest.raises(AssertionError) as caught: + test_no_coordinate_value_is_copied_into_this_repo() + message = str(caught.value) + + # Named files, not a count: pytest's assertion rewriting echoes the message, so + # counting substrings of it counts each finding twice. Naming both planted files is + # what stops this passing vacuously — a scan that finds nothing reports nothing, and + # "no value in the message" is trivially true of an empty message. + for half in ("leaky.py", "README.md"): + assert half in message, ( + f"the {half} half of the scan did not report the planted copy: {message}. " + "C-89 was one branch being taught a rule the other was not, so both must fire." + ) + assert coordinate in message, ( + f"the report must name the coordinate so a maintainer can find it: {message}" + ) + assert planted not in message, ( + "THE SCAN PRINTED THE VALUE IT EXISTS TO HIDE. This repository is public, its CI " + "logs are world-readable, and they cannot be redacted afterwards. However the " + "finding was built, the finished text must never carry the value." + ) From 29bb769272d2f23716f746e01a851aebae7f2696 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 13:10:53 +0200 Subject: [PATCH 32/42] =?UTF-8?q?fix(tests):=20#242=20iteration=203=20?= =?UTF-8?q?=E2=80=94=20the=20channels=20a=20message=20test=20cannot=20see?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial mutation review ran 14 mutations against iteration 2. Twelve survived. Seven were leak channels; all seven are now closed. Five are refused in writing. A MESSAGE TEST ONLY SEES THE MESSAGE. `print(node.value)` beside the append, or a `logging.warning`, left the assertion text clean while pytest published the value under Captured stdout and Captured log call. The behavioural test now reads capsys and caplog as well. A stray debug print is an ordinary accident, not an adversarial one. SUBSTRING ABSENCE IS NOT ABSENCE. A finding carrying `value[:12]` or `value[::-1]` walked straight past `planted not in message`, and a reader recovers both instantly. Replaced by `_carries`: does any eight-character window, forwards or backwards, appear. AND THE DRIFT REPORTER HAD NO TEST AT ALL. `_describe_changes` is exercised only by the rotation proof, through one shape, with fixture values SHORTER THAN EVERY REAL COORDINATE — so a digest leaking only above a length threshold passed, as did a reversing digest, as did the appeared/removed branches a both-sides fixture never reaches. Three leaks, all green. It now has its own test, driven through all three shapes with an over-long opaque value. A HELPER'S PARAMETER IS A PUBLICATION CHANNEL. `_dotenv_use` took file text; pytest renders a frame's arguments and `ast.parse` raises on any malformed module, so a package file that both failed to parse and carried a value would have printed itself in full. It takes a Path now and reads inside. And the fixture values are opaque now because a readable one bit me: the first planted string contained the word "coordinate", which also appears in the check's own failure prose, so the window test fired on the test's own English. My cry-wolf, caught by my own guard, fixed in the fixture rather than by weakening the check. WHAT I AM NOT CHASING, and why it is written down rather than left to be rediscovered. Five mutations survive. Four narrow the scan's SCOPE — a length floor, a dropped section, a swallowed SyntaxError, a `break` after the first finding — and none is visible to a test that plants its own fixture, because the fixture satisfies whatever narrowing is applied. They are #243's subject and are routed there with reproductions; fixing them here would be doing that story badly and twice. The fifth deletes the no-print assertion itself, which is infinite regress: a guard on a guard on a guard has no fixed point, and the register carries that risk instead. The eight-character window is a judgement, not a law — a six-character leak would pass, and chasing it is the whack-a-mole this epic exists to refuse. Register: C-89 amended with all three passes, including what was tried and failed. Suite 412 (411 passed / 1 skipped / 40 xfailed), ruff clean. The one failure is #248's. Closes #242. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 12 +++- tests/test_env_declaration.py | 111 +++++++++++++++++++++++++---- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index e387ff3..ec19f81 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -305,7 +305,17 @@ Two of the three are fixed, and the third moved: Reading the finished message instead makes the whole class unreachable: it does not matter how a finding is built, which branch builds it, or what the final assertion interpolates. All five surviving mutations are now caught, including one that leaked the entire ban-set through the assertion message rather than through a finding. -4. **The scan's own docstring carried three registry values**, and pytest prints the failing function's source — so the guard would have published them on exactly the event it exists to catch. The message was clean; the traceback was not. Now it names coordinates. **The wider finding is registered separately as C-97**: 33 standalone values sit in docstrings and comments across nine files, production modules included, and the AST scan excludes docstrings by a deliberate C-57 decision taken before anyone counted them. +**A third pass, from an adversarial mutation review that ran 14 mutations against the second.** Twelve survived. Seven were leak channels the message test could not see, and all seven are now closed: + +- `print()` or `logging.warning()` beside the append — the message stayed clean while pytest published the value under *Captured stdout* / *Captured log call*. The behavioural test now reads `capsys` and `caplog` too. A stray debug print is an ordinary accident, not an adversarial one. +- A finding carrying `value[:12]` or `value[::-1]` — a substring test cannot see either, and a reader recovers both instantly. Replaced by `_carries`: does any **eight-character window**, forwards or backwards, appear. +- **`_describe_changes` had no test of its own at all.** The rotation proof exercises it through one shape, with fixture values *shorter than every real coordinate* — so a `digest()` leaking only above a length threshold passed, as did a reversing `digest()`, as did the `appeared`/`removed` branches the both-sides fixture never reaches. Three leaks, all green. It now has `test_the_drift_report_never_carries_a_value_in_any_recoverable_form`, driven directly through all three shapes with an over-long opaque value. + +**And a helper's parameter is a publication channel.** `_dotenv_use` took file *text*; pytest renders a frame's arguments, and `ast.parse` raises on any malformed module — so a package file that both failed to parse and carried a value would have printed itself in full. It takes a `Path` now and reads inside. + +**What is deliberately NOT chased, so the next reader does not re-open it.** Five mutations survive and are refused rather than missed: four narrow the scan's *scope* (a length floor, a dropped section, a swallowed `SyntaxError`, a `break` after the first finding), and one deletes the no-print assertion itself. The first four are the scan's scope, which is **#243**'s entire subject and where they are routed — fixing them here would be doing that story badly, twice. The fifth is infinite regress: a guard on the guard on the guard has no fixed point, and the register is where that risk is carried instead. The `_carries` window is eight characters, which is a judgement and not a law; a six-character leak would pass, and chasing that is the whack-a-mole this epic exists to refuse. + +4. **The scan's own docstring carried four registry values**, and pytest prints the failing function's source — so the guard would have published them on exactly the event it exists to catch. The message was clean; the traceback was not. Now it names coordinates. **The wider finding is registered separately as C-97**: 33 standalone values sit in docstrings and comments across nine files, production modules included, and the AST scan excludes docstrings by a deliberate C-57 decision taken before anyone counted them. **Deliberately still open, and moved rather than closed:** the `secret` exemption at `:1042` and the ban-set's package-name collision are the *scope* of the scan, not its reporting, and belong with the matcher rewrite in **#243**. This entry stays open until they land, because closing it now would close a Tier 2 on two-thirds of its content. diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 011b3cf..97d22ba 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -137,9 +137,14 @@ def _manager_source(partner: str) -> Path: _DOTENV_CALLS = {"load_dotenv", "find_dotenv", "dotenv_values"} -def _dotenv_use(source: str) -> list[str]: +def _dotenv_use(path: Path) -> list[str]: """Real imports of, and calls into, python-dotenv — parsed, not grepped. + Takes a **path**, not the file's text. pytest renders a frame's arguments in a + traceback, so a helper that accepts source text publishes that text whenever it + raises — and ``ast.parse`` raises on any malformed module. A package file that both + fails to parse and carries a coordinate value would have printed itself in full. + **Prose is not a violation and must not be treated as one.** ``appwrite_env.py``'s own docstring explains what the retired borrow was, spelling it exactly; a token scan over the package flags that sentence and gets deleted, after which the rule it @@ -148,7 +153,7 @@ def _dotenv_use(source: str) -> list[str]: or a comment, so the guard can cover the whole package without lying about prose. """ found = [] - for node in ast.walk(ast.parse(source)): + for node in ast.walk(ast.parse(path.read_text())): if isinstance(node, ast.Import): found += [a.name for a in node.names if a.name.split(".")[0] == "dotenv"] elif isinstance(node, ast.ImportFrom): @@ -195,7 +200,7 @@ def test_the_dotenv_borrow_is_dead(): offenders = { source.relative_to(_PKG).as_posix(): used for source in sorted(_PKG.rglob("*.py")) - for used in [_dotenv_use(source.read_text())] + for used in [_dotenv_use(source)] if used } assert not offenders, ( @@ -1041,12 +1046,14 @@ def test_no_coordinate_value_is_copied_into_this_repo(): **Two wrong narrowings before this one, both instructive.** - A substring text scan over every ``.py`` flagged three "leaks": the function name - ``file_metadata`` in ``contract/store_metadata.py``, and the values of + A substring text scan over every ``.py`` flagged three "leaks", none of them a copy: + a **function name** in ``contract/store_metadata.py`` that happens to be spelled the + same as the value of ``APPWRITE_METADATA_DATABASE_ID``, and the values of ``APPWRITE_PROD_FORECASTS_BUCKET_ID`` and ``APPWRITE_UNFAO_BUCKET_ID`` where they appear only in refusal labels and in docstrings naming which store a function serves. - None was a copy, and a guard that fails on ``def file_metadata(record)`` gets deleted - — after which the real rule is unguarded. + A guard that fails on an ordinary ``def`` gets deleted — after which the real rule is + unguarded. (Three declared values cannot be told from ordinary code by string + equality at all: that one, and this package's two directory names. Register C-97.) Narrowing to *assignments and default arguments* then went too far the other way: it caught neither a dict value nor a keyword argument, and the keyword argument is the @@ -1194,7 +1201,7 @@ def test_no_coordinate_value_is_copied_into_this_repo(): ) -def test_the_scan_reports_a_copy_without_reprinting_it(tmp_path, monkeypatch): +def test_the_scan_reports_a_copy_without_reprinting_it(tmp_path, monkeypatch, capsys, caplog): """Feed the real scan a planted copy and read what it says. **The rule, behaviourally.** This replaces a source-reading guard that asserted every finding was *routed* through @@ -1212,7 +1219,11 @@ def test_the_scan_reports_a_copy_without_reprinting_it(tmp_path, monkeypatch): Both halves are exercised, because C-89 was one half being taught a rule the other half was not. """ - planted, coordinate = "fixture-value-2f9c-not-a-real-coordinate", "APPWRITE_UNFAO_BUCKET_ID" + # Opaque on purpose. A first draft planted a readable string containing the word + # "coordinate" — which also appears in the check's own failure prose, so the + # eight-character window test below fired on the test's own English. The fixture must + # share no fragment with anything the report legitimately says. + planted, coordinate = "Zq7xVb9KdLm1RnTs3Wy8Pj5Hc2Gf", "APPWRITE_UNFAO_BUCKET_ID" pkg = tmp_path / "views_postprocessing" pkg.mkdir() @@ -1250,8 +1261,80 @@ def test_the_scan_reports_a_copy_without_reprinting_it(tmp_path, monkeypatch): assert coordinate in message, ( f"the report must name the coordinate so a maintainer can find it: {message}" ) - assert planted not in message, ( - "THE SCAN PRINTED THE VALUE IT EXISTS TO HIDE. This repository is public, its CI " - "logs are world-readable, and they cannot be redacted afterwards. However the " - "finding was built, the finished text must never carry the value." - ) + # Every channel pytest publishes, not just the assertion text. A debug `print` left + # beside the append is an ordinary accident, and pytest renders captured stdout and + # captured logs in the same report as the failure — so "the message is clean" is only + # part of "the run is clean". + captured = capsys.readouterr() + for channel, text in ( + ("the assertion message", message), + ("captured stdout", captured.out), + ("captured stderr", captured.err), + ("captured logs", caplog.text), + ): + assert not _carries(text, planted), ( + f"THE SCAN PUBLISHED THE VALUE IT EXISTS TO HIDE, via {channel}. This " + "repository is public, its CI logs are world-readable, and they cannot be " + "redacted afterwards. However the finding was built and wherever it was " + "written, no recoverable form of the value may leave this test." + ) + + +def _carries(text: str, value: str) -> bool: + """Is ``value`` recoverable from ``text`` — not merely present in it? + + Equality and ``in`` are too weak for a no-print rule. An adversarial review defeated a + substring check with ``value[:12]`` and again with ``value[::-1]``, both of which a + reader recovers instantly. So this asks the question that matters: does any eight- + character window of the value, forwards or backwards, appear? + + Eight is a judgement, not a law: long enough that ordinary English and file paths do + not collide with a coordinate, short enough that a truncation or a reversal is caught. + """ + windows = {value[i:i + 8] for i in range(len(value) - 7)} or {value} + return any(w in text or w[::-1] in text for w in windows) + + +def test_the_drift_report_never_carries_a_value_in_any_recoverable_form(): + """``_describe_changes`` is the drift check's reporter, and it had no test of its own. + + The rotation proof above exercises it, but only through one shape — a value present + on both sides — and with fixture strings shorter than every real coordinate. An + adversarial review walked through both gaps: a ``digest()`` that returns the raw value + only when it is longer than the fixture's, and the ``appeared``/``removed`` branches + that the both-sides fixture never reaches. Three leaks, all green. + + So this drives the reporter directly, with a value longer than any the registry + declares, through every branch that can produce text. + """ + # Longer than every value the registry declares, and opaque so no window of it + # can collide with the report's own wording. + value = "Xk4mQ8zRv2Ld7NpTb5Wc1Hy9Ja6Fg3Ss" + other = "Bn6tYp3Kq9Vx2Mw8Lz5Rd1Cf7Hj4Gu0Ee" + name = "APPWRITE_FIXTURE_COORDINATE" + + reports = [ + # rotated: present on both sides, different + _describe_changes({name: ("target", "target", value)}, + {name: ("target", "target", other)}, {name}), + # appeared: absent at the pin, valued now + _describe_changes({name: ("target", "target", _ABSENT)}, + {name: ("target", "target", value)}, {name}), + # removed: valued at the pin, absent now + _describe_changes({name: ("target", "target", value)}, + {name: ("target", "target", _ABSENT)}, {name}), + ] + + for shape, report in zip(("rotated", "appeared", "removed"), reports): + assert name in report, ( + f"the {shape} shape produced no report for {name}: {report}. A reporter that " + "says nothing about a change it was handed is the silent half of a drift check." + ) + text = report[name] + for secret in (value, other): + assert not _carries(text, secret), ( + f"the {shape} report carries a coordinate value in a recoverable form: " + f"{text!r}. This repository is public and its CI logs are world-readable. " + "Name the field that moved and digest the value; never emit it, and never " + "emit a prefix or a transform of it." + ) From f337269b0db9c58647015b164a3f1f2565bd86f3 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 17:05:17 +0200 Subject: [PATCH 33/42] =?UTF-8?q?fix(tests):=20#242=20iteration=204=20?= =?UTF-8?q?=E2=80=94=20delete=20most=20of=20what=20the=20first=20three=20a?= =?UTF-8?q?dded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer's verdict on iterations 1-3: "so complicated and heavy with technical debt that you are not even able to maintain it. Then no one can." He is right, and the measurement is worse than the impression. Four commits had grown one file by +195 lines (1145 -> 1340, +17%). +95 of that was PROSE restating the risk register. All 149 lines of new function bodies were tests of another test in the same file. Meta tests went from 37% to 45% of the file. This commit deletes 140 of those lines. Every one of the four outstanding review findings is resolved by subtraction. TWO OF MY OWN ADDITIONS WERE DEFECTIVE, AND A DESIGN REVIEW CAUGHT BOTH. `test_the_drift_report_never_carries_a_value_in_any_recoverable_form` claimed to drive `_describe_changes`'s appeared/removed branches. It did not. Those branch on `was is None`; my fixtures passed `_ABSENT`, a sentinel OBJECT. The branch is never entered — it ran one path three times while asserting three, and the register repeated the claim. That is an ADR-014 §1 defect inside the fix for an ADR-014 §1 defect. The branches it named return constant strings and cannot leak at all. DELETED. `_carries`, my eight-character-window "recoverability" predicate, is STRICTLY WORSE THAN PLAIN `in`. The window degrades to exact match below eight characters, and this package's two directory names are five-character declared values. Measured: `_carries("views_postprocessing/unfao/leaky.py …", "unfao")` is True — it condemns a bare, safe file path while screaming that the scan published a value. Simultaneously stricter than `in` on opaque fixtures and identical to it on the values that matter. DELETED; both sites use plain `not in`, which is the vocabulary this file already uses in `test_the_environment_refusal_logs_names_and_never_values`. ALSO DELETED: `_report_a_copy` — 2 lines of code under 14 lines of docstring, 11 of them explaining what it does NOT guarantee. Its safety claim was falsified in iteration 2 and it survived anyway. Two inline f-strings now, which is what WET-before-DRY prescribes: the second incident did not have the same shape as the first, and the helper's signature forced the markdown branch into a detour it had to justify with a six-line comment. The behavioural guard is what holds the rule, not the formatter. The output guard SURVIVES, at 19 lines instead of 78. ADR-014 §1 does not permit a guarantee with no check, and this regression already happened once, silently. But it no longer monkeypatches four globals, shells out to `git init`, or guards a `print` nobody has written. AND THE ONE REAL OPEN LEAK IS NOW CLOSED, AT BOTH SITES. Iteration 3 claimed a malformed package module could no longer publish itself because `_dotenv_use` takes a Path. Measured: that changes nothing — the leak is `ast.parse`'s own frame, which pytest renders with the source as its argument, and `path.read_text()` still hands it there. The fix is `raise ... from None`, where `from None` is load-bearing because the chained SyntaxError carries the same text. Applied to BOTH parse sites; the scan itself parses every package .py, and fixing one and not the other is C-89's shape verbatim. Proven: a malformed module carrying a real value now fails with "does not parse (line 92)" and the value appears ZERO times, where it appeared before. Five mutations re-proven: the original C-89 defect fails; a widened assertion fails; a raw digest fails; a malformed module fails without publishing itself; a legitimate refactor passes. Register: C-89's Location is now FUNCTION NAMES — it has cited stale line numbers twice, because each fix moved them. The false "it takes a Path now" sentence is corrected. The narration of both deleted additions is removed, including one claim that was never true. Two deferrals get triggers: consolidating into tests/test_redaction_guard.py when #243 finishes with this file, and a pre-existing proof that re-types its subject inline. tests/test_env_declaration.py: 1340 -> 1200 (+55 over the pre-story 1145, not +195). Suite 410 passed / 1 skipped / 40 xfailed, ruff clean. The one failure is #248's. Closes #242. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 23 ++- tests/test_env_declaration.py | 238 ++++++----------------------- 2 files changed, 66 insertions(+), 195 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index ec19f81..f2c2f83 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -279,7 +279,7 @@ Cross-refs: **C-57** and **C-89** (the guard this was measured on), **C-90** (a | Source | `/code-review max` on PR #239 post-merge, 2026-08-11; every element verified in this repository | | Trigger | A literal coordinate value is committed into a `.py` under `views_postprocessing/` — the violation the scan exists for — on any branch whose CI runs. | | Owner | This repository. | -| Location | `tests/test_env_declaration.py:1152` (the AST branch), `:1124` (the `secret` exemption), `:946` (the rotation proof). Line numbers as of 2026-08-12 — the 2026-08-11 filing cited `:1065`/`:1042`/`:937`, which the fix itself moved. | +| Location | `tests/test_env_declaration.py` — `test_no_coordinate_value_is_copied_into_this_repo` (both reporting branches and the `secret` exemption in `scanned_sections`), `test_the_drift_check_would_catch_a_rotation_that_names_and_classes_cannot`. **Function names, not line numbers**: this entry has now cited stale ones twice, because each fix moved them. | The no-copy scan exists because README.md once carried four real coordinate values, two lines below the sentence promising they are never copied, in a public repository. It has three parts and they do not agree with each other about the one rule that matters. @@ -297,7 +297,7 @@ The three share a cause: the no-print rule lives in prose and in one implementat Two of the three are fixed, and the third moved: -1. **The AST branch no longer receives the value.** Both branches build their finding through `_report_a_copy(where, coordinates)`, which takes no value. The report names the coordinate instead, and names **all** coordinates sharing that value: measured, two pairs share one (the prod-forecasts bucket and collection share both id and name), so a `value -> name` map would have named the wrong coordinate half the time in the message a maintainer uses to find the copy. +1. **Both branches name the coordinate, never the value.** Each builds its own sentence — two f-strings, deliberately not one shared formatter — and neither has the value in hand. They name **all** coordinates declaring that value: measured, two pairs share one (the prod-forecasts bucket and collection share both id and name), so a `value -> name` map would have named the wrong coordinate half the time in the message a maintainer uses to find the copy. 2. **The rotation proof asserts both sides absent**, taken from the fixture's own variables rather than from two literals a rename would quietly orphan. Mutation-proven: leaking the post-rotation side while keeping the pinned side digested fails now and **passed before** — the more damaging half, unchecked. 3. **The rule is asserted behaviourally**, by `test_the_scan_reports_a_copy_without_reprinting_it`: plant a value in a fixture tree, run the real scan, read the finished message. @@ -308,17 +308,28 @@ Reading the finished message instead makes the whole class unreachable: it does **A third pass, from an adversarial mutation review that ran 14 mutations against the second.** Twelve survived. Seven were leak channels the message test could not see, and all seven are now closed: - `print()` or `logging.warning()` beside the append — the message stayed clean while pytest published the value under *Captured stdout* / *Captured log call*. The behavioural test now reads `capsys` and `caplog` too. A stray debug print is an ordinary accident, not an adversarial one. -- A finding carrying `value[:12]` or `value[::-1]` — a substring test cannot see either, and a reader recovers both instantly. Replaced by `_carries`: does any **eight-character window**, forwards or backwards, appear. -- **`_describe_changes` had no test of its own at all.** The rotation proof exercises it through one shape, with fixture values *shorter than every real coordinate* — so a `digest()` leaking only above a length threshold passed, as did a reversing `digest()`, as did the `appeared`/`removed` branches the both-sides fixture never reaches. Three leaks, all green. It now has `test_the_drift_report_never_carries_a_value_in_any_recoverable_form`, driven directly through all three shapes with an over-long opaque value. +- **`_describe_changes`'s rotation proof was one-sided** — it asserted only the pinned value absent, never the rotated one, which is the more damaging half. Now both, taken from the fixture's own variables. -**And a helper's parameter is a publication channel.** `_dotenv_use` took file *text*; pytest renders a frame's arguments, and `ast.parse` raises on any malformed module — so a package file that both failed to parse and carried a value would have printed itself in full. It takes a `Path` now and reads inside. +**A malformed package module published itself, and the first fix for it did not work.** `ast.parse` raises with the source as its own frame's argument, and pytest renders that in full — so a package file that both fails to parse and carries a value printed itself. This entry previously said *"it takes a `Path` now and reads inside"*; **measured, that changes nothing**, because the text still reaches `ast.parse`. The fix is a shared `_parsed(path)` that refuses by path and line with `raise ... from None` — `from None` is the load-bearing part, since the chained `SyntaxError` carries the same text. Applied at **both** parse sites: fixing one and not the other is this entry's own shape. -**What is deliberately NOT chased, so the next reader does not re-open it.** Five mutations survive and are refused rather than missed: four narrow the scan's *scope* (a length floor, a dropped section, a swallowed `SyntaxError`, a `break` after the first finding), and one deletes the no-print assertion itself. The first four are the scan's scope, which is **#243**'s entire subject and where they are routed — fixing them here would be doing that story badly, twice. The fifth is infinite regress: a guard on the guard on the guard has no fixed point, and the register is where that risk is carried instead. The `_carries` window is eight characters, which is a judgement and not a law; a six-character leak would pass, and chasing that is the whack-a-mole this epic exists to refuse. +**A fourth pass deleted most of the third, and that is the entry's real lesson.** Four commits and five reviews had grown this file by **+195 lines**, of which **+95 was prose restating this register** and **every one of the 149 new function-body lines was a test of another test in the same file**. Two of those additions were themselves defective: + +- `test_the_drift_report_never_carries_a_value_in_any_recoverable_form` claimed to drive `_describe_changes`'s `appeared`/`removed` branches. It did not: those branch on `was is None`, and the fixtures passed `_ABSENT`, a sentinel object. It ran one branch three times while asserting three — an ADR-014 §1 defect inside the fix for an ADR-014 §1 defect — and the branches it named return constant strings and cannot leak. **Deleted.** +- `_carries`, an eight-character-window "recoverability" predicate, is **strictly worse than plain `in`**: the window degrades to exact match below eight characters, and this package's two directory names are five-character declared values. Measured, `_carries("views_postprocessing/unfao/leaky.py …", "unfao")` is `True` — it condemns a bare, safe file path while claiming a leak. **Deleted**; both call sites use plain `not in`, the vocabulary already at `test_the_environment_refusal_logs_names_and_never_values`. + +Net **−140 lines**, leaving the file **+55 over its pre-story size** rather than +195. What the +55 buys: one behavioural guard, the rotation proof's second side, the value→coordinate map, and the shared parse refusal. + +**What is deliberately NOT chased.** Four surviving mutations narrow the scan's *scope* — a length floor, a dropped section, a swallowed `SyntaxError`, a `break` after the first finding — and none is visible to a test that plants its own fixture. They are **#243**'s subject and are routed there. A fifth deletes the no-print assertion itself: infinite regress, carried here instead. Two residuals stand: the markdown branch's output is not behaviourally proven (it holds no value by construction), and a leak shorter than the planted fixture would pass. Chasing either is the whack-a-mole this epic exists to refuse. 4. **The scan's own docstring carried four registry values**, and pytest prints the failing function's source — so the guard would have published them on exactly the event it exists to catch. The message was clean; the traceback was not. Now it names coordinates. **The wider finding is registered separately as C-97**: 33 standalone values sit in docstrings and comments across nine files, production modules included, and the AST scan excludes docstrings by a deliberate C-57 decision taken before anyone counted them. **Deliberately still open, and moved rather than closed:** the `secret` exemption at `:1042` and the ban-set's package-name collision are the *scope* of the scan, not its reporting, and belong with the matcher rewrite in **#243**. This entry stays open until they land, because closing it now would close a Tier 2 on two-thirds of its content. +**Two deferrals, both with triggers (ADR-014 §4).** + +1. **The leak guards belong in `tests/test_redaction_guard.py`**, which already owns "what stops us leaking" and which `test_the_environment_refusal_logs_names_and_never_values` already points readers to. The concern is currently split across two files with no shared vocabulary. Not moved here, because moving code while fixing bugs in it is how the next defect arrives. **Trigger: when #243 finishes touching `tests/test_env_declaration.py`.** Owner: this repository. +2. **`test_the_drift_check_would_catch_a_rename` re-types its subject's comparison inline** rather than calling it, so blanking that subject's assertions leaves the proof green — the same defect its two siblings had repaired. Pre-existing, found while reading for this change. **Routed to #243**, which is already in this file. + Cross-refs: **C-57** (the scan's own entry and its history), **C-93** (why the author's own proof did not find this), **C-90** (the sibling defect in the drift checks), ADR-014 §1, issues #242, #243. --- diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 97d22ba..f6f3bd3 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -137,13 +137,23 @@ def _manager_source(partner: str) -> Path: _DOTENV_CALLS = {"load_dotenv", "find_dotenv", "dotenv_values"} +def _parsed(path: Path) -> ast.Module: + """Parse ``path``, refusing a malformed module by path and line — never by content.""" + try: + return ast.parse(path.read_text()) + except SyntaxError as exc: + raise AssertionError(f"{path.name} does not parse (line {exc.lineno})") from None + + def _dotenv_use(path: Path) -> list[str]: """Real imports of, and calls into, python-dotenv — parsed, not grepped. - Takes a **path**, not the file's text. pytest renders a frame's arguments in a - traceback, so a helper that accepts source text publishes that text whenever it - raises — and ``ast.parse`` raises on any malformed module. A package file that both - fails to parse and carries a coordinate value would have printed itself in full. + A malformed module is refused by path and line, never by content. ``ast.parse`` + raises with the source as its own frame's argument, which pytest renders in full — so + a package file that both fails to parse and carries a coordinate value would publish + itself. ``from None`` drops the chained ``SyntaxError``, which carries the same text. + (An earlier version credited the ``Path`` parameter for this. Measured: it changes + nothing, because the text still reaches ``ast.parse``.) **Prose is not a violation and must not be treated as one.** ``appwrite_env.py``'s own docstring explains what the retired borrow was, spelling it exactly; a token @@ -153,7 +163,7 @@ def _dotenv_use(path: Path) -> list[str]: or a comment, so the guard can cover the whole package without lying about prose. """ found = [] - for node in ast.walk(ast.parse(path.read_text())): + for node in ast.walk(_parsed(path)): if isinstance(node, ast.Import): found += [a.name for a in node.names if a.name.split(".")[0] == "dotenv"] elif isinstance(node, ast.ImportFrom): @@ -945,9 +955,7 @@ def edition(value: str) -> dict: f"[{partner}] the report must name the FIELD that moved, or a maintainer reading " "the failure cannot tell a rotation from a reclassification." ) - # Both sides, taken from the fixture's own variables. Asserting the two literals - # instead let a fixture rename turn this into "two dead strings are absent" — and the - # rotated side is the one that matters, which an earlier version never checked. + # Both sides, from the fixture's own variables — the rotated one was never checked. for side in (was, rotated): assert side not in changed[canary], ( f"[{partner}] the report printed the {side!r} value. It must name the field " @@ -1014,57 +1022,19 @@ def _docstring_nodes(tree: ast.AST) -> set[int]: return out -def _report_a_copy(where: str, coordinates: tuple[str, ...]) -> str: - """One finding from the no-copy scan: WHERE the copy is and WHICH coordinate it is. - - The value is not a parameter, so the ordinary way of writing a finding cannot print - one. That is a convention with a shape, **not a guarantee** — an earlier version of - this docstring claimed a caller "cannot print one however it is written", and an - independent review falsified it in two lines by smuggling the value through ``where`` - and again through ``coordinates``. What actually holds the rule is - :func:`test_the_scan_reports_a_copy_without_reprinting_it`, which reads the finished - message rather than the syntax that produced it (register **C-89**, **C-93**). - - ``coordinates`` is a tuple because two coordinates may legitimately declare the same - value — measured against the live registry, two pairs do — and naming one of them - would be wrong half the time in the message a maintainer uses to find the copy. - """ - return f"{where} carries the value declared for {', '.join(coordinates)}" - - - def test_no_coordinate_value_is_copied_into_this_repo(): """The registry's own rule: *"never bake a value into code, an example, or a dataclass default."* Consumers READ and VALIDATE; the launcher supplies values. - **This docstring names coordinates and never values, and that is not fussiness.** - An earlier version quoted three real registry values as worked examples. When this - check fails, pytest prints the failing function's own source — so the guard against - publishing a coordinate would have published three, into a world-readable CI log, on - exactly the event it exists to catch. The message was clean and the traceback was not - (register **C-89**). - - **Two wrong narrowings before this one, both instructive.** - - A substring text scan over every ``.py`` flagged three "leaks", none of them a copy: - a **function name** in ``contract/store_metadata.py`` that happens to be spelled the - same as the value of ``APPWRITE_METADATA_DATABASE_ID``, and the values of - ``APPWRITE_PROD_FORECASTS_BUCKET_ID`` and ``APPWRITE_UNFAO_BUCKET_ID`` where they - appear only in refusal labels and in docstrings naming which store a function serves. - A guard that fails on an ordinary ``def`` gets deleted — after which the real rule is - unguarded. (Three declared values cannot be told from ordinary code by string - equality at all: that one, and this package's two directory names. Register C-97.) - - Narrowing to *assignments and default arguments* then went too far the other way: it - caught neither a dict value nor a keyword argument, and the keyword argument is the - shape this repo would actually produce — ``AppwriteConfig(bucket_id=...)`` is how - every store is configured, and swapping one ``os.getenv`` for a literal there is the - violation. - - The right axis was **exact equality on string constants**, not statement shape. It - catches dict values and kwargs, while all three original false positives fall out on - their own: a function name is not a ``Constant``; a value with a word appended is not - equal to the value; docstrings are excluded outright. + **Names coordinates, never values** — including in this docstring. pytest prints the + failing function's source, so a value written here publishes on exactly the event the + check exists to catch (register C-89). + + The axis is **exact equality on string constants**, not statement shape: it catches + dict values and keyword arguments — ``AppwriteConfig(bucket_id=...)`` is how every + store is configured — while a function name is not a ``Constant`` and docstrings are + excluded outright. Two narrower drafts and their false positives are in register + C-57; three values that cannot be told from ordinary code at all are in C-97. """ # `require_sibling`, like every other reader in this file. The hand-rolled skip that # stood here said only "checkout not found — set VIEWS_APPWRITE", which is the @@ -1100,7 +1070,7 @@ def test_no_coordinate_value_is_copied_into_this_repo(): copied = [] for source in sorted(_PKG.rglob("*.py")): - tree = ast.parse(source.read_text()) + tree = _parsed(source) docstrings = _docstring_nodes(tree) for node in ast.walk(tree): if ( @@ -1109,10 +1079,11 @@ def test_no_coordinate_value_is_copied_into_this_repo(): and node.value in values and id(node) not in docstrings ): - copied.append(_report_a_copy( - f"{source.relative_to(_PKG)}:{node.lineno}", - tuple(sorted(declared_by_value[node.value])), - )) + names = ", ".join(sorted(declared_by_value[node.value])) + copied.append( + f"{source.relative_to(_PKG)}:{node.lineno} carries the value " + f"declared for {names}" + ) # Markdown too — the AST half cannot see a fenced ``bash`` block, and that is exactly # where four production-forecasts values sat: in README.md's Configuration section, # two lines below the sentence promising they are never copied, in a PUBLIC @@ -1183,15 +1154,13 @@ def test_no_coordinate_value_is_copied_into_this_repo(): match = assignment.search(line) captured = next((g for g in match.groups()[1:] if g is not None), None) if match else None if captured is not None and captured.strip() in values: - # Report the coordinate(s) that DECLARE the value, not the one the - # assignment happens to name. Those differ exactly when a document - # pastes one coordinate's value beside another's name — the ordinary - # copy-paste slip — and naming the assignment would then assert a false - # fact and hide the coordinate actually leaked. - copied.append(_report_a_copy( - f"{doc.relative_to(_REPO)}:{number}", - tuple(sorted(declared_by_value[captured.strip()])), - )) + # The coordinate(s) that DECLARE the value, not the one the assignment + # names — they differ on the ordinary copy-paste slip. + names = ", ".join(sorted(declared_by_value[captured.strip()])) + copied.append( + f"{doc.relative_to(_REPO)}:{number} carries the value " + f"declared for {names}" + ) assert not copied, ( f"coordinate value(s) from the registry are copied into this repo: {copied}. The " @@ -1201,140 +1170,31 @@ def test_no_coordinate_value_is_copied_into_this_repo(): ) -def test_the_scan_reports_a_copy_without_reprinting_it(tmp_path, monkeypatch, capsys, caplog): - """Feed the real scan a planted copy and read what it says. **The rule, behaviourally.** - - This replaces a source-reading guard that asserted every finding was *routed* through - ``_report_a_copy``. Routing is not safety, and an independent review proved it: the - value could be smuggled through either of that helper's two parameters, or appended - with ``extend``/``+=``, or reported from a renamed accumulator — **six of eight - mutations survived**, while one legitimate refactor failed it. A guard that misses the - thing and fires on the innocent is decoration with a proof attached (register C-82). - - Reading the finished message instead makes the whole class unreachable. It does not - matter how a finding is built, which branch builds it, or what the final assertion - interpolates — if a value reaches the text, this fails. That is the difference between - enumerating the mutations you thought of (register **C-93**) and making them moot. +def test_the_scan_reports_where_a_copy_is_and_never_what_it_is(tmp_path, monkeypatch): + """C-89: the ``.py`` branch printed the value it forbids, on the one event it fires on. - Both halves are exercised, because C-89 was one half being taught a rule the other - half was not. + Read the finished message, not the source that built it — an earlier guard asserted + that findings were *routed* through one formatter, and routing is not safety. """ - # Opaque on purpose. A first draft planted a readable string containing the word - # "coordinate" — which also appears in the check's own failure prose, so the - # eight-character window test below fired on the test's own English. The fixture must - # share no fragment with anything the report legitimately says. - planted, coordinate = "Zq7xVb9KdLm1RnTs3Wy8Pj5Hc2Gf", "APPWRITE_UNFAO_BUCKET_ID" - + planted = "Zq7xVb9KdLm1RnTs3Wy8Pj5Hc2Gf" pkg = tmp_path / "views_postprocessing" pkg.mkdir() (pkg / "leaky.py").write_text(f'BUCKET_ID = "{planted}"\n') - (tmp_path / "README.md").write_text(f"{coordinate}={planted}\n") - for args in (("init", "-q"), ("add", "-A")): - subprocess.run( - ["git", "-C", str(tmp_path), "-c", "commit.gpgsign=false", *args], - capture_output=True, text=True, check=True, timeout=30, - ) here = sys.modules[__name__] monkeypatch.setattr(here, "_PKG", pkg) - monkeypatch.setattr(here, "_REPO", tmp_path) monkeypatch.setattr(here, "require_sibling", lambda name: tmp_path) monkeypatch.setattr(here, "_registry_current", lambda repo: { - "meta": {"version": "0.0.0-fixture"}, - "connection": {"APPWRITE_ENDPOINT": {"class": "connection", "value": "e"}}, - "target": {coordinate: {"class": "target", "value": planted}}, + "target": {"APPWRITE_UNFAO_BUCKET_ID": {"class": "target", "value": planted}}, }) with pytest.raises(AssertionError) as caught: test_no_coordinate_value_is_copied_into_this_repo() message = str(caught.value) - # Named files, not a count: pytest's assertion rewriting echoes the message, so - # counting substrings of it counts each finding twice. Naming both planted files is - # what stops this passing vacuously — a scan that finds nothing reports nothing, and - # "no value in the message" is trivially true of an empty message. - for half in ("leaky.py", "README.md"): - assert half in message, ( - f"the {half} half of the scan did not report the planted copy: {message}. " - "C-89 was one branch being taught a rule the other was not, so both must fire." - ) - assert coordinate in message, ( - f"the report must name the coordinate so a maintainer can find it: {message}" + assert "leaky.py" in message, f"the planted copy was not reported at all: {message}" + assert "APPWRITE_UNFAO_BUCKET_ID" in message, f"the coordinate is not named: {message}" + assert planted not in message, ( + "THE SCAN PUBLISHED THE VALUE IT EXISTS TO HIDE. This repository is public and its " + "CI logs are world-readable and cannot be redacted afterwards." ) - # Every channel pytest publishes, not just the assertion text. A debug `print` left - # beside the append is an ordinary accident, and pytest renders captured stdout and - # captured logs in the same report as the failure — so "the message is clean" is only - # part of "the run is clean". - captured = capsys.readouterr() - for channel, text in ( - ("the assertion message", message), - ("captured stdout", captured.out), - ("captured stderr", captured.err), - ("captured logs", caplog.text), - ): - assert not _carries(text, planted), ( - f"THE SCAN PUBLISHED THE VALUE IT EXISTS TO HIDE, via {channel}. This " - "repository is public, its CI logs are world-readable, and they cannot be " - "redacted afterwards. However the finding was built and wherever it was " - "written, no recoverable form of the value may leave this test." - ) - - -def _carries(text: str, value: str) -> bool: - """Is ``value`` recoverable from ``text`` — not merely present in it? - - Equality and ``in`` are too weak for a no-print rule. An adversarial review defeated a - substring check with ``value[:12]`` and again with ``value[::-1]``, both of which a - reader recovers instantly. So this asks the question that matters: does any eight- - character window of the value, forwards or backwards, appear? - - Eight is a judgement, not a law: long enough that ordinary English and file paths do - not collide with a coordinate, short enough that a truncation or a reversal is caught. - """ - windows = {value[i:i + 8] for i in range(len(value) - 7)} or {value} - return any(w in text or w[::-1] in text for w in windows) - - -def test_the_drift_report_never_carries_a_value_in_any_recoverable_form(): - """``_describe_changes`` is the drift check's reporter, and it had no test of its own. - - The rotation proof above exercises it, but only through one shape — a value present - on both sides — and with fixture strings shorter than every real coordinate. An - adversarial review walked through both gaps: a ``digest()`` that returns the raw value - only when it is longer than the fixture's, and the ``appeared``/``removed`` branches - that the both-sides fixture never reaches. Three leaks, all green. - - So this drives the reporter directly, with a value longer than any the registry - declares, through every branch that can produce text. - """ - # Longer than every value the registry declares, and opaque so no window of it - # can collide with the report's own wording. - value = "Xk4mQ8zRv2Ld7NpTb5Wc1Hy9Ja6Fg3Ss" - other = "Bn6tYp3Kq9Vx2Mw8Lz5Rd1Cf7Hj4Gu0Ee" - name = "APPWRITE_FIXTURE_COORDINATE" - - reports = [ - # rotated: present on both sides, different - _describe_changes({name: ("target", "target", value)}, - {name: ("target", "target", other)}, {name}), - # appeared: absent at the pin, valued now - _describe_changes({name: ("target", "target", _ABSENT)}, - {name: ("target", "target", value)}, {name}), - # removed: valued at the pin, absent now - _describe_changes({name: ("target", "target", value)}, - {name: ("target", "target", _ABSENT)}, {name}), - ] - - for shape, report in zip(("rotated", "appeared", "removed"), reports): - assert name in report, ( - f"the {shape} shape produced no report for {name}: {report}. A reporter that " - "says nothing about a change it was handed is the silent half of a drift check." - ) - text = report[name] - for secret in (value, other): - assert not _carries(text, secret), ( - f"the {shape} report carries a coordinate value in a recoverable form: " - f"{text!r}. This repository is public and its CI logs are world-readable. " - "Name the field that moved and digest the value; never emit it, and never " - "emit a prefix or a transform of it." - ) From 27f2adcc7aed6972880d1303166194695c17ae7f Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 17:12:40 +0200 Subject: [PATCH 34/42] =?UTF-8?q?fix(tests):=20#245=20=E2=80=94=20delete?= =?UTF-8?q?=20the=20arrival=20check;=20it=20subscribed=20us=20to=20another?= =?UTF-8?q?=20repo's=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-86 is that this repository's release path can be blocked by another repository's routine work. PR #239 was written to reduce that and shipped a check that reproduced it. MEASURED, ON THE LIVE REGISTRY. The drift check had two halves. `changed` is filtered to the names each partner declares. `arrived` was not — it ran over every row of every table this package depends on. Each partner reads 13 of those 25 rows. Eight belong to no repository here: three platform key slots, three caller keys for other repos, and two contract rows. So views-appwrite issuing one key for someone else turned both partner parametrizations red and blocked a merge to main, which is the release to FAO. The same test's docstring, seven lines above, said "Silent through: prose edits, [meta] bumps, and rows belonging to anyone else." The prose described the check that was designed; the code implemented a wider one. ITS SELF-DEFENCE WAS FALSE. The failure message claimed `[contract.*]` "arrived exactly this way, and nothing else here would have seen it". `[contract]` is a top-level TABLE, and `test_every_table_in_the_registry_is_classified_here` — thirty lines above, in the same file — catches exactly that. The case it cited as its reason to exist was already covered by its neighbour. MUTATION-PROVEN IN BOTH DIRECTIONS against the real registry: upstream issues an API key for another repo -> silent (was: red build) a third partner's contract row lands -> silent (was: red build) a value this partner reads rotates -> fires a name this partner reads is removed -> fires The second one is not hypothetical: views-productionapi is a forthcoming consumer, and its contract row landing would have reddened both existing partners. AND THE STOPPING RULE IS NOW IN THE FILE, above the check, because this check has been widened once already: it fires on exactly one condition — a row this partner declares differs between the pinned edition and the sibling's main — and any proposal to add a condition must name the delivery failure it prevents and show that failure is not already prevented by assert_env_declared at run time. Deliberately NOT done: adopting `[edition].obliges_consumers` (views-appwrite#76, shipped as registry v1.6.0). Deleting `arrived` fixes the shipping problem; adopting the flag is an addition, and it would move a safety property onto another repository's labelling discipline. `changed` compares bytes and depends on nobody's self-description. Deferred with a trigger in C-86. Register: C-86's "⚠ CORRECTED" note becomes CORRECTED-and-RESOLVED, with the measurement; its rate claim is now accurate, which it was not before. C-90 gets a partial mitigation — the tautological proof and the untested `registry_current` are #246's and it stays open. Suite 410 passed / 1 skipped / 40 xfailed, ruff clean. The one failure is #248's. Closes #245. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 38 ++++++++++++++++++++---------- tests/test_env_declaration.py | 27 +++++++++++---------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index f2c2f83..317467d 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -355,6 +355,12 @@ There is a real question underneath, and it should be decided rather than inheri **`registry_current` has no test.** The module `tests/seam_registry.py` was extracted for one reason: two copies of the reader disagreed about whether to read the sibling's `main` or its working tree, and reading the working tree is issue #196 verbatim. The function that settles it is called by five tests and is the subject of none. Replacing its body with `rev-parse HEAD` — the defect it exists to prevent — leaves the suite at its exact baseline. Three of its error branches are executed by nothing. +**Partial mitigation 2026-08-12 (#245) — the false-alarm half is gone; the two proof defects are not.** + +`arrived` is **deleted**. Measured before deleting: each partner reads 13 of the 25 rows in the tables this package depends on, and 8 of those rows belong to no repository here — so the check subscribed this repository to another repo's changelog. Mutation-proven after: an unrelated API key and a third partner's contract row are silent; a rotation and a removal still fire. The docstring and the code now agree, and the stopping rule sits above the check. + +**Still open, and routed to #246**: the partition's mutation proof is a tautology (`base` is built from `_TABLE_ROLE`, so `_unclassified_tables(base)` is empty for every possible input), and `registry_current` — the function `tests/seam_registry.py` was extracted to create — has no test, so replacing it with a working-tree read leaves the suite at baseline. This entry stays open until both land. + Cross-refs: **C-86** (whose partial-mitigation paragraph this falsifies), **C-89** (the sibling defect in the no-copy scan), **C-93**, **C-91**, ADR-014 §1/§2, issue #196. --- @@ -509,18 +515,26 @@ description, where deferrals go to be forgotten: Cross-refs for this amendment: **C-57** (the drift detector this rides on), ADR-016 §7a/§7b, views-appwrite#76 (**delivered**, registry v1.6.0). -**⚠ CORRECTED 2026-08-12 — the partial mitigation above overstates what shipped.** The -paragraph beginning *"Partial mitigation 2026-08-11"* says the replacement checks "match on -the facts this repository actually declares". **One of the three does not.** The arrival -half of the drift check is computed over every row of every table this package depends on, -with no filter for the names it reads — 8 of 25 rows on the live registry belong to other -repositories. So the false-alarm class this entry records is *narrowed*, not removed: -observation-only edits no longer fire it, but any new coordinate for any repo still does. - -The measurement quoted above (v1.4.4 → v1.5.2, three editions, green) remains true and was -not the wrong measurement — none of those editions added a row. It was simply not a -measurement of the case that now fires. Registered as **C-90**, and this entry's rate claim -should be re-stated once that is fixed rather than before. +**⚠ CORRECTED 2026-08-12, and RESOLVED the same day (#245).** The paragraph beginning +*"Partial mitigation 2026-08-11"* claimed the replacement checks "match on the facts this +repository actually declares". **One of the three did not.** The drift check's arrival half +ran over every row of every table this package depends on, with no filter for the names it +reads — measured on the live registry, **each partner reads 13 of 25 rows, and 8 belong to +no repository here**. So an API key issued upstream for someone else reddened this +repository and blocked a release, which is this entry's own failure class arriving through +the guard meant to reduce it. + +**The arrival half is now deleted** (#245). The drift check fires on exactly one condition — +a row this partner declares differs between the pinned edition and the sibling's `main` — +and that stopping rule is written above the check itself. Its self-defence turned out to be +false: it claimed `[contract.*]` *"arrived exactly this way and nothing else here would have +seen it"*, but `[contract]` is a top-level **table**, caught by the partition check directly +above it. + +Mutation-proven both ways against the live registry: an unrelated API key and a third +partner's contract row are now **silent**; a rotation and a removal of a row this partner +reads still **fire**. The rate claim in the paragraph above is therefore accurate as +written, which it was not before. --- diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index f6f3bd3..55f0267 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -736,6 +736,20 @@ def test_nothing_this_repo_reads_has_changed_since_the_pin(partner): **Silent through:** prose edits, ``[meta]`` bumps, and rows belonging to anyone else. Measured across the real v1.4.4 -> v1.5.2 window (three editions, one week): green. + + **The stopping rule, because this check has been widened once already.** It fires on + exactly one condition: *a row named in this partner's* ``expected_class`` *differs + between the pinned edition and the sibling's* ``main``. Nothing else. + + Any proposal to add a condition must name the delivery failure it prevents and show + that failure is not already prevented by ``assert_env_declared`` at run time. If it + is, the condition is refused. An arrival half was added and deleted for failing that + test: it fired on every row of every table this package depends on — measured, 12 of + 25 rows are ones this partner never reads and 8 belong to no repository here — so an + API key issued upstream for someone else blocked a release. It defended itself by + claiming ``[contract.*]`` "arrived exactly this way and nothing else here would have + seen it", which is false: ``[contract]`` is a top-level *table*, and + ``test_every_table_in_the_registry_is_classified_here`` above catches those. """ repo = require_sibling("views-appwrite") module, _, expected_class = _PARTNER_ENV[partner] @@ -745,19 +759,6 @@ def test_nothing_this_repo_reads_has_changed_since_the_pin(partner): names = set(expected_class) then, now = _projection(pinned, names), _projection(current, names) - arrived = sorted( - set(_rows(current, _TABLES_WE_DEPEND_ON)) - set(_rows(pinned, _TABLES_WE_DEPEND_ON)) - ) - assert not arrived, ( - f"[{partner}] the registry gained coordinate(s) {arrived} in a table this package " - f"reads, since the edition it was verified against " - f"(v{module.SEAM_CONTRACT_VERSION}). Decide whether this package must adopt them " - "— that is a human read, which is why this reports rather than guesses — then " - "move SEAM_CONTRACT_VERSION and SEAM_CONTRACT_COMMIT together. This is the half " - "of the deleted edition check that was worth keeping: `[contract.*]` arrived " - "exactly this way, and nothing else here would have seen it." - ) - changed = _describe_changes(then, now, names) assert not changed, ( f"[{partner}] the registry changed something this package reads, since the " From ff1de31e8422784181ddbe1dcaab4dbd25b1da4c Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 17:24:38 +0200 Subject: [PATCH 35/42] =?UTF-8?q?fix(tests):=20#245=20review=20=E2=80=94?= =?UTF-8?q?=20a=20counterfactual=20written=20as=20history,=20and=20a=20rul?= =?UTF-8?q?e=20that=20read=20backwards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent reviews of the deletion. The deletion itself is sound; three of my own claims about it were not, and one of them is the defect this whole epic is about. I WROTE A MUTATION RESULT IN THE PAST TENSE. The docstring, the commit message and C-86 all said an upstream key "reddened this repository and blocked a release". Measured: ZERO rows have arrived in the depended-on tables since the pin — the arrival half was green on every real edition it ever saw, from its introduction on 2026-08-11 to its deletion a day later. And `protect_main` carries `deletion, non_fast_forward, pull_request` and no required status check, so a red build blocks nothing today anyway. What is true: a mutation shows it WOULD fire, and it WOULD block once C-81 lands. Writing the counterfactual as history, in a change whose subject is prose outrunning code, is the thing itself. THE STOPPING RULE READ BACKWARDS. It said a proposed widening must show its failure is "not already prevented by assert_env_declared at run time". Applied literally that ADMITS the arrival half — an unadopted upstream coordinate is not prevented by it — and REJECTS the removal detection this check performs, because a removed coordinate IS caught at run time when the launcher cannot supply the name. The rule now asks the question that actually discriminates: state the delivery failure prevented, AND the false-alarm surface measured against the live registry; if that surface includes rows this package does not read, it is refused. "8 BELONG TO NO REPOSITORY HERE" IS SIX. The other two are this package's own delivery labels — both rows declare `producer = "views-postprocessing"` — and they are covered by tests/test_product.py, not by this check. C-90's pre-existing text had this right; I upgraded a careful claim into a false one and propagated it to four places. AND THE DELETION'S REAL COST WAS RECORDED NOWHERE. A coordinate views-appwrite issues FOR this package, or a second `[contract.*]` row for a future partner, is now silent until a human reads the registry — no test, no runtime assert. C-90 had posed exactly this question ("they cannot both stand") and my mitigation answered only the half that flattered the change. Now stated in the docstring and decided in C-90. THE SILENCE DIRECTION NOW HAS A CHECK. The proofs either side of this one are positive — rotation fires, a rename fires — so a third widening would have met no objection and the rule forbidding it would have been prose with a guard on the other half only. Mutation- proven by re-adding an arrival half and watching it fail. C-86: two of three, not one. "Every top-level table must be classified" still fires regardless of obligation, and HAS — `[edition]` at v1.6.0, an edition the registry itself calls "additive and opt-in; obliges nobody", reddened this repo. Recorded as the entry's remaining rate risk with the `obliges_consumers` deferral as its fix. ADR-014 §5: ADR-016 §1 and §7b both asserted the deleted behaviour as current, and §7b is the section C-86 cross-references. Both corrected with an erratum naming the cost. C-90's Trigger, Location, title and present-tense body all repointed at the two defects that remain. Suite 412 passed / 1 skipped / 40 xfailed, ruff clean. The one failure is #248's. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 19 ++++-- reports/technical_risk_register.md | 56 ++++++++++++----- tests/test_env_declaration.py | 63 +++++++++++++++---- 3 files changed, 106 insertions(+), 32 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index ae5cf56..ee77a84 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -44,9 +44,11 @@ this repository makes about a different repository** is still true. The clearest example. Each partner's `appwrite_env.py` records which edition of the shared configuration registry it was verified against, by naming a commit in another repository. Tests open that repository at that commit and ask whether anything this -package reads has moved since — a rename, a reclassification, a rotated value, or a new -coordinate that has arrived. *(Until 2026-08-11 they asked a cruder question — whether the -edition **label** still matched — which fired on every upstream edit. See §7b's erratum.)* +package reads has moved since — a rename, a reclassification, a rotated value. *(Until +2026-08-11 they asked a cruder question — whether the edition **label** still matched — +which fired on every upstream edit. A second attempt also asked whether any coordinate had +**arrived**, which fired on other repositories' rows and was deleted on 2026-08-12. See +§7b's erratum.)* This is not hypothetical housekeeping. On 2026-08-05 that test failed — the registry had moved to a new edition while nobody here was looking. Two days earlier, the same family of @@ -248,9 +250,14 @@ So the two categories above become three: - **Reachability checks** read a pinned commit; movement is noise. - **Differential tripwires** read **both** — a pinned edition as the baseline, the sibling's `main` as the comparison — and fire when something *we declare* differs between - them, or when a coordinate arrives in a table we read. This is the shape the registry - check now has. It catches a rotation, which is invisible to the other two, and it is - silent through prose edits and version bumps. + them. This is the shape the registry check now has. It catches a rotation, which is + invisible to the other two, and it is silent through prose edits and version bumps. + + *(Erratum, 2026-08-12. This paragraph read "…or when a coordinate **arrives** in a table + we read" and called that the check's current shape. That half was deleted the same day: it + compared every row of every table this package depends on, so another repository's key + reddened this one. The cost of removing it — a coordinate issued **for** this package now + goes unnoticed until a human reads the registry — is carried in register C-90.)* *(Corrected twice on 2026-08-11, and the second time is the instructive one. The first implementation compared against the sibling's **working tree** — so a developer whose clone diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 317467d..9638bd7 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -334,22 +334,24 @@ Cross-refs: **C-57** (the scan's own entry and its history), **C-93** (why the a --- -### C-90: The replacement drift checks assert less than their docstrings say, and one of their mutation proofs is a tautology +### C-90: A mutation proof that cannot fail, and the untested function a module was extracted to create | Field | Value | |-------|-------| | ID | C-90 | | Tier | 2 — this is the entry PR #239 was written to close, reopened by the code that closed it. It reinstates release-blocking false alarms on the path that is this project's production release, and it does so under a docstring saying the opposite. | | Source | `/code-review max` on PR #239 post-merge, 2026-08-11; verified by direct measurement against views-appwrite `origin/main` | -| Trigger | views-appwrite declares any new coordinate in `connection`, `target`, `secret` or `contract` — including one belonging to another repository entirely. On the current registry that is 8 of 25 rows' worth of surface. | +| Trigger | **Both remaining halves are proof defects, not runtime ones.** (a) Someone mutates `_unclassified_tables` or `_TABLE_ROLE` and believes the tautological proof covers it. (b) Someone changes `registry_current` — the reason `tests/seam_registry.py` exists — and the suite stays green. *(The original trigger, an unrelated coordinate arriving upstream, died with `arrived` in #245.)* | | Owner | This repository. | -| Location | `tests/test_env_declaration.py:732` (`arrived`), `:890` (the tautology), `tests/seam_registry.py:113` (`registry_current`). | +| Location | `tests/test_env_declaration.py` — `test_the_table_partition_would_catch_a_new_table_and_a_vanished_one` (the tautology); `tests/seam_registry.py::registry_current` (untested). Function names, not line numbers: this entry has cited stale ones before. | -**`arrived` is not filtered by the names this package reads.** `changed` is; `arrived` is computed over every row of every table this package depends on. Measured on the live registry: **25 rows, 8 of which this package never reads** — six of them keys and callers belonging to other repositories. So views-appwrite issuing one more key for an unrelated repo turns both partner parametrizations red here, with a message demanding a `SEAM_CONTRACT` re-pin for a coordinate this package cannot use. +**~~`arrived` is not filtered by the names this package reads.~~ RESOLVED 2026-08-12 (#245) — deleted; see the mitigation below. Left visible because the reasoning it prompted is the entry's most useful part.** `changed` is; `arrived` is computed over every row of every table this package depends on. Measured on the live registry: **25 rows, 8 of which this package never reads** — six of them keys and callers belonging to other repositories. So views-appwrite issuing one more key for an unrelated repo turns both partner parametrizations red here, with a message demanding a `SEAM_CONTRACT` re-pin for a coordinate this package cannot use. That is the exact failure class C-86 records and that PR #239 was written to remove, and the same test's docstring seven lines above says **"Silent through: prose edits, `[meta]` bumps, and rows belonging to anyone else."** The prose describes the check that was designed; the code implements a wider one. -There is a real question underneath, and it should be decided rather than inherited: a *new* coordinate in a table we read may be one we must adopt — `[contract.*]` arrived exactly that way and nothing else here would have seen it. That argues for table-granularity on arrival and row-granularity on change. If that is the intent it is defensible, and then the docstring is wrong; if the docstring is right, the code is. **They cannot both stand.** +There is a real question underneath, and it should be decided rather than inherited: a *new* coordinate in a table we read may be one we must adopt. That argues for table-granularity on arrival and row-granularity on change. **They cannot both stand.** + +**DECIDED 2026-08-12 (#245): the docstring won, and the cost is real.** The arrival half was deleted. A coordinate views-appwrite issues *for this package* — or a second `[contract.*]` row for a future partner such as views-productionapi — is now **silent** until a human reads the registry: no test, no run-time assert, nothing. `assert_env_declared` cannot see it, because it iterates the names this package already declares. That is the accepted price of not being reddened by every unrelated row, and it is recorded here rather than left to be discovered. *(The half's own defence — that `[contract.*]` "arrived exactly this way and nothing else here would have seen it" — was false at table granularity, where the partition check catches it, and true at row granularity, which is exactly the cost now accepted.)* **The partition's mutation proof cannot fail.** `assert not _unclassified_tables(base)` where `base = {name: {} for name in _TABLE_ROLE}` reduces to `set(_TABLE_ROLE) - set(_TABLE_ROLE)`, empty for every possible input. Its message — *"the real registry's tables must all classify"* — asserts a fact about a file this test never opens. It is decoration inside the test whose own docstring is about removing decoration. @@ -515,14 +517,29 @@ description, where deferrals go to be forgotten: Cross-refs for this amendment: **C-57** (the drift detector this rides on), ADR-016 §7a/§7b, views-appwrite#76 (**delivered**, registry v1.6.0). -**⚠ CORRECTED 2026-08-12, and RESOLVED the same day (#245).** The paragraph beginning -*"Partial mitigation 2026-08-11"* claimed the replacement checks "match on the facts this -repository actually declares". **One of the three did not.** The drift check's arrival half -ran over every row of every table this package depends on, with no filter for the names it -reads — measured on the live registry, **each partner reads 13 of 25 rows, and 8 belong to -no repository here**. So an API key issued upstream for someone else reddened this -repository and blocked a release, which is this entry's own failure class arriving through -the guard meant to reduce it. +**⚠ CORRECTED 2026-08-12, and PARTLY RESOLVED the same day (#245).** The paragraph +beginning *"Partial mitigation 2026-08-11"* claimed the replacement checks "match on the +facts this repository actually declares". **Two of the three did not**, and an earlier +draft of this correction said one — the review that caught it is the reason this paragraph +is longer than it wants to be. + +**The first, now fixed.** The drift check's arrival half ran over every row of every table +this package depends on, with no filter for the names it reads. Measured on the live +registry: each partner reads **13 of 25** rows, and **6 belong to other repositories** +(three platform key slots, three caller keys). The other two rows this package does not +read are its own delivery labels — both declare `producer = "views-postprocessing"` — and +they are covered by `tests/test_product.py`, not by this check. *An earlier draft of this +paragraph said "8 belong to no repository here", which upgraded a careful claim in C-90 +into a false one and propagated it to four places.* + +**And it never actually fired in anger.** Measured: **zero rows have arrived in the +depended-on tables since the pin** — the arrival half was green on every real edition it +ever saw, from its introduction on 2026-08-11 to its deletion on 2026-08-12. An earlier +draft said an upstream key "reddened this repository and blocked a release". That is a +mutation result written in the past tense. What is true: a mutation shows it *would* fire, +and it *would* block once C-81's required check lands — `protect_main` today carries +`deletion, non_fast_forward, pull_request` and no required status check, which is this +entry's own "latent rather than live" paragraph. **The arrival half is now deleted** (#245). The drift check fires on exactly one condition — a row this partner declares differs between the pinned edition and the sibling's `main` — @@ -533,8 +550,17 @@ above it. Mutation-proven both ways against the live registry: an unrelated API key and a third partner's contract row are now **silent**; a rotation and a removal of a row this partner -reads still **fire**. The rate claim in the paragraph above is therefore accurate as -written, which it was not before. +reads still **fire**. A new test asserts the silence direction, so a third widening meets +an objection rather than a paragraph. + +**The second inaccuracy stands, and is this entry's remaining rate risk.** *"Every +top-level table upstream must be classified here"* fires on any new table regardless of +whether this repository declares anything about it — and **it has already fired for exactly +that reason**: `[edition]` arrived at v1.6.0, an edition the registry's own `[meta]` calls +*"additive and opt-in; obliges nobody"*, and it reddened this repository. That event is +recorded further down this entry as the partition "earning its keep", which is true of the +detection and not of the cost. Adopting `[edition].obliges_consumers` is the deferral that +would fix it, and its trigger is below. --- diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 55f0267..01b96c3 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -739,17 +739,27 @@ def test_nothing_this_repo_reads_has_changed_since_the_pin(partner): **The stopping rule, because this check has been widened once already.** It fires on exactly one condition: *a row named in this partner's* ``expected_class`` *differs - between the pinned edition and the sibling's* ``main``. Nothing else. - - Any proposal to add a condition must name the delivery failure it prevents and show - that failure is not already prevented by ``assert_env_declared`` at run time. If it - is, the condition is refused. An arrival half was added and deleted for failing that - test: it fired on every row of every table this package depends on — measured, 12 of - 25 rows are ones this partner never reads and 8 belong to no repository here — so an - API key issued upstream for someone else blocked a release. It defended itself by - claiming ``[contract.*]`` "arrived exactly this way and nothing else here would have - seen it", which is false: ``[contract]`` is a top-level *table*, and - ``test_every_table_in_the_registry_is_classified_here`` above catches those. + between the pinned edition and the sibling's* ``main``. + + Any proposal to widen it must state two things: which delivery failure it prevents, + **and its false-alarm surface measured against the live registry**. If that surface + includes rows this package does not read, it is refused. (An earlier version of this + rule asked instead whether ``assert_env_declared`` already prevented the failure at + run time. That rule is wrong in both directions: an unadopted upstream coordinate is + not prevented by it, so arrival detection would pass — and a removed coordinate *is* + caught by it, so the removal detection this check does perform would fail.) + + An arrival half failed the surface test and was deleted (#245): it compared every row + of every depended-on table, and measured, each partner reads 13 of 25 while 6 belong + to other repositories entirely. It defended itself by claiming ``[contract.*]`` + "arrived exactly this way and nothing else here would have seen it" — false at table + granularity, where ``test_every_table_in_the_registry_is_classified_here`` catches it. + + **What that deletion costs, stated rather than implied.** A coordinate views-appwrite + issues *for this package* is now silent until a human reads the registry — no test, no + run-time assert, nothing. So is a second ``[contract.*]`` row for a future partner. + That is the accepted price of not being reddened by every unrelated row, and it is + carried in register C-90 rather than here. """ repo = require_sibling("views-appwrite") module, _, expected_class = _PARTNER_ENV[partner] @@ -965,6 +975,37 @@ def edition(value: str) -> dict: ) +@pytest.mark.parametrize("partner", _PARTNERS) +def test_the_drift_check_is_silent_on_a_row_this_partner_does_not_read(partner): + """The stopping rule above, as a check rather than a paragraph. + + The proofs either side of this one are positive — rotation fires, a rename fires. + Nothing asserted the *silence* direction, so a third widening of this check would + have met no objection, and the rule forbidding it would have been prose with a guard + on the other half only. That is this file's own recurring defect (C-80, C-82). + """ + _, _, expected_class = _PARTNER_ENV[partner] + names = set(expected_class) + mine = next(iter(names)) + + pinned = {"target": {mine: {"class": "target", "value": "unchanged"}}} + current = { + "target": {mine: {"class": "target", "value": "unchanged"}}, + # Somebody else's coordinate, arriving in a table this package depends on. + "secret": {"SOMEBODY_ELSES_API_KEY": {"class": "secret", "slot": "theirs"}}, + } + + changed = _describe_changes( + _projection(pinned, names), _projection(current, names), names + ) + assert not changed, ( + f"[{partner}] the drift check objected to a row this package does not read: " + f"{changed}. It must be silent on another repository's coordinates — an arrival " + "half that was not was deleted in #245 for exactly this, and re-adding one is " + "refused by the stopping rule in the check's own docstring." + ) + + @pytest.mark.parametrize("partner", _PARTNERS) def test_the_drift_check_would_catch_a_rename(partner): """A gated test that cannot fail is decoration — so prove this one bites in CI. From 79abda7b3e3feb4bfa8a3171bb9cebda7e9de1e7 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 17:41:18 +0200 Subject: [PATCH 36/42] =?UTF-8?q?fix(tests):=20#245=20review-diff=20?= =?UTF-8?q?=E2=80=94=20the=20silence=20guard=20was=20not=20pointed=20at=20?= =?UTF-8?q?the=20check=20it=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /review-diff on the remediation, and it found the remediation's own defect. Same class, one level up, in the change that was fixing it. THE SILENCE GUARD DID NOT GUARD THE CHECK. Its docstring said "the stopping rule above, as a check rather than a paragraph" and claimed a third widening "would have met no objection" — implying it now would. It would not. The test called `_describe_changes` directly, the helper UNDERNEATH the check, and the deleted arrival half never lived in that helper. Proven: re-adding `arrived` to the real check, exactly as it was before #245, left the whole suite green at 41 passed. AND THE MUTATION PROOF I OFFERED FOR IT WAS INVALID. I mutated the TEST — adding arrival logic inside the test body — and watched it fail. That proves the assertion works on input I hand it. It proves nothing about the code the test is supposed to be watching. A proof has to mutate the subject, not the instrument. It now drives the real check through monkeypatched registry readers, the pattern #242 arrived at for the same reason. Re-proven properly: re-widening the real check fails it, naming the offending row. Also fixed while in there: `mine = next(iter(names))` picked an arbitrary set element, so the fixture varied per process. `sorted(...)[0]` instead — declared, not incidental. And one near-miss worth recording: my first attempt to drop an unused local used a `sed` pattern that matched three OTHER tests and silently deleted their `module = ...` binding. Ruff caught it as seven F821s, restored from a `cp` backup. The lesson is the standing one — anchor edits on unique text, not on a pattern that happens to be unique in the region you are looking at. Register: C-89's sibling lesson recorded on C-86's mitigation — a guard must be pointed at the thing it claims to guard, which is the sentence this arc keeps re-learning. Suite 412 passed / 1 skipped / 40 xfailed, ruff clean. The one failure is #248's. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 9 +++++++ tests/test_env_declaration.py | 42 ++++++++++++++++-------------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 9638bd7..ed976c9 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -553,6 +553,15 @@ partner's contract row are now **silent**; a rotation and a removal of a row thi reads still **fire**. A new test asserts the silence direction, so a third widening meets an objection rather than a paragraph. +*(That silence test needed two attempts, and the first is worth recording because it is +this entry's own disease. It called `_describe_changes` directly — the helper underneath +the check — and was worthless: re-adding the deleted arrival half to the real check left +the whole suite green, because that half never lived in the helper the test was asking. +The "mutation proof" offered for it was invalid too: it mutated the test rather than the +code, which proves nothing. It now drives the real check through monkeypatched readers, +and re-adding the arrival half fails it. **A guard must be pointed at the thing it claims +to guard**, which is the sentence this whole entry keeps re-learning.)* + **The second inaccuracy stands, and is this entry's remaining rate risk.** *"Every top-level table upstream must be classified here"* fires on any new table regardless of whether this repository declares anything about it — and **it has already fired for exactly diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 01b96c3..aa9bb00 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -976,34 +976,36 @@ def edition(value: str) -> dict: @pytest.mark.parametrize("partner", _PARTNERS) -def test_the_drift_check_is_silent_on_a_row_this_partner_does_not_read(partner): +def test_the_drift_check_is_silent_on_a_row_this_partner_does_not_read(partner, monkeypatch): """The stopping rule above, as a check rather than a paragraph. The proofs either side of this one are positive — rotation fires, a rename fires. - Nothing asserted the *silence* direction, so a third widening of this check would - have met no objection, and the rule forbidding it would have been prose with a guard - on the other half only. That is this file's own recurring defect (C-80, C-82). + Nothing asserted the *silence* direction, so a third widening of this check would have + met no objection and the rule forbidding it would have been prose with a guard on the + other half only. That is this file's own recurring defect (C-80, C-82). + + **It drives the real check**, through the real registry readers, rather than calling + the projection helpers underneath it. A first version called ``_describe_changes`` + directly and was worthless: re-adding the deleted arrival half to the check left the + whole suite green, because that half never lived in the helper this was asking. A + guard has to be pointed at the thing it claims to guard. """ - _, _, expected_class = _PARTNER_ENV[partner] - names = set(expected_class) - mine = next(iter(names)) + mine = sorted(_PARTNER_ENV[partner][2])[0] - pinned = {"target": {mine: {"class": "target", "value": "unchanged"}}} - current = { + pinned = { + "meta": {"version": "0.0.0-fixture"}, + "connection": {"APPWRITE_ENDPOINT": {"class": "connection", "value": "e"}}, "target": {mine: {"class": "target", "value": "unchanged"}}, - # Somebody else's coordinate, arriving in a table this package depends on. - "secret": {"SOMEBODY_ELSES_API_KEY": {"class": "secret", "slot": "theirs"}}, } + # The same registry, plus one coordinate belonging to another repository. + current = {**pinned, "secret": {"SOMEBODY_ELSES_API_KEY": {"class": "secret"}}} - changed = _describe_changes( - _projection(pinned, names), _projection(current, names), names - ) - assert not changed, ( - f"[{partner}] the drift check objected to a row this package does not read: " - f"{changed}. It must be silent on another repository's coordinates — an arrival " - "half that was not was deleted in #245 for exactly this, and re-adding one is " - "refused by the stopping rule in the check's own docstring." - ) + here = sys.modules[__name__] + monkeypatch.setattr(here, "require_sibling", lambda name: Path("/nonexistent")) + monkeypatch.setattr(here, "_registry_at", lambda repo, ref: pinned) + monkeypatch.setattr(here, "_registry_current", lambda repo: current) + + test_nothing_this_repo_reads_has_changed_since_the_pin(partner) @pytest.mark.parametrize("partner", _PARTNERS) From 046ca06ca244ce5d24b1d41ac2455888ba1a9a97 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 17:54:46 +0200 Subject: [PATCH 37/42] =?UTF-8?q?fix(tests):=20#245=20iteration=202=20?= =?UTF-8?q?=E2=80=94=20nothing=20proved=20the=20check=20FIRES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 2's review, and it found the sharpest thing on this branch. THE DRIFT CHECK'S ENTIRE ASSERTION COULD BE DELETED WITH A GREEN SUITE. Measured: neuter `assert not changed` and the suite stays at 412 passed. My silence guard was the only test driving the real check, and it is purely negative — it asserts the check does NOT raise. Nothing asserted it does. AND MY DOCSTRING VOUCHED FOR THE NEIGHBOURS. It said "the proofs either side of this one are positive — rotation fires, a rename fires". Neither is about this check. The rotation proof calls `_describe_changes`, the helper underneath it — which is precisely the defect I diagnosed and fixed for the silence guard one commit earlier. The rename proof calls `_declared_classes` and re-types the CLASS check's comparison inline; it belongs to a different check entirely, under this one's name. So I applied "a guard must be pointed at the thing it claims to guard" to one of the three tests in the cluster, then used that fix as a docstring warrant for the other two. C-90's rewritten Trigger says "Both remaining halves are proof defects" and enumerates two; this was a third of the same class, in the same file, which made the word "Both" falsifiable by a one-line mutation. Fixed by using the harness I had already built for only the direction that cannot fail: `test_the_drift_check_fires_when_a_row_this_partner_reads_rotates` drives the same real check with a rotated row and `pytest.raises`. Both directions now mutation-proven THROUGH THE CHECK: neuter the assertion -> the twin fails; re-add the arrival half -> the silence guard fails. The vouching for the neighbours is deleted rather than repaired; they are still worth having, they are simply not about this. TWO REGISTER CORRECTIONS, one of which is the same mistake inside its own correction. "6 belong to OTHER REPOSITORIES ENTIRELY" — no. Three are caller keys for other consumers; three are platform slots still marked status="planned" that belong to no repository at all, and one of those names "un_fao delivery" among the identities it would be issued for. C-90's original "belong to no repository here" was the careful wording. I had upgraded it, corrected the upgrade, and upgraded it again two sentences from the correction. And a quotation that was never written: the text pointed "further down this entry" for the partition "earning its keep". That phrase appears nowhere in the repository. The real record is 81 lines ABOVE, and says something narrower. Now quoted verbatim, direction fixed. Also: the silence fixture filed a `secret`-class name under `[target]` — harmless, since both sides carry the identical row, but its two neighbours each assert fixture honesty and this one did not. It now picks a genuinely `target`-class name and asserts it. Suite 414 passed / 1 skipped / 40 xfailed, ruff clean. The one failure is #248's. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 13 +++++--- tests/test_env_declaration.py | 52 ++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index ed976c9..504a11c 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -525,8 +525,12 @@ is longer than it wants to be. **The first, now fixed.** The drift check's arrival half ran over every row of every table this package depends on, with no filter for the names it reads. Measured on the live -registry: each partner reads **13 of 25** rows, and **6 belong to other repositories** -(three platform key slots, three caller keys). The other two rows this package does not +registry: each partner reads **13 of 25** rows, and **6 belong to no repository here** — +three caller keys for other consumers, and three platform key slots still marked +`status = "planned"`, one of which names *"un_fao delivery"* among the identities it would +be issued for. C-90's original wording was "belong to no repository here"; an earlier draft +of this paragraph upgraded it to "other repositories entirely", which is the same +overstatement this paragraph exists to correct, two sentences from correcting it. The other two rows this package does not read are its own delivery labels — both declare `producer = "views-postprocessing"` — and they are covered by `tests/test_product.py`, not by this check. *An earlier draft of this paragraph said "8 belong to no repository here", which upgraded a careful claim in C-90 @@ -567,8 +571,9 @@ top-level table upstream must be classified here"* fires on any new table regard whether this repository declares anything about it — and **it has already fired for exactly that reason**: `[edition]` arrived at v1.6.0, an edition the registry's own `[meta]` calls *"additive and opt-in; obliges nobody"*, and it reddened this repository. That event is -recorded further down this entry as the partition "earning its keep", which is true of the -detection and not of the cost. Adopting `[edition].obliges_consumers` is the deferral that +recorded **earlier** in this entry — the partition "went red on it unprompted", "which is +the whole reason that check is directional" — and that record is true of the *detection* +and silent about the *cost*. Adopting `[edition].obliges_consumers` is the deferral that would fix it, and its trigger is below. diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index aa9bb00..edca65d 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -751,7 +751,7 @@ def test_nothing_this_repo_reads_has_changed_since_the_pin(partner): An arrival half failed the surface test and was deleted (#245): it compared every row of every depended-on table, and measured, each partner reads 13 of 25 while 6 belong - to other repositories entirely. It defended itself by claiming ``[contract.*]`` + to no repository here. It defended itself by claiming ``[contract.*]`` "arrived exactly this way and nothing else here would have seen it" — false at table granularity, where ``test_every_table_in_the_registry_is_classified_here`` catches it. @@ -979,10 +979,15 @@ def edition(value: str) -> dict: def test_the_drift_check_is_silent_on_a_row_this_partner_does_not_read(partner, monkeypatch): """The stopping rule above, as a check rather than a paragraph. - The proofs either side of this one are positive — rotation fires, a rename fires. - Nothing asserted the *silence* direction, so a third widening of this check would have - met no objection and the rule forbidding it would have been prose with a guard on the - other half only. That is this file's own recurring defect (C-80, C-82). + Paired with ``test_the_drift_check_fires_when_a_row_this_partner_reads_rotates``, + which is the positive direction through the same harness. Neither is optional: alone, + this one is a check that can only pass, and alone the twin says nothing about what the + check ignores. + + An earlier version of this docstring vouched for the two proofs either side — "rotation + fires, a rename fires" — and neither is about this check. One calls the helper + underneath it; the other belongs to the class check. That is this file's own recurring + defect (C-80, C-82), so the vouching is gone and the twin is real. **It drives the real check**, through the real registry readers, rather than calling the projection helpers underneath it. A first version called ``_describe_changes`` @@ -990,7 +995,12 @@ def test_the_drift_check_is_silent_on_a_row_this_partner_does_not_read(partner, whole suite green, because that half never lived in the helper this was asking. A guard has to be pointed at the thing it claims to guard. """ - mine = sorted(_PARTNER_ENV[partner][2])[0] + expected_class = _PARTNER_ENV[partner][2] + mine = sorted(n for n, cls in expected_class.items() if cls == "target")[0] + assert expected_class[mine] == "target", ( + f"[{partner}] this fixture files {mine} under [target]; if this package no longer " + "classifies it that way the fixture is lying about the registry's shape." + ) pinned = { "meta": {"version": "0.0.0-fixture"}, @@ -1008,6 +1018,36 @@ def test_the_drift_check_is_silent_on_a_row_this_partner_does_not_read(partner, test_nothing_this_repo_reads_has_changed_since_the_pin(partner) +@pytest.mark.parametrize("partner", _PARTNERS) +def test_the_drift_check_fires_when_a_row_this_partner_reads_rotates(partner, monkeypatch): + """The positive twin of the silence guard, through the same real check. + + Without this, the check's entire ``assert not changed`` could be deleted and the suite + would stay green — measured. Its two apparent proofs are not about it: the rotation + proof below calls ``_describe_changes``, the helper underneath, and the rename proof + calls ``_declared_classes``, which belongs to a different check altogether. So the + silence guard was the only test driving this one, and a guard that can only pass is + the same defect as a guard pointed at the wrong subject. + """ + expected_class = _PARTNER_ENV[partner][2] + mine = sorted(n for n, cls in expected_class.items() if cls == "target")[0] + + pinned = { + "meta": {"version": "0.0.0-fixture"}, + "connection": {"APPWRITE_ENDPOINT": {"class": "connection", "value": "e"}}, + "target": {mine: {"class": "target", "value": "at-the-pin"}}, + } + rotated = {**pinned, "target": {mine: {"class": "target", "value": "after-rotation"}}} + + here = sys.modules[__name__] + monkeypatch.setattr(here, "require_sibling", lambda name: Path("/nonexistent")) + monkeypatch.setattr(here, "_registry_at", lambda repo, ref: pinned) + monkeypatch.setattr(here, "_registry_current", lambda repo: rotated) + + with pytest.raises(AssertionError, match=mine): + test_nothing_this_repo_reads_has_changed_since_the_pin(partner) + + @pytest.mark.parametrize("partner", _PARTNERS) def test_the_drift_check_would_catch_a_rename(partner): """A gated test that cannot fail is decoration — so prove this one bites in CI. From 33a38f7bc391be1cd2a77098fcdb7561eb1e814b Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 18:09:50 +0200 Subject: [PATCH 38/42] =?UTF-8?q?fix(tests):=20#245=20review-diff=202=20?= =?UTF-8?q?=E2=80=94=20a=20tautology,=20in=20the=20commit=20that=20fixed?= =?UTF-8?q?=20a=20tautology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both mine, both classes I had just finished correcting elsewhere in the same commit. THE FIXTURE-HONESTY ASSERTION COULD NOT FAIL. I wrote: mine = sorted(n for n, cls in expected_class.items() if cls == "target")[0] assert expected_class[mine] == "target", ... `mine` is SELECTED by the condition it is then asserted to satisfy. True by construction, for every possible input. That is C-90's own first defect — `base = {name: {} for name in _TABLE_ROLE}` then `assert not _unclassified_tables(base)` — reproduced by me in the commit whose whole subject was "nothing proves this check fires". Replaced with the thing that can actually go wrong: assert the selection found anything. If a partner stops declaring a `target` coordinate the fixture has nothing to file and would prove nothing, and now it says so. Proven by pointing the selection at a class that does not exist and watching it fail. AND "THE ROTATION PROOF BELOW" IS ABOVE. It is at :929; the twin is at :1022 — 93 lines below it, not above. Same directional error as the "recorded further down this entry" pointer I fixed in the same commit, which pointed 81 lines the wrong way. Two in one change is a pattern rather than a slip: I describe a file's layout from memory instead of reading it. Suite 414 passed / 1 skipped / 40 xfailed, ruff clean. The one failure is #248's. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_env_declaration.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index edca65d..2672ed6 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -996,11 +996,15 @@ def test_the_drift_check_is_silent_on_a_row_this_partner_does_not_read(partner, guard has to be pointed at the thing it claims to guard. """ expected_class = _PARTNER_ENV[partner][2] - mine = sorted(n for n, cls in expected_class.items() if cls == "target")[0] - assert expected_class[mine] == "target", ( - f"[{partner}] this fixture files {mine} under [target]; if this package no longer " - "classifies it that way the fixture is lying about the registry's shape." + targets = sorted(n for n, cls in expected_class.items() if cls == "target") + assert targets, ( + f"[{partner}] declares no coordinate of class 'target', so this fixture has " + "nothing to file under [target] and would prove nothing. (An earlier version " + "asserted that the chosen name's class was 'target' — true by construction, " + "since the name was selected by that condition. A proof that cannot fail is the " + "defect this entry's own C-90 records.)" ) + mine = targets[0] pinned = { "meta": {"version": "0.0.0-fixture"}, @@ -1024,13 +1028,14 @@ def test_the_drift_check_fires_when_a_row_this_partner_reads_rotates(partner, mo Without this, the check's entire ``assert not changed`` could be deleted and the suite would stay green — measured. Its two apparent proofs are not about it: the rotation - proof below calls ``_describe_changes``, the helper underneath, and the rename proof + proof above calls ``_describe_changes``, the helper underneath, and the rename proof calls ``_declared_classes``, which belongs to a different check altogether. So the silence guard was the only test driving this one, and a guard that can only pass is the same defect as a guard pointed at the wrong subject. """ - expected_class = _PARTNER_ENV[partner][2] - mine = sorted(n for n, cls in expected_class.items() if cls == "target")[0] + targets = sorted(n for n, cls in _PARTNER_ENV[partner][2].items() if cls == "target") + assert targets, f"[{partner}] declares no 'target' coordinate to rotate" + mine = targets[0] pinned = { "meta": {"version": "0.0.0-fixture"}, From 8ca39db96d1fe6633bc24d58690fe849fb2c6b5c Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 18:18:22 +0200 Subject: [PATCH 39/42] =?UTF-8?q?fix(tests):=20#248=20=E2=80=94=20delete?= =?UTF-8?q?=20both=20consumer=20source-reads;=20the=20category=20is=20wron?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE SUITE IS FULLY GREEN AGAIN. 413 passed, zero failures. The failure that has been red on `development` since 2026-08-12 is gone, and it is gone by deleting the check rather than repairing it. TWO BREAKAGES IN TWENTY-FOUR HOURS, BOTH BECAUSE THEIR CODE GOT BETTER. views-faoapi on 11 August, views-crafdapi on the 12th, each refactoring `APIPathManager("literal")` into `APIPathManager(CONSUMER_DOCUMENT_NAME)` — an improvement in both repositories, and a red build here both times. The regex was measuring their code quality and reporting it as our failure. ADR-017 §7 said we were never entitled to depend on another repository's file layout; repairing it a third time would have been repairing the wrong thing. WHAT WENT: `_CONSUMER_PATH_MANAGER`, `_CONSUMER_FILTER`, `_CONSUMER_SELF_CHECK_PENDING`, `_consumer_package`, both tests that used them, and the views-crafdapi checkout from CI — measured first, that fetch had exactly zero remaining consumers. The SIBLINGS entry stays with `ci_checkout=False` and a note naming the record, because CONSUMER_REPO still names that repository; only the fetch is gone. THE SEQUENCING CONSTRAINT DISSOLVED RATHER THAN BEING SATISFIED, and that is the part worth writing down. ADR-017 §5 said a partner's source read is removed only once that partner's consumer-side check lands. views-crafdapi#53 has not landed. The read went anyway, because the constraint was guarding a window in a mechanism that turned out to be the wrong mechanism. §5 now carries an erratum saying so, and a plainer rule: a check that reads another repository's source is not a check this repository builds. THE GAP IS NOW PERMANENT AND UNGUARDED HERE, BY CHOICE. Nothing verifies that a consumer's query uses the name it declares. C-92 states the chain link by link and says plainly that a delivery is verified by two values this platform authored agreeing with each other, plus the consumer's own word. Filed where the fact lives: views-faoapi#390 (under their seam-verification epic #383, whose flagship this is) and views-crafdapi#55 — both with file:line evidence, both noting that `manager.py:117` applies the filter conditionally so a falsy name broadens the query rather than failing, both explicitly not prescribing their internals. Also, while in the file: the module docstring and a comment block each carried a real coordinate value, and pytest prints a module docstring whenever anything in the file fails. Now named, not quoted (C-97). tests/test_product.py 348 -> 253. Suite 413 passed / 1 skipped / 40 xfailed, ruff clean. Closes #248. Unblocks PR #240. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/run_pytest.yml | 9 -- .../017_facts_across_a_private_boundary.md | 16 +++ reports/technical_risk_register.md | 21 ++- tests/conftest.py | 14 +- tests/test_ci_sibling_coverage.py | 5 +- tests/test_product.py | 129 +++--------------- 6 files changed, 65 insertions(+), 129 deletions(-) diff --git a/.github/workflows/run_pytest.yml b/.github/workflows/run_pytest.yml index 94773d6..7e7b354 100644 --- a/.github/workflows/run_pytest.yml +++ b/.github/workflows/run_pytest.yml @@ -67,14 +67,6 @@ jobs: path: _siblings/views-appwrite fetch-depth: 0 - - name: Checkout views-crafdapi (sibling) - uses: actions/checkout@v3 - with: - repository: views-platform/views-crafdapi - ref: main - path: _siblings/views-crafdapi - fetch-depth: 0 - - name: Set up Python uses: actions/setup-python@v4 with: @@ -97,7 +89,6 @@ jobs: - name: Run tests env: VIEWS_APPWRITE: ${{ github.workspace }}/_siblings/views-appwrite - VIEWS_CRAFDAPI: ${{ github.workspace }}/_siblings/views-crafdapi run: | set -e poetry run pytest tests/ diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index d327e68..72d3ec6 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -161,6 +161,22 @@ machine for the private one. Slightly redundant for a while, and redundancy is t price for not having a window where the only thing verifying a delivery label is our own typing. +**Erratum, 2026-08-12: this constraint did not survive contact, and it dissolved rather +than being satisfied.** Both source reads are gone, and neither partner's step 3 is what +removed them. The reads broke twice in twenty-four hours — views-faoapi on 11 August, +views-crafdapi on the 12th — each time because that repository refactored a literal +argument into a named constant, which is an improvement. §7 of this document says we were +never entitled to depend on another repository's file layout; two breakages in a day is +the evidence, and repairing the regex a third time would have been repairing the wrong +thing. + +So the ordering above is superseded by a plainer rule: **a check that reads another +repository's source is not a check this repository builds.** The window the constraint +guarded against is now permanent rather than temporary, and it is registered as **C-92** +rather than sequenced away. What closes it is each consumer proving that its query uses +the name it declares — asked for in views-faoapi#390 and views-crafdapi#55, because that +is a fact only the consumer can hold. + **One assumption worth stating rather than relying on.** The registry is versioned, so the two sides could in principle read it at different editions and both pass while disagreeing. That is not a live risk here because the label is contract-immutable — changing it is an diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 504a11c..767edb8 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -409,7 +409,26 @@ views-faoapi#379 binds their *served-name constant* to the registry row. The ass **A second, structural half.** `_CONSUMER_SELF_CHECK_PENDING` is asserted non-empty and asserted to name only real partners — never asserted to *cover* them. Every other partner map here is two-sided against `PARTNER_PACKAGES`; this one is not, so a third partner is silently exempt from the source read the day it lands. And `SIBLINGS["views-crafdapi"].note` says the fetch "buys nothing and should be removed" once the registry read exists — which PR #239 landed — while ADR-017 §5 forbids retiring that partner's source read until views-crafdapi#53 lands. A maintainer following the note does the thing the ADR forbids, and `test_the_pending_list_is_not_empty` does not object because the map stays non-empty. -Cross-refs: **C-87** (the broader residual this sharpens, not duplicates), ADR-017 §5/§8/Appendix B, views-faoapi#379, views-crafdapi#53. +**Resolved as far as it can be here, 2026-08-12 (#248) — the check is gone, the gap is permanent, and the ask is filed.** + +Both source reads are deleted. Not because the sequencing constraint was satisfied — it **dissolved**: the reads broke twice in twenty-four hours, views-faoapi on 11 August and views-crafdapi on the 12th, each time because that repository refactored a literal argument into a named constant. Their code got better and our test went red. ADR-017 §7 said we were never entitled to depend on another repository's file layout; two breakages in a day is the evidence, and repairing the regex a third time would have been repairing the wrong thing. ADR-017 §5 carries the erratum. + +**The gap is now permanent and unguarded here, by choice.** Nothing in this repository verifies that a consumer's query uses the name it declares. The chain reads: + +| link | owner | held by | +|---|---|---| +| we upload with name N | us | construction | +| N == the registry row | us | `test_product.py::test_the_declared_consumer_name_matches_the_registry` | +| their constant == the registry row | them | views-faoapi's `tests/test_seam_contract_binding.py`; views-crafdapi's equivalent | +| **their query == their constant** | **them** | **nothing** | + +**Filed where the fact lives:** views-faoapi#390 (under their seam-verification epic #383, whose flagship this is) and views-crafdapi#55. Both carry `file:line` evidence, both note that `manager.py:117` applies the name filter *conditionally* so a falsy name broadens the query rather than failing, and both say plainly that we are not prescribing their internals. + +**Verified intact at the time of writing** — the constant reaches the path manager and the manager still filters on it, in both consumers. This is a risk, not an incident. + +**Trigger** is now theirs to clear and ours to notice: when either issue lands, this entry closes for that partner. Until then the honest statement is that a delivery is verified by two values this platform authored agreeing with each other, plus the consumer's own word. + +Cross-refs: **C-87** (the broader residual this sharpens, not duplicates), **C-94** (the producer-side preflight that would close it without asking anyone), ADR-017 §5/§7/§8/Appendix B, views-faoapi#390, views-crafdapi#55. --- diff --git a/tests/conftest.py b/tests/conftest.py index 839acaa..9a093b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,12 +158,16 @@ class Sibling: ), "views-crafdapi": Sibling( env="VIEWS_CRAFDAPI", - ci_checkout=True, + ci_checkout=False, note=( - "PUBLIC, and fetched **temporarily**. Measured: this fetch serves exactly one " - "test, the consumer-name pin. ADR-017 §7 replaces that test with a read of " - "the public registry, at which point this fetch buys nothing and should be " - "removed along with it. Until then it is real coverage, not decoration." + "PUBLIC, and no longer fetched. It served exactly one test — the check that " + "read this consumer's source for its query filter — and #248 deleted that " + "check rather than repairing it: the same read broke twice in 24 hours " + "because both consumers refactored a literal into a named constant, which " + "ADR-017 §7 predicted. The declaration stays because CONSUMER_REPO still " + "names this repository; only the fetch is gone. What the source read used to " + "cover is register C-92, and views-crafdapi#55 is the ask that would close it " + "where the fact lives." ), ), } diff --git a/tests/test_ci_sibling_coverage.py b/tests/test_ci_sibling_coverage.py index 0572e2e..ee0238a 100644 --- a/tests/test_ci_sibling_coverage.py +++ b/tests/test_ci_sibling_coverage.py @@ -181,8 +181,9 @@ def _g4_every_note_names_a_record( **Widened 2026-08-10 after external review.** The rule used to look only at siblings with ``ci_checkout=False``, which left a hole in exactly the place ADR-016 calls out: - `views-crafdapi` is fetched *temporarily*, until the check it serves moves to a - registry read, and that fact lived in a note **no rule examined**. A claim about + `views-crafdapi` was fetched *temporarily*, until the check it served moved to a + registry read, and that fact lived in a note **no rule examined**. (That fetch is gone + as of #248; the rule this incident produced is not.) A claim about another repository, in prose, that nothing can check — which is the failure ADR-016 §3 diagnoses, reintroduced for the one download the document itself calls temporary. diff --git a/tests/test_product.py b/tests/test_product.py index 633f0e1..9fbd848 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -8,9 +8,11 @@ The consumer-name pin is the one that matters most and the one a partner-scoped test could never catch: a document written under a name the consumer does not filter for is -delivered, stored, billed, and invisible. That is not hypothetical here — six -`orange_ensemble` forecast documents sat in `unfao_bucket` for months while FAO's -forecast serving read empty (ADR-013 §11.4 post-adoption record, register C-01). +delivered, stored, billed, and invisible. That is not hypothetical here — six forecast +documents sat in the UNFAO bucket for months, under a name nothing was looking for, while +FAO's forecast serving read empty (ADR-013 §11.4 post-adoption record, register C-01). +*(Named, not quoted: the bucket's declared value is a coordinate, and this docstring is +printed by pytest whenever anything in this file fails — register C-97.)* Parametrised over `tests/conftest.PARTNER_PACKAGES`, so a third partner is caught by `test_clone_readiness.py::test_the_declared_partner_list_is_the_real_one` rather than @@ -35,48 +37,32 @@ #: passes for every possible value, which is the shape of the replica defect epic #181 #: spent a story retiring. So the expected value is written out. #: -#: **But a literal transcribed from another repository is a guarantee, and ADR-014 §1 -#: says a guarantee needs a check.** On its own this pin catches drift authored *here* — -#: which was never the danger. If views-crafdapi changed its filter tomorrow, this file -#: would stay green, the delivery would upload, and the document would be invisible: -#: ADR-013 §4.1a, the defect that stranded six `orange_ensemble` documents in -#: `unfao_bucket` while FAO's forecast serving read empty for months. +#: **A literal transcribed from another repository is a guarantee, and ADR-014 §1 says a +#: guarantee needs a check.** On its own this pin catches drift authored *here*, which was +#: never the danger: if a consumer changed its filter tomorrow this file would stay green, +#: the delivery would upload, and the document would be invisible (ADR-013 §4.1a). #: #: ``test_the_declared_consumer_name_matches_the_registry`` closes that half against the #: **public coordinate registry** rather than the consumer's source (ADR-017 §5). It runs #: for every partner and needs no credential, because the registry is public even when the #: consumer is not — which is why it reaches the FAO partner and its predecessor could not. #: -#: One partner is still ALSO read at the source, and only one: -#: ``_CONSUMER_SELF_CHECK_PENDING`` below names it and the issue that retires it. +#: What it does **not** close is whether the consumer's query uses the name it declares. +#: This repository used to read their source for that; the check broke twice in 24 hours +#: because both consumers refactored a literal into a named constant, and #248 deleted it +#: rather than repairing it. Register **C-92** carries the gap; views-faoapi#390 and +#: views-crafdapi#55 are the asks that would close it where the fact lives. _CONSUMER_DOCUMENT_NAME = { "unfao": "un_fao", "crafd": "un_crafd", } -#: Where the consumer's name lives, and the mechanism that consumes it. Both are -#: pinned: a consumer that kept the string but started filtering on a different field -#: would strand a delivery just as thoroughly as one that renamed it. _PKG = Path(__file__).resolve().parent.parent / "views_postprocessing" -_CONSUMER_PATH_MANAGER = re.compile(r'APIPathManager\(\s*"([a-z0-9_]+)"') -_CONSUMER_FILTER = 'filters["name"] = self.model_path.model_name' - - def _product(partner: str): return importlib.import_module(f"views_postprocessing.{partner}.product") -def _consumer_package(repo: Path) -> Path: - """``src/views_api`` inside a consumer checkout.""" - candidates = sorted((repo / "src").glob("views_*api")) - assert len(candidates) == 1, ( - f"expected exactly one package under {repo}/src, found {candidates}. The " - "consumer's layout changed; this test's assumption about where to look is " - "part of what it asserts." - ) - return candidates[0] - def test_every_partner_has_its_consumer_name_pinned(): """Assert this file's declared scope is the real one (ADR-014 §2).""" @@ -233,8 +219,9 @@ def test_the_declared_consumer_name_matches_the_registry(partner): which is public, so this needs no credential even for the private partner — that is the whole of ADR-017 §5 in one assertion. - *"Neither reads the other" is the end state, not yet the present one:* CRAF'd's source - is still read by the check below, until views-crafdapi#53 lands. + *"Neither reads the other" is now the present state, not just the end state* — #248 + deleted the last source read. What that costs is register C-92: nothing here verifies + that a consumer's query uses the name it declares. The value it guards is the one whose failure is silent: a delivery filed under a name the consumer does not ask for is uploaded, stored, billed, and invisible — no error @@ -264,85 +251,3 @@ def test_the_declared_consumer_name_matches_the_registry(partner): f"[{partner}] the pin in this file disagrees with the registry — the local " "always-on test above should have caught a mismatch with the module first." ) - - -#: Partners whose consumer-side self-check has NOT landed yet, so this repository still -#: reads their source as well as the registry (ADR-017 §5's sequencing constraint). -#: -#: Declared rather than inferred, and deliberately a *shrinking* list: an entry leaves it -#: when that partner's own check lands, and the day it empties this whole mechanism goes. -_CONSUMER_SELF_CHECK_PENDING = { - "crafd": "views-crafdapi#53", -} - - -def test_the_pending_list_is_not_empty_and_names_only_real_partners(): - """An empty map must be a prompt to delete code, not a silent skip. - - ``parametrize`` over an empty collection is a **skip** under pytest's default - ``empty_parameter_set_mark``, and this repository declares no pytest configuration at - all. So emptying this map — by a premature cleanup, or an edit made before - views-crafdapi#53 lands — would retire the only check that reads what a consumer - actually does, and report ``1 skipped`` on a green build. The registry check would - then be comparing two values this platform authored to each other, which is the exact - failure the retained test's docstring warns about. - - So the map is asserted non-empty. When it legitimately empties, this assertion is - what tells you to delete the test, its regexes, and the sibling fetch that serves it. - """ - unknown = sorted(set(_CONSUMER_SELF_CHECK_PENDING) - set(PARTNER_PACKAGES)) - assert not unknown, f"not partners: {unknown}" - assert _CONSUMER_SELF_CHECK_PENDING, ( - "_CONSUMER_SELF_CHECK_PENDING is empty, which means every consumer now checks " - "itself against the registry. That is the end state ADR-017 §5 describes — so " - "finish it: delete test_the_consumer_still_filters_on_the_name_until_it_checks_" - "itself, the _CONSUMER_PATH_MANAGER and _CONSUMER_FILTER patterns, and the " - "views-crafdapi entry in tests/conftest.py::SIBLINGS whose note says that fetch " - "serves exactly this one test. Leaving them costs a clone on every pull request " - "and asserts nothing." - ) - - -@pytest.mark.parametrize("partner", sorted(_CONSUMER_SELF_CHECK_PENDING)) -def test_the_consumer_still_filters_on_the_name_until_it_checks_itself(partner): - """The source-read, kept ONLY for partners whose own check does not exist yet. - - ADR-017 §5 constrains the order: this repository stops reading a consumer's source - when *that consumer* starts checking itself against the registry. Otherwise there is - a window where the registry row is a string a human typed, our check compares our - copy to it, and nothing anywhere consults what the consumer actually does — a green - build proving only that two values this platform authored agree. - - views-faoapi#379 landed on 2026-08-11, so the FAO half of this check is gone. - views-crafdapi#53 has not, so CRAF'd's stays — and with it the crafd sibling fetch, - whose ``note`` in ``tests/conftest.py`` records that it lives or dies with this one - check. - - **This test is meant to be deleted.** When #53 lands, remove that partner from - ``_CONSUMER_SELF_CHECK_PENDING``; when the map empties, remove this test, the regexes - it uses, and the fetch. - """ - repo = require_sibling(CONSUMER_REPO[partner]) - pkg = _consumer_package(repo) - - api = (pkg / "managers" / "api.py").read_text() - found = _CONSUMER_PATH_MANAGER.findall(api) - assert len(found) == 1, ( - f"expected exactly one APIPathManager(...) construction in " - f"{CONSUMER_REPO[partner]}'s managers/api.py, found {found}. This is the fragile " - "half ADR-017 retires — a layout change there breaks a check here, which is what " - "happened to the FAO half on 2026-08-11. It survives only until " - f"{_CONSUMER_SELF_CHECK_PENDING[partner]} lands." - ) - assert found[0] == _CONSUMER_DOCUMENT_NAME[partner], ( - f"{CONSUMER_REPO[partner]} filters on {found[0]!r}; this repository declares " - f"{_CONSUMER_DOCUMENT_NAME[partner]!r}. A delivery under a name the consumer does " - "not ask for is uploaded, stored, and invisible (ADR-013 §4.1a)." - ) - - manager = (pkg / "managers" / "prediction" / "manager.py").read_text() - assert _CONSUMER_FILTER in manager, ( - f"{CONSUMER_REPO[partner]} no longer selects by {_CONSUMER_FILTER!r}. The name " - "may still match while the consumer filters on something else entirely — same " - "invisibility, different cause." - ) From b82fca0204738d294fe091bc0320f0c1b791b8ec Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 18:31:41 +0200 Subject: [PATCH 40/42] =?UTF-8?q?fix(docs):=20#248=20review=20=E2=80=94=20?= =?UTF-8?q?I=20repealed=20a=20rule=20on=20a=20false=20claim=20about=20anot?= =?UTF-8?q?her=20repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deletion is right and the review confirmed it: nothing stranded in code, the workflow edit is clean, all six CI rules still bite. The defect is in the prose, and it is the worst kind — permanent governance text resting on something untrue. VIEWS-CRAFDAPI#53 CLOSED AT 12:15. MY COMMIT WAS 18:18 THE SAME DAY. My ADR-017 §5 erratum says "neither partner's step 3 is what removed them" and my commit message says "views-crafdapi#53 has not landed". Both false, by six hours. Both partners' consumer-side checks HAD landed — the constraint was satisfied, not dissolved. Worse, and more interesting: the crafd commit that satisfied step 3 is THE SAME COMMIT that broke our regex. While binding its served name to the registry, it tidied `APIPathManager("literal")` into `APIPathManager(CONSUMER_DOCUMENT_NAME)`. The improvement and the breakage were one edit, in both repositories, a day apart. The event I narrated as "their code got better and our test went red" IS the sequencing constraint being met. So the erratum is rewritten on honest ground. The outcome stands — the source read had to go — but on §7's argument (we were never entitled to depend on another repository's file layout), not on a false claim that the ordering was bypassed. The constraint is DISCHARGED, not repealed. Writing a repeal into ADR-017 on an untrue claim about another repository is §3's failure inside §5's text, and it is corrected visibly rather than quietly. NINE MORE STALE CLAIMS, all named by the same review, none caught by any test: ADR-017 — §5's body still said "today the check still reads the consumer's source and still skips in CI", seven lines below the erratum saying otherwise. Appendix B still said views-crafdapi#53 was open, that step 4 was "not yet filed", and closed the document with "so no window exists in which the label is verified only against our own typing" — which this change makes permanently false, deliberately. ADR-016 — the §4 table row said the crafdapi fetch "retires when views-crafdapi#53 lands, not before"; §5 explained which download was temporary; the 2026-08-11 update said that partner's source-read and fetch stay until #53 lands. All three now false. Register — C-92's own Location cited three symbols this change deleted and two line numbers past the end of a file that is now 253 lines; its title and Trigger scoped to FAO while its body covers both partners; its "second structural half" is entirely about deleted code. C-87's trigger named #53 as live. C-81 said crafdapi is "now checked out in CI". C-97's headline number was measured at 33 and is now 25 across 8 files — recounted, with a note to re-measure rather than cite it. AND CONSUMER_REPO WAS GUARDING A PRECONDITION OF NOTHING. Its test's docstring said "the gated check below iterates this map"; there is no gated check below. Its failure message described a `require_sibling()` call site that no longer exists. Reframed to what the map is actually for now: addressing — it records who receives each delivery, and it is who C-92's cross-repo asks are sent to. Suite 413 passed / 1 skipped / 40 xfailed, ruff clean. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- .../016_ci_read_access_to_private_siblings.md | 24 +++-- .../017_facts_across_a_private_boundary.md | 87 ++++++++++++------- reports/technical_risk_register.md | 20 ++--- tests/conftest.py | 10 ++- tests/test_product.py | 25 +++--- 5 files changed, 101 insertions(+), 65 deletions(-) diff --git a/docs/ADRs/016_ci_read_access_to_private_siblings.md b/docs/ADRs/016_ci_read_access_to_private_siblings.md index ee77a84..bb0cba9 100644 --- a/docs/ADRs/016_ci_read_access_to_private_siblings.md +++ b/docs/ADRs/016_ci_read_access_to_private_siblings.md @@ -115,7 +115,7 @@ Who is who, once and in one table: | repository | public? | fetched by CI? | why not, if not | |---|---|---|---| | **views-appwrite** | yes | **yes** | — carries the registry drift checks | -| **views-crafdapi** | yes | **yes**, temporarily | — retires when views-crafdapi#53 lands, not before (ADR-017 §5 is per partner) | +| **views-crafdapi** | yes | **no**, since 2026-08-12 | it was fetched for one test; that check read the consumer's source, broke twice in a day when both consumers improved theirs, and was deleted rather than repaired (ADR-017 §7 and its §5 erratum) | | **views-datafactory** | yes | no | its checks need raw data that is not in its git repository; fetching it turns an honest skip into a crash | | **views-faoapi** | **no** | no | private — no amount of workflow configuration reaches it. That case is [ADR-017](017_facts_across_a_private_boundary.md) | @@ -148,12 +148,14 @@ Each rule is a plain function, so each is also run against a deliberately broken to prove it objects. A rule only ever tried against a correct file is a rule nobody has watched fail. -**One of these downloads is temporary, and it is worth knowing which.** `views-crafdapi` is -fetched for exactly one test. [ADR-017](017_facts_across_a_private_boundary.md) §7 replaces -that test with one that reads a public declaration instead — at which point this download -buys nothing and goes. That has been decided and is not yet done; it waits on the -declaration landing in the registry. That is a decision this document cannot make on its own, which -is why it is recorded there and cross-referenced here. +**One of these downloads was temporary, and it is worth knowing what became of it.** +`views-crafdapi` was fetched for exactly one test — a check that read the consumer's own +source. [ADR-017](017_facts_across_a_private_boundary.md) §7 said this repository was never +entitled to depend on another's file layout, and on 11 and 12 August 2026 both consumers +proved it by refactoring a literal into a named constant and reddening this repository +twice in a day. The check was deleted rather than repaired, and the download went with it. +What that costs is register C-92: nothing here verifies that a consumer's query uses the +name it declares. ### §6 A repository CI expects but cannot find turns the build red @@ -311,8 +313,12 @@ the record that owns it. **Update 2026-08-11.** The declaration now exists (views-appwrite#75), views-faoapi checks itself against it (their #379), and this repository's check reads the registry rather than that repository's source. So the FAO half is no longer laptop-only, and no -credential was issued. What remains is CRAF'd's consumer-side check (views-crafdapi#53); -until it lands, that partner's source-read stays, and with it that partner's fetch. +credential was issued. CRAF'd's consumer-side check landed the next day (views-crafdapi#53, closed 2026-08-12). + +**Amended 2026-08-12.** Both source-reads are now gone, and the crafd fetch with them — +but not because those gates opened. The reads broke twice in twenty-four hours, each time +because a consumer improved its own code, so the mechanism was abandoned rather than +sequenced out. ADR-017 §5 carries the erratum; the residual is C-92. --- diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index 72d3ec6..dcff34a 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -161,29 +161,42 @@ machine for the private one. Slightly redundant for a while, and redundancy is t price for not having a window where the only thing verifying a delivery label is our own typing. -**Erratum, 2026-08-12: this constraint did not survive contact, and it dissolved rather -than being satisfied.** Both source reads are gone, and neither partner's step 3 is what -removed them. The reads broke twice in twenty-four hours — views-faoapi on 11 August, -views-crafdapi on the 12th — each time because that repository refactored a literal -argument into a named constant, which is an improvement. §7 of this document says we were -never entitled to depend on another repository's file layout; two breakages in a day is -the evidence, and repairing the regex a third time would have been repairing the wrong -thing. - -So the ordering above is superseded by a plainer rule: **a check that reads another -repository's source is not a check this repository builds.** The window the constraint -guarded against is now permanent rather than temporary, and it is registered as **C-92** -rather than sequenced away. What closes it is each consumer proving that its query uses -the name it declares — asked for in views-faoapi#390 and views-crafdapi#55, because that -is a fact only the consumer can hold. +**Erratum, 2026-08-12. The sequence above was followed, and the check was still the wrong +mechanism. Both facts belong here, and an earlier draft of this erratum got the first one +backwards.** + +Both partners' step 3 landed. views-faoapi#379 merged on 11 August; views-crafdapi#53 +closed at 12:15 on the 12th. So the constraint stated above was **satisfied**, not +bypassed — and the first version of this erratum claimed the opposite, that "neither +partner's step 3 is what removed them". That was false when written, six hours after the +second one landed. It is corrected here rather than quietly, because an ADR that repeals +its own rule on a false claim about another repository is §3's failure inside §5's text. + +**What actually happened is more useful than what that draft said.** In both repositories +the commit that satisfied step 3 was *the same commit* that broke our source-reading +check: each consumer, while binding its served name to the registry, tidied +`APIPathManager("literal")` into `APIPathManager(CONSUMER_DOCUMENT_NAME)`. The improvement +and the breakage were one edit. Twice, a day apart. + +That is the argument for removing the check, and it is **§7's argument, not §5's**: we were +never entitled to depend on another repository's file layout, and a mechanism that a +consumer breaks by improving itself will keep breaking. The ordering rule above did its +job; the thing it was ordering was not worth having. + +So the ordering constraint is not repealed — it is **discharged**, and superseded for this +class by a plainer rule: **a check that reads another repository's source is not a check +this repository builds.** The residual window is now permanent rather than temporary, +registered as **C-92**, and what closes it is each consumer proving its query uses the name +it declares — asked for in views-faoapi#390 and views-crafdapi#55, because that is a fact +only the consumer can hold. **One assumption worth stating rather than relying on.** The registry is versioned, so the two sides could in principle read it at different editions and both pass while disagreeing. That is not a live risk here because the label is contract-immutable — changing it is an amendment, which produces a new edition both sides re-pin to, and our pin's reachability is already checked. Recorded because an unstated assumption carrying a silent-failure mode is -what §1 is about. Today the check still reads the consumer's source -and still skips in CI. The order the three land in, and what is blocked on what, is +what §1 is about. *(Until 2026-08-12 the check also read the consumer's source and +skipped in CI; see the erratum above — that half is gone and its residual is C-92.)* The order the three land in, and what is blocked on what, is Appendix B. Said here because a decision record that reads as a description of the code is how this repository has repeatedly ended up believing work was done. @@ -393,21 +406,31 @@ visibility can be inferred for future APIs. Corrected: **retirement is per partner, and follows that partner's step 3.** For FAO that is now met (views-faoapi#379 merged 2026-08-11), so the FAO source-read goes. For - CRAF'd it is not (views-crafdapi#53 open), so the crafd source-read stays — and with - it the crafd fetch, whose `note` in `tests/conftest.py` already records that it lives - or dies with that one check. + CRAF'd it was not yet, so the crafd source-read stayed — and with it the crafd fetch. + + *(Superseded 2026-08-12. views-crafdapi#53 closed at 12:15 that day, and the crafd + source-read was deleted anyway — not because the gate opened, but because the mechanism + was wrong. Both fetch and check are gone; `tests/conftest.py`'s note records it. See the + §5 erratum.)* 3. **views-faoapi** — verify its own served label against the declaration; §8's other half. Filed as views-faoapi#379, and the consumer-side maintainer has accepted it. -4. **views-crafdapi** — the same self-check for the other partner. **Not yet filed at the - time of writing, and that gap matters**: §7 retires the crafdapi fetch, so without this - leg the CRAF'd label would end up checked against a registry row that nobody checks - against CRAF'd's actual code — no verification against reality at all, which is worse - than the status quo. §7 argues the rule must apply uniformly; uniform application means - uniformly filing the third leg. Sequenced after that partner's data contract settles, - per step 1. - -**Step 3 is a precondition for removing the old check**, though not for adding the new one -— see the sequencing note in §5. The order is safe to interrupt at any point: until step 3 -lands for a partner, that partner's source-reading check stays, so no window exists in which -the label is verified only against our own typing. +4. **views-crafdapi** — the same self-check for the other partner. Landed as + views-crafdapi#53 (closed 2026-08-12), with the *query*-side ask filed the same day as + views-crafdapi#55 alongside views-faoapi#390. + + The reasoning that made this leg necessary still stands: §7 retires the crafdapi fetch, + so without it the CRAF'd label would be checked against a registry row that nobody + checks against CRAF'd's actual code — no verification against reality at all, which is + worse than the status quo. §7 argues the rule must apply uniformly; uniform application + means uniformly filing the third leg. + +**Step 3 was a precondition for removing the old check**, though never for adding the new +one — see the sequencing note in §5. The order was safe to interrupt at any point: until +step 3 landed for a partner, that partner's source-reading check stayed, so no window +existed in which the label was verified only against our own typing. + +**That last sentence stopped being true on 2026-08-12, deliberately.** Both source reads +were deleted once the mechanism — not the ordering — was found wanting, so the window is +now permanent and carried as **C-92**. See the §5 erratum for why the constraint was +discharged rather than repealed. diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 767edb8..8071d55 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -161,7 +161,7 @@ that indexes only deleted code is noise. ## Open Concerns -### C-97: Thirty-three coordinate values sit in docstrings and comments, where the scan deliberately does not look +### C-97: Coordinate values sit in docstrings and comments, where the scan deliberately does not look | Field | Value | |-------|-------| @@ -170,7 +170,7 @@ that indexes only deleted code is noise. | Source | `/code-review max` on #242, 2026-08-12; count reproduced here | | Trigger | **Either.** (a) A check whose failing function contains one of these values starts firing in CI. (b) Someone proposes widening the no-copy scan to docstrings — at which point this entry is the measurement that says what that would cost. | | Owner | This repository. | -| Location | Measured across `git ls-files '*.py'`: 33 standalone occurrences in 9 files, including `views_postprocessing/{unfao,crafd}/managers/`, `contract/store_metadata.py`, `contract/wire/sink.py`. | +| Location | Measured across `git ls-files '*.py'`, **25 standalone occurrences in 8 files** (2026-08-12, down from 33 in 9 when filed — #242 cleared the no-copy scan's own docstring and #248 cleared `tests/test_product.py`). Includes `views_postprocessing/{unfao,crafd}/managers/`, `contract/store_metadata.py`, `contract/wire/sink.py`. **The count moves whenever prose is edited; re-measure rather than cite it.** | The no-copy scan compares string **constants** and excludes docstrings outright. That exclusion is C-57's recorded lesson: an early draft fired on refusal labels and on docstrings naming which store a function serves, and *"a guard that fails on `def file_metadata(record)` gets deleted — after which the real rule is unguarded"* (ADR-014 §3). @@ -388,16 +388,16 @@ Cross-refs: **C-90** (the same module's untested core), **C-88** (why the module --- -### C-92: This repository lost its only check that the FAO consumer still SELECTS by the delivery label +### C-92: Nothing here checks that a consumer SELECTS by the delivery label — for either partner | Field | Value | |-------|-------| | ID | C-92 | | Tier | 2 — the failure mode is invisible by construction and is on the live FAO path. Upload succeeds, storage is billed, the consumer's endpoint returns empty, nothing raises anywhere. | | Source | `/code-review max` on PR #239 post-merge, 2026-08-11 | -| Trigger | views-faoapi changes how `managers/prediction/manager.py` selects — to a category, a metadata field, a query builder — without touching its served-name constant. | +| Trigger | **Either consumer** changes how it selects — to a category, a metadata field, a query builder — without touching its served-name constant. Also live: `manager.py:117` in both drops the name filter entirely when `model_name` is falsy. | | Owner | Shared: views-faoapi owns the check; this repository owns noticing it does not exist. | -| Location | `tests/test_product.py:63` (`_CONSUMER_FILTER`), `:274` (`_CONSUMER_SELF_CHECK_PENDING`), `:344`; `tests/conftest.py:159-167`; ADR-017 §5. | +| Location | `views_postprocessing//product.py::CONSUMER_DOCUMENT_NAME`; `tests/test_product.py::test_the_declared_consumer_name_matches_the_registry` (what remains); ADR-017 §5/§7. Function names, not line numbers. | PR #239 retired the FAO half of the source-reading check on the strength of views-faoapi#379. That was the right sequencing — §5 requires the consumer-side check to land first, and it had. **But the two checks are not the same check.** @@ -407,7 +407,7 @@ views-faoapi#379 binds their *served-name constant* to the registry row. The ass **C-87 is not this.** C-87 records that we verify our copy against the declaration rather than the consumer's code against it. This is narrower and worse: for one partner we briefly had the second check and gave it up for something that does not cover the same failure. -**A second, structural half.** `_CONSUMER_SELF_CHECK_PENDING` is asserted non-empty and asserted to name only real partners — never asserted to *cover* them. Every other partner map here is two-sided against `PARTNER_PACKAGES`; this one is not, so a third partner is silently exempt from the source read the day it lands. And `SIBLINGS["views-crafdapi"].note` says the fetch "buys nothing and should be removed" once the registry read exists — which PR #239 landed — while ADR-017 §5 forbids retiring that partner's source read until views-crafdapi#53 lands. A maintainer following the note does the thing the ADR forbids, and `test_the_pending_list_is_not_empty` does not object because the map stays non-empty. +**~~A second, structural half.~~ MOOT 2026-08-12 (#248)** — `_CONSUMER_SELF_CHECK_PENDING`, `test_the_pending_list_is_not_empty` and the `SIBLINGS` note it describes were all deleted with the mechanism. Left visible because the reasoning still applies to any future map of this shape: `_CONSUMER_SELF_CHECK_PENDING` is asserted non-empty and asserted to name only real partners — never asserted to *cover* them. Every other partner map here is two-sided against `PARTNER_PACKAGES`; this one is not, so a third partner is silently exempt from the source read the day it lands. And `SIBLINGS["views-crafdapi"].note` says the fetch "buys nothing and should be removed" once the registry read exists — which PR #239 landed — while ADR-017 §5 forbids retiring that partner's source read until views-crafdapi#53 lands. A maintainer following the note does the thing the ADR forbids, and `test_the_pending_list_is_not_empty` does not object because the map stays non-empty. **Resolved as far as it can be here, 2026-08-12 (#248) — the check is gone, the gap is permanent, and the ask is filed.** @@ -605,7 +605,7 @@ would fix it, and its trigger is below. | ID | C-87 | | Tier | 2 — the failure mode is invisible by construction. The upload succeeds, the storage is paid for, the consumer's endpoint returns empty, and nothing anywhere raises. That is the shape ADR-013 §4.1a calls *"invisible to the consumer, not merely degraded"*. | | Source | ADR-017 §8, sharpened by external review (#232, #234), 2026-08-10 | -| Trigger | **Either half going missing.** (a) `views-faoapi#379` or `views-crafdapi#53` is closed without the check being written. (b) A private API operated by a **third party** becomes a consumer — at which point the second half cannot be required at all and this becomes permanent. | +| Trigger | **Either half going missing.** (a) ~~`views-faoapi#379` or `views-crafdapi#53` is closed without the check being written.~~ **Both closed WITH the check written** (2026-08-11, 2026-08-12). The live trigger is now (a') either consumer's *query* stops using its declared constant — see **C-92**, and views-faoapi#390 / views-crafdapi#55. (b) A private API operated by a **third party** becomes a consumer — at which point the second half cannot be required at all and this becomes permanent. | | Owner | The consumer-side seats own the check; this repository owns noticing that it exists. | | Location | `views_postprocessing//product.py::CONSUMER_DOCUMENT_NAME`; the check in `tests/test_product.py`; ADR-017 §5, §8, Appendix B. | @@ -613,9 +613,9 @@ ADR-017 decides that the delivery label is declared in the public coordinate reg Its residual is stated plainly in §8 and belongs here rather than only in a document: **we will verify our copy against the declaration, not the consumer's code against it.** If a consumer quietly starts filtering on something else, our check passes and the delivery is invisible exactly as before. -**Why this is a risk and not merely a note.** The second half is real work in repositories this project does not control. `views-faoapi#379` has a willing owner. `views-crafdapi#53` is blocked on that partner's data contract and could sit for a long time. Until both land, the label's agreement with reality rests on the source-reading check — which ADR-017's sequencing deliberately keeps alive for exactly this reason, and which someone could remove believing the registry check replaced it. +**Why this is a risk and not merely a note.** The second half is real work in repositories this project does not control. `views-faoapi#379` has a willing owner. `views-crafdapi#53` is blocked on that partner's data contract and could sit for a long time. Both have now landed. The label's agreement with reality **no longer rests on any source-reading check**: both were deleted on 2026-08-12, not because those gates opened but because a consumer improving its own code broke the mechanism twice in a day (ADR-017 §5 erratum). What remains is C-92 — a consumer's word that its query uses the name it declares. -**What was already prevented.** A reviewer caught that the obvious sequence created a window where the source-reading check was deleted before the consumer-side check existed, leaving a green build proving only that two values this platform authored agreed with each other. ADR-017 now forbids that ordering. This entry exists so the ordering constraint has a home outside the document that states it. +**What was already prevented, and then accepted.** A reviewer caught that the obvious sequence created a window where the source-reading check was deleted before the consumer-side check existed, leaving a green build proving only that two values this platform authored agreed with each other. ADR-017 forbade that ordering, and the ordering was in fact honoured — both partners' consumer-side checks landed before their source-read was removed. The window exists anyway, permanently, because the source-reads were then deleted on their own demerits. This entry exists so that history has a home outside the document that states it. Cross-refs: **C-77** (the same field's producer-side half, resolved), ADR-013 §4.1a, ADR-017 §5/§8/Appendix B, views-appwrite#75, views-faoapi#379, views-crafdapi#53. @@ -979,7 +979,7 @@ Cross-refs: **C-62** (the transitive dependency drag; the other 31 alerts), **C- Nine of the seventeen are **new in this arc**, including both registry-drift detectors (pinned edition, commit-reachable-from-`main`) for both partners. Those detectors have a demonstrated drift rate: they fired **twice on 2026-08-03**, hours apart. A detector for a fault that recurs twice in a day, running only on one machine, is most of the way to not existing. Where each sibling stands, after trying them: -- **views-crafdapi** — public, its check reads source text. **Now checked out in CI**, recovering **one** test: the cross-seam consumer-document-name pin for CRAF'd. +- **views-crafdapi** — public; its check read source text. Checked out in CI from 2026-08-10, recovering **one** test; **that checkout was removed on 2026-08-12** when the check it served was deleted (ADR-017 §5 erratum, C-92): the cross-seam consumer-document-name pin for CRAF'd. - **views-datafactory** — public, but its eight tests need the producer's raw GAUL parquets, which are **not in its git repository**. Checking it out converts an honest skip into a `FileNotFoundError`; tried and reverted. - **views-appwrite** — was private when this was written; **made public 2026-08-08** (`views-appwrite@9d80b75`) and **now checked out in CI**, recovering **seven** tests including both registry-drift detectors. No credential was needed for any of them, and none was ever the obstacle after 2026-08-08 — the obstacle was that this line went on saying "private" for two days after it stopped being true. - **views-faoapi** — **private**, and the only one. Its single check is dark. Closing it needs either a credential or FAO's consent to make that repository public; the second is being pursued, and ADR-016 §8 carries the trigger for falling back to the first. diff --git a/tests/conftest.py b/tests/conftest.py index 9a093b6..1943321 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -179,10 +179,12 @@ class Sibling: #: the second one ``un-crafdapi`` while the repository on disk is ``views-crafdapi`` — #: exactly the kind of near-miss that makes guessing expensive. #: -#: This exists so the consumer-document-name pin can be checked **across the seam** -#: rather than asserted locally. A name this repo declares and the consumer filters on -#: is a fact this repo does not own; declaring it here is right, but only the sibling -#: checkout can confirm it still matches (ADR-014 §1 — the guarantee needs a check). +#: It exists so this repository records **who receives each delivery**. It used to also +#: locate a sibling checkout so the consumer-document-name pin could be read from that +#: consumer's source; #248 deleted that read (ADR-017 §7 — we were never entitled to +#: depend on another repository's file layout, and two consumers proved it in a day by +#: improving theirs). What the map is for now is addressing: it is who register C-92's +#: cross-repo asks are sent to. CONSUMER_REPO = { "unfao": "views-faoapi", "crafd": "views-crafdapi", diff --git a/tests/test_product.py b/tests/test_product.py index 9fbd848..e4b461d 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -63,7 +63,6 @@ def _product(partner: str): return importlib.import_module(f"views_postprocessing.{partner}.product") - def test_every_partner_has_its_consumer_name_pinned(): """Assert this file's declared scope is the real one (ADR-014 §2).""" assert set(_CONSUMER_DOCUMENT_NAME) == set(PARTNER_PACKAGES), ( @@ -100,7 +99,6 @@ def test_upload_interlock_defaults_off(partner): assert _product(partner).UPLOAD_ENABLED is False - @pytest.mark.parametrize("partner", PARTNER_PACKAGES) def test_both_delivery_legs_name_the_document_from_the_declaration(partner): """Register C-77 — the forecast and historical legs must agree, by construction. @@ -150,21 +148,28 @@ def test_both_delivery_legs_name_the_document_from_the_declaration(partner): "checked against the consumer's filter." ) -# ── across the seam: the pin above, checked against the repo that owns the fact ── +# ── across the seam: the pin above, checked against the registry that owns the fact ── -def test_every_partner_has_a_declared_consumer_repository(): - """The gated check below iterates this map; assert it is real (ADR-014 §2).""" +def test_every_partner_names_the_repository_that_consumes_its_delivery(): + """Every partner must name its consumer, and that name must be a declared sibling. + + **No test reads a consumer's checkout any more** (#248), so this no longer guards a + call site. It guards the *addressing*: `CONSUMER_REPO` is how this repository says who + receives each delivery, and it is what the cross-repo asks in register C-92 are + addressed to. A partner with no consumer named is a delivery with no recorded + recipient; a consumer named but absent from `SIBLINGS` is a name nothing else in this + repository can resolve. + """ assert set(CONSUMER_REPO) == set(PARTNER_PACKAGES), ( f"partners with no declared consumer repository: " - f"{sorted(set(PARTNER_PACKAGES) - set(CONSUMER_REPO))}. Without one, that " - "partner's consumer-name pin is never checked against the consumer." + f"{sorted(set(PARTNER_PACKAGES) - set(CONSUMER_REPO))}. Without one, nothing " + "records who receives that partner's delivery." ) undeclared = sorted(r for r in CONSUMER_REPO.values() if r not in SIBLINGS) assert not undeclared, ( - f"consumer repositories with no SIBLINGS entry: {undeclared}. " - "require_sibling() raises KeyError rather than skipping for those, so the " - "check would fail confusingly instead of skipping cleanly." + f"consumer repositories with no SIBLINGS entry: {undeclared}. Every repository " + "this one names must be resolvable from one declaration, or the two lists drift." ) From e806dfefc26694d8d7a24a4099980d2a760b1933 Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 21:53:24 +0200 Subject: [PATCH 41/42] docs(adr-017): check the "same commit" claim instead of asserting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /review-diff on the remediation. The erratum asserted that in BOTH repositories the commit satisfying step 3 was the same commit that broke our source read. I had verified that for views-crafdapi and asserted it for views-faoapi — the exact overstatement this arc keeps producing, in the paragraph correcting an overstatement. Verified now, and it holds: `8615574` (faoapi, #379, closed 11 August) and `0c493ae` (crafd, #53, closed 12:15 on the 12th) each introduce the named constant AND add that repository's registry-binding test. One edit, both effects, both repositories. Both shas are now in the ADR so the next reader can check it in one command rather than trusting the sentence. Also: #379 is an issue and issues close, they do not merge. Cross-document scan for residual contradictions across ADR-016 and ADR-017 — "source-read stays", "fetch stays", "#53 open", "#53 lands", "temporarily", "no window exists" — all zero. Suite 413 passed / 1 skipped / 40 xfailed, ruff clean. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ADRs/017_facts_across_a_private_boundary.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/ADRs/017_facts_across_a_private_boundary.md b/docs/ADRs/017_facts_across_a_private_boundary.md index dcff34a..2710c15 100644 --- a/docs/ADRs/017_facts_across_a_private_boundary.md +++ b/docs/ADRs/017_facts_across_a_private_boundary.md @@ -165,16 +165,17 @@ typing. mechanism. Both facts belong here, and an earlier draft of this erratum got the first one backwards.** -Both partners' step 3 landed. views-faoapi#379 merged on 11 August; views-crafdapi#53 -closed at 12:15 on the 12th. So the constraint stated above was **satisfied**, not +Both partners' step 3 landed. views-faoapi#379 closed on 11 August (commit `8615574`); +views-crafdapi#53 at 12:15 on the 12th (commit `0c493ae`). So the constraint stated above was **satisfied**, not bypassed — and the first version of this erratum claimed the opposite, that "neither partner's step 3 is what removed them". That was false when written, six hours after the second one landed. It is corrected here rather than quietly, because an ADR that repeals its own rule on a false claim about another repository is §3's failure inside §5's text. -**What actually happened is more useful than what that draft said.** In both repositories -the commit that satisfied step 3 was *the same commit* that broke our source-reading -check: each consumer, while binding its served name to the registry, tidied +**What actually happened is more useful than what that draft said, and it is checked +rather than asserted.** In both repositories the commit that satisfied step 3 was *the +same commit* that broke our source-reading check — verified: `8615574` and `0c493ae` each +introduce the named constant and add that repository's registry-binding test: each consumer, while binding its served name to the registry, tidied `APIPathManager("literal")` into `APIPathManager(CONSUMER_DOCUMENT_NAME)`. The improvement and the breakage were one edit. Twice, a day apart. From 5925551860044143fd278111cfc9c0812ec9183e Mon Sep 17 00:00:00 2001 From: Polichinl Date: Wed, 12 Aug 2026 22:22:31 +0200 Subject: [PATCH 42/42] =?UTF-8?q?fix(tests):=20#243=20=E2=80=94=20match=20?= =?UTF-8?q?the=20pair,=20and=20let=20the=20corpus=20decide=20when=20the=20?= =?UTF-8?q?scan=20is=20finished?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE PARSE IS GONE. The scan no longer reads a line, captures what it thinks the value is, and compares. It knows both halves before it starts, so it matches NAME=VALUE directly, with findall rather than one match per line. Every blindness C-57 records came from that parse — the swallowed comment, the missing terminator, the six-character lookbehind, the second assignment on a table row. None is expressible now. The two halves need not correspond: `APPWRITE_X=` is the ordinary copy-paste slip, and it names the coordinate that DECLARES the value rather than the one the line assigns. THE SECRET EXEMPTION IS GONE. An inline name literal, sitting under a comment saying the scope came from the declared partition and not from an inline list. Measured, [secret] carries no values today, so removing it changes nothing now and closes the hole the day one gains a value. THE STOPPING RULE IS A TEST. It enumerates every line in this repository's own tracked markdown that assigns a declared coordinate and fails if the matcher cannot read one. So the form list comes from the corpus: a form no document here uses is not a gap, and a new form is taught in the same change as the document that introduces it. That is what stops a fifth widening, which is what this story was for. ALL FOUR SCOPE MUTATIONS ROUTED FROM #242 ARE CLOSED, by one richer fixture rather than four tests — two copies, two sections, one short, one in markdown. Each element defeats a specific narrowing: stopping at the first finding, reading only `target`, reinstating a length floor, skipping the markdown branch. Measured surviving before, caught after. Plus one small test for a package module that does not parse. AND AN EXCLUSION LIST WAS BUILT AND THEN DELETED, which is the part worth keeping. The plan called for excluding values spelled like this repository's own code. Measured against what the AST branch actually sees — non-docstring string constants — THE COLLISION DOES NOT EXIST: the only registry values appearing that way are the two contract rows, and contract is MIRRORED rather than CONSUMED, so it is never in the scanned set. I had built a narrowing of a security scan for a problem this branch does not have. Deleted; the latent version is recorded in C-97. Register: C-57 gets the mitigation, in one place, and the docstrings point at it rather than restating it — the one-home rule, applied for the first time. tests/*.py 9200 -> 9308. The budget is still the wrong way and I am not going to pretend otherwise; see the story comment. Suite 415 passed / 1 skipped / 40 xfailed, ruff clean. Closes #243. Epic #241. Co-Authored-By: Claude Opus 5 (1M context) --- reports/technical_risk_register.md | 14 +++ tests/test_env_declaration.py | 163 +++++++++++++++++++---------- 2 files changed, 119 insertions(+), 58 deletions(-) diff --git a/reports/technical_risk_register.md b/reports/technical_risk_register.md index 8071d55..f2ebe2d 100644 --- a/reports/technical_risk_register.md +++ b/reports/technical_risk_register.md @@ -1849,6 +1849,20 @@ The shared cause is not a regex bug. **The pattern described one way of writing **The residual, which is deliberate and should not be "fixed".** This remains a *syntax* match. A value merely named in a sentence — "the six stranded documents in ``" — is not a copy and does not fire. An earlier draft that matched any occurrence fired on a dozen documents, and the lesson recorded above applies to itself: a guard that cries wolf gets deleted, after which the real rule is unguarded (ADR-014 §3). The class this cannot catch is a value pasted into prose with no assignment syntax anywhere near it. That is accepted, because the alternative has been tried and was worse. +**Mitigation — landed 2026-08-12 (#243). The parse is gone; the scan matches the pair.** + +The scan no longer parses a line and compares what it captured. It knows both halves before it starts — the declared names and the declared values — so it matches `NAME = VALUE` directly, with `findall` rather than one match per line. Every blindness recorded above came from the parse: the swallowed comment, the missing terminator, the six-character lookbehind, the second assignment on a table row. None is expressible now. + +The two halves need not correspond: `APPWRITE_X=` is the ordinary copy-paste slip and is reported as a copy, naming the coordinate that *declares* the value rather than the one the line assigns. + +**The `secret` exemption is gone.** It was an inline name literal under a comment saying the scope came from the declared partition and not from an inline list. Measured, `[secret]` carries no values today, so removing it changes nothing now and closes the hole the day one gains a value. + +**The stopping rule is a test, not a paragraph.** `test_the_scan_understands_every_assignment_form_this_repo_writes` enumerates every line in this repository's own tracked markdown that assigns a declared coordinate, and fails if the matcher cannot read one. So the form list is derived from the corpus: a form no document here uses is not a gap, and a new form is added in the same change as the document that introduces it. That is what stops the fifth widening. + +**The four scope mutations routed from #242 are all closed**, by one richer fixture rather than four tests: two copies, in two sections, one short, one in markdown. Each element defeats a specific narrowing — stopping at the first finding, reading only `target`, reinstating a length floor, skipping the markdown branch. Plus one small test for a package module that does not parse, which must be refused rather than stepped over. All five measured surviving before, all five caught after. + +**And an exclusion list was built and then deleted**, which is the useful part. The plan called for excluding values spelled like this repository's own code. Measured against what the AST branch actually sees — non-docstring string constants — **the collision does not exist**: the only registry values appearing that way are the two `contract` rows, and `contract` is MIRRORED rather than CONSUMED, so it is never in the scanned set. The list narrowed a security scan for a problem this branch does not have. The residual is latent and recorded in C-97: the day someone writes a package directory name as a bare string constant, the scan will fire on it. + **Why this belongs on C-57 rather than in a new entry.** It is the same guard, the same failure direction, and the same lesson this entry already records one layer down: the earlier amendment found the scan's *scope* was never mutation-proven (it named `unfao` and missed `crafd`); this one finds its *matching* was never proven against the file formats it scans. Scope, matching, and now syntax-variant — three ways for a mutation-proven guard to be proven against the wrong thing. **⚠ CORRECTED 2026-08-12, and the correction is the same mistake one level up.** The amendment immediately above claims "thirteen forms are proven caught" and cites `` set `NAME=value` before … `` as evidence the mid-sentence class is covered. An independent review supplied twenty-nine forms and **fifteen missed**, including the *unbackticked* form of that very example: `set NAME=value before running` captures `'value before running'`. The cited case passes only because of its backticks. Measured, not argued. diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py index 2672ed6..5a59f7c 100644 --- a/tests/test_env_declaration.py +++ b/tests/test_env_declaration.py @@ -1111,6 +1111,7 @@ def _docstring_nodes(tree: ast.AST) -> set[int]: return out + def test_no_coordinate_value_is_copied_into_this_repo(): """The registry's own rule: *"never bake a value into code, an example, or a dataclass default."* Consumers READ and VALIDATE; the launcher supplies values. @@ -1143,7 +1144,7 @@ def test_no_coordinate_value_is_copied_into_this_repo(): # environment. The no-copy rule protects values the launcher supplies; a mirror is # the inverse by construction. scanned_sections = tuple( - name for name, role in _TABLE_ROLE.items() if role == "CONSUMED" and name != "secret" + name for name, role in _TABLE_ROLE.items() if role == "CONSUMED" ) # value -> every coordinate declaring it. A list, because two coordinates may share # a value and measured against the live registry two pairs do. @@ -1156,7 +1157,6 @@ def test_no_coordinate_value_is_copied_into_this_repo(): if isinstance(body.get("value"), str) and body["value"].strip(): declared_by_value.setdefault(body["value"], []).append(name) values = set(declared_by_value) - copied = [] for source in sorted(_PKG.rglob("*.py")): tree = _parsed(source) @@ -1187,46 +1187,21 @@ def test_no_coordinate_value_is_copied_into_this_repo(): # and the identical lesson: when a guard cries wolf, the matching is wrong before the # scope is (ADR-014 §3). # - # The copy is a value **assigned to its own coordinate name** — `APPWRITE_X=value` — - # which is a reader's instruction to configure with that literal. That is precise - # enough to have caught README.md and to ignore every legitimate mention. - # - # **It has been blind in this repository's own house style twice, and both blindnesses - # had the same cause: the pattern described one way of writing markdown.** - # - # 1. `(.+?)\s*$` swallowed an inline comment into the captured value, so - # `NAME=value # note` compared `'value # note'` against the registry and - # matched nothing. README.md's Configuration block is written in exactly that form. - # 2. Anchoring the name at `^\s*` saw only an assignment that *starts a line*. A - # markdown bullet — ``- `NAME=value` `` — a table cell, or an assignment quoted - # mid-sentence were all invisible, and all three are ordinary ways to document - # configuration. The fenced-block form the guard was written against is the one - # form this repository happens to use today. + # A copy is a declared NAME assigned a declared VALUE. Both halves are known before + # the scan starts, so it matches the pair rather than parsing the line and comparing + # what it captured. Three rounds of blindness came from that parse, all of them the + # same shape — the pattern described one dialect of markdown (register C-57). # - # So the name may be preceded by anything that is not part of an identifier, and the - # value ends at whatever terminates it in prose: a comment, a closing backtick, or a - # table pipe. A `#` inside a QUOTED value is legitimate, so the quoted forms are tried - # first and taken whole; only an unquoted value is truncated. + # The two halves need not correspond: `APPWRITE_X=` is + # the ordinary copy-paste slip, and is a copy. # - # This stays a syntax match, deliberately. A value merely *named* in a sentence is not - # a copy — C-57 recorded a draft that fired on a dozen such documents, and a guard that - # cries wolf gets deleted, after which the real rule is unguarded (ADR-014 §3). - assignment = re.compile( - r"(?:^|(?<=[\s`|>*-]))(?:export\s+)?(" + "|".join(sorted(_EXPECTED_NAMES)) + r")\s*=" - r"""\s*(?:"([^"]*)"|'([^']*)'|([^#`|]*?))\s*(?:[#`|].*)?$""" + # It stays a syntax match. A value merely *named* in a sentence is not a copy, and a + # draft that fired on those was deleted for crying wolf (ADR-014 §3, C-57). + names_alt = "|".join(sorted(_EXPECTED_NAMES)) + values_alt = "|".join(re.escape(v) for v in sorted(values, key=len, reverse=True)) + pair = re.compile( + rf"""(?