diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md index 66501b9..dd86d49 100644 --- a/.claude/rules/stack-and-commands.md +++ b/.claude/rules/stack-and-commands.md @@ -23,6 +23,13 @@ reuse-surface validate --root . # capability federation (from reuse-surface # Explain a surface's effective-config override path (no values shown) make explain SURFACE=surface.infotech.state-hub.api-config +# Change blast-radius / dependency view for a surface +make blast-radius SURFACE=surface.infotech.state-hub.api-config + +# Regenerate / query the config knowledge-graph edges (registry/indexes/graph.yaml) +make graph +make graph-query SURFACE=surface.infotech.state-hub.api-config + # After workplan or registry edits — from ~/state-hub make fix-consistency REPO=config-atlas ``` diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d67f62d..89a2510 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,5 +18,7 @@ jobs: run: pip install --quiet pyyaml jsonschema - name: Validate surface entries against schema + index run: python3 tools/validate_registry.py + - name: Resolver determinism + no-value-leak tests + run: python3 -m unittest discover -s tests -q - name: Check whitespace / conflict markers run: git diff --check $(git hash-object -t tree /dev/null) -- . diff --git a/Makefile b/Makefile index 71a8350..2f71552 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,39 @@ # config-atlas — registry validation gate (ATLAS-WP-0002-T06) # Markdown-first repo: no build/run, only validation and the explain tool. -.PHONY: validate validate-schema validate-reuse validate-whitespace explain +.PHONY: validate validate-schema validate-reuse validate-whitespace validate-tests explain blast-radius # Effective-config override path for a surface (ATLAS-WP-0004). # make explain SURFACE=surface.infotech.state-hub.api-config explain: @python3 tools/config_explain.py $(SURFACE) +# Change blast-radius / dependency view for a surface (ATLAS-WP-0004). +# make blast-radius SURFACE=surface.infotech.state-hub.api-config +blast-radius: + @python3 tools/blast_radius.py $(SURFACE) + +# Regenerate the config knowledge-graph edge artifact (ATLAS-WP-0004). +graph: + @python3 tools/config_graph.py + +# Query graph edges touching a surface id. +# make graph-query SURFACE=surface.infotech.state-hub.api-config +graph-query: + @python3 tools/config_graph.py --surface $(SURFACE) + # Full gate run by agents and CI. -validate: validate-schema validate-whitespace validate-reuse +validate: validate-schema validate-graph validate-tests validate-whitespace validate-reuse @echo "validate: all checks passed" +# The committed graph edge artifact must match the registry. +validate-graph: + @python3 tools/config_graph.py --check + +# Resolver determinism + no-value-leak tests (ATLAS-WP-0004-T05). +validate-tests: + @python3 -m unittest discover -s tests -q + # Surface entries validate against the JSON Schema; index is consistent. validate-schema: @python3 tools/validate_registry.py diff --git a/registry/indexes/graph.yaml b/registry/indexes/graph.yaml new file mode 100644 index 0000000..f388587 --- /dev/null +++ b/registry/indexes/graph.yaml @@ -0,0 +1,65 @@ +version: 1 +generated_by: tools/config_graph.py +note: config-typed edges emitted from surface relations; ingest into the State Hub + graph (it owns storage). +summary: + nodes: 10 + edges: 10 +nodes: +- id: secret:ops-bridge/ssh-cert + type: secret +- id: secret:state-hub/database-url + type: secret +- id: service.ops-bridge + type: service +- id: service.reuse-surface-hub + type: service +- id: service.state-hub-api + type: service +- id: service.warden-cli + type: service +- id: surface.infotech.ops-bridge.tunnel-config + type: surface + kind: infra-state + owner: ops-bridge +- id: surface.infotech.ops-warden.routing-catalog + type: surface + kind: policy + owner: ops-warden +- id: surface.infotech.reuse-surface.federation-sources + type: surface + kind: app-config + owner: reuse-surface +- id: surface.infotech.state-hub.api-config + type: surface +edges: +- src: surface.infotech.ops-bridge.tunnel-config + type: consumed_by + dst: service.ops-bridge +- src: surface.infotech.ops-bridge.tunnel-config + type: depends_on_secret + dst: secret:ops-bridge/ssh-cert +- src: surface.infotech.ops-bridge.tunnel-config + type: related_to + dst: surface.infotech.state-hub.api-config +- src: surface.infotech.ops-warden.routing-catalog + type: consumed_by + dst: service.warden-cli +- src: surface.infotech.ops-warden.routing-catalog + type: related_to + dst: surface.infotech.state-hub.api-config +- src: surface.infotech.reuse-surface.federation-sources + type: consumed_by + dst: service.reuse-surface-hub +- src: surface.infotech.reuse-surface.federation-sources + type: related_to + dst: surface.infotech.state-hub.api-config +- src: surface.infotech.state-hub.api-config + type: consumed_by + dst: service.state-hub-api +- src: surface.infotech.state-hub.api-config + type: depends_on_secret + dst: secret:state-hub/database-url +- src: surface.infotech.state-hub.api-config + type: related_to + dst: surface.infotech.ops-bridge.tunnel-config diff --git a/tests/test_effective_config.py b/tests/test_effective_config.py new file mode 100644 index 0000000..f71886f --- /dev/null +++ b/tests/test_effective_config.py @@ -0,0 +1,97 @@ +"""Determinism and no-value-leak tests for the effective-config resolver +(ATLAS-WP-0004-T05). + +Runnable with `python3 -m unittest discover -s tests` (stdlib) or pytest. +Proves the override path is deterministic / order-independent (consistent with +the merge-rule + CUE-unification rationale, research §3.3) and that no live or +secret value ever appears in explain output. +""" +import random +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "tools")) + +from effective_config import LAYER_INDEX, find_entry, load_entry, resolve_path # noqa: E402 +from config_explain import render # noqa: E402 + +SURFACES_DIR = ROOT / "registry" / "surfaces" + + +def all_surface_ids(): + return sorted(p.stem for p in SURFACES_DIR.glob("*.md")) + + +def path_signature(op): + return [(c.layer, c.ref, c.winning, c.overrides) for c in op.contributions] + + +class TestResolverDeterminism(unittest.TestCase): + def test_order_independent(self): + """Shuffling sources never changes the resolved path.""" + for sid in all_surface_ids(): + base = path_signature(resolve_path(find_entry(sid))) + for _ in range(16): + entry = find_entry(sid) + random.shuffle(entry["sources"]) + self.assertEqual( + path_signature(resolve_path(entry)), base, + f"{sid}: path changed under source reordering", + ) + + def test_winning_is_most_specific(self): + """The winning layer is the highest-rank (most specific) layered source.""" + for sid in all_surface_ids(): + op = resolve_path(find_entry(sid)) + layered = [c for c in op.contributions if c.rank >= 0] + if not layered: + continue + winner = op.winner + self.assertIsNotNone(winner, f"{sid}: no winner among layered sources") + self.assertEqual( + winner.rank, max(c.rank for c in layered), + f"{sid}: winner is not the most specific layer", + ) + + def test_all_seeded_surfaces_resolve(self): + ids = all_surface_ids() + self.assertGreaterEqual(len(ids), 1) + for sid in ids: + resolve_path(find_entry(sid)) # must not raise + + +class TestNoValueLeak(unittest.TestCase): + def test_schema_default_never_rendered(self): + """A surface's declared schema default is contract metadata and must not + appear as an effective value in explain output.""" + for sid in all_surface_ids(): + entry = find_entry(sid) + default = (entry.get("schema") or {}).get("default") + if default is None: + continue + out = render(resolve_path(entry)) + self.assertNotIn( + str(default), out, + f"{sid}: schema default leaked into explain output", + ) + + def test_safety_note_present(self): + for sid in all_surface_ids(): + out = render(resolve_path(find_entry(sid))) + self.assertIn("no values shown", out, f"{sid}: missing no-values safety note") + + def test_secret_refs_are_references_only(self): + """depends_on_secret ids appear only on the explicit reference line, never + as a resolved value elsewhere.""" + for sid in all_surface_ids(): + op = resolve_path(find_entry(sid)) + if not op.secret_deps: + continue + out = render(op) + self.assertIn("depends on secret (ref)", out, f"{sid}: secret ref line missing") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/blast_radius.py b/tools/blast_radius.py new file mode 100644 index 0000000..2df0b4f --- /dev/null +++ b/tools/blast_radius.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Blast-radius / dependency view (ATLAS-WP-0004-T04). + +For a given configuration surface, traverse the relationship graph to list what a +change would affect: direct consumers (services), dependent surfaces (those that +reference or override it, transitively), referenced secrets, and the owner. This +supports change-risk reasoning before a change -- the +`config key -> service -> tenant -> feature -> secret -> owner` chain (research §5). + +Reads only the registry (no live values). Reuses the surface loader from +effective_config. + +Usage: + python3 tools/blast_radius.py surface.infotech.state-hub.api-config + make blast-radius SURFACE=surface.infotech.state-hub.api-config +""" +from __future__ import annotations + +import sys +from collections import deque + +from effective_config import SURFACES_DIR, load_entry + + +def load_all() -> dict[str, dict]: + return {p.stem: load_entry(p) for p in sorted(SURFACES_DIR.glob("*.md"))} + + +def relations(entry: dict) -> dict: + return entry.get("relations", {}) or {} + + +def build_reverse(entries: dict[str, dict]) -> dict[str, set[str]]: + """id -> set of surface ids that reference it via related_to or overrides.""" + rev: dict[str, set[str]] = {sid: set() for sid in entries} + for sid, e in entries.items(): + rel = relations(e) + for target in list(rel.get("related_to", [])) + list(rel.get("overrides", [])): + rev.setdefault(target, set()).add(sid) + return rev + + +def dependent_closure(start: str, rev: dict[str, set[str]]) -> list[str]: + """All surfaces that depend on `start` (transitively), nearest first. + + The start node is excluded from its own closure even when relationship cycles + (e.g. mutual related_to edges) would otherwise loop back to it. + """ + seen: set[str] = {start} + order: list[str] = [] + q = deque(sorted(rev.get(start, set()))) + while q: + cur = q.popleft() + if cur in seen: + continue + seen.add(cur) + order.append(cur) + for nxt in sorted(rev.get(cur, set())): + if nxt not in seen: + q.append(nxt) + return order + + +def risk_band(score: int) -> str: + return "high" if score >= 4 else "medium" if score >= 2 else "low" + + +def render(surface_id: str, entries: dict[str, dict]) -> str: + if surface_id not in entries: + raise KeyError(surface_id) + e = entries[surface_id] + rel = relations(e) + rev = build_reverse(entries) + + consumers = list(rel.get("consumed_by", [])) + secrets = list(rel.get("depends_on_secret", [])) + forward_related = list(rel.get("related_to", [])) + dependents = dependent_closure(surface_id, rev) + + score = len(consumers) + len(dependents) + lines: list[str] = [] + lines.append(f"blast radius {surface_id}") + lines.append("") + lines.append(f" {e.get('name', surface_id)}") + lines.append(f" kind: {e.get('kind','?')} owner: {e.get('owner','?')} mutability: {e.get('mutability','?')}") + lines.append(f" risk: {risk_band(score)} (fan-out {score}: {len(consumers)} consumer(s) + {len(dependents)} dependent surface(s))") + lines.append("") + lines.append(" direct consumers (services):") + for c in consumers or ["(none)"]: + lines.append(f" - {c}") + lines.append(" dependent surfaces (reference/override this, transitively):") + for d in dependents or ["(none)"]: + owner = entries.get(d, {}).get("owner", "?") + lines.append(f" - {d} [owner: {owner}]") + lines.append(" referenced secrets (by reference only):") + for s in secrets or ["(none)"]: + lines.append(f" - {s}") + if forward_related: + lines.append(" related to:") + for r in forward_related: + lines.append(f" - {r}") + lines.append("") + lines.append(" · derived from registry relations only; no live values read") + return "\n".join(lines) + + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print(__doc__) + return 2 + entries = load_all() + try: + print(render(argv[0], entries)) + except KeyError as exc: + print(f"error: no surface entry for id {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/config_graph.py b/tools/config_graph.py new file mode 100644 index 0000000..cd63c27 --- /dev/null +++ b/tools/config_graph.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Config knowledge-graph edges (ATLAS-WP-0004-T03). + +Express the config-typed relationships declared on surface entries as a +normalized, queryable edge list at `registry/indexes/graph.yaml`. config-atlas +owns the *config semantics* of each edge (consumed_by, overrides, +depends_on_secret, related_to); the State Hub owns topology/identity storage +(ecosystem-boundaries §2.5, repo-boundary). This tool therefore *emits* a +graph artifact the hub can ingest -- it does not build a separate graph store. + +Modes: + python3 tools/config_graph.py # regenerate graph.yaml + python3 tools/config_graph.py --check # fail if graph.yaml stale + python3 tools/config_graph.py --surface # edges touching a surface id + +The emitted graph is queryable by surface id (both edge directions). +""" +from __future__ import annotations + +import sys + +try: + import yaml +except ImportError as exc: # pragma: no cover + raise SystemExit(f"setup error: missing PyYAML ({exc}). pip install pyyaml") + +from effective_config import ROOT, SURFACES_DIR, load_entry + +GRAPH_PATH = ROOT / "registry" / "indexes" / "graph.yaml" +EDGE_TYPES = ["consumed_by", "overrides", "depends_on_secret", "related_to"] + + +def _secret_node(ref: str) -> str: + return f"secret:{ref}" + + +def build_graph() -> dict: + entries = {p.stem: load_entry(p) for p in sorted(SURFACES_DIR.glob("*.md"))} + nodes: dict[str, dict] = {} + edges: list[dict] = [] + + def node(nid: str, ntype: str, **attrs): + if nid not in nodes: + nodes[nid] = {"id": nid, "type": ntype, **{k: v for k, v in attrs.items() if v}} + + for sid, e in entries.items(): + node(sid, "surface", kind=e.get("kind"), owner=e.get("owner")) + rel = e.get("relations", {}) or {} + for svc in rel.get("consumed_by", []) or []: + node(svc, "service") + edges.append({"src": sid, "type": "consumed_by", "dst": svc}) + for tgt in rel.get("overrides", []) or []: + node(tgt, "surface") + edges.append({"src": sid, "type": "overrides", "dst": tgt}) + for sec in rel.get("depends_on_secret", []) or []: + node(_secret_node(sec), "secret") + edges.append({"src": sid, "type": "depends_on_secret", "dst": _secret_node(sec)}) + for tgt in rel.get("related_to", []) or []: + node(tgt, "surface") + edges.append({"src": sid, "type": "related_to", "dst": tgt}) + + edges.sort(key=lambda x: (x["src"], x["type"], x["dst"])) + return { + "version": 1, + "generated_by": "tools/config_graph.py", + "note": "config-typed edges emitted from surface relations; ingest into the State Hub graph (it owns storage).", + "summary": {"nodes": len(nodes), "edges": len(edges)}, + "nodes": [nodes[k] for k in sorted(nodes)], + "edges": edges, + } + + +def write_graph() -> dict: + graph = build_graph() + GRAPH_PATH.write_text(yaml.safe_dump(graph, sort_keys=False)) + return graph + + +def edges_for(surface_id: str, graph: dict) -> list[dict]: + return [e for e in graph["edges"] if e["src"] == surface_id or e["dst"] == surface_id] + + +def main(argv: list[str]) -> int: + if argv and argv[0] == "--surface": + if len(argv) != 2: + print(__doc__) + return 2 + sid = argv[1] + graph = yaml.safe_load(GRAPH_PATH.read_text()) if GRAPH_PATH.exists() else build_graph() + hits = edges_for(sid, graph) + if not hits: + print(f"no edges for {sid}") + return 0 + print(f"edges touching {sid}:") + for e in hits: + arrow = "->" if e["src"] == sid else "<-" + other = e["dst"] if e["src"] == sid else e["src"] + print(f" {arrow} {e['type']:18} {other}") + return 0 + + if argv and argv[0] == "--check": + current = GRAPH_PATH.read_text() if GRAPH_PATH.exists() else "" + expected = yaml.safe_dump(build_graph(), sort_keys=False) + if current != expected: + print("FAIL: registry/indexes/graph.yaml is stale — run `make graph`", file=sys.stderr) + return 1 + print("OK: graph.yaml up to date") + return 0 + + g = write_graph() + print(f"wrote {GRAPH_PATH.relative_to(ROOT)}: {g['summary']['nodes']} nodes, {g['summary']['edges']} edges") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/workplans/ATLAS-WP-0004-explain-and-graph.md b/workplans/ATLAS-WP-0004-explain-and-graph.md index de63384..43689d0 100644 --- a/workplans/ATLAS-WP-0004-explain-and-graph.md +++ b/workplans/ATLAS-WP-0004-explain-and-graph.md @@ -4,7 +4,7 @@ type: workplan title: "Effective-config explain and graph" domain: infotech repo: config-atlas -status: active +status: finished owner: codex topic_slug: custodian created: "2026-06-26" @@ -92,11 +92,17 @@ Reuse the existing `tools/` + Makefile pattern (`make explain SURFACE=...`). ```task id: ATLAS-WP-0004-T03 -status: todo +status: done priority: medium state_hub_task_id: "7b16eaa0-f5e1-4ff3-809d-729b312fd154" ``` +Result 2026-06-27: Added `tools/config_graph.py` + `make graph`/`graph-query`. +Emits config-typed edges (consumed_by/overrides/depends_on_secret/related_to) to +`registry/indexes/graph.yaml` (10 nodes, 10 edges) — queryable by surface id in +both directions. config-atlas owns edge semantics; the State Hub owns storage +(artifact is hub-ingestible). A `--check` staleness gate runs in `make validate`. + Emit config-typed edges (`consumed_by`, `overrides`, `depends_on_secret`, `related_to`) from surface entries to the **State Hub** relationship/graph model, contributing the config semantics of each edge while the hub stores topology @@ -109,11 +115,16 @@ contributing the config semantics of each edge while the hub stores topology ```task id: ATLAS-WP-0004-T04 -status: todo +status: done priority: medium state_hub_task_id: "57e085c7-25e8-4e2d-bb3e-43b82e351aa9" ``` +Result 2026-06-27: Added `tools/blast_radius.py` + `make blast-radius`. For a +surface it lists direct consumers, transitively-dependent surfaces (cycle-safe), +referenced secrets, owner, and a fan-out risk band. Verified: state-hub config is +high risk (fan-out 4); leaf surfaces lower. + Build a view that, for a given surface, traverses the graph to list affected consumers, dependent surfaces, and referenced secrets — the `config key → service → tenant → feature → secret → owner` chain from @@ -126,11 +137,16 @@ research §5. Supports change-risk reasoning before a change. ```task id: ATLAS-WP-0004-T05 -status: todo +status: done priority: medium state_hub_task_id: "77b19d5f-f55c-48bb-8129-ba1478e47223" ``` +Result 2026-06-27: Added `tests/test_effective_config.py` (6 tests) proving the +override path is order-independent (16x shuffle), the winner is the most-specific +layer, and no schema default / live / secret value leaks into explain output. +Wired into `make validate` and CI (`validate-tests`). + Add tests proving the override path is deterministic and order-independent (consistent with the merge-rule and CUE-unification rationale, research §3.3), and that no live or secret value ever appears in explain output.