From 13c2b82281e2e45aa8d18e2c3ba58e9589cd0b36 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 15 Aug 2026 18:42:06 +0200 Subject: [PATCH] feat: restate the backup case in ITC-CAP terms; add evidence basis; publish consumption-mode signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things. 1. CANON RESTATEMENT (info-tech-canon's ask after accepting our demand) data/capability/platform-audit-storage.json restates the backup case against ITC-CAP 0.2.0: requirement with profile, targets and the failure-domain constraint that decided the procurement; two provisions (data.object and data.backup); all four data.backup evidence hooks satisfied and measured; and consumption in native units — GB, hours, tokens — with unknown never zero. tools/capability.py reads their capabilities.yaml directly rather than copying it, so drift in either repo fails here. The requirement asks D5, the provision is D4, and the review reports below_requirement rather than inflating maturity. 2. EVIDENCE BASIS (tools/basis.py, docs/evidence-basis.md) Every value declares how it was obtained on an ordered scale: invoiced, measured, quoted, derived, projected, estimated, assumed, unknown. A derived value resolves to the weakest basis among its inputs, so precise arithmetic cannot launder weak assumptions. First application is a finding about our own biggest decision: the Scaleway vs Hetzner comparison, EUR 29.14/month stated to the cent, grades "indicative" — 1 of 4 load-bearing values evidenced, weakest "assumed". The direction is robust; the magnitude is a model output. The cheapest fix is recording real operator hours, not better arithmetic. 3. CONSUMPTION-MODE SIGNAL (railiance-platform RAILIANCE-WP-0017) settlement.py gains a consumption-mode command projecting statements into the signal they consume; make consumption-mode PERIOD=YYYY-MM publishes data/consumption-mode/current.json. Currently an empty list: no live charges for 2026-09, so no entity is restricted. Publishing the empty list makes that an assertion rather than an absence, which their contract distinguishes. The validator fails if the published signal is stale. 185 tests pass. Co-Authored-By: Claude Opus 5 --- Makefile | 11 +- data/capability/platform-audit-storage.json | 217 ++++++++++++++++++ data/consumption-mode/current.json | 1 + docs/evidence-basis.md | 103 +++++++++ .../RESOURCE-canon-restatement-2026-08-15.md | 98 ++++++++ tests/test_basis.py | 147 ++++++++++++ tests/test_capability.py | 167 ++++++++++++++ tools/basis.py | 163 +++++++++++++ tools/capability.py | 207 +++++++++++++++++ tools/settlement.py | 30 ++- tools/validate.py | 22 +- 11 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 data/capability/platform-audit-storage.json create mode 100644 data/consumption-mode/current.json create mode 100644 docs/evidence-basis.md create mode 100644 docs/evidence/RESOURCE-canon-restatement-2026-08-15.md create mode 100644 tests/test_basis.py create mode 100644 tests/test_capability.py create mode 100644 tools/basis.py create mode 100644 tools/capability.py diff --git a/Makefile b/Makefile index 1b0ebea..7414fa9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test forecast variance control-cycle exchange-forecast exchange-usage optimization portfolio-report settlement thresholds +.PHONY: test forecast variance control-cycle exchange-forecast exchange-usage optimization portfolio-report settlement capability consumption-mode thresholds test: python3 -m unittest discover -s tests -p 'test_*.py' @@ -34,6 +34,15 @@ optimization: portfolio-report: python3 tools/portfolio_report.py . +capability: + python3 tools/capability.py + +consumption-mode: + @test -n "$(PERIOD)" || { echo 'PERIOD=YYYY-MM is required' >&2; exit 2; } + python3 tools/settlement.py consumption-mode --period $(PERIOD) \ + > data/consumption-mode/current.json + @echo "wrote data/consumption-mode/current.json for $(PERIOD)" + settlement: @test -n "$(PERIOD)" || { echo 'PERIOD=YYYY-MM is required' >&2; exit 2; } python3 tools/settlement.py close --period $(PERIOD) diff --git a/data/capability/platform-audit-storage.json b/data/capability/platform-audit-storage.json new file mode 100644 index 0000000..26ed2a1 --- /dev/null +++ b/data/capability/platform-audit-storage.json @@ -0,0 +1,217 @@ +{ + "schema_version": "0.1", + "record_scope": "operational", + "canon": { + "model": "ITC-CAP", + "model_version": "0.2.0", + "canon_version": "0.3.0", + "catalog": "info-tech-canon/infospace/models/capability/capabilities.yaml" + }, + "record_id": "capability-case:platform-audit-storage:2026-08", + "created_at": "2026-08-15T00:00:00Z", + "subject": "PostgreSQL backup for rapp-postgres/platform-pg, restated in canon terms", + "note": "Real provisioned resource. Every value carries an evidence basis; a derived value is only as strong as its weakest input.", + + "requires": [ + { + "consumer": "rapp-postgres/platform-pg", + "capability": "data.backup", + "profile": "database", + "minimum_maturity": "D5", + "targets": { + "rpo": { "value": 5, "unit": "minutes" }, + "rto": { "value": 60, "unit": "minutes" }, + "retention": { "value": 30, "unit": "days" } + }, + "constraints": [ + { + "dimension": "geographical_separation", + "predicate": "not_in", + "of": [ + { "kind": "host", "id": "railiance01" }, + { "kind": "provider", "id": "host-europe" } + ] + } + ], + "requirement_note": "The constraint is the one that decided the procurement: the backup must not share a failure domain with the host it protects. Before ITC-CAP 0.2.0 this lived only in prose." + } + ], + + "provisions": [ + { + "provider": "resource:platform:audit-storage", + "capability": "data.object", + "environment": "production", + "maturity": "D4", + "implements": "Scaleway Standard Multi-AZ object storage, nl-ams", + "resource_ref": "data/resources/platform-audit-storage.json", + "maturity_rationale": "Approved for production dependency and in production use since 2026-08-14. Not D5: object-integrity testing is inherited from the backup restore drill rather than measured on this provision.", + "consumes": [ + { + "class": "S", + "name": "stored objects", + "quantity": { "value": 0.0066, "unit": "GB" }, + "period": "month", + "basis": "measured", + "evidence": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#archive", + "observed_at": "2026-08-14", + "measurement_note": "8 objects, 6 604 031 B in prefix platform-pg/, four hours after commissioning" + }, + { + "class": "N", + "name": "restore egress", + "quantity": { "value": null, "unit": "GB" }, + "period": "month", + "basis": "unknown", + "gap": "two scratch restores read from the bucket but bytes transferred were not recorded (owner: resource-control, add to the next drill)" + }, + { + "class": "P", + "name": "object-store service", + "quantity": { "value": 1, "unit": "unit" }, + "period": "month", + "supply": "external", + "basis": "quoted", + "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az", + "measurement_note": "Published list price; no account quote and no invoice yet" + }, + { + "class": "H", + "name": "object-store operation", + "quantity": { "value": 1.0, "unit": "hour" }, + "period": "month", + "supply": "internal", + "basis": "assumed", + "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az.operator_hours_per_month", + "measurement_note": "A planning allowance chosen at model time, not an observed or recorded figure" + }, + { + "class": "I", + "name": "intelligence", + "quantity": { "value": null, "unit": "token" }, + "period": "month", + "basis": "unknown", + "gap": "resource-control does not yet meter token consumption against a provision (owner: resource-control)" + } + ] + }, + { + "provider": "rapp-postgres/platform-pg CNPG barmanObjectStore", + "capability": "data.backup", + "profile": "database", + "environment": "production", + "maturity": "D4", + "implements": "CloudNativePG 1.25 / Barman, PostgreSQL 16.10", + "depends_on_provision": "resource:platform:audit-storage provides data.object", + "maturity_rationale": "D4 and not D5. The requirement asks for D5 (reliability measured and actively controlled). Thresholds are declared in data/thresholds/platform-audit-storage.json, but the evidence behind them is one backup, one full restore, one PITR, and about four hours of operation. wal_archive_gap_minutes is declared and not yet emitted. Reliability is therefore claimed and not yet substantiated. The requirement is not met; the gap is stated rather than the maturity inflated.", + "evidence": [ + { + "hook": "successful_backup", + "basis": "measured", + "value": "platform-pg-daily-20260814181421 completed in 48 s, method barmanObjectStore, verified", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#first_backup" + }, + { + "hook": "successful_restore_test", + "basis": "measured", + "value": "full restore to scratch cluster in 65 s; audit_core.events 30 = 30; PITR to a corrected target in 65 s with keep marker present and destroy absent", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#full_restore,pitr" + }, + { + "hook": "measured_rpo", + "basis": "measured", + "value": "WAL segment archived about 2 s after pg_switch_wal, against a 5-minute archive_timeout", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json#rpo_notes", + "caveat": "A single observation, not a distribution. The target is met on the evidence available." + }, + { + "hook": "measured_rto", + "basis": "measured", + "value": "65 s full-restore RTO, 65 s PITR RTO, on 636 MB of PGDATA", + "observed_at": "2026-08-14", + "ref": "docs/evidence/RESOURCE-WP-0002-T05-restore-2026-08-14.json" + } + ], + "consumes": [ + { + "class": "H", + "name": "backup operation", + "quantity": { "value": null, "unit": "hour" }, + "period": "month", + "supply": "internal", + "basis": "unknown", + "gap": "real operator time was spent on procurement, credential custody, and two restore drills in this period, but no time record was kept (owner: resource-control)" + }, + { + "class": "P", + "name": "credential custody", + "quantity": { "value": 1, "unit": "unit" }, + "period": "month", + "supply": "internal", + "basis": "measured", + "evidence": "OpenBao ClusterSecretStore openbao-backup-object-storage, ExternalSecret synced to databases/platform-pg-backup-s3", + "observed_at": "2026-08-14", + "measurement_note": "security.secrets is a separate capability; this row records only that this provision consumes it" + }, + { + "class": "I", + "name": "intelligence", + "quantity": { "value": null, "unit": "token" }, + "period": "month", + "basis": "unknown", + "gap": "not yet metered (owner: resource-control)" + } + ] + } + ], + + "modelled_alternatives": { + "note": "The provider comparison that produced the decision, graded by evidence basis. The euro figures look precise and are not evidenced: they are derived from quoted list prices, projected demand, and an assumed labour rate.", + "review_period": "2026-08", + "values": [ + { + "name": "scaleway.infrastructure_eur_month", + "value": 7.35, + "basis": "derived", + "derived_from": [ + { "name": "scaleway.storage_eur_per_gb_month", "value": 0.01606, "basis": "quoted", "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az" }, + { "name": "stored_gb.month12", "value": 457.968, "basis": "projected", "evidence": "data/demand/platform-audit-storage.json base scenario, month 12" } + ] + }, + { + "name": "scaleway.labour_eur_month", + "value": 60.0, + "basis": "derived", + "derived_from": [ + { "name": "scaleway.operator_hours_per_month", "value": 1.0, "basis": "assumed", "evidence": "data/providers/object-storage.json#scaleway-standard-multi-az" }, + { "name": "operator_hourly_eur", "value": 60, "basis": "assumed", "evidence": "data/demand/platform-audit-storage.json scenarios[*].operator_hourly_eur" } + ] + }, + { + "name": "hetzner.infrastructure_eur_month", + "value": 6.49, + "basis": "quoted", + "evidence": "data/providers/object-storage.json#hetzner-object-storage monthly_minimum_eur" + }, + { + "name": "hetzner.labour_eur_month", + "value": 90.0, + "basis": "derived", + "derived_from": [ + { "name": "hetzner.operator_hours_per_month", "value": 1.5, "basis": "assumed", "evidence": "data/providers/object-storage.json#hetzner-object-storage" }, + { "name": "operator_hourly_eur", "value": 60, "basis": "assumed", "evidence": "data/demand/platform-audit-storage.json scenarios[*].operator_hourly_eur" } + ] + } + ] + }, + + "open_items": [ + "The requirement asks for data.backup at D5; the provision is D4. Closing it needs a drill cadence, an emitted wal_archive_gap_minutes, and more than one backup.", + "Class I consumption is unknown on both provisions. resource-control does not meter tokens against a provision yet.", + "Class H on the data.backup provision is unknown: effort was spent and not recorded. A time record starts in 2026-09.", + "No invoiced basis exists anywhere in this record. The first booked Scaleway cost from fin-hub (FIN-WP-0004) would be the first." + ] +} diff --git a/data/consumption-mode/current.json b/data/consumption-mode/current.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/data/consumption-mode/current.json @@ -0,0 +1 @@ +[] diff --git a/docs/evidence-basis.md b/docs/evidence-basis.md new file mode 100644 index 0000000..5c36500 --- /dev/null +++ b/docs/evidence-basis.md @@ -0,0 +1,103 @@ +# Evidence basis — where we know, and where we guess + +A counted object and an assumed hourly rate are both numbers. They are not both +knowledge. Every quantity in this repository declares **how it was obtained**, +so a decision can be graded by the weakest thing it actually rests on. + +Vocabulary and propagation: `tools/basis.py`. + +## The scale + +Ordered strongest to weakest. The order is the point — it is what makes +"weakest input wins" computable. + +| Basis | Meaning | Example here | +|---|---|---| +| `invoiced` | A booked financial fact, authoritative from fin-hub | none yet | +| `measured` | Directly observed from the authoritative system | 8 objects, 6 604 031 B in the backup prefix | +| `quoted` | Stated by a provider or counterparty in a citable source | Scaleway €0.01606/GB-month | +| `derived` | Computed from other values by a stated rule | €7.35/month infrastructure | +| `projected` | Interpolated between, or extrapolated beyond, observations | 457.968 GB stored at month 12 | +| `estimated` | Human judgement, neither observed nor computed | apps-pg 6 operator-hours setup | +| `assumed` | A modelling constant we chose | €60/hour operator rate | +| `unknown` | No value exists | railiance01 monthly price | + +`invoiced`, `measured`, and `quoted` are **evidenced**: they assert an observed +or contracted fact. Everything below them is inference. + +## The propagation rule + +> A derived value is only as strong as its weakest input. + +Without this rule a precise-looking figure launders weak assumptions. The +monthly labour line for the backup store is €60.00, which reads like a +measurement. It is `derived` from an `assumed` operator-hour allowance and an +`assumed` hourly rate, so it resolves to **`assumed`** — the weakest class of +input we have. + +Deriving in the other direction is not penalised: GB computed from measured +bytes resolves to `measured`, because both inputs are. + +## Two orthogonal qualifiers + +**`proxy_for`** — the value is a genuine measurement of a *different* quantity +than the one being reasoned about. It does not weaken the measurement; it +weakens the inference. The apps-pg allocation driver measures database bytes +accurately, and one of those databases has never recorded a transaction, so as +a driver for cost it is a proxy whose validity is separate from its precision. + +**`observed_at`** — a measurement has an age. A single sample from a +four-hour-old resource is `measured` and still thin. + +## Unknown is never zero + +A value with basis `unknown` carries no quantity and must name the gap and its +owner. A value with any other basis must carry a quantity. Zero means "measured +and none"; unknown means "not measured". The validator enforces both directions. + +## Decision grades + +`decision_grade(values)` reports the distribution, the weakest basis, the +evidenced ratio, and a grade: + +| Grade | Condition | +|---|---| +| `evidenced` | Every load-bearing value is invoiced, measured, or quoted | +| `projected` | The conclusion rests on values projected from observations | +| `indicative` | The conclusion is no stronger than an estimated or assumed value | +| `insufficient` | At least one load-bearing value is unknown | + +### The first real application + +The provider comparison that selected Scaleway over Hetzner — a €29.14/month +difference stated to the cent — grades **`indicative`**: + +``` +by_basis: {quoted: 1, projected: 1, assumed: 2} +weakest: assumed +evidenced_ratio: 0.25 +``` + +One of four load-bearing values is evidenced. The two labour figures, which are +what actually inverted the ranking, are both `assumed` on top of `assumed`. + +This does not overturn the decision. The direction is robust — Hetzner needs +more operator time under any plausible rate, and the alternative was five times +dearer. But it does say plainly that the *magnitude* is a model output, not a +measurement, and that the cheapest way to strengthen it is to record real +operator hours rather than to refine the arithmetic. + +That is the whole purpose of the scale: to make the difference between a firm +conclusion and a confident-looking one visible before it is acted on. + +## Where it applies today + +- `data/capability/*.json` — every consumption row and evidence item. +- Provider comparisons carried in a capability restatement. + +Not yet retrofitted: `data/actuals/`, `data/control-cycle/`, and the option +fields of `data/optimization/`. Those already distinguish known from unknown and +carry `measurement_gaps` and `uncertainty` notes, but they do not yet grade a +present value. That is the next step, and it is the one that would let +`make thresholds` report a breach alongside the strength of the evidence +producing it. diff --git a/docs/evidence/RESOURCE-canon-restatement-2026-08-15.md b/docs/evidence/RESOURCE-canon-restatement-2026-08-15.md new file mode 100644 index 0000000..bea6490 --- /dev/null +++ b/docs/evidence/RESOURCE-canon-restatement-2026-08-15.md @@ -0,0 +1,98 @@ +# Backup case restated in ITC-CAP terms — 2026-08-15 + +## Result + +info-tech-canon accepted `demand/CapabilityProvisionEconomics.md` in full and +shipped canon 0.3.0 / ITC-CAP 0.2.0. They asked resource-control to restate the +backup case against the new shape, and said they would treat that restatement as +the real-provision proof for their §10 promotion requirement 3 rather than +inventing a worked example. + +Delivered: + +- `data/capability/platform-audit-storage.json` — the restatement +- `tools/capability.py`, `make capability` — validation against the live catalog +- `tools/basis.py`, `docs/evidence-basis.md` — the evidence-basis vocabulary +- 44 tests across `tests/test_capability.py` and `tests/test_basis.py` + +`tools/capability.py` reads +`info-tech-canon/infospace/models/capability/capabilities.yaml` directly rather +than copying it. Every capability id, profile, quality dimension, maturity level, +resource class, native unit, and constraint predicate we use is checked against +what the canon actually declares, so drift in either repository fails here. + +## What the restatement says + +**The requirement is now expressible.** The constraint that decided the +procurement — the backup must not sit in the failure domain of the host it +protects — was prose in an acceptance-requirements section. It is now: + +```yaml +constraints: + - dimension: geographical_separation + predicate: not_in + of: [{kind: host, id: railiance01}, {kind: provider, id: host-europe}] +``` + +**Two provisions, not one.** `resource:platform:audit-storage` provides +`data.object`; the CNPG/Barman configuration provides `data.backup` and depends +on it. This is the layering our resource records already had and could not name. + +**The requirement is not met, and the record says so.** The requirement asks for +`data.backup` at D5. The provision is D4. Thresholds are declared, but the +evidence behind them is one backup, one full restore, one PITR, and about four +hours of operation; `wal_archive_gap_minutes` is declared and not yet emitted. +Reliability is claimed, not substantiated. The review reports +`status: below_requirement` rather than inflating the maturity to match. + +**All four `data.backup` evidence hooks are satisfied** — +`successful_backup`, `successful_restore_test`, `measured_rpo`, `measured_rto` — +each `measured`, each pointing at the T05 evidence file. The `data.object` +provision is honestly incomplete: it satisfies neither `object_integrity_tests` +nor `availability_metrics`. + +**Consumption is in native units.** Storage in GB, human effort in hours, tokens +for intelligence, and `P` for the purchased service — with unknown recorded as +unknown and never as zero. Two rows the old model could not have carried: + +- `H` on the `data.backup` provision is `unknown`, because real operator time + was spent on procurement, credential custody, and two restore drills and no + time record was kept. A time record starts in 2026-09. +- `I` is `unknown` on both provisions. We do not meter tokens against a + provision yet. + +## The evidence-basis addition + +Beyond what the canon asked for, every value in the restatement declares how it +was obtained, on an ordered scale from `invoiced` through `measured`, `quoted`, +`derived`, `projected`, `estimated`, `assumed`, to `unknown`. A derived value +resolves to the weakest basis among its inputs. + +The rule earns its place immediately. The provider comparison that produced the +decision — Scaleway over Hetzner by €29.14/month, stated to the cent — grades: + +| | | +|---|---| +| by basis | `quoted` 1, `projected` 1, `assumed` 2 | +| weakest | `assumed` | +| evidenced ratio | 0.25 | +| **grade** | **`indicative`** | + +One of four load-bearing values is evidenced. The two labour figures — the ones +that actually inverted the ranking — are `derived` from an `assumed` hour count +and an `assumed` rate, so they resolve to `assumed`. + +This does not overturn the decision. The direction is robust under any plausible +rate, and the remaining alternative was five times dearer. It does say that the +*magnitude* is a model output rather than a measurement, and it identifies the +cheapest way to strengthen it: record real operator hours, not better arithmetic. + +Note that this is a finding about our own most consequential decision, produced +by a mechanism that did not exist yesterday. It is the intended effect. + +## Boundary held + +No canon artifact was copied into this repository, and nothing was written into +info-tech-canon for this restatement. The capability catalog stays theirs; the +provisioned facts stay ours; the join is by capability id, which is exactly the +durable interface CAP-R5 describes and the reason the canon was not split. diff --git a/tests/test_basis.py b/tests/test_basis.py new file mode 100644 index 0000000..f70b31c --- /dev/null +++ b/tests/test_basis.py @@ -0,0 +1,147 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1] / "tools")) +from basis import ( + BASIS_ORDER, + decision_grade, + is_evidenced, + profile, + rank, + resolve, + strongest, + validate_value, + weakest, +) + + +def value(basis="measured", **overrides): + result = {"name": "v", "basis": basis, "value": 1} + if basis == "unknown": + result = {"name": "v", "basis": basis, "value": None, "gap": "not measured (owner: x)"} + result.update(overrides) + return result + + +class OrderTest(unittest.TestCase): + def test_order_runs_strongest_to_weakest(self): + self.assertEqual("invoiced", BASIS_ORDER[0]) + self.assertEqual("unknown", BASIS_ORDER[-1]) + self.assertLess(rank("measured"), rank("estimated")) + self.assertLess(rank("estimated"), rank("assumed")) + + def test_weakest_and_strongest_pick_opposite_ends(self): + bases = ["measured", "assumed", "quoted"] + self.assertEqual("assumed", weakest(bases)) + self.assertEqual("measured", strongest(bases)) + + def test_empty_collection_is_unknown_not_an_error(self): + self.assertEqual("unknown", weakest([])) + + def test_only_observed_or_contracted_bases_count_as_evidenced(self): + for basis in ("invoiced", "measured", "quoted"): + self.assertTrue(is_evidenced(basis)) + for basis in ("derived", "projected", "estimated", "assumed", "unknown"): + self.assertFalse(is_evidenced(basis)) + + def test_unknown_basis_name_is_rejected(self): + with self.assertRaises(ValueError): + rank("vibes") + + +class PropagationTest(unittest.TestCase): + """A derived value is only as strong as its weakest input.""" + + def test_derivation_from_measured_inputs_stays_measured(self): + derived = value("derived", derived_from=[value("measured"), value("measured")]) + self.assertEqual("measured", resolve(derived)) + + def test_one_assumed_input_drags_the_result_down(self): + derived = value("derived", derived_from=[value("quoted"), value("assumed")]) + self.assertEqual("assumed", resolve(derived)) + + def test_propagation_is_recursive(self): + inner = value("derived", derived_from=[value("measured"), value("estimated")]) + outer = value("derived", derived_from=[value("measured"), inner]) + self.assertEqual("estimated", resolve(outer)) + + def test_derived_without_inputs_is_rejected(self): + with self.assertRaisesRegex(ValueError, "derived_from"): + resolve(value("derived")) + + def test_non_derived_value_resolves_to_itself(self): + self.assertEqual("quoted", resolve(value("quoted"))) + + +class ValidationTest(unittest.TestCase): + def test_unknown_must_not_carry_a_quantity(self): + with self.assertRaises(ValueError): + validate_value({"basis": "unknown", "value": 0, "gap": "g"}) + + def test_unknown_must_name_the_gap_and_owner(self): + with self.assertRaisesRegex(ValueError, "name the gap"): + validate_value({"basis": "unknown", "value": None}) + + def test_known_basis_must_carry_a_quantity(self): + with self.assertRaisesRegex(ValueError, "must carry a quantity"): + validate_value({"basis": "measured", "value": None}) + + def test_zero_is_a_measurement_not_an_absence(self): + validate_value({"basis": "measured", "value": 0}) + + def test_derived_from_only_allowed_on_derived(self): + with self.assertRaisesRegex(ValueError, "only a derived value"): + validate_value(value("measured", derived_from=[value()])) + + def test_empty_proxy_target_is_rejected_but_absent_one_is_fine(self): + with self.assertRaisesRegex(ValueError, "proxy_for"): + validate_value(value("measured", proxy_for=" ")) + validate_value(value("measured", proxy_for=None)) + validate_value(value("measured", proxy_for="cost of the shared host")) + + +class DecisionGradeTest(unittest.TestCase): + def test_all_evidenced_values_grade_evidenced(self): + result = decision_grade([value("measured"), value("invoiced"), value("quoted")]) + self.assertEqual("evidenced", result["grade"]) + self.assertEqual(1.0, result["evidenced_ratio"]) + + def test_a_single_assumption_makes_the_whole_decision_indicative(self): + result = decision_grade([value("measured"), value("measured"), value("assumed")]) + self.assertEqual("indicative", result["grade"]) + self.assertEqual("assumed", result["weakest"]) + + def test_an_unknown_makes_the_decision_insufficient(self): + result = decision_grade([value("measured"), value("unknown")]) + self.assertEqual("insufficient", result["grade"]) + + def test_precise_arithmetic_does_not_upgrade_weak_inputs(self): + """The provider comparison: a two-decimal euro figure built on assumptions.""" + euros = value( + "derived", + name="labour_eur_month", + value=60.0, + derived_from=[value("assumed", name="hours"), value("assumed", name="rate")], + ) + result = decision_grade([value("quoted", name="price"), euros]) + self.assertEqual("assumed", result["weakest"]) + self.assertEqual("indicative", result["grade"]) + + def test_projected_values_grade_separately_from_estimates(self): + result = decision_grade([value("measured"), value("projected")]) + self.assertEqual("projected", result["grade"]) + + def test_proxies_are_reported_in_the_note(self): + result = decision_grade([value("measured", proxy_for="cost of the shared host")]) + self.assertEqual(1, len(result["proxies"])) + self.assertIn("proxy", result["note"]) + + def test_profile_counts_by_basis_in_canonical_order(self): + result = profile([value("assumed"), value("measured"), value("measured")]) + self.assertEqual(["measured", "assumed"], list(result["by_basis"])) + self.assertEqual(2, result["by_basis"]["measured"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_capability.py b/tests/test_capability.py new file mode 100644 index 0000000..6e66996 --- /dev/null +++ b/tests/test_capability.py @@ -0,0 +1,167 @@ +import json +import sys +import unittest +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT / "tools")) +from capability import ( + consumption_profile, + evidence_coverage, + load_canon, + review, + validate_provision, + validate_requirement, +) + +RECORD = json.loads((ROOT / "data/capability/platform-audit-storage.json").read_text()) + + +def canon(): + return load_canon() + + +class CanonBindingTest(unittest.TestCase): + """We read the canon rather than copying it; drift must fail here.""" + + def setUp(self): + self.canon = canon() + + def test_catalog_is_the_version_we_restated_against(self): + self.assertEqual("0.2.0", self.canon["version"]) + self.assertEqual("0.3.0", self.canon["canon_version"]) + + def test_human_effort_and_intelligence_classes_exist_with_native_units(self): + classes = self.canon["resource_classes"] + self.assertEqual("hour", classes["H"]["native_unit"]) + self.assertEqual("internal", classes["H"]["supply"]) + self.assertEqual("constrained", classes["H"]["capacity_behaviour"]) + self.assertEqual("token", classes["I"]["native_unit"]) + + def test_unknown_capability_is_rejected(self): + with self.assertRaisesRegex(ValueError, "unknown capability"): + validate_requirement({"capability": "data.telepathy"}, self.canon) + + +class RequirementTest(unittest.TestCase): + def setUp(self): + self.canon = canon() + self.requirement = deepcopy(RECORD["requires"][0]) + + def test_the_real_requirement_validates(self): + validate_requirement(self.requirement, self.canon) + + def test_target_key_must_be_a_declared_quality_dimension(self): + self.requirement["targets"]["chattiness"] = {"value": 1, "unit": "x"} + with self.assertRaisesRegex(ValueError, "quality dimension"): + validate_requirement(self.requirement, self.canon) + + def test_constraint_dimension_must_be_declared(self): + self.requirement["constraints"][0]["dimension"] = "vibes" + with self.assertRaisesRegex(ValueError, "quality dimension"): + validate_requirement(self.requirement, self.canon) + + def test_predicate_must_be_in_the_closed_set(self): + self.requirement["constraints"][0]["predicate"] = "sort_of_near" + with self.assertRaisesRegex(ValueError, "closed set"): + validate_requirement(self.requirement, self.canon) + + def test_profile_must_be_declared_on_that_capability(self): + self.requirement["profile"] = "volume-of-vibes" + with self.assertRaisesRegex(ValueError, "profile"): + validate_requirement(self.requirement, self.canon) + + def test_the_constraint_that_decided_procurement_is_expressible(self): + constraint = self.requirement["constraints"][0] + self.assertEqual("geographical_separation", constraint["dimension"]) + self.assertEqual("not_in", constraint["predicate"]) + self.assertIn("railiance01", [entry["id"] for entry in constraint["of"]]) + + +class ProvisionTest(unittest.TestCase): + def setUp(self): + self.canon = canon() + self.provisions = {p["capability"]: deepcopy(p) for p in RECORD["provisions"]} + + def test_both_real_provisions_validate(self): + for provision in self.provisions.values(): + validate_provision(provision, self.canon) + + def test_consumption_unit_must_be_the_class_native_unit(self): + provision = self.provisions["data.object"] + row = next(r for r in provision["consumes"] if r["class"] == "H") + row["quantity"]["unit"] = "eur" + with self.assertRaisesRegex(ValueError, "native unit"): + validate_provision(provision, self.canon) + + def test_currency_cannot_be_smuggled_in_as_a_class(self): + provision = self.provisions["data.object"] + provision["consumes"].append( + {"class": "EUR", "quantity": {"value": 7.35, "unit": "eur"}, "basis": "quoted"} + ) + with self.assertRaisesRegex(ValueError, "unknown resource class"): + validate_provision(provision, self.canon) + + def test_unknown_consumption_may_not_be_recorded_as_zero(self): + provision = self.provisions["data.backup"] + row = next(r for r in provision["consumes"] if r["basis"] == "unknown") + row["quantity"]["value"] = 0 + with self.assertRaises(ValueError): + validate_provision(provision, self.canon) + + def test_evidence_hook_must_be_declared_on_the_capability(self): + provision = self.provisions["data.backup"] + provision["evidence"][0]["hook"] = "vibes_check" + with self.assertRaisesRegex(ValueError, "evidence hook"): + validate_provision(provision, self.canon) + + def test_duplicate_class_rows_are_rejected(self): + provision = self.provisions["data.backup"] + provision["consumes"].append(deepcopy(provision["consumes"][0])) + with self.assertRaisesRegex(ValueError, "duplicate"): + validate_provision(provision, self.canon) + + def test_backup_provision_satisfies_all_four_declared_evidence_hooks(self): + coverage = evidence_coverage(self.provisions["data.backup"], self.canon) + self.assertTrue(coverage["complete"]) + self.assertEqual([], coverage["missing"]) + self.assertEqual(4, len(coverage["supplied"])) + + def test_object_provision_is_honest_about_missing_hooks(self): + coverage = evidence_coverage(self.provisions["data.object"], self.canon) + self.assertFalse(coverage["complete"]) + self.assertIn("object_integrity_tests", coverage["missing"]) + + def test_effort_and_tokens_are_recorded_in_native_units(self): + rows = {r["class"]: r for r in self.provisions["data.object"]["consumes"]} + self.assertEqual("hour", rows["H"]["quantity"]["unit"]) + self.assertEqual("token", rows["I"]["quantity"]["unit"]) + self.assertEqual("unknown", rows["I"]["basis"]) + + +class ReviewTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.report = review(deepcopy(RECORD), canon()) + + def test_requirement_is_reported_as_below_the_asked_maturity(self): + requirement = self.report["requirements"][0] + self.assertEqual("D5", requirement["required"]) + self.assertEqual("D4", requirement["provided"]) + self.assertEqual("below_requirement", requirement["status"]) + + def test_provider_comparison_grades_as_indicative_not_evidenced(self): + grade = self.report["alternatives_grade"] + self.assertEqual("indicative", grade["grade"]) + self.assertEqual("assumed", grade["weakest"]) + self.assertEqual(0.25, grade["evidenced_ratio"]) + + def test_consumption_profiles_report_what_is_still_unknown(self): + for provision in self.report["provisions"]: + self.assertEqual("unknown", provision["consumption"]["weakest"]) + self.assertGreater(provision["consumption"]["count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/basis.py b/tools/basis.py new file mode 100644 index 0000000..9cd259a --- /dev/null +++ b/tools/basis.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Evidence basis: how a value was obtained, and how far it can be trusted. + +Every quantity in this repository is one of a small number of epistemic kinds. +A counted object and an assumed hourly rate are both numbers; they are not both +knowledge. This module names the difference and propagates it. + +The central rule is that a derived value is only as strong as its weakest +input. Without it, a precise-looking figure launders weak assumptions: EUR 30.00 +of monthly labour reads like a measurement, when it is another repository's +estimate of hours multiplied by a rate we chose. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Ordered strongest to weakest. The order is the whole point: it is what makes +# "weakest input wins" computable. +BASIS_ORDER = ( + "invoiced", # a booked financial fact, authoritative from fin-hub + "measured", # directly observed from the authoritative system + "quoted", # stated by a provider or counterparty in a citable source + "derived", # computed from other values by a stated rule + "projected", # interpolated between, or extrapolated beyond, observations + "estimated", # human judgement, neither observed nor computed + "assumed", # a modelling constant we chose + "unknown", # no value exists +) +BASES = frozenset(BASIS_ORDER) +_RANK = {name: index for index, name in enumerate(BASIS_ORDER)} + +# Bases that assert an observed or contracted fact about the world. +EVIDENCED = frozenset({"invoiced", "measured", "quoted"}) + + +def rank(basis: str) -> int: + if basis not in _RANK: + raise ValueError(f"unknown evidence basis {basis!r}") + return _RANK[basis] + + +def weakest(bases) -> str: + """The weakest basis in a collection. Empty means nothing is known.""" + bases = list(bases) + if not bases: + return "unknown" + return max(bases, key=rank) + + +def strongest(bases) -> str: + bases = list(bases) + if not bases: + return "unknown" + return min(bases, key=rank) + + +def is_evidenced(basis: str) -> bool: + """True when the value asserts an observed or contracted fact.""" + return basis in EVIDENCED + + +def resolve(value: dict) -> str: + """Effective basis of a value, propagating through derivation. + + A `derived` value resolves to the weakest basis among its inputs: deriving + GB from measured bytes stays measured, while deriving euros from estimated + hours and an assumed rate is no better than assumed. + """ + basis = value.get("basis", "unknown") + if basis not in BASES: + raise ValueError(f"unknown evidence basis {basis!r}") + if basis != "derived": + return basis + inputs = value.get("derived_from") or [] + if not inputs: + raise ValueError("a derived value must record derived_from") + return weakest(resolve(item) for item in inputs) + + +def validate_value(value: dict) -> None: + basis = value.get("basis") + if basis not in BASES: + raise ValueError(f"unknown evidence basis {basis!r}") + if basis == "derived" and not value.get("derived_from"): + raise ValueError("a derived value must record derived_from") + if basis != "derived" and value.get("derived_from"): + raise ValueError("only a derived value may record derived_from") + if basis == "unknown": + if value.get("value") is not None: + raise ValueError("a value with basis unknown must not carry a quantity") + if not value.get("gap"): + raise ValueError("an unknown value must name the gap and its owner") + elif value.get("value") is None: + raise ValueError("a known basis must carry a quantity; use basis unknown instead") + # A proxy measures a different quantity than the one named. That does not + # weaken the measurement, but it does weaken the inference drawn from it. + proxy_for = value.get("proxy_for") + if proxy_for is not None and not str(proxy_for).strip(): + raise ValueError("proxy_for must name the quantity actually wanted") + for item in value.get("derived_from") or []: + validate_value(item) + + +def profile(values) -> dict: + """Summarize a set of values for decision review.""" + resolved = [] + proxies = [] + for value in values: + validate_value(value) + effective = resolve(value) + resolved.append(effective) + if value.get("proxy_for"): + proxies.append({"name": value.get("name"), "proxy_for": value["proxy_for"]}) + counts: dict[str, int] = {} + for basis in resolved: + counts[basis] = counts.get(basis, 0) + 1 + evidenced = [b for b in resolved if is_evidenced(b)] + return { + "count": len(resolved), + "by_basis": {k: counts[k] for k in BASIS_ORDER if k in counts}, + "weakest": weakest(resolved), + "evidenced": len(evidenced), + "evidenced_ratio": round(len(evidenced) / len(resolved), 4) if resolved else None, + "proxies": proxies, + } + + +def decision_grade(values) -> dict: + """Grade a decision by the weakest evidence it actually rests on. + + A conclusion is not stronger than its weakest load-bearing input, however + precise the arithmetic between them looks. + """ + summary = profile(values) + basis = summary["weakest"] + if basis == "unknown": + grade, note = "insufficient", "at least one load-bearing value is unknown" + elif is_evidenced(basis): + grade, note = "evidenced", "every load-bearing value is observed, invoiced, or quoted" + elif basis == "projected": + grade, note = "projected", "the conclusion rests on values projected from observations" + else: + grade, note = "indicative", f"the conclusion is no stronger than an {basis} value" if basis[0] in "aeiou" else f"the conclusion is no stronger than a {basis} value" + if summary["proxies"]: + note += f"; {len(summary['proxies'])} value(s) measure a proxy rather than the quantity named" + return {**summary, "grade": grade, "note": note} + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} VALUES.json", file=sys.stderr) + return 2 + payload = json.loads(Path(sys.argv[1]).read_text()) + values = payload["values"] if isinstance(payload, dict) else payload + print(json.dumps(decision_grade(values), indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/capability.py b/tools/capability.py new file mode 100644 index 0000000..7d2fdee --- /dev/null +++ b/tools/capability.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Validate capability requirements and provisions against the live ITC-CAP catalog. + +resource-control does not copy the canon. It reads `capabilities.yaml` from +info-tech-canon and checks that every capability id, profile, quality dimension, +maturity level, resource class, and predicate this repository uses is one the +canon actually declares. A drift in either repository fails here rather than +silently producing a record the canon would reject. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import basis as basis_module + +CANON_PATHS = ( + Path("/home/worsch/info-tech-canon/infospace/models/capability/capabilities.yaml"), + Path("../info-tech-canon/infospace/models/capability/capabilities.yaml"), +) +PREDICATES = frozenset({"not_in", "in", "equals", "lte", "gte"}) + + +def _parse_yaml(text: str) -> dict: + try: + import yaml + except ModuleNotFoundError: # pragma: no cover - yaml is present in this env + raise RuntimeError("PyYAML is required to read the canon catalog") + return yaml.safe_load(text) + + +def load_canon(paths=CANON_PATHS) -> dict: + for path in paths: + if path.exists(): + catalog = _parse_yaml(path.read_text()) + capabilities = { + cap["id"]: cap + for domain in catalog["domains"] + for cap in domain.get("capabilities", []) + } + return { + "version": catalog["canon"]["version"], + "canon_version": catalog["canon"]["canon_version"], + "capabilities": capabilities, + "resource_classes": {rc["id"]: rc for rc in catalog["resource_classes"]}, + "maturity_levels": {m["id"] if isinstance(m, dict) else m for m in catalog["maturity_levels"]}, + "source": str(path), + } + raise FileNotFoundError("ITC-CAP catalog not found; is info-tech-canon checked out?") + + +def validate_requirement(requirement: dict, canon: dict) -> None: + cap_id = requirement["capability"] + capability = canon["capabilities"].get(cap_id) + if capability is None: + raise ValueError(f"unknown capability {cap_id}") + + profile = requirement.get("profile") + if profile and profile not in (capability.get("profiles") or []): + raise ValueError(f"{cap_id} does not declare profile {profile!r}") + + maturity = requirement.get("minimum_maturity") + if maturity and maturity not in canon["maturity_levels"]: + raise ValueError(f"unknown maturity level {maturity!r}") + + dimensions = set(capability.get("quality_dimensions") or []) + # CAP-R9: a target key or constraint dimension must be declared on the + # capability. This is what stops a consumer inventing quality vocabulary. + for key in (requirement.get("targets") or {}): + if key not in dimensions: + raise ValueError(f"{cap_id} does not declare quality dimension {key!r}") + for constraint in requirement.get("constraints") or []: + if constraint["dimension"] not in dimensions: + raise ValueError(f"{cap_id} does not declare quality dimension {constraint['dimension']!r}") + if constraint["predicate"] not in PREDICATES: + raise ValueError(f"predicate {constraint['predicate']!r} is not in the closed set") + if not constraint.get("of"): + raise ValueError("a constraint must name what it applies to") + + +def validate_provision(provision: dict, canon: dict) -> None: + cap_id = provision["capability"] + capability = canon["capabilities"].get(cap_id) + if capability is None: + raise ValueError(f"unknown capability {cap_id}") + if provision["maturity"] not in canon["maturity_levels"]: + raise ValueError(f"unknown maturity level {provision['maturity']!r}") + profile = provision.get("profile") + if profile and profile not in (capability.get("profiles") or []): + raise ValueError(f"{cap_id} does not declare profile {profile!r}") + + hooks = set(capability.get("evidence_hooks") or []) + for item in provision.get("evidence") or []: + if item["hook"] not in hooks: + raise ValueError(f"{cap_id} does not declare evidence hook {item['hook']!r}") + basis_module.rank(item["basis"]) + + seen = set() + for row in provision.get("consumes") or []: + klass = row["class"] + if klass not in canon["resource_classes"]: + raise ValueError(f"unknown resource class {klass!r}") + if klass in seen: + raise ValueError(f"duplicate consumption row for class {klass}") + seen.add(klass) + + declared = canon["resource_classes"][klass] + unit = row["quantity"]["unit"] + if unit != declared["native_unit"]: + raise ValueError( + f"class {klass} native unit is {declared['native_unit']!r}, got {unit!r}" + ) + # Unknown is recorded as unknown, never as zero. + basis_module.validate_value({ + "basis": row["basis"], + "value": row["quantity"]["value"], + "gap": row.get("gap"), + "derived_from": row.get("derived_from"), + }) + if row.get("supply") and row["supply"] not in {"internal", "external"}: + raise ValueError(f"supply must be internal or external, got {row['supply']!r}") + + +def evidence_coverage(provision: dict, canon: dict) -> dict: + """Which of the capability's declared evidence hooks this provision satisfies.""" + capability = canon["capabilities"][provision["capability"]] + declared = list(capability.get("evidence_hooks") or []) + supplied = {item["hook"] for item in provision.get("evidence") or []} + return { + "declared": declared, + "supplied": sorted(supplied), + "missing": sorted(set(declared) - supplied), + "complete": not (set(declared) - supplied), + } + + +def consumption_profile(provision: dict) -> dict: + values = [ + { + "name": f"{row['class']}:{row.get('name', '')}", + "basis": row["basis"], + "value": row["quantity"]["value"], + "gap": row.get("gap"), + "derived_from": row.get("derived_from"), + "proxy_for": row.get("proxy_for"), + } + for row in provision.get("consumes") or [] + ] + return basis_module.profile(values) + + +def review(record: dict, canon: dict) -> dict: + for requirement in record.get("requires") or []: + validate_requirement(requirement, canon) + for provision in record.get("provisions") or []: + validate_provision(provision, canon) + + # Does each requirement actually have a provision that meets it? + provisions = {p["capability"]: p for p in record.get("provisions") or []} + met = [] + for requirement in record.get("requires") or []: + provision = provisions.get(requirement["capability"]) + wanted = requirement.get("minimum_maturity") + if provision is None: + met.append({"capability": requirement["capability"], "status": "unprovided"}) + continue + levels = sorted(canon["maturity_levels"]) + satisfied = levels.index(provision["maturity"]) >= levels.index(wanted) if wanted else True + met.append({ + "capability": requirement["capability"], + "required": wanted, + "provided": provision["maturity"], + "status": "met" if satisfied else "below_requirement", + }) + + return { + "record_id": record["record_id"], + "canon": {k: canon[k] for k in ("version", "canon_version", "source")}, + "requirements": met, + "provisions": [ + { + "capability": provision["capability"], + "maturity": provision["maturity"], + "evidence": evidence_coverage(provision, canon), + "consumption": consumption_profile(provision), + } + for provision in record.get("provisions") or [] + ], + "alternatives_grade": ( + basis_module.decision_grade(record["modelled_alternatives"]["values"]) + if record.get("modelled_alternatives") else None + ), + } + + +def main() -> int: + paths = sys.argv[1:] or sorted(str(p) for p in Path("data/capability").glob("*.json")) + canon = load_canon() + reports = [review(json.loads(Path(p).read_text()), canon) for p in paths] + print(json.dumps(reports, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/settlement.py b/tools/settlement.py index f0cb521..1821ebc 100644 --- a/tools/settlement.py +++ b/tools/settlement.py @@ -323,9 +323,31 @@ def close_live(root: Path, period: str) -> list[dict]: return close_fixture(payload, terms, register) +def consumption_mode_signal(statements: list[dict], period: str) -> list[dict]: + """Project settlement statements into the signal railiance-platform consumes. + + An empty list is a real answer, not a missing one: it means no entity is + restricted. Per the consumption-mode contract a *missing* signal is neither + open nor restricted and does not refuse, so publishing the empty list is + what makes "nobody is restricted" an assertion rather than an absence. + """ + return [ + { + "schema_version": "0.1", + "record_type": "consumption_mode", + "financial_entity_id": statement["financial_entity_id"], + "period": period, + "consumption_mode": statement["consumption_mode"], + "new_transfer_charges_allowed_eur": statement["next_month_allowance_eur"], + "terms_version": statement["terms_version"], + } + for statement in statements + ] + + def main() -> int: parser = argparse.ArgumentParser(description="Monthly settlement close") - parser.add_argument("command", choices=["close"]) + parser.add_argument("command", choices=["close", "consumption-mode"]) parser.add_argument("--period", help="YYYY-MM") parser.add_argument("--fixture", help="path to a settlement fixture") parser.add_argument("--root", default=str(ROOT)) @@ -341,6 +363,12 @@ def main() -> int: print("PERIOD=YYYY-MM is required without --fixture", file=sys.stderr) return 2 statements = close_live(root, args.period) + if args.command == "consumption-mode": + if not args.period: + print("--period YYYY-MM is required for consumption-mode", file=sys.stderr) + return 2 + print(json.dumps(consumption_mode_signal(statements, args.period), indent=2)) + return 0 print(json.dumps(statements, indent=2)) return 0 diff --git a/tools/validate.py b/tools/validate.py index 30d5330..2421985 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -8,7 +8,8 @@ from entities import REQUIRED_ENTITY_IDS, load_register, load_terms, require_ent from optimization import validate_case from portfolio import validate_record from portfolio_report import build as build_portfolio_report -from settlement import close_fixture, close_live +from capability import load_canon, review as review_capability +from settlement import close_fixture, close_live, consumption_mode_signal def load(path: str) -> dict: @@ -138,6 +139,25 @@ def main() -> int: assert any("reef-storage" in note for note in view.get("notes") or []) if view["reef_id"] == "reef-storage": assert view["role"] == "storage_substrate" + # Capability restatements bind to the live ITC-CAP catalog rather than a + # copy of it, so drift in either repository fails here. + canon = load_canon() + capability_records = [load(str(p)) for p in Path("data/capability").glob("*.json")] + assert capability_records, "no capability restatement present" + for record in capability_records: + report = review_capability(record, canon) + assert report["provisions"], f"{record['record_id']} declares no provision" + for provision in report["provisions"]: + # Unknown consumption must be named, never silently absent. + assert provision["consumption"]["count"] > 0 + backup = next( + p for r in capability_records for p in review_capability(r, canon)["provisions"] + if p["capability"] == "data.backup" + ) + assert backup["evidence"]["complete"], "the backup provision must satisfy every declared hook" + signal = consumption_mode_signal(close_live(Path("."), "2026-09"), "2026-09") + published = load("data/consumption-mode/current.json") + assert published == signal, "consumption-mode signal is stale; run make consumption-mode" print("resource-control declarations: valid") return 0