Cross-repo registry drift guard
A runnable check that proves each executable repo's live observability registry surface is a subset of the canonical PRAMAAN registries, and fails CI on drift.
Why it matters
check_metric_registry.py only validates the canonical metric table's internal consistency. Nothing proved the actual span enum and metric specs shipping in functions / app / admin / agents still matched canonical — so a repo could add a span name, an extra metric label, change a unit, or carry a stale semconv source, and every per-repo test would still pass. That is the system-boundary defect: green in isolation while the cross-repo contract silently drifts.
This guard reads each repo's real registry surface and makes the drift fail CI, not production. It pairs the canonical docs with enforcement — a doc-only CI spec is insufficient without a runnable check.
What changed (pramaan-docs only, one PR)
.github/scripts/check_registry_drift.py— the drift guard (canonical parsers, per-repo extractors, fetch layer,--selftest)..github/workflows/registry-drift-check.yml— CI: self-test + live check on PR, push to main, and a daily schedule (to catch sibling-repo merges).docs/rules/registry-drift.md— the doctrine page: what is proven, the mechanism choice, and how to run it.
No sibling repo is touched. No runtime behavior changes. ADR-124 is parsed read-only and never edited.
Mechanism: fetch-and-parse the source of truth
The guard fetches each repo's source-of-truth file and extracts the registry surface directly — Python via the standard-library ast module, TypeScript via a structural parse — rather than relying on a per-repo committed manifest.
| Option | Pro | Con |
|---|---|---|
| A — committed manifest per repo | decoupled from source layout | needs a PR in each sibling repo (forbidden cross-repo write); a stale manifest is itself invisible drift |
| B — fetch + parse (chosen) | one repo / one PR; reads the source of truth, so no manifest-staleness gap | extractor coupled to each surface shape — mitigated by frozen registry shapes and loud (exit 1) parse failures |
Canonical span names come from the ADR-124 closed span table (anchored on the literal Closed manual span registry: sentinel); canonical metrics from the METRIC-REGISTRY:BEGIN/END markers in metric-registry.md.
Drift laws enforced
- Every manual-span name ∈ canonical closed span registry, and
SpanKindmatches where declared. - Every metric ∈ canonical metric registry, with matching type, matching unit, allowed-labels ⊆ canonical, and matching semconv source where declared.
Evidence: green on live, red on each planted drift
Self-test (network-free fixtures) and the live check both pass; each planted drift driven through the full live pipeline (a local mirror of all six surfaces) fails with the right message.
| Scenario | Result |
|---|---|
| Live registries (functions, app, admin, agents) | PASS — 29 spans + 6 metrics, no drift |
Planted out-of-registry span functions.rogue.leak | FAIL — out-of-registry span name |
Planted extra metric label session_id | FAIL — extra metric label |
Planted unit mismatch (s → ms) | FAIL — unit mismatch |
Planted stale semconv (OTel HTTP → OTel HTTP/1.0) | FAIL — stale semconv key |
Planted span-kind drift (INTERNAL → PRODUCER) | FAIL — SpanKind mismatch |
$ python3 .github/scripts/check_registry_drift.py --selftest registry-drift guard SELFTEST PASSED: good fixtures clean; rogue span, span-kind, extra label, unit, and semconv drifts all caught. $ python3 .github/scripts/check_registry_drift.py --source gh canonical: 34 span(s), 22 metric(s). source=gh pramaan-functions: 8 span(s) | pramaan-agents: 12 span(s) pramaan-app: 5 span(s) | pramaan-admin: 4 span(s) pramaan-functions: 4 metric(s)| pramaan-agents: 2 metric(s) registry-drift guard PASSED: 29 span(s) + 6 metric(s) across 4 + 2 surface(s), no drift.
Compliance snapshot (2026-06-20)
All four repos are currently in compliance. Their live span/metric surfaces are a clean subset of canonical. The guard is green on live and red on each planted drift.
Not in scope
No changes to any sibling repo, no edit to ADR-124 or the metric registry table, no runtime/instrumentation code, and no board mutation. Required-attribute matching for spans is intentionally out of scope (the story covers name, unit, allowed-labels, and semconv).