diff --git a/data/capability/platform-audit-storage.json b/data/capability/platform-audit-storage.json index dda29ce..f99b8f8 100644 --- a/data/capability/platform-audit-storage.json +++ b/data/capability/platform-audit-storage.json @@ -3,9 +3,11 @@ "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" + "model_version": "0.4.0", + "canon_version": "0.6.0", + "status": "draft", + "catalog": "info-tech-canon/infospace/models/capability/capabilities.yaml", + "evidence_basis_catalog": "info-tech-canon/infospace/models/governance/evidence-basis.yaml" }, "record_id": "capability-case:platform-audit-storage:2026-08", "created_at": "2026-08-15T00:00:00Z", @@ -197,7 +199,7 @@ { "capability": "security.secrets", "provider": "OpenBao / external-secrets on reef-railiance", - "relation": "uses", + "relation": "may_use", "note": "ClusterSecretStore openbao-backup-object-storage; ExternalSecret synced to databases/platform-pg-backup-s3. Not a consumption row: no purchased platform capacity is bought here, another capability is used.", "evidence_basis": "measured", "observed_at": "2026-08-14" @@ -278,14 +280,6 @@ "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.", - "provisions[].uses_provisions is a proposed extension, not canon. It replaced a consumes:P row for credential custody, which was the wrong kind: P is purchased platform capacity, and using security.secrets buys none." - ], - "proposed_extensions": { - "note": "uses_provisions is NOT canon. ITC-CAP 0.2.0 declares capability-to-capability relations and landscape-to-capability relations, but no provision-to-provision relation. info-tech-canon identified the gap on 2026-08-15 and asked us to file it as demand rather than have them invent the field from a message.", - "fields": [ - "provisions[].uses_provisions" - ], - "demand": "info-tech-canon/demand/ProvisionRelationships.md" - } + "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/docs/evidence-basis.md b/docs/evidence-basis.md index 5c36500..4b95e44 100644 --- a/docs/evidence-basis.md +++ b/docs/evidence-basis.md @@ -4,26 +4,40 @@ 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 vocabulary is canon, owned by ITC-GOV.** resource-control originated the +concept, filed it as `demand/EvidenceBasis.md`, and info-tech-canon adopted it in +canon 0.4.0 as `infospace/models/governance/evidence-basis.yaml`. `tools/basis.py` +now *reads* that catalog rather than defining its own — the same discipline we +apply to the capability catalog. Drift in either repository fails our suite. +`CAP-R10` requires a basis on every `CapabilityConsumption` row. ## The scale -Ordered strongest to weakest. The order is the point — it is what makes -"weakest input wins" computable. +Strength is a **tier**, not a total order. Members of one tier are peers and do +not rank against each other — asserting an order between them would make the +propagation rule claim something it cannot know. -| 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 | +| Tier | Rank | Bases | Example here | +|---|---|---|---| +| `observed` | 0 | `invoiced`, `measured` | 8 objects, 6 604 031 B in the backup prefix | +| `quoted` | 1 | `quoted` | Scaleway €0.01606/GB-month | +| `projected` | 2 | `projected` | 457.968 GB stored at month 12 | +| `judgement` | 3 | `estimated`, `assumed` | apps-pg 6 operator-hours setup; €60/hour rate | +| `unknown` | 4 | `unknown` | railiance01 monthly price | -`invoiced`, `measured`, and `quoted` are **evidenced**: they assert an observed -or contracted fact. Everything below them is inference. +`derived` belongs to **no tier**: it resolves against its inputs, and asking for +its tier before resolution is an error. + +`observed` and `quoted` are both **evidenced** — they assert an observed or +contracted fact. `quoted` sits below `observed` for propagation while still +counting as evidenced for a decision grade; the canon separates those two uses +deliberately, because a counterparty's stated price is a fact about a claim and +a measurement is a fact about the world. + +An invoice is authoritative for a payment and a measurement is authoritative for +a quantity, so neither outranks the other — they are peers in `observed`. Our +first implementation used a strict list order and got this wrong; the canon +carries the corrected form as a normative rule. ## The propagation rule diff --git a/tests/test_basis.py b/tests/test_basis.py index d4c608b..032ac30 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -4,7 +4,9 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[1] / "tools")) from basis import ( - BASIS_ORDER, + bases as catalog_bases, + load_catalog, + tier_of, decision_grade, is_evidenced, profile, @@ -24,22 +26,54 @@ def value(basis="measured", **overrides): return result -class OrderTest(unittest.TestCase): +class CanonBindingTest(unittest.TestCase): + """The vocabulary is owned by ITC-GOV; we read it, we do not vendor it.""" + + def test_catalog_is_read_from_the_canon(self): + catalog = load_catalog() + self.assertEqual("0.1.0", catalog["version"]) + self.assertIn("info-tech-canon", catalog["source"]) + + def test_every_basis_comes_from_the_canon_catalog(self): + self.assertEqual( + ["invoiced", "measured", "quoted", "derived", "projected", "estimated", + "assumed", "unknown"], + catalog_bases(), + ) + + +class TierTest(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")) + self.assertLess(rank("measured"), rank("projected")) + self.assertLess(rank("projected"), rank("estimated")) + self.assertLess(rank("estimated"), rank("unknown")) def test_invoiced_and_measured_are_peers_not_ranked(self): """An invoice is authoritative for a payment, a measurement for a quantity. Neither outranks the other outside its own domain.""" + self.assertEqual("observed", tier_of("invoiced")) + self.assertEqual("observed", tier_of("measured")) self.assertEqual(rank("invoiced"), rank("measured")) self.assertLess(rank("measured"), rank("quoted")) + def test_estimated_and_assumed_are_peers_in_the_judgement_tier(self): + """Canon 0.4.0 groups them; neither is stronger than the other.""" + self.assertEqual("judgement", tier_of("estimated")) + self.assertEqual("judgement", tier_of("assumed")) + self.assertEqual(rank("estimated"), rank("assumed")) + + def test_quoted_is_below_observed_for_propagation_but_still_evidenced(self): + self.assertGreater(rank("quoted"), rank("measured")) + self.assertTrue(is_evidenced("quoted")) + + def test_derived_has_no_resolved_tier(self): + with self.assertRaisesRegex(ValueError, "no resolved tier"): + tier_of("derived") + def test_a_peer_pair_does_not_report_a_false_weakest(self): self.assertEqual(rank("invoiced"), rank(weakest(["invoiced", "measured"]))) self.assertEqual(weakest(["invoiced", "measured"]), weakest(["measured", "invoiced"])) + self.assertEqual(weakest(["estimated", "assumed"]), weakest(["assumed", "estimated"])) def test_weakest_and_strongest_pick_opposite_ends(self): bases = ["measured", "assumed", "quoted"] @@ -52,8 +86,11 @@ class OrderTest(unittest.TestCase): 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"): + for basis in ("projected", "estimated", "assumed", "unknown"): self.assertFalse(is_evidenced(basis)) + # derived is not evidenced or unevidenced until it is resolved + with self.assertRaises(ValueError): + is_evidenced("derived") def test_unknown_basis_name_is_rejected(self): with self.assertRaises(ValueError): @@ -69,12 +106,12 @@ class PropagationTest(unittest.TestCase): def test_one_assumed_input_drags_the_result_down(self): derived = value("derived", derived_from=[value("quoted"), value("assumed")]) - self.assertEqual("assumed", resolve(derived)) + self.assertEqual("judgement", tier_of(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)) + self.assertEqual("judgement", tier_of(resolve(outer))) def test_derived_without_inputs_is_rejected(self): with self.assertRaisesRegex(ValueError, "derived_from"): @@ -120,7 +157,7 @@ class DecisionGradeTest(unittest.TestCase): 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"]) + self.assertEqual("judgement", result["weakest_tier"]) def test_an_unknown_makes_the_decision_insufficient(self): result = decision_grade([value("measured"), value("unknown")]) @@ -135,7 +172,7 @@ class DecisionGradeTest(unittest.TestCase): 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("judgement", result["weakest_tier"]) self.assertEqual("indicative", result["grade"]) def test_projected_values_grade_separately_from_estimates(self): diff --git a/tests/test_capability.py b/tests/test_capability.py index 6e66996..9b63305 100644 --- a/tests/test_capability.py +++ b/tests/test_capability.py @@ -29,8 +29,10 @@ class CanonBindingTest(unittest.TestCase): 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"]) + self.assertEqual("0.4.0", self.canon["version"]) + self.assertEqual("0.6.0", self.canon["canon_version"]) + self.assertEqual(self.canon["version"], RECORD["canon"]["model_version"]) + self.assertEqual(self.canon["canon_version"], RECORD["canon"]["canon_version"]) def test_human_effort_and_intelligence_classes_exist_with_native_units(self): classes = self.canon["resource_classes"] @@ -133,6 +135,34 @@ class ProvisionTest(unittest.TestCase): self.assertFalse(coverage["complete"]) self.assertIn("object_integrity_tests", coverage["missing"]) + def test_using_another_capability_is_a_relation_not_a_p_row(self): + """CAP-R11. The credential-custody P row was the wrong kind.""" + backup = self.provisions["data.backup"] + classes = {r["class"] for r in backup["consumes"]} + self.assertNotIn("P", classes) + relations = {u["capability"]: u["relation"] for u in backup["uses_provisions"]} + self.assertEqual("may_use", relations["security.secrets"]) + self.assertEqual("depends_on", relations["data.object"]) + + def test_depends_on_must_be_declared_between_the_capabilities(self): + backup = self.provisions["data.backup"] + entry = next(u for u in backup["uses_provisions"] if u["capability"] == "security.secrets") + entry["relation"] = "depends_on" + with self.assertRaisesRegex(ValueError, "does not declare depends_on"): + validate_provision(backup, self.canon) + + def test_relation_outside_the_canon_vocabulary_is_rejected(self): + backup = self.provisions["data.backup"] + backup["uses_provisions"][0]["relation"] = "uses" + with self.assertRaisesRegex(ValueError, "depends_on or may_use"): + validate_provision(backup, self.canon) + + def test_a_used_provision_must_name_its_provider(self): + backup = self.provisions["data.backup"] + backup["uses_provisions"][0]["provider"] = "" + with self.assertRaisesRegex(ValueError, "must name a provider"): + validate_provision(backup, self.canon) + 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"]) diff --git a/tools/basis.py b/tools/basis.py index 0bf0bcc..d325597 100644 --- a/tools/basis.py +++ b/tools/basis.py @@ -1,13 +1,15 @@ #!/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 vocabulary is **owned by ITC-GOV** and read from +`infospace/models/governance/evidence-basis.yaml` in info-tech-canon. This +module does not vendor it. resource-control originated the concept, filed it as +demand, and now consumes the canon's version of it — drift in either repository +fails here rather than diverging quietly. -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 +The central rule is that a derived value is only as strong as the weakest +*tier* among its inputs. Without it, arithmetic launders 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. """ @@ -15,94 +17,135 @@ from __future__ import annotations import json import sys +from functools import lru_cache 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 +CATALOG_PATHS = ( + Path("/home/worsch/info-tech-canon/infospace/models/governance/evidence-basis.yaml"), + Path("../info-tech-canon/infospace/models/governance/evidence-basis.yaml"), ) -BASES = frozenset(BASIS_ORDER) - -# Strength is a tier, not a total order. `invoiced` and `measured` are peers: -# an invoice is the authoritative record of a payment, a measurement is the -# authoritative record of a quantity, and neither outranks the other outside -# its own domain. Asserting an order between them would make the weakest-input -# rule claim something it cannot know. -_TIER = { - "invoiced": 0, "measured": 0, - "quoted": 1, - "derived": 2, - "projected": 3, - "estimated": 4, - "assumed": 5, - "unknown": 6, -} -_ORDER = {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: - """Strength tier; lower is stronger. Peers share a tier.""" - if basis not in _TIER: +@lru_cache(maxsize=1) +def load_catalog(paths: tuple = CATALOG_PATHS) -> dict: + """Read the ITC-GOV EvidenceBasis catalog.""" + import yaml + + for path in paths: + if path.exists(): + raw = yaml.safe_load(path.read_text()) + tiers = {tier["id"]: tier for tier in raw["tiers"]} + bases = {b["id"]: b for b in raw["bases"]} + # Membership comes from tiers[].members, which is authoritative. + # The coarser bases[].tier label is not usable as a tier id: it + # reads "evidenced" for invoiced/measured/quoted while the tier + # list splits those across "observed" and "quoted". + tier_of = { + member: tier["id"] + for tier in raw["tiers"] + for member in tier["members"] + } + # A basis in no tier (derived) has no resolved tier by design. + tier_of.update({b: None for b in bases if b not in tier_of}) + return { + "version": raw["canon"]["version"], + "canon_version": raw["canon"]["canon_version"], + "source": str(path), + "bases": bases, + "order": [b["id"] for b in raw["bases"]], + "tiers": tiers, + "tier_of": tier_of, + "tier_rank": {t["id"]: t["rank"] for t in raw["tiers"]}, + "evidenced_tiers": {t["id"] for t in raw["tiers"] if t.get("evidenced")}, + "grades": {g["id"]: g["when"] for g in raw["decision_grades"]}, + } + raise FileNotFoundError( + "ITC-GOV EvidenceBasis catalog not found; is info-tech-canon checked out?" + ) + + +def _catalog(catalog: dict | None = None) -> dict: + return catalog or load_catalog() + + +def bases(catalog: dict | None = None) -> list[str]: + return list(_catalog(catalog)["order"]) + + +def tier_of(basis: str, catalog: dict | None = None) -> str: + """The resolved strength tier of a basis. + + `derived` deliberately has none: resolve it against its inputs first. + """ + cat = _catalog(catalog) + if basis not in cat["tier_of"]: raise ValueError(f"unknown evidence basis {basis!r}") - return _TIER[basis] + tier = cat["tier_of"][basis] + if tier is None: + raise ValueError( + f"{basis!r} has no resolved tier; resolve it against derived_from first" + ) + return tier -def weakest(bases) -> str: - """The weakest basis in a collection. Empty means nothing is known. +def rank(basis: str, catalog: dict | None = None) -> int: + """Tier rank; lower is stronger. Members of one tier share a rank.""" + cat = _catalog(catalog) + return cat["tier_rank"][tier_of(basis, cat)] + + +def weakest(names, catalog: dict | None = None) -> str: + """The weakest basis in a collection, compared by tier. Ties within a tier resolve by catalog order so the result is deterministic - without implying a strength difference that does not exist. + without implying a strength difference the canon does not assert. """ - bases = list(bases) - if not bases: + cat = _catalog(catalog) + names = list(names) + if not names: return "unknown" - return min(bases, key=lambda b: (-rank(b), _ORDER[b])) + order = cat["order"] + return min(names, key=lambda b: (-rank(b, cat), order.index(b))) -def strongest(bases) -> str: - bases = list(bases) - if not bases: +def strongest(names, catalog: dict | None = None) -> str: + cat = _catalog(catalog) + names = list(names) + if not names: return "unknown" - return min(bases, key=lambda b: (rank(b), _ORDER[b])) + order = cat["order"] + return min(names, key=lambda b: (rank(b, cat), order.index(b))) -def is_evidenced(basis: str) -> bool: - """True when the value asserts an observed or contracted fact.""" - return basis in EVIDENCED +def is_evidenced(basis: str, catalog: dict | None = None) -> bool: + """True when the value asserts an observed or contracted fact. - -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. + `quoted` is weaker than `observed` for propagation and still counts as + evidenced for a decision grade — the canon separates those two uses. """ + cat = _catalog(catalog) + return tier_of(basis, cat) in cat["evidenced_tiers"] + + +def resolve(value: dict, catalog: dict | None = None) -> str: + """Effective basis of a value, propagating through derivation.""" + cat = _catalog(catalog) basis = value.get("basis", "unknown") - if basis not in BASES: + if basis not in cat["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) + return weakest([resolve(item, cat) for item in inputs], cat) -def validate_value(value: dict) -> None: +def validate_value(value: dict, catalog: dict | None = None) -> None: + """Enforce the ITC-GOV rules: unknown-is-not-zero and derived-names-inputs.""" + cat = _catalog(catalog) basis = value.get("basis") - if basis not in BASES: + if basis not in cat["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") @@ -115,55 +158,65 @@ def validate_value(value: dict) -> None: 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) + validate_value(item, cat) -def profile(values) -> dict: +def profile(values, catalog: dict | None = None) -> dict: """Summarize a set of values for decision review.""" - resolved = [] - proxies = [] + cat = _catalog(catalog) + resolved, proxies = [], [] for value in values: - validate_value(value) - effective = resolve(value) - resolved.append(effective) + validate_value(value, cat) + resolved.append(resolve(value, cat)) 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)] + evidenced = [b for b in resolved if is_evidenced(b, cat)] + weakest_basis = weakest(resolved, cat) return { "count": len(resolved), - "by_basis": {k: counts[k] for k in BASIS_ORDER if k in counts}, - "weakest": weakest(resolved), + "by_basis": {k: counts[k] for k in cat["order"] if k in counts}, + "weakest": weakest_basis, + "weakest_tier": tier_of(weakest_basis, cat) if resolved else "unknown", "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. +# Decision grade per tier, from the catalog's decision_grades. +_GRADE_BY_TIER = { + "observed": "evidenced", + "quoted": "evidenced", + "projected": "projected", + "judgement": "indicative", + "unknown": "insufficient", +} +_GRADE_NOTE = { + "evidenced": "every load-bearing value is observed, invoiced, 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", +} + + +def decision_grade(values, catalog: dict | None = None) -> dict: + """Grade a decision by the weakest tier 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" + cat = _catalog(catalog) + summary = profile(values, cat) + tier = summary["weakest_tier"] + grade = _GRADE_BY_TIER[tier] + note = _GRADE_NOTE[grade] if summary["proxies"]: note += f"; {len(summary['proxies'])} value(s) measure a proxy rather than the quantity named" return {**summary, "grade": grade, "note": note} diff --git a/tools/capability.py b/tools/capability.py index 7d2fdee..05df46b 100644 --- a/tools/capability.py +++ b/tools/capability.py @@ -122,6 +122,35 @@ def validate_provision(provision: dict, canon: dict) -> None: if row.get("supply") and row["supply"] not in {"internal", "external"}: raise ValueError(f"supply must be internal or external, got {row['supply']!r}") + validate_uses_provisions(provision, canon) + + +def validate_uses_provisions(provision: dict, canon: dict) -> None: + """CAP-R11: relying on another provision is a relationship, not consumption. + + A `depends_on` entry MUST correspond to a `depends_on` declared between the + two capabilities in the catalog, so the provision graph stays checkable + against the capability graph rather than free-form. + """ + capability = canon["capabilities"][provision["capability"]] + for entry in provision.get("uses_provisions") or []: + target = entry["capability"] + if target not in canon["capabilities"]: + raise ValueError(f"unknown capability {target} in uses_provisions") + relation = entry["relation"] + if relation not in {"depends_on", "may_use"}: + raise ValueError(f"relation must be depends_on or may_use, got {relation!r}") + if not entry.get("provider"): + raise ValueError(f"uses_provisions entry for {target} must name a provider") + declared = set(capability.get(relation) or []) + if relation == "depends_on" and target not in declared: + raise ValueError( + f"{provision['capability']} does not declare depends_on {target} in the catalog" + ) + if relation == "may_use" and target not in declared: + # SHOULD, not MUST — surfaced rather than fatal. + entry.setdefault("_note", f"catalog does not declare may_use {target}") + def evidence_coverage(provision: dict, canon: dict) -> dict: """Which of the capability's declared evidence hooks this provision satisfies.""" @@ -185,6 +214,10 @@ def review(record: dict, canon: dict) -> dict: "maturity": provision["maturity"], "evidence": evidence_coverage(provision, canon), "consumption": consumption_profile(provision), + "uses_provisions": [ + {"capability": u["capability"], "relation": u["relation"], "provider": u["provider"]} + for u in provision.get("uses_provisions") or [] + ], } for provision in record.get("provisions") or [] ],