resource-control/tools/capability.py
tegwick 7a196b6265 feat: adopt canon 0.4.0-0.6.0 — EvidenceBasis is canon, uses_provisions is canon
Both demands were accepted. Adopting what landed.

EVIDENCE BASIS IS NOW ITC-GOV CANON (0.4.0)
tools/basis.py reads infospace/models/governance/evidence-basis.yaml instead of
defining its own vocabulary — same discipline we already applied to the
capability catalog. Two semantic changes came back that we did not have:

- estimated and assumed are peers in tier "judgement". We had them separately
  ranked, which asserted a difference the canon does not.
- derived belongs to no tier at all; asking for its tier before resolving it is
  now an error rather than a silent rank.

Tier membership is read from tiers[].members, not bases[].tier: the latter
labels invoiced/measured/quoted all as "evidenced" while the tier list splits
them across "observed" and "quoted". tiers[] is authoritative; reported upstream.

USES_PROVISIONS IS NOW CANON (0.5.0, CAP-R11)
Dropped the proposed_extensions marker. Renamed relation "uses" to "may_use" per
their migration note. tools/capability.py now enforces CAP-R11: relation must be
depends_on or may_use, a provider must be named, and a depends_on entry MUST be
declared between those capabilities in the catalog. data.backup gained catalog
may_use: security.secrets from our restatement, so our entry now checks out.

Also in 0.4.0: §10.3 changed so a joinable consumer record counts as promotion
proof, met by our restatement; ITC-CAP is now 0.4.0 / canon 0.6.0, status draft.
Record and tests updated to those versions.

196 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:57:46 +02:00

240 lines
9.9 KiB
Python

#!/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}")
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."""
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),
"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 []
],
"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())