diff --git a/MANIFEST.sha256 b/MANIFEST.sha256
index 9ebbc88..8315cc3 100644
--- a/MANIFEST.sha256
+++ b/MANIFEST.sha256
@@ -36,7 +36,6 @@
168cd37e606cb1646305d512096290e6aeeb612997a8d0200280f821025dc79d go/objectmodel/errors.go
16a0a5ade85c937b51c4a7e5131eb8b0b9b03e4bfb19dd3d0654c9c7b6f44aa0 conformance/materialization/007_normalize_scalar/meta.yaml
171735e8e595777d10212f048329f6d0ca0885b79df841d55ce34adc2466a26c conformance/materialization/010_normalize_empty_object/input.yaml
-1774b9631c1cf1b7abdb9a651277bb03dc71454524376a482f63d86def993bb1 Makefile
1b3e25bf2122e2d44a8cfc13620797fd129911c5c74d8e1c1db96cee6994f1ce docs/hu/concept/git-management.meta.yaml
1cbf2e8be1e4909a51180757ab9362abe0b8212025a4ea63d60d1f9a4c15ca6a tests/test_tools/test_compiler.yaml
1d446b9b82e3a4e641645bb34a371dd42063f8732044d88108dd1715e9238a83 conformance/validation/008_origin_terms_out_of_order/meta.yaml
@@ -45,7 +44,6 @@
1fb7dd2afe2b230d7c335db5c5da4e72e0ad271ce2dcd1683f446945e0abb917 conformance/materialization/011_discriminator_envelope/input.yaml
2020cd3da7008987b81055f0ef1bc7481567202da7f8bf49d616b1018f52bc21 mk/infra.mk
2063bf8ac333858a9b5c96de552fc4879ca3c35e5d0e34b0dd4067cec94bb5da Dockerfile
-214885986c75f2a6928c9e2a8326804353310ade3834815c5d64f3e74634ddbf project.yaml
21a2f539315cb8500c9d812776d0e777b2eca73d668b3079b694b651fe8539d8 conformance/materialization/012_discriminator_payload_keywords/input.yaml
21a9994fa283ffca9aa0d063526ba4bc8b7d53d781bfbbee0d0041dcc04d143a conformance/invalid/005_origin_in_authoring_input/schema.yaml
21a9994fa283ffca9aa0d063526ba4bc8b7d53d781bfbbee0d0041dcc04d143a conformance/invalid/006_unknown_primitive/schema.yaml
@@ -53,7 +51,6 @@
225e266950c6f85bfd2be20cacb3de0ac011414f92010b831136640ffe9a46be conformance/invalid/007_sealed_missing_path/expected-error.yaml
229376bac93d6b5d81c8c3015b4981999cf3a3c82bf8a8e1d63eecaf61d1e35d conformance/validation/007_origin_duplicate_term/expected-error.yaml
24219339b219898ecb69d09495bc078745cef2c1b5065eedfff886dd95032881 conformance/invalid/010_required_value_missing/expected-error.yaml
-2758d20cf5aa0688060bce28a52786eee272986714a8376d7f190eb2dc90a43e reviews/README.md
275acc4de6f4d5fcd87330039777ceb8f8d8a7933c4e4924eccabe21a9f28da2 conformance/materialization/002_origin_schema/meta.yaml
27fc384a0d7987e4cb311648408b9b5a3995c0b58871ed55389ae154061209c9 conformance/validation/011_more_than_one_document/object.yaml
289ba61b1eec45ea29706ab25d49c8321c537ee7b2a509485bce0d206ef5d808 conformance/validation/004_documentation_member/meta.yaml
@@ -83,6 +80,7 @@
3aa343e865f65a1c38d7caf7e87237e3cb31eff799ecbeb11bf7de1539c0d766 docs/branch-decision.md
3ae763882179fe25648851b319adc020621c33e3d58558a9922cb34be1987bd1 .github/workflows/ci.yml
3b8aa382e06a8c03e74304dca7f8e7de9891c1504a1134b2774e5f84593d0c2e tools/compiler.yaml
+3c810d01d23750a1cd815131cc3f3891257f67155b12b0c69949057a6c3fb3f4 reviews/README.md
3d22e1f391a04e7cbef7dc401c3901a482ab361bdc0879c8b69ab231fe4a1371 conformance/materialization/008_normalize_list/schema.yaml
401e20e529bf4645e1ea75fb59bda0082e8607cee306b0b3e8ceb503945a10f4 conformance/materialization/011_discriminator_envelope/schema.yaml
404cc649efa184f8caa6cc9280cf078081eea60a67257d7bc2996cce3168aaf6 conformance/invalid/011_list_position_scalar_payload/meta.yaml
@@ -99,12 +97,14 @@
475c277c385ff0cb55248bbfaf1888a4dcd86c0d9f2e8661eaffe6616c0a0e60 rust/src/canonical.rs
477943bac345e5da068ae948891595f6c3659961a144dc0f8495836a9bdc84f8 conformance/materialization/005_closure_structured/input.yaml
4798b99c0448d4e338bdb21567d8fee0aef1d0751f49315f3f3fb6ea986f2c38 conformance/materialization/014_hostile_keys/meta.yaml
+47c67a390720dfc0c4879f0a2652302e3b772a0bce73324bd3fbd602b86608df SPEC.md
490aad144271534a93573d970887b60d36e2aa5648f955e522225a5d2ca729c9 conformance/invalid/012_scalar_position_sequence_payload/schema.yaml
490aad144271534a93573d970887b60d36e2aa5648f955e522225a5d2ca729c9 conformance/materialization/007_normalize_scalar/schema.yaml
4d22a4da883cc4a5aa0e0401e5af26d7ae67779a464610343920d8bfcd799ff9 rust/tests/cli.rs
4fe73a4abf70523602e1fded96685b014d67e098f63c9fbdde1b3c26f97a8dcf conformance/invalid/008_duplicate_mapping_key/input.yaml
4fffcdac095c0623fefb8515ab80b3e9629c0febc454456cd08b3cdf3c941720 LICENSE.md
5028d0500342d09e11c1771e2c3500d0710576d82cb08d42273d8d64568d1b91 conformance/materialization/005_closure_structured/expected.yaml
+5171e5e847c0d9a74be405382923c62c6e3bb92947f3f8476f65ec8b4b73e074 project.yaml
51b2d42be6a1e6e2f7449f3c0bf29f5a151910ef79db7d67bfcbe257a3642831 docs/decision-delta.md
528112e3921c47f13c7ec8ef080dd6f59d0b6d1610d17047fba7fb46c69f2e05 tools/mutate.py
534ca57fb760f1e30ec4d790a4e9ab71a228b4d0dbe8ac48c4c0ddf5dbcb37b9 conformance/materialization/006_closure_opaque/meta.yaml
@@ -155,7 +155,6 @@
72c48fa014dea46236640cf52037631aa5db63a6f58807b3bf24577a247aa238 tools/check_doc_links.yaml
72e9b7c69632c1e1877e9f9d69812f3259003989b69f6b0fa7f23612d4514c94 tools/releaselib/git_service.yaml
7426752feb1140b2023192728df35248e4f802617c150bb0523ab97dc1638c0b tools/init-hooks.sh
-753f2eb2a6b70c2e35e76b61baa67b9f9533f8f86713da7b9ff62ab44def6ccb docs/pending-decisions.md
75b59a6cf6a2ce8af4fd04c8a0dcf7d33e5e5f132a48a981252ce61c05cf97c9 docs/hu/makefile-cheatsheet.md
75d086d878abe5943b2dd0c080c0ffb4fd88b517289c216ffa6bc5c1f12056f7 conformance/invalid/003_closure_undeclared/input.yaml
764d101d6bb896af8c4f082768f30f9f7cf77a6f0a0fed940b7b39805330d842 requirements.txt
@@ -179,7 +178,9 @@
8364e5bdf9d568a1c94ea1414673bd584c0fceb125f15690269d3505a5f2e17a conformance/materialization/004_origin_sealed_schema/expected.yaml
83af0f2f2e46f877594a330342b4336cf48b996715939e422c40824e6507aad8 conformance/invalid/001_sealed_yaml_conflict/schema.yaml
83eb6b0f9847e5ac3102a79e49afecb3fe9459f6da0fd2db12e7692bc448615d conformance/validation/001_origin_yaml_schema_conflict/object.yaml
+83f7e5e490bb7a136566df690b940622025c4bf75ef5f76f399c633328545ba9 docs/pending-decisions.md
8511de46e89723d35ea8a6ba6ebd75a86e73a61d4ef5020784217056aa346fee conformance/invalid/001_sealed_yaml_conflict/expected-error.yaml
+857a5ff6fc1905a334ec14d09f8355b4cc8f0d472b381387dcb78603b9620fcc mk/ci.mk
85a21ef03ffdf0f4edd2612ce71c044c95361b808a0d3f93a9ea26ba2f23a8e0 spec/index.yaml
85cc259951a5be28438a1a7fa84d2ec7964e02f36f7ffd846ee886e77c69097d conformance/invalid/004_schema_declares_values_child/schema.yaml
85db5ead37f57de271fde52d9c61048fcaacd80d1d66221f0c121cc2ccb96968 go/objectmodel/entry.yaml
@@ -203,6 +204,7 @@
8de5e87b52c306eae98af3bcf5b940d0e87f90087878cb51efb54518a896503b tools/schemalib/__init__.yaml
8e53ccf0da86dea86513645275f18542579434cce02c8227ad8a8de4c82874ad tests/test_tools/test_releaselib/test_vault_service.yaml
90500311a3cb76038f37c3de19ad25b4b0404ddef84fa7d5706966cf15cc49f2 docs/migration-surface.md
+9148b50133d976376292f4c1b05b569c7d6fe8c20116615c07c933228dd2f361 reviews/cbaf928.disposition.md
91d327f1771e52ad4ffb858b5889338c95bbb340f3419cd996708822c2b1e5d8 conformance/invalid/008_duplicate_mapping_key/expected-error.yaml
91e1b1394a19b9ab15ce1b4600c551341a8ccecd22d9b9cc8e4cbb0124ff6949 conformance/invalid/004_schema_declares_values_child/meta.yaml
92beb5273b4f3b912edfe35ee860eb4e01e6524504ceb5c86ab4dda9e330a291 tools/__init__.yaml
@@ -212,14 +214,12 @@
94dabea4d0bdb7a81c750c4b2830f096c9ee2e59952fd1b9f7d4e235cddeb2f9 mk/golang.mk
96a0612d7f3d7d3faf96bbc408da0b32979eaee2599962e8d6d9e97c15b38d40 conformance/materialization/015_nested_sealed_template/schema.yaml
992423396d17250586acefe1ca589c21d650fb75c7f578bdff9cbb42fa1351fa conformance/invalid/012_scalar_position_sequence_payload/expected-error.yaml
-998c8f3ef914d9447a6f565be986c0c8d3459b55b6a131290609d912b709e542 SPEC.md
9c11fd643268e578437b015dc5a4ea6e5a3f290f0303ac04ea4d429768208617 tools/schemalib/__init__.py
9c2c853fb20be48e4ebbfdf70a98e6890df116fd77fd85a6be2b010674afcaab go/conformance/conformance_test.go
9d0d594b86072a88bbda400992cb62d0b4faf71176a76c8f30c2e632c8eb5fb1 conformance/validation/011_more_than_one_document/meta.yaml
9da86d5094602943eeb486dae2ad45dec5f918b62db8d1783fb70a3bad3223d2 tests/test_tools/test_infra.yaml
9e78d6bee3ce3d4f6f73b296feb1de14dea12909e82220c8b405b359d0c0be84 conformance/materialization/001_origin_yaml/input.yaml
9e78d6bee3ce3d4f6f73b296feb1de14dea12909e82220c8b405b359d0c0be84 conformance/materialization/013_access_inherit_injection/input.yaml
-9e8da6ff0cf2e6a54c8df92c1dce1cf7d66b0a4d22115ea41906b5055c00b058 docs/spec-vector-map.md
9ea1b819377debe7ec47bc8d487a104f5bb991608c4863cda24060264506711d go/objectmodel/schema.go
9f61f29a58d467eb8eb1c40fedb3b5e46342b19eed91fd4fd7233d7da83f510d conformance/invalid/005_origin_in_authoring_input/meta.yaml
a03b3fccfc1f8eb7f76833fdff8a50ea84c80c039db87bc0e41109e96f64420b docs/en/concept/declarative_ecosystem_integration.md
@@ -238,6 +238,7 @@ aa44b2b55d2ced16b5fc9072a97f2a0c0c30a34afd4055b8ef95591e77e02bfd reviews/82c05a
aa44b2b55d2ced16b5fc9072a97f2a0c0c30a34afd4055b8ef95591e77e02bfd reviews/cbaf928.md
aad20e1630b166e5ca74baad558d0f47cc9be1d6b6aea22c23deebeaa75447fc conformance/materialization/007_normalize_scalar/expected.yaml
ac2343c803409938f2414e5833bab1ae33061506fe2678307121837036e3ada2 conformance/invalid/003_closure_undeclared/schema.yaml
+aeb4d33e7a96a9e51e9389eeaa14abf6b663d6dc8eb40c3991c58cc480a7e3ef tools/check_status_claims.py
af1074d4241e3ac01c7b4197ffcef9e270dbde4c4ddccd7b1d6c0aab4a75cbb0 docs/hu/architecture.md
af6cd3c7db5089246c1c7533fb2763351d03dab7c26424a8827e347fdaee618e tools/schemalib/artifact.yaml
b1f4c81b89b74764e424b6f963615944c64b5c11d3e61ef8027334836a51b18d README.md
@@ -254,7 +255,6 @@ b75db4a2182e6ebd8b246deedb6af40900dbc24520eaaec02c4738e9dc98d47a reviews/cbaf92
b78df50dc6d7807172273a4ded207fc85511901446f95b472e4ab26c389b4be7 conformance/validation/002_origin_empty/meta.yaml
b7c2102b45f5be699a817f2b0e5010d03dddfb67bb74a471994dcb0c70d1e167 spec/origin.schema.yaml
b7cc2d3d36c6fef33a7eaa1d13b35465ab293f7c22b6edeb1a86dd2ca834bc87 go/objectmodel/fuzz_test.go
-b92e00b843b3423caab27477ac02305bdd077a1c454a5f0dd142e0a90e8188a4 mk/ci.mk
b9325f58a95216b240f3715a773e34fe8cdfa4d5fe48a5e7b32002a40be5cc2e tests/test_tools/test_finalize_release.py
ba6a2c483d594433ee00f2cc64eec463a68ef2e47babdef5103dff597e3e1773 go/objectmodel/origin.go
ba714d37d58ba80b5a99fe675c9dc1f6ace9fc48a2031846f90e1641f78faef1 conformance/invalid/005_origin_in_authoring_input/input.yaml
@@ -263,6 +263,7 @@ ba894dcea22bdb7388f28a7a403889ded5d340f436e43c961b2ac5ee5f29ba21 go/module/modu
ba8c9ca30ac6149c8a61b177427f2cb4706da1a44ea3d4071695c5cd41aa660d conformance/invalid/009_yaml_alias/expected-error.yaml
bab7c71b85a9603d68c7d7b34086a771f1e59452f0d8e02de38c0b8aa27d929b conformance/validation/003_origin_not_terminal/meta.yaml
bb7dfd3690714b26ace46ee1f6147feac7c62758f6c4046a10b2cc545e217abc go/module/boundary_test.yaml
+bc483249fa73689499646bded7e0e26ff17f2528e93ba1570b292053a5f5f824 docs/spec-vector-map.md
bebbdc2c67e4aba1ca6dbb65f8673ea4e443ad986c94f85346d5bcebfbe4db8c reviews/cbaf928.semantic.md
bfccbd3cd778ac6f0458b7d45d7b25e2cbdc0a789f40e81c1de476f8aab44363 tools/schemalib/loader.py
c077fdb2c25a5db12197280a20de96a1a5b7d59697f26275e0569bd9edf71cbd mk/rust.mk
@@ -293,7 +294,6 @@ d75063538a29c37caad5e4f1ba680c81bc4827ee935e78281ab168b72d5e53a2 tools/check_do
d807555962a3e6a0fb6d885537d89e3e93c864e0452511fa4aa8b1c3a6231ff9 conformance/invalid/009_yaml_alias/input.yaml
d84bf2b93671212d5ef27baed6eff3a8c7c5431273967d1be762b7141696eccc conformance/validation/005_default_member/expected-error.yaml
d90f607cbe23c9f809c121670f33e6d023db0a65f8ce9d7bbf33e2d35655727c go/go.mod
-db8324d0f397a43b44fb910586ad1f767ff5056709609a120d4b635b25f2ca67 tools/check_status_claims.py
dc1bbc3a438b44ae3cc67725dfa916811d24670d4649211987f973aed48b378a go/objectmodel/doc.go
df1a1d134a0917f492882af78775b072430c47dd43008625140ad35e206457e9 tests/test_tools/test_releaselib/test_vault_service.py
e0841977943dbbd886d89bdd7b3fdf9a91405608d2a2060453669919c4679d31 go/objectmodel/primitives.go
@@ -328,12 +328,14 @@ f1d53defce5812b83a7097db81af39bc0b04060d86ddf6e832d21227a793c107 conformance/in
f4c4ae3c4c072dc4f44ca1998323ca7ce27b8473e48f1f328f3e3faf143a5af6 tests/infra/__init__.yaml
f50c73798e9bb5c4920e78fd0b0b099bed9661f841a3092aebd4f55fa197638b conformance/materialization/013_access_inherit_injection/expected.yaml
f5d91d616d254df0e7f6500bc5dff95b2e2f33a66f20a7401d8f1510f5c6bac1 tools/release.sh
+f5f675bf64a455bbde4c32a1dbce7824969701af111ea1120e64541dae4bff3f Makefile
f67767777225305bf92fe4eb14028b1c9270ccb0699c11ddb57a514108ae3088 tools/releaselib/exceptions.yaml
f7a9d92103841ec46c20442f7efc6e14b42212aa16098e851bc471d2d6626a95 conformance/materialization/008_normalize_list/input.yaml
f8a48b6ca4c4744cffd03edb72748831df9b83822989bba2bf72c5665466ea1b conformance/invalid/002_sealed_yaml_schema_conflict/meta.yaml
f8d5eb9a78aad0da576ad083f7345da57620f3ca0c08d317727e353b44e4da49 go/objectmodel/doc.yaml
fa3d02b5fc0677eb715d676e88b98b94016a76eb13e47abfa1040d0e6f12719f docs/rust-gate-extraction.md
fa8afcf490e0b9b1e3ce94789f3233ed73289553ec94f16d2c347afd5e08b367 conformance/invalid/002_sealed_yaml_schema_conflict/input.yaml
+fa97f776c7e1ed99ffd6fd545462fc64f029ae0f3182386889f958152bee60eb tools/check_review_ledger.py
fab14c32130c2345945fa55f41e6614bc443815733249fce81ed7d97e1d1f4cf rust/src/value.rs
fd4ba258680da20f4084db2696fa942c09e47de8ec4650104f2b2e18c53980bc go/module/adversarial_test.go
ff005c27b6c185b065c2121b01fa6f71c9668af920483714c9b137e411cec32c go/objectmodel/emit_test.go
diff --git a/Makefile b/Makefile
index b2d574d..a93f224 100644
--- a/Makefile
+++ b/Makefile
@@ -12,7 +12,7 @@ include mk/ci.mk
-include mk/rust.mk
# ---- Phony ----
-.PHONY: verify verify.fuzz verify.mutate release.subject release.verify review.check status-claims all help validate release test up down shell build fmt lint check typecheck repo.init manifest-verify manifest-update docs.link-check conformance
+.PHONY: verify verify.fuzz verify.mutate release.subject release.verify review.check status-claims review-ledger all help validate release test up down shell build fmt lint check typecheck repo.init manifest-verify manifest-update docs.link-check conformance
# Default to showing help
all: help
@@ -167,6 +167,10 @@ manifest-update: ##manifest-update
# Documentation
# =============================================================================
+review-ledger: ## Verify every review finding is accounted for (INV-046)
+ @echo "--- Review ledger ---"
+ @docker compose exec -T builder python tools/check_review_ledger.py
+
status-claims: ## Verify the documentation's counts and status match the tree
@echo "--- Status claims ---"
@docker compose exec -T builder python tools/check_status_claims.py
@@ -188,7 +192,7 @@ conformance: ## Run the conformance corpus against every present implementation
@echo "--- Running conformance vectors ---"
@ran=0; \
if [ -f go/go.mod ]; then $(MAKE) golang.test && ran=1; fi; \
- if [ -f rust/Cargo.toml ] && [ -f mk/rust.mk ]; then $(MAKE) test-rust && ran=1; fi; \
+ if [ -f rust/Cargo.toml ] && [ -f mk/rust.mk ]; then $(MAKE) rust.test && ran=1; fi; \
if [ "$$ran" -eq 0 ]; then \
echo "NO IMPLEMENTATION PRESENT — 0 vectors executed."; \
echo "The vector corpus exists but is unverified until go/ or rust/ lands."; \
diff --git a/SPEC.md b/SPEC.md
index 0bf835d..4ab1874 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -1206,7 +1206,7 @@ The procedure, and the three commissioning prompts a request carries, are in
## 13. Related documents
- [`docs/pending-decisions.md`](docs/pending-decisions.md) — questions this
- document does not answer, which an implementation has answered anyway. Twelve
+ document does not answer, which an implementation has answered anyway. Fourteen
of them, three answered differently in the two implementations. Read this
before adding a rule: several of these are load-bearing and unwritten.
- [`docs/spec-defects.md`](docs/spec-defects.md) — where this document is not
diff --git a/docs/pending-decisions.md b/docs/pending-decisions.md
index 453b7c6..ef3cb9b 100644
--- a/docs/pending-decisions.md
+++ b/docs/pending-decisions.md
@@ -21,8 +21,48 @@ what remains is here.
---
+## Where the trust boundary is
+
+Three of the decisions below turn on this, and it was not written down
+anywhere, so it is written down here first.
+
+`Materialize` **is** the pre-entry step. It validates authoring input against
+the schema descriptor and injects the declared defaults, marked — the mark is
+`origin`: `[schema]` for a value the model supplied, `[yaml]` for one the author
+wrote. What crosses the module boundary afterwards is the complete extracted
+set, which is what INV-031's seven prohibitions describe.
+
+Two consequences that read the wrong way without it:
+
+- **`Materialize` and `ValidateCanonicalDocument` are the untrusted edge.** The
+ schema validation is not something that happened before them; it is what they
+ are. So the alias bomb and the quadratic scan were real threats at exactly
+ that point, and resource budgets belong there — not at the module boundary,
+ where the set is already complete.
+- **A module never sees an incomplete object.** So "a declared position with no
+ value" is not a state the boundary can be in. It is a pipeline failure, and
+ D-2 follows from that rather than from a preference for strictness.
+
+---
+
## D-1 — Does `scalar_type` constrain a value, or describe it?
+> **DECIDED: constraining, with no coercion.** Part of D-5 rather than separate
+> work, and a prerequisite for it.
+>
+> Not for symmetry. A contract cannot be evaluated without a type: `range: [1,
+> 100]` against the string `"50"` has no answer. If `scalar_type` stays
+> descriptive, the contract evaluator invents its own typing and the model has
+> TWO type systems — one declared and unchecked, one implicit — and when they
+> disagree the rejection arrives at the wrong stage naming the wrong thing.
+>
+> No coercion: `9000` is an integer, `"9000"` is a string, and
+> `scalar_type: integer` refuses the second. Silent coercion in a provenance
+> model is the same failure class as leaving `on`/`off` unquoted.
+>
+> Sequencing: `scalar_type` enforcement lands BEFORE contract evaluation. The
+> other order does not build.
+
**Measured:** neither implementation reads `scalar_type` during validation. It
is parsed, carried, and emitted into the `shape` primitive, and nothing compares
it to the payload. `scalar_type: integer` with a string value materializes and
@@ -44,6 +84,28 @@ question in an emitter is how a specification acquires rules nobody wrote.
## D-2 — What happens to a declared position that is absent, optional, and has no default?
+> **DECIDED: rejection.** Every declared position has a value or a default.
+>
+> This is not a preference for strictness; it follows from the trust boundary
+> above. A complete extracted set crosses into a module, so "a declared position
+> with no value" is not a state that can exist there. Either the pipeline
+> produces a value or it cannot produce the set, and the second is an error.
+>
+> **And `required` keeps a meaning, a sharper one.** It stops being about
+> whether a value exists on the output — one always does — and becomes about
+> where it may come from:
+>
+> `required: true` = the INSTANCE must supply it; a schema default does not
+> satisfy it.
+>
+> Which is expressible in the object itself: a required member may not carry
+> `origin: [schema]`. The keyword is not removed.
+>
+> Refused: **omit** (Go's behaviour) leaves a reader unable to tell "not
+> configured" from "not declared" without the schema. **Null** (Rust's) makes
+> every optional position a node holding a value nobody wrote, and `origin` has
+> no term that is true of it.
+
**Measured:** Go removes the node. Rust materializes a scalar as `null`, a list
as empty, and walks object children. `docs/spec-defects.md` SD-007 already
records three readings — omit, null, reject — and calls none of them decided.
@@ -104,6 +166,22 @@ authored a value; the author did not write this one.
## D-5 — Is `access` a grammar or a shape?
+> **DECIDED: enforce, and enforce `contract` with it.** The document argued
+> these are one question; they are answered as one.
+>
+> `access` gets a closed member set and typed values in §6.4. `contract` gets an
+> evaluator — the primitive §8.7 already requires to be enforced and which
+> nothing has ever read. This is the largest single piece of work in the
+> repository: a grammar, an evaluator, both implementations, and a vector family
+> for each.
+>
+> Refused: declaring both unenforced was cheap and honest, and would have left
+> two primitives that describe guarantees the model does not provide. The
+> current state — a MUST in the specification with no code behind it — is worse
+> than either, because it looks like a guarantee.
+>
+> Depends on D-1, which is the type foundation a contract evaluates against.
+
**Measured:** the operations are checked (`read`, `modify`, and `write` is
refused). Nothing else is: `inherit` may be any value, an operation block may
carry any member, and `rules` may be anything at all.
@@ -126,6 +204,26 @@ checks". They should be decided together or the answer will not be consistent.
## D-6 — What characters may a node name contain?
+> **DECIDED: restrict declared names; opaque payload keys are exempt and not
+> addressable.**
+>
+> Schema-declared names take a declared character set, and a schema declaring
+> `a.b` becomes a schema-load rejection. Because names arrive from a compiled,
+> validated schema descriptor, the restriction is enforceable upstream too and
+> the model only has to state it.
+>
+> An opaque payload's keys are chosen by a foreign system, not by the schema,
+> so they stay unrestricted — and §2.5 will say that INV-040 covers the
+> MODEL's nodes, not the keys of data carried verbatim. Nothing references into
+> an opaque blob: not evidence, not policy. That is what makes the exemption
+> free rather than a hole.
+>
+> Refused: **escape in the address grammar** keeps every name addressable and
+> costs a second byte-level rule to pin and reconcile across two
+> implementations — §8.8.1 took a day. **Restricting opaque keys too** would
+> make §4's "carried verbatim" false for a config blob the model does not even
+> interpret.
+
**Measured:** a child named `a.b` materializes. `Path()` returns
`$.values.a.b`, and `Get` splits it into two segments, so the address does not
round-trip. Key quoting was fixed for serialization; addressing was not, and the
@@ -269,6 +367,76 @@ for a different kind of repository.
---
+## D-13 — Is `make ci` the gate, or part of it?
+
+**Measured:** `README.md` says a green badge and a green local run mean the same
+thing. They do not. CI adds `review.check` for pull requests into `main`, which
+`make ci` deliberately does not run. And the environment is not fixed:
+`Dockerfile` builds `FROM python:3.11-slim` — a moving tag — installs apt
+packages without versions, and downloads the Go toolchain over the network with
+no checksum in the repository. `mk/rust.mk` installs `cargo-llvm-cov` and
+`cargo-deny` unpinned at run time. The Rust base image is the one thing pinned
+by digest.
+
+**Accident on both halves.** The review step was added deliberately and the
+README sentence was not revisited; the base image and toolchain were inherited.
+
+| option | cost |
+|---|---|
+| **Say "shared core gate"** and add a local target that runs what a main-PR runs | free, and honest; the equivalence claim shrinks to what is true |
+| **Pin everything** — digest the Python base, version the apt set, checksum the Go tarball, pin the cargo tools | a reproducible build; every pin becomes something to update |
+| **Both** | the only combination under which "the same pipeline" is a statement about bytes rather than about command names |
+
+**Blocks:** any argument that a green CI run is evidence about a specific tree
+rather than about a tree built from whatever the network served that day.
+**Related:** audit claim F-08.
+
+---
+
+## D-14 — What does `TestVersionIdentity` promise?
+
+**Measured:** `README.md` says it holds *every other declaration* in the
+repository to `SPEC.md`. It checks two Go constants, two fields of
+`spec/index.yaml`, the major and minor of `project.yaml`, and every vector
+schema. It contains **zero** references to `rust/`, which has its own
+`MODEL_VERSION` and its own `Cargo.toml` version. Those agree today; nothing
+makes them.
+
+**Accident**, and a familiar one: the test was written when there was one
+implementation, and its claim was true then.
+
+| option | cost |
+|---|---|
+| **Enumerate the Rust declarations too** | one edit; the list grows by hand every time a declaration site appears, which is the failure mode the test exists to prevent, one level up |
+| **Discover declaration sites** — scan for a registered pattern across the tree | the claim becomes true rather than maintained; needs a convention for what counts as a declaration |
+| **Narrow the README** — name what the test checks | free, and leaves the Rust side unguarded by design rather than by oversight |
+
+**Related:** audit claim F-10.
+
+---
+
+## Status
+
+**Four decided, ten open.**
+
+| decided | choice |
+|---|---|
+| D-1 | `scalar_type` constrains, no coercion — part of D-5 |
+| D-2 | rejection; `required: true` means the instance must supply it |
+| D-5 | enforce `access` AND `contract` |
+| D-6 | restrict declared names; opaque keys exempt and not addressable |
+
+D-1, D-2 and D-6 are cheap to implement and change both implementations. D-5 is
+a sprint: a grammar, a contract evaluator, two implementations, two vector
+families — and D-1 has to land first, because a contract is evaluated against
+a type.
+
+Recommendations exist for the ten still open and are not decisions. D-4
+(`[schema]` for an injected default), D-8 (make the stages and codes normative),
+D-12 (replace the release path) and D-13 (pin the build, narrow the `make ci`
+claim) look to me like they have one defensible answer each; D-3 follows from
+D-5 once the grammar exists; D-7, D-9, D-10, D-11 and D-14 need choosing.
+
## How these get decided
Not by me and not by an implementation. Each one is settled by an entry in
diff --git a/docs/spec-vector-map.md b/docs/spec-vector-map.md
index 1ec6043..2dd207f 100644
--- a/docs/spec-vector-map.md
+++ b/docs/spec-vector-map.md
@@ -65,8 +65,13 @@ for months and "nothing else is" never was.
| INV-043 | The canonical serialization, byte for byte | 8.8.1 | every `materialization/*` vector: the runners compare bytes
`materialization/014_hostile_keys`
`validation/011_more_than_one_document` |
| INV-044 | Canonical member order | 8.8.2 | `materialization/006_closure_opaque`
`materialization/012_discriminator_payload_keywords`
`materialization/013_access_inherit_injection` |
-**32 of 34 invariants are vector-covered.** The remaining
-2 are below.
+**40 of the 46 invariants are vector-covered.** 7 are declared unvectorizable
+with a reason each, below; one invariant appears in both lists because part of
+it is vectorizable and part is not.
+
+These numbers are checked by `tools/check_status_claims.py`. They were "32 of
+34" for months after they stopped being true, because a count copied into prose
+is a claim with no owner.
## Unvectorizable
@@ -77,7 +82,7 @@ a check of a different sort rather than being silently left uncovered.
| Invariant | Why no vector | How it is checked instead |
|---|---|---|
-| INV-031 | The seven prohibitions (a–g) are properties of what crosses a module boundary, and this repository contains no module and no boundary. Each clause is nonetheless enforced *upstream* by an existing vector — the pipeline eliminates the condition before a module could observe it — so the risk is not that a clause is unenforced but that no single test names the boundary itself. | Clause-by-clause upstream coverage: (a) unresolved references — `invalid/003_closure_undeclared`; (b) short forms — `materialization/007_normalize_scalar`, `008`, `009`; (c) templates — `materialization/003_origin_sealed`, `004`; (d) sealed fragments — `invalid/001_sealed_yaml_conflict`; (e) unapplied defaults — `materialization/002_origin_schema`, `010`; (f) unknown primitives — `invalid/006_unknown_primitive`; (g) unvalidated objects — all `validation/*`. A true boundary test is deferred to the sub-jobs, which must add one integration test per clause. |
+| INV-031 | The seven prohibitions (a–g) are properties of what crosses a module boundary. A vector is YAML in, YAML out and cannot construct a boundary crossing, so the clauses are enforced *upstream* — the pipeline eliminates each condition before a module could observe it. | Clause-by-clause upstream coverage: (a) unresolved references — `invalid/003_closure_undeclared`; (b) short forms — `materialization/007_normalize_scalar`, `008`, `009`; (c) templates — `materialization/003_origin_sealed`, `004`; (d) sealed fragments — `invalid/001_sealed_yaml_conflict`; (e) unapplied defaults — `materialization/002_origin_schema`, `010`; (f) unknown primitives — `invalid/006_unknown_primitive`; (g) unvalidated objects — all `validation/*`. **The boundary itself is tested**: `go/module/boundary_test.go` asserts INV-031 clause by clause, and `go/module/adversarial_test.go` attacks it — that is where the split-view and counting forgeries were built. This entry said "this repository contains no module and no boundary" and "a true boundary test is deferred to the sub-jobs" long after `go/module/` existed and both files were written. |
| INV-005 | 0.2 terminates on schema finiteness, and the schema language is a finite literal tree with no references (§8.2 is out of scope). A schema that cannot be walked to a leaf **cannot be written** in it, so no vector can violate the invariant. 0.1 hid this by testing a different and stronger property — name-acyclicity — which the corpus could express; see `spec-defects.md` SD-005. | Nothing to check today, and that is the honest state. When the reference syntax lands (§8.2), finiteness stops being structural and this invariant becomes both violable and vectorizable. INV-005's forward note in §2.3 is the reminder. |
| INV-045 | The conformance corpus is YAML in, YAML out: a vector states what the MODEL does with a document. A release is a property of the repository — which files exist and what they hash to — and there is no authoring input that can violate it. A vector could only test it by containing a copy of the tree it is about. | `make release.verify`, which recomputes the subject from the tree and compares it to the descriptor's claim, and `tests/test_tools/test_release_subject.py`, which builds a temporary repository and asserts that changing one byte of the specification, of a vector, or of an implementation breaks the subject. Run in `ci.gates`, so a tree whose descriptor has gone stale fails before anything else is checked. |
| INV-046 | A review is a person looking at a tree. No authoring input can violate it, and no vector can establish that someone examined something — a corpus can only state what the MODEL does with a document. | `make review.check`, which requires a record in `reviews/` named for the tree's subject digest, run by CI on every pull request into `main`. `tests/test_tools/test_release_subject.py` asserts the binding: a record for a different tree does not count, and one byte of change to the specification makes an existing record stop applying. What no check establishes — that a person did the work, or did it well — is stated as not claimed rather than implied by a green line. |
diff --git a/mk/ci.mk b/mk/ci.mk
index 05640e6..24c5bb9 100644
--- a/mk/ci.mk
+++ b/mk/ci.mk
@@ -62,7 +62,7 @@ ci.deps-drift:
# ci.gates is everything that holds for this repository whether or not an
# implementation exists: integrity, links, code quality, security.
-ci.gates: manifest-verify release.verify status-claims docs.link-check check ci.security
+ci.gates: manifest-verify release.verify status-claims review-ledger docs.link-check check ci.security
# ci.security runs the scanners as their own step rather than hiding inside
# `check`. A security finding should be legible as a security finding.
diff --git a/project.yaml b/project.yaml
index b11451e..2a7201f 100644
--- a/project.yaml
+++ b/project.yaml
@@ -49,7 +49,7 @@ metadata:
#
# Recompute with `make release.subject`; check with `make release.verify`,
# which a third party can run with a clone and a Python and nothing else.
- buildHash: '90f232d2fede1efd83bbdd4b7e6e5928030edaa9b67adf6d67fe300a405ad4c8'
+ buildHash: 'd28fb422fd0d730dea6054a5308e3cf152e5ab6bdeb2b42df341bdcfd48b402f'
cicSign: 'TBD'
cicSignedCA:
certificate: "TBD — filled by the release process with the CIC Root CA certificate"
diff --git a/reviews/README.md b/reviews/README.md
index f6eb18a..aacd75d 100644
--- a/reviews/README.md
+++ b/reviews/README.md
@@ -33,6 +33,22 @@ saw it — a false positive in the test written to check this gate.
An external audit found it by computing both digests. Nothing in the repository
did.
+## The disposition ledger
+
+`.disposition.md` carries one row per finding: closed, partial, or
+open, and where. `make review-ledger` reconciles it against the findings the
+review threads raise and the decisions in
+[`../docs/pending-decisions.md`](../docs/pending-decisions.md), and fails when
+any of the three disagree.
+
+It exists because the first attempt lost two. The decisions document listed
+twelve; `claim/F-08` and `claim/F-10` had been raised, measured and confirmed,
+and then appeared in no decision and no commit. Nothing compared the list to its
+source, so nothing could say so.
+
+A `partial` row is not a closed one. The count that matters for "is this review
+discharged" counts only `closed`.
+
## What is here
Records from the first commissioned review, run as three separate threads
diff --git a/reviews/cbaf928.disposition.md b/reviews/cbaf928.disposition.md
new file mode 100644
index 0000000..4736985
--- /dev/null
+++ b/reviews/cbaf928.disposition.md
@@ -0,0 +1,64 @@
+# Disposition — review of `cbaf928`
+
+One row per finding of the three review threads. `tools/check_review_ledger.py`
+requires that every finding raised in `cbaf928.{claim,semantic,adversarial}.md`
+appears here exactly once, and that every row not marked `closed` names a
+decision that exists in [`../docs/pending-decisions.md`](../docs/pending-decisions.md).
+
+That gate exists because this ledger's first version lost two findings. The
+decisions document listed twelve; `claim/F-08` and `claim/F-10` were in neither
+it nor any commit, and the omission was invisible because nothing compared the
+list to its source. Which is the failure this repository has now found in the
+manifest, in the status claims, in the vector map, and here — a list that looks
+complete because nothing checks it against what it is a list of.
+
+**Status vocabulary**
+
+- `closed` — the finding no longer reproduces; the commit is named.
+- `partial` — the executable half is fixed and the specification half is a
+ decision. Both halves are named. A `partial` row is NOT closed, and the gate
+ treats it as open.
+- `open` — untouched, or a decision only.
+
+| finding | status | where |
+|---|---|---|
+| adversarial/F-01 | closed | Rust `required` enforced — PR #17 |
+| adversarial/F-02 | closed | list position rejects a non-sequence — PR #17 |
+| adversarial/F-03 | closed | scalar position rejects a collection, both — PR #17 |
+| adversarial/F-04 | closed | keys quoted; Go reparses its output — PR #18 |
+| adversarial/F-05 | closed | a primitive member must be a node — PR #20 |
+| adversarial/F-06 | closed | more than one document is refused, both — PR #20 |
+| adversarial/F-07 | closed | boundary reads each method once, `Delivered` — PR #16 |
+| adversarial/F-08 | closed | scan and map insert de-quadratised — PR #22 |
+| adversarial/extra-origin-term | closed | sealed term is exactly two scalars — PR #22 |
+| adversarial/extra-nonstring-keys | closed | Go rejects non-string keys — PR #22 |
+| adversarial/budgets | open | D-11 |
+| claim/F-01 | closed | `reviews/` excluded from the subject — PR #15 |
+| claim/F-02 | partial | schema accepts `repo_type: spec` (PR #22); the release path still does not compute the subject — D-12 |
+| claim/F-03 | partial | four divergences closed with vectors (PR #17, #20, #22, #23); the general equivalence claim remains bounded by the corpus — D-2 |
+| claim/F-04 | partial | serialization quoting closed (PR #18); addressing for names containing `.` — D-6 |
+| claim/F-05 | open | D-9 |
+| claim/F-06 | partial | counts corrected and gated (PR #21, #24); the gate reads `INV-nnn` and not RFC-2119 clauses — D-10 |
+| claim/F-07 | closed | status-claims gate — PR #21, #24 |
+| claim/F-08 | open | D-13 |
+| claim/F-09 | closed | release docs replaced — PR #22 |
+| claim/F-10 | open | D-14 |
+| claim/F-11 | partial | arity enforced (PR #17); `scalar_type` — D-1 |
+| semantic/F-01 | open | D-3 |
+| semantic/F-02 | open | D-4 |
+| semantic/F-03 | open | D-5 |
+| semantic/F-04 | open | D-1 |
+| semantic/F-05 | open | D-2 |
+| semantic/F-06 | partial | nested expansion and innermost origin pinned by `materialization/015` (PR #23); adoption semantics unwritten — D-3 |
+| semantic/F-07 | partial | 64-level bound in both (PR #23); §2.3's finiteness argument still assumes no references — D-7 |
+| semantic/F-08 | partial | term shape tightened (PR #22); the machine schema and the validators have not been compared — D-8 |
+| semantic/F-09 | open | D-6 |
+| semantic/F-10 | partial | keys closed (PR #18); the exact decimal form of a float is unspecified — D-1 |
+| semantic/F-11 | partial | anchors, aliases, duplicate and non-string keys refused (PR #20, #22); tags, `!!binary`, version directives — D-7 |
+| semantic/F-12 | open | D-8 |
+| semantic/F-13 | open | D-8 |
+| semantic/F-14 | open | D-9 |
+| semantic/F-15 | closed | status-claims gate — PR #21, #24 |
+
+**Totals: 14 closed, 10 partial, 13 open.** A partial is not a closed one; the
+count that matters for "is this review discharged" is 14 of 37.
diff --git a/tools/check_review_ledger.py b/tools/check_review_ledger.py
new file mode 100644
index 0000000..bb09242
--- /dev/null
+++ b/tools/check_review_ledger.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Every review finding is accounted for, and every open one names a decision.
+
+A review produces findings; a repository closes some and defers others. The
+deferral is the dangerous half: a finding that is neither fixed nor written down
+has not been decided against, it has been forgotten, and nothing about the tree
+looks different afterwards.
+
+This reconciles three lists that are supposed to describe the same set:
+
+ the findings raised in reviews/.{thread}.md
+ the rows of reviews/.disposition.md
+ the decisions in docs/pending-decisions.md
+
+and fails when they disagree. Specifically:
+
+ * a finding with no ledger row — raised and then lost
+ * a ledger row for a finding nobody raised — a row that outlived its finding
+ * an open or partial row naming a decision that does not exist
+ * a decision nothing refers to — either the finding it came from was closed
+ without updating the row, or the decision was invented
+
+The last two are what happened here. The decisions document listed twelve;
+`claim/F-08` and `claim/F-10` had been raised, measured, confirmed, and then
+appeared in no decision and no commit. Nothing compared the list to its source,
+so nothing could say so — the same shape as the manifest that verified only the
+files it listed, and the status claims that were true when written.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+THREADS = ("claim", "semantic", "adversarial")
+
+
+def repo_root() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def subjects(reviews: Path) -> list[str]:
+ """Every subject that has a disposition ledger."""
+ return sorted(
+ p.name[: -len(".disposition.md")] for p in reviews.glob("*.disposition.md")
+ )
+
+
+def raised(reviews: Path, subject: str) -> set[str]:
+ """Findings the review threads raise, namespaced by thread.
+
+ Namespaced because the threads number independently: `claim/F-01` and
+ `adversarial/F-01` are different findings, and a ledger keyed on `F-01`
+ alone would silently hold one row for two.
+ """
+ out: set[str] = set()
+ for thread in THREADS:
+ path = reviews / f"{subject}.{thread}.md"
+ if not path.is_file():
+ continue
+ for fid in re.findall(
+ r"^#{2,3} (F-\d+)", path.read_text(encoding="utf-8"), re.M
+ ):
+ out.add(f"{thread}/{fid}")
+ return out
+
+
+def ledger(path: Path) -> dict[str, tuple[str, str]]:
+ """finding -> (status, where), read from the disposition table."""
+ rows: dict[str, tuple[str, str]] = {}
+ for line in path.read_text(encoding="utf-8").splitlines():
+ m = re.match(
+ r"^\| ([a-z]+/[A-Za-z0-9-]+) \| (closed|partial|open) \| (.*?) \|$", line
+ )
+ if m:
+ rows[m.group(1)] = (m.group(2), m.group(3))
+ return rows
+
+
+def decisions(path: Path) -> set[str]:
+ return set(re.findall(r"^## (D-\d+) —", path.read_text(encoding="utf-8"), re.M))
+
+
+def main() -> int:
+ root = repo_root()
+ reviews = root / "reviews"
+ pending = root / "docs" / "pending-decisions.md"
+ known = decisions(pending)
+ failures: list[str] = []
+ referenced: set[str] = set()
+ totals = {"closed": 0, "partial": 0, "open": 0}
+
+ found_any = False
+ for subject in subjects(reviews):
+ found_any = True
+ rows = ledger(reviews / f"{subject}.disposition.md")
+ raised_here = raised(reviews, subject)
+
+ # Findings raised outside the numbered headings — a section rather than
+ # an F-nn — are carried in the ledger under a descriptive id. They
+ # cannot be discovered automatically, so they are allowed to exist in
+ # the ledger without a heading, and that indulgence is stated rather
+ # than silent.
+ extra_ok = {k for k in rows if not re.match(r"^[a-z]+/F-\d+$", k)}
+
+ for fid in sorted(raised_here - set(rows)):
+ failures.append(f"{subject}: {fid} was raised and has no ledger row")
+ for fid in sorted(set(rows) - raised_here - extra_ok):
+ failures.append(
+ f"{subject}: ledger has a row for {fid}, which no thread raises"
+ )
+
+ for fid, (status, where) in sorted(rows.items()):
+ totals[status] += 1
+ if status == "closed":
+ continue
+ named = set(re.findall(r"\bD-\d+\b", where))
+ if not named:
+ failures.append(f"{subject}: {fid} is {status} and names no decision")
+ continue
+ for d in named:
+ referenced.add(d)
+ if d not in known:
+ failures.append(
+ f"{subject}: {fid} names {d}, which docs/pending-decisions.md does not define"
+ )
+
+ if not found_any:
+ print("no disposition ledger found; nothing to reconcile")
+ return 0
+
+ for d in sorted(known - referenced):
+ failures.append(
+ f"docs/pending-decisions.md defines {d}, which no open finding refers to"
+ )
+
+ print(
+ f"findings: {totals['closed']} closed, {totals['partial']} partial, "
+ f"{totals['open']} open, over {len(known)} decisions"
+ )
+ print()
+
+ if failures:
+ print("FAIL the review ledger, the findings and the decisions disagree:")
+ for f in failures:
+ print(f" {f}")
+ return 1
+
+ print(
+ "ok every finding has a row, and every open row names a decision that exists"
+ )
+ print()
+ print("A partial is not a closed one. This checks that nothing was lost, not")
+ print("that anything was solved.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/check_status_claims.py b/tools/check_status_claims.py
index d1ccef3..d19c09e 100644
--- a/tools/check_status_claims.py
+++ b/tools/check_status_claims.py
@@ -55,6 +55,19 @@ def measured(root: Path) -> dict[str, int]:
vectors = len(list((root / "conformance").glob("*/*/meta.yaml")))
+ # How many invariants a VECTOR claims, which is the definition
+ # check_spec_vectors.py uses when it reports coverage. Counting the map's
+ # table instead gave a different number for the same fact — two tools
+ # disagreeing about one thing, which is the defect this file exists to
+ # catch, committed while catching it.
+ covered = set()
+ for meta in (root / "conformance").glob("*/*/meta.yaml"):
+ text = meta.read_text(encoding="utf-8")
+ block = re.search(r"^invariants:\s*\[([^\]]*)\]", text, re.M)
+ if block:
+ covered.update(re.findall(r"INV-\d+", block.group(1)))
+ covered &= set(re.findall(r"^\| (INV-\d+) \|", index, re.M))
+
# An implementation counts when it has BOTH sources and a conformance
# runner. A directory with a .gitkeep is not an implementation, and one
# that cannot run the corpus is not evidence about the model.
@@ -66,14 +79,14 @@ def measured(root: Path) -> dict[str, int]:
has_sources = any((root / lang).rglob("*.go")) or any(
(root / lang).rglob("*.rs")
)
- has_runner = (root / runner).exists()
- if has_sources and has_runner:
+ if has_sources and (root / runner).exists():
implementations += 1
return {
"invariants": invariants,
"vectors": vectors,
"implementations": implementations,
+ "covered": len(covered),
}
@@ -88,6 +101,18 @@ def measured(root: Path) -> dict[str, int]:
("README.md", r"pass all (\d+) vectors", "vectors"),
("docs/en/architecture.md", r"SPEC\.md — (\d+) numbered invariants", "invariants"),
("docs/en/architecture.md", r"conformance/ — (\d+) vectors", "vectors"),
+ # The sentence that said "32 of 34" for months. Both halves are checked, so
+ # neither can drift alone.
+ (
+ "docs/spec-vector-map.md",
+ r"\*\*(\d+) of the \d+ invariants are vector-covered",
+ "covered",
+ ),
+ (
+ "docs/spec-vector-map.md",
+ r"\*\*\d+ of the (\d+) invariants are vector-covered",
+ "invariants",
+ ),
# docs/hu/architecture.md states no counts, deliberately: it describes the
# layers and points at the register. A document that does not make a
# numeric claim has nothing here to go stale, which is the cheapest fix