From 85401b21029b562c052220921a1aed4551bf8327 Mon Sep 17 00:00:00 2001 From: ProtocolWarden <32967198+ProtocolWarden@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:21:44 -0400 Subject: [PATCH 1/2] feat(capabilities): add capability registry schema and read-model projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the fleet capability plane as a graph-native module in RepoGraph: capabilities are first-class nodes with typed edges (owns/targets/executes/ requires/validates/produces) to repos and artifacts. Ergonomic flat authoring fields compile to the canonical node+edge graph — the graph is the truth. - enums/models/compiler/validation/projection under src/repograph/capabilities/ - SchemaKind.CAPABILITIES @ 1.0.0; EntityKind.CAPABILITY; public-safe projection drops non-public capabilities; to_payload() gives a deterministic boundary hash - invariants: exactly one owns edge per capability; target_scope repo/repo_set/ fleet (selector validated, membership NOT expanded); dangerous risk requires an explicit preferred_lane; unknown enums fail closed - tests cover the full matrix; T1/T6 satisfied by genuine direct-import tests, only T7 excluded for capabilities/** (flat tests/ layout) RepoGraph stays import-free of the fleet: invocation.ref is recorded opaque and resolved by Custodian (CAP1), not here. Co-Authored-By: Claude Opus 4.8 --- .console/log.md | 23 ++ .custodian/config.yaml | 4 + src/repograph/__init__.py | 34 +++ src/repograph/capabilities/__init__.py | 66 +++++ src/repograph/capabilities/compiler.py | 148 +++++++++++ src/repograph/capabilities/enums.py | 68 +++++ src/repograph/capabilities/models.py | 192 ++++++++++++++ src/repograph/capabilities/projection.py | 29 ++ src/repograph/capabilities/validation.py | 121 +++++++++ src/repograph/ontology/enums.py | 1 + src/repograph/projection/models.py | 4 +- src/repograph/schema/kinds.py | 1 + src/repograph/schema/versions.py | 1 + tests/test_capability_registry.py | 324 +++++++++++++++++++++++ 14 files changed, 1014 insertions(+), 2 deletions(-) create mode 100644 src/repograph/capabilities/__init__.py create mode 100644 src/repograph/capabilities/compiler.py create mode 100644 src/repograph/capabilities/enums.py create mode 100644 src/repograph/capabilities/models.py create mode 100644 src/repograph/capabilities/projection.py create mode 100644 src/repograph/capabilities/validation.py create mode 100644 tests/test_capability_registry.py diff --git a/.console/log.md b/.console/log.md index 2a64711..d82b950 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,4 +1,27 @@ # Log +## 2026-06-15 — Capability registry: schema + read-model projection + +Added the fleet capability plane as a graph-native module (`src/repograph/capabilities/`): +capabilities are first-class nodes with typed edges (owns/targets/executes/requires/ +validates/produces). Ergonomic flat authoring fields compile to the canonical node+edge +graph — the graph is the truth, the fields are sugar. + +- `SchemaKind.CAPABILITIES` @ 1.0.0; `EntityKind.CAPABILITY`; "Capability" added to the + public_safe/public_docs projection entity kinds; `project_capability_registry` drops + non-public capabilities; `to_payload()` gives a deterministic hash for boundary coverage. +- Invariants: exactly one `owns` edge per capability (accountability is one, participation + is many); `target_scope` in {repo (resolves), repo_set (selector validated, membership NOT + expanded), fleet (no id/selector)}; risk >= mutates_fleet requires an explicit + preferred_lane; unknown enums fail closed. Repo stays import-free of the fleet: + `invocation.ref` is opaque, resolved later by Custodian (the CAP1 detector, separate PR). +- Tests: `tests/test_capability_registry.py`. Honoured the repo's "genuine tests over + over-excluding" preference — T1/T6 satisfied by direct-import per-symbol tests; only T7 + excluded for `capabilities/**` (this repo uses a flat tests/ layout; no parallel-file + tree exists for any subpackage). +- Genericised the illustrative example off a private-repo name to the neutral `MediaForge` + placeholder (B1 boundary). +- 98 tests pass; `custodian-multi` clean (0 findings). + ## 2026-06-04 — Console reconciliation: scrub + enforce Reconciled `.console/` per the console-reconciliation spec (scrub-target boundary, I2). diff --git a/.custodian/config.yaml b/.custodian/config.yaml index 524d3ba..cf0fb2d 100644 --- a/.custodian/config.yaml +++ b/.custodian/config.yaml @@ -56,6 +56,10 @@ audit: - "src/repograph/schema/**" - "src/repograph/topography/**" - "src/repograph/topology/**" + # T7 expects tests/unit/repograph/capabilities/test_*.py; this repo uses a + # flat tests/ layout (no parallel-file tree exists for any subpackage). + # T1/T6 are satisfied by genuine direct-import tests, not excluded. + - "src/repograph/capabilities/**" - "src/repograph/diff.py" - "src/repograph/errors.py" diff --git a/src/repograph/__init__.py b/src/repograph/__init__.py index 70e9f4f..d424586 100644 --- a/src/repograph/__init__.py +++ b/src/repograph/__init__.py @@ -73,6 +73,24 @@ RepoEdgeType, RepoGraph, ) +from .capabilities import ( + CapabilityCategory, + CapabilityEdge, + CapabilityEdgeKind, + CapabilityInput, + CapabilityInvocation, + CapabilityInvocationKind, + CapabilityNode, + CapabilityRegistry, + CapabilityRisk, + TargetScope, + TargetScopeKind, + compile_capability, + compile_registry, + load_capability_registry, + project_capability_registry, + validate_capability_registry, +) __all__ = [ "AlsoHostsEntry", @@ -86,6 +104,15 @@ "resolve_private_manifest", "BoundaryDisclosureArtifact", "BreakingChangePolicy", + "CapabilityCategory", + "CapabilityEdge", + "CapabilityEdgeKind", + "CapabilityInput", + "CapabilityInvocation", + "CapabilityInvocationKind", + "CapabilityNode", + "CapabilityRegistry", + "CapabilityRisk", "CompatibilityPolicy", "CURRENT_SCHEMA_VERSIONS", "DisclosureMode", @@ -120,7 +147,14 @@ "Source", "SchemaKind", "SchemaVersion", + "TargetScope", + "TargetScopeKind", "Visibility", + "compile_capability", + "compile_registry", + "load_capability_registry", + "project_capability_registry", + "validate_capability_registry", "build_projection_profile", "build_boundary_artifact", "build_public_projection", diff --git a/src/repograph/capabilities/__init__.py b/src/repograph/capabilities/__init__.py new file mode 100644 index 0000000..d1e0d5b --- /dev/null +++ b/src/repograph/capabilities/__init__.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Capability registry — the fleet's action plane. + +A capability describes *what the ecosystem can do*, independent of how the +action executes. It is a first-class graph node with exactly one accountable +owner and typed edges to participating repos/artifacts. This package owns the +language; instances live in the platform/private manifests, and Custodian +(CAP1) verifies that ``invocation.ref`` resolves in the owning repo. +""" + +from .compiler import ( + compile_capability, + compile_registry, + load_capability_registry, +) +from .enums import ( + LANE_REQUIRING_RISKS, + REPO_TARGETING_EDGE_KINDS, + CapabilityCategory, + CapabilityEdgeKind, + CapabilityInvocationKind, + CapabilityRisk, + TargetScopeKind, +) +from .models import ( + CapabilityEdge, + CapabilityInput, + CapabilityInvocation, + CapabilityNode, + CapabilityRegistry, + TargetScope, +) +from .projection import project_capability_registry +from .validation import ( + parse_capability_category, + parse_capability_invocation_kind, + parse_capability_risk, + parse_target_scope_kind, + validate_capability_registry, +) + +__all__ = [ + "CapabilityCategory", + "CapabilityEdge", + "CapabilityEdgeKind", + "CapabilityInput", + "CapabilityInvocation", + "CapabilityInvocationKind", + "CapabilityNode", + "CapabilityRegistry", + "CapabilityRisk", + "LANE_REQUIRING_RISKS", + "REPO_TARGETING_EDGE_KINDS", + "TargetScope", + "TargetScopeKind", + "compile_capability", + "compile_registry", + "load_capability_registry", + "parse_capability_category", + "parse_capability_invocation_kind", + "parse_capability_risk", + "parse_target_scope_kind", + "project_capability_registry", + "validate_capability_registry", +] diff --git a/src/repograph/capabilities/compiler.py b/src/repograph/capabilities/compiler.py new file mode 100644 index 0000000..6472b2f --- /dev/null +++ b/src/repograph/capabilities/compiler.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Compile ergonomic authoring fields into the canonical capability graph. + +Authors write flat fields (owner_repo_id / target_scope / executes_on / +requires / validated_by / produces); this module expands them into a +``CapabilityNode`` plus typed ``CapabilityEdge`` list. The compiled graph is the +truth — the flat fields are sugar. +""" + +from __future__ import annotations + +from typing import Any + +from ..errors import RepoGraphConfigError +from ..ontology.enums import DisclosureMode, RepoVisibility +from ..schema.kinds import SchemaKind +from ..schema.validation import validate_schema_document +from .enums import CapabilityEdgeKind, TargetScopeKind +from .models import ( + CapabilityEdge, + CapabilityInput, + CapabilityInvocation, + CapabilityNode, + CapabilityRegistry, + TargetScope, +) +from .validation import ( + parse_capability_category, + parse_capability_invocation_kind, + parse_capability_risk, + parse_target_scope_kind, +) + + +def _compile_target_scope(idx: int, raw: dict[str, Any]) -> TargetScope: + kind = parse_target_scope_kind(idx, raw.get("kind")) + selector_raw = raw.get("selector") or {} + if not isinstance(selector_raw, dict): + raise RepoGraphConfigError( + f"capability #{idx} target_scope.selector must be a mapping" + ) + selector = tuple(sorted((str(k), v) for k, v in selector_raw.items())) + return TargetScope(kind=kind, repo_id=raw.get("repo_id"), selector=selector) + + +def _compile_optional_enum(idx: int, raw: Any, enum_cls: Any, default: Any, field_name: str) -> Any: + if raw is None: + return default + try: + return enum_cls(raw) + except ValueError as exc: + raise RepoGraphConfigError( + f"capability #{idx} has unknown {field_name} '{raw}'" + ) from exc + + +def compile_capability( + idx: int, raw: dict[str, Any] +) -> tuple[CapabilityNode, list[CapabilityEdge]]: + action_id = raw.get("action_id") + if not action_id: + raise RepoGraphConfigError(f"capability #{idx} requires action_id") + + invocation_raw = raw.get("invocation") or {} + invocation = CapabilityInvocation( + kind=parse_capability_invocation_kind(idx, invocation_raw.get("kind")), + ref=invocation_raw.get("ref", ""), + ) + + inputs = tuple( + CapabilityInput( + name=item.get("name", ""), + type=item.get("type", ""), + required=bool(item.get("required", False)), + ) + for item in (raw.get("inputs") or []) + ) + + routing = raw.get("routing") or {} + target_scope = _compile_target_scope(idx, raw.get("target_scope") or {}) + + node = CapabilityNode( + action_id=action_id, + name=raw.get("name", action_id), + category=parse_capability_category(idx, raw.get("category")), + risk=parse_capability_risk(idx, raw.get("risk")), + invocation=invocation, + target_scope=target_scope, + inputs=inputs, + preferred_lane=routing.get("preferred_lane"), + description=raw.get("description"), + visibility=_compile_optional_enum( + idx, raw.get("visibility"), RepoVisibility, RepoVisibility.PUBLIC, "visibility" + ), + disclosure_mode=_compile_optional_enum( + idx, raw.get("disclosure_mode"), DisclosureMode, DisclosureMode.PUBLIC, "disclosure_mode" + ), + ) + + owner = raw.get("owner_repo_id") + if not owner: + raise RepoGraphConfigError(f"capability '{action_id}' requires owner_repo_id") + + edges: list[CapabilityEdge] = [ + CapabilityEdge(action_id, owner, CapabilityEdgeKind.OWNS) + ] + if target_scope.kind is TargetScopeKind.REPO and target_scope.repo_id: + edges.append( + CapabilityEdge(action_id, target_scope.repo_id, CapabilityEdgeKind.TARGETS) + ) + if raw.get("executes_on"): + edges.append( + CapabilityEdge(action_id, raw["executes_on"], CapabilityEdgeKind.EXECUTES) + ) + for repo in raw.get("requires") or []: + edges.append(CapabilityEdge(action_id, repo, CapabilityEdgeKind.REQUIRES)) + for repo in raw.get("validated_by") or []: + edges.append(CapabilityEdge(action_id, repo, CapabilityEdgeKind.VALIDATES)) + for artifact in raw.get("produces") or []: + edges.append(CapabilityEdge(action_id, artifact, CapabilityEdgeKind.PRODUCES)) + + return node, edges + + +def compile_registry( + documents: list[dict[str, Any]], + *, + known_repo_ids: set[str] | None = None, +) -> CapabilityRegistry: + nodes: list[CapabilityNode] = [] + edges: list[CapabilityEdge] = [] + for idx, raw in enumerate(documents): + node, node_edges = compile_capability(idx, raw) + nodes.append(node) + edges.extend(node_edges) + return CapabilityRegistry.build(nodes, edges, known_repo_ids=known_repo_ids) + + +def load_capability_registry( + payload: dict[str, Any], + *, + known_repo_ids: set[str] | None = None, +) -> CapabilityRegistry: + """Validate a versioned ``capabilities`` document and compile it.""" + validate_schema_document(payload, expected_kind=SchemaKind.CAPABILITIES) + documents = payload.get("capabilities") or [] + return compile_registry(documents, known_repo_ids=known_repo_ids) diff --git a/src/repograph/capabilities/enums.py b/src/repograph/capabilities/enums.py new file mode 100644 index 0000000..2a699d9 --- /dev/null +++ b/src/repograph/capabilities/enums.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Capability registry vocabulary. + +The capability plane is graph-native: a capability is a first-class node with +typed edges to repos/artifacts, NOT a property hung off a repo. These enums are +the language; instances live in the platform/private manifests. +""" + +from __future__ import annotations + +from enum import Enum + + +class CapabilityCategory(str, Enum): + AUDIT = "audit" + ORCHESTRATION = "orchestration" + EXECUTION = "execution" + MIGRATION = "migration" + VERIFICATION = "verification" + MAINTENANCE = "maintenance" + + +class CapabilityRisk(str, Enum): + READ_ONLY = "read_only" + MUTATES_REPO = "mutates_repo" + MUTATES_FLEET = "mutates_fleet" + IRREVERSIBLE = "irreversible" + + +class CapabilityEdgeKind(str, Enum): + OWNS = "owns" + TARGETS = "targets" + EXECUTES = "executes" + REQUIRES = "requires" + VALIDATES = "validates" + PRODUCES = "produces" + + +class CapabilityInvocationKind(str, Enum): + ENTRYPOINT = "entrypoint" + CLI = "cli" + EXECUTOR = "executor" + + +class TargetScopeKind(str, Enum): + REPO = "repo" + REPO_SET = "repo_set" + FLEET = "fleet" + + +# Edge kinds whose ``target_id`` must resolve to a known repo. PRODUCES is +# excluded: its target is an artifact identifier, not a repo. +REPO_TARGETING_EDGE_KINDS: frozenset[CapabilityEdgeKind] = frozenset({ + CapabilityEdgeKind.OWNS, + CapabilityEdgeKind.TARGETS, + CapabilityEdgeKind.EXECUTES, + CapabilityEdgeKind.REQUIRES, + CapabilityEdgeKind.VALIDATES, +}) + + +# Risk levels that demand an explicit, deliberate route — dangerous actions must +# not ride the default lane. +LANE_REQUIRING_RISKS: frozenset[CapabilityRisk] = frozenset({ + CapabilityRisk.MUTATES_FLEET, + CapabilityRisk.IRREVERSIBLE, +}) diff --git a/src/repograph/capabilities/models.py b/src/repograph/capabilities/models.py new file mode 100644 index 0000000..bbe4408 --- /dev/null +++ b/src/repograph/capabilities/models.py @@ -0,0 +1,192 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Capability registry models. + +Authoring is ergonomic flat fields (see ``compiler``); the canonical truth is +this node + typed-edge graph. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from ..errors import RepoGraphConfigError +from ..ontology.enums import DisclosureMode, EntityKind, RepoVisibility +from ..ontology.models import MetadataValue +from ..schema.kinds import SchemaKind +from ..schema.versions import schema_version_for +from .enums import ( + LANE_REQUIRING_RISKS, + CapabilityCategory, + CapabilityEdgeKind, + CapabilityInvocationKind, + CapabilityRisk, + TargetScopeKind, +) +from .validation import validate_capability_registry + + +@dataclass(frozen=True) +class CapabilityInput: + name: str + type: str + required: bool = False + + +@dataclass(frozen=True) +class CapabilityInvocation: + kind: CapabilityInvocationKind + ref: str + + def __post_init__(self) -> None: + if not self.ref or not self.ref.strip(): + raise RepoGraphConfigError("capability invocation requires a non-empty ref") + + +@dataclass(frozen=True) +class TargetScope: + kind: TargetScopeKind + repo_id: str | None = None + selector: tuple[tuple[str, MetadataValue], ...] = () + + def __post_init__(self) -> None: + if self.kind is TargetScopeKind.REPO: + if not self.repo_id: + raise RepoGraphConfigError("target_scope kind='repo' requires repo_id") + if self.selector: + raise RepoGraphConfigError("target_scope kind='repo' must not set a selector") + elif self.kind is TargetScopeKind.REPO_SET: + if not self.selector: + raise RepoGraphConfigError("target_scope kind='repo_set' requires a selector") + if self.repo_id: + raise RepoGraphConfigError("target_scope kind='repo_set' must not set repo_id") + else: # FLEET + if self.repo_id or self.selector: + raise RepoGraphConfigError( + "target_scope kind='fleet' must not set repo_id or selector" + ) + + +@dataclass(frozen=True) +class CapabilityEdge: + source_id: str # capability action_id + target_id: str # repo_id (repo-targeting kinds) or artifact id (produces) + kind: CapabilityEdgeKind + + +@dataclass(frozen=True) +class CapabilityNode: + action_id: str + name: str + category: CapabilityCategory + risk: CapabilityRisk + invocation: CapabilityInvocation + target_scope: TargetScope + inputs: tuple[CapabilityInput, ...] = () + preferred_lane: str | None = None + description: str | None = None + visibility: RepoVisibility = RepoVisibility.PUBLIC + disclosure_mode: DisclosureMode = DisclosureMode.PUBLIC + kind: EntityKind = EntityKind.CAPABILITY + metadata: tuple[tuple[str, MetadataValue], ...] = () + + def __post_init__(self) -> None: + if not self.action_id or not self.action_id.strip(): + raise RepoGraphConfigError("capability requires a non-empty action_id") + if self.risk in LANE_REQUIRING_RISKS and not self.preferred_lane: + raise RepoGraphConfigError( + f"capability '{self.action_id}' risk={self.risk.value!r} requires an " + "explicit routing.preferred_lane" + ) + if ( + self.visibility is RepoVisibility.LOCAL + and self.disclosure_mode is DisclosureMode.PUBLIC + ): + raise RepoGraphConfigError( + f"capability '{self.action_id}' cannot be local visibility with public disclosure" + ) + + @property + def is_public(self) -> bool: + return ( + self.visibility is RepoVisibility.PUBLIC + and self.disclosure_mode is DisclosureMode.PUBLIC + ) + + +@dataclass +class CapabilityRegistry: + nodes: dict[str, CapabilityNode] = field(default_factory=dict) + edges: tuple[CapabilityEdge, ...] = () + + @classmethod + def build( + cls, + nodes: list[CapabilityNode], + edges: list[CapabilityEdge], + *, + known_repo_ids: set[str] | None = None, + ) -> "CapabilityRegistry": + registry = cls( + nodes={node.action_id: node for node in nodes}, + edges=tuple(edges), + ) + validate_capability_registry(registry, nodes, known_repo_ids=known_repo_ids) + return registry + + def list_capabilities(self) -> list[CapabilityNode]: + return sorted(self.nodes.values(), key=lambda n: n.action_id) + + def capability(self, action_id: str) -> CapabilityNode | None: + return self.nodes.get(action_id) + + def edges_for(self, action_id: str) -> list[CapabilityEdge]: + return [e for e in self.edges if e.source_id == action_id] + + def targets_by_kind(self, action_id: str, kind: CapabilityEdgeKind) -> list[str]: + return [ + e.target_id + for e in self.edges + if e.source_id == action_id and e.kind is kind + ] + + def owner_of(self, action_id: str) -> str | None: + owners = self.targets_by_kind(action_id, CapabilityEdgeKind.OWNS) + return owners[0] if owners else None + + def to_payload(self) -> dict: + """Canonical, deterministic projection for boundary hashing.""" + return { + "schema_kind": SchemaKind.CAPABILITIES.value, + "schema_version": schema_version_for(SchemaKind.CAPABILITIES).version, + "capabilities": [ + { + "action_id": n.action_id, + "name": n.name, + "category": n.category.value, + "risk": n.risk.value, + "visibility": n.visibility.value, + "disclosure_mode": n.disclosure_mode.value, + "preferred_lane": n.preferred_lane, + "description": n.description, + "invocation": { + "kind": n.invocation.kind.value, + "ref": n.invocation.ref, + }, + "target_scope": { + "kind": n.target_scope.kind.value, + "repo_id": n.target_scope.repo_id, + "selector": [list(pair) for pair in n.target_scope.selector], + }, + "inputs": [ + {"name": i.name, "type": i.type, "required": i.required} + for i in n.inputs + ], + "metadata": [list(pair) for pair in n.metadata], + } + for n in self.list_capabilities() + ], + "edges": sorted( + [e.source_id, e.kind.value, e.target_id] for e in self.edges + ), + } diff --git a/src/repograph/capabilities/projection.py b/src/repograph/capabilities/projection.py new file mode 100644 index 0000000..fc4476e --- /dev/null +++ b/src/repograph/capabilities/projection.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Capability registry projection. + +Public-safe projection drops capabilities that are not public — same boundary +discipline the repo graph already applies to private entities. +""" + +from __future__ import annotations + +from ..projection.models import DEFAULT_PROJECTION_PROFILE_RULES, ProjectionProfileKind +from .models import CapabilityRegistry + + +def project_capability_registry( + registry: CapabilityRegistry, + profile_kind: ProjectionProfileKind, +) -> CapabilityRegistry: + rules = DEFAULT_PROJECTION_PROFILE_RULES[profile_kind] + public_safe = rules.output_safety_class == "public_safe" + + kept = [ + node + for node in registry.list_capabilities() + if not public_safe or node.is_public + ] + kept_ids = {node.action_id for node in kept} + edges = [edge for edge in registry.edges if edge.source_id in kept_ids] + return CapabilityRegistry.build(kept, edges) diff --git a/src/repograph/capabilities/validation.py b/src/repograph/capabilities/validation.py new file mode 100644 index 0000000..2f2e1a6 --- /dev/null +++ b/src/repograph/capabilities/validation.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Capability registry validation. + +RepoGraph validates *shape* and graph invariants. It stays import-free of the +fleet: ``invocation.ref`` is recorded as an opaque string and resolved by +Custodian (detector CAP1), not here. When ``known_repo_ids`` is supplied, +repo-targeting edges are also checked to resolve. +""" + +from __future__ import annotations + +from typing import Any + +from ..errors import RepoGraphConfigError +from .enums import ( + REPO_TARGETING_EDGE_KINDS, + CapabilityCategory, + CapabilityEdgeKind, + CapabilityInvocationKind, + CapabilityRisk, + TargetScopeKind, +) + + +def parse_capability_category(idx: int, raw: Any) -> CapabilityCategory: + try: + return CapabilityCategory(raw) + except ValueError as exc: + raise RepoGraphConfigError( + f"capability #{idx} has unknown category '{raw}'; " + f"allowed: {[c.value for c in CapabilityCategory]}" + ) from exc + + +def parse_capability_risk(idx: int, raw: Any) -> CapabilityRisk: + try: + return CapabilityRisk(raw) + except ValueError as exc: + raise RepoGraphConfigError( + f"capability #{idx} has unknown risk '{raw}'; " + f"allowed: {[r.value for r in CapabilityRisk]}" + ) from exc + + +def parse_capability_invocation_kind(idx: int, raw: Any) -> CapabilityInvocationKind: + try: + return CapabilityInvocationKind(raw) + except ValueError as exc: + raise RepoGraphConfigError( + f"capability #{idx} has unknown invocation kind '{raw}'; " + f"allowed: {[k.value for k in CapabilityInvocationKind]}" + ) from exc + + +def parse_target_scope_kind(idx: int, raw: Any) -> TargetScopeKind: + try: + return TargetScopeKind(raw) + except ValueError as exc: + raise RepoGraphConfigError( + f"capability #{idx} has unknown target_scope kind '{raw}'; " + f"allowed: {[k.value for k in TargetScopeKind]}" + ) from exc + + +def validate_capability_registry( + registry: Any, + nodes: list[Any], + *, + known_repo_ids: set[str] | None = None, +) -> None: + seen: set[str] = set() + for node in nodes: + if node.action_id in seen: + raise RepoGraphConfigError(f"duplicate capability action_id: {node.action_id}") + seen.add(node.action_id) + + owns_count: dict[str, int] = {action_id: 0 for action_id in registry.nodes} + for edge in registry.edges: + if edge.source_id not in registry.nodes: + raise RepoGraphConfigError( + f"capability edge references unknown capability '{edge.source_id}'" + ) + if edge.kind is CapabilityEdgeKind.OWNS: + owns_count[edge.source_id] += 1 + if ( + known_repo_ids is not None + and edge.kind in REPO_TARGETING_EDGE_KINDS + and edge.target_id not in known_repo_ids + ): + raise RepoGraphConfigError( + f"capability '{edge.source_id}' {edge.kind.value} edge references " + f"unknown repo '{edge.target_id}'" + ) + + for action_id, count in owns_count.items(): + if count != 1: + raise RepoGraphConfigError( + f"capability '{action_id}' must have exactly one owns edge, found {count}" + ) + + # target_scope <-> targets-edge consistency: a concrete repo target emits + # exactly one resolvable targets edge; repo_set / fleet emit none (the scope + # is dynamic and lives on the node, not as an edge). + for node in nodes: + targets = [ + e.target_id + for e in registry.edges + if e.source_id == node.action_id and e.kind is CapabilityEdgeKind.TARGETS + ] + if node.target_scope.kind is TargetScopeKind.REPO: + if node.target_scope.repo_id not in targets: + raise RepoGraphConfigError( + f"capability '{node.action_id}' target_scope repo " + f"'{node.target_scope.repo_id}' has no matching targets edge" + ) + elif targets: + raise RepoGraphConfigError( + f"capability '{node.action_id}' target_scope " + f"kind={node.target_scope.kind.value!r} must not emit a targets edge" + ) diff --git a/src/repograph/ontology/enums.py b/src/repograph/ontology/enums.py index 17372f5..a4c4d75 100644 --- a/src/repograph/ontology/enums.py +++ b/src/repograph/ontology/enums.py @@ -107,3 +107,4 @@ class EntityKind(str, Enum): REDACTION_RULE = "RedactionRule" MIRROR_RELATIONSHIP = "MirrorRelationship" SUPERSET_RELATIONSHIP = "SupersetRelationship" + CAPABILITY = "Capability" diff --git a/src/repograph/projection/models.py b/src/repograph/projection/models.py index 125d611..18263ff 100644 --- a/src/repograph/projection/models.py +++ b/src/repograph/projection/models.py @@ -62,7 +62,7 @@ class ProjectionProfile: DEFAULT_PROJECTION_PROFILE_RULES: dict[ProjectionProfileKind, ProjectionProfileRules] = { ProjectionProfileKind.PUBLIC_SAFE: ProjectionProfileRules( - allowed_entity_kinds=("Repository", "Project", "Manifest"), + allowed_entity_kinds=("Repository", "Project", "Manifest", "Capability"), allowed_edge_kinds=("consumes_manifest", "publishes_projection", "reads_artifact", "writes_artifact", "deploys", "hosts"), allowed_fields=("canonical_name", "visibility", "projection_behavior", "public_alias", "github_url", "description", "owner", "scope", "runtime_role", "kind", "metadata"), redaction_rules=("hide_private_names", "hide_local_paths", "drop_secret_refs", "collapse_private_entities"), @@ -71,7 +71,7 @@ class ProjectionProfile: output_safety_class="public_safe", ), ProjectionProfileKind.PUBLIC_DOCS: ProjectionProfileRules( - allowed_entity_kinds=("Repository", "Project", "Manifest"), + allowed_entity_kinds=("Repository", "Project", "Manifest", "Capability"), allowed_edge_kinds=("consumes_manifest", "publishes_projection", "reads_artifact", "writes_artifact", "deploys", "hosts"), allowed_fields=("canonical_name", "visibility", "projection_behavior", "public_alias", "github_url", "description", "owner", "scope", "runtime_role", "kind", "metadata"), redaction_rules=("hide_private_names", "hide_local_paths", "drop_secret_refs"), diff --git a/src/repograph/schema/kinds.py b/src/repograph/schema/kinds.py index ac5b114..03d37c5 100644 --- a/src/repograph/schema/kinds.py +++ b/src/repograph/schema/kinds.py @@ -12,6 +12,7 @@ class SchemaKind(str, Enum): TOPOLOGY = "topology" PROJECTION = "projection" BOUNDARY_ARTIFACT = "boundary_artifact" + CAPABILITIES = "capabilities" class CompatibilityPolicy(str, Enum): diff --git a/src/repograph/schema/versions.py b/src/repograph/schema/versions.py index 624425a..ae206bc 100644 --- a/src/repograph/schema/versions.py +++ b/src/repograph/schema/versions.py @@ -22,6 +22,7 @@ class SchemaVersion: SchemaKind.TOPOLOGY: SchemaVersion(SchemaKind.TOPOLOGY, "1.0.0"), SchemaKind.PROJECTION: SchemaVersion(SchemaKind.PROJECTION, "1.0.0"), SchemaKind.BOUNDARY_ARTIFACT: SchemaVersion(SchemaKind.BOUNDARY_ARTIFACT, "1.0.0"), + SchemaKind.CAPABILITIES: SchemaVersion(SchemaKind.CAPABILITIES, "1.0.0"), } diff --git a/tests/test_capability_registry.py b/tests/test_capability_registry.py new file mode 100644 index 0000000..458d4fb --- /dev/null +++ b/tests/test_capability_registry.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import pytest + +from repograph import ( + DEFAULT_PROJECTION_PROFILE_RULES, + CapabilityEdge, + CapabilityEdgeKind, + CapabilityInvocation, + CapabilityInvocationKind, + CapabilityNode, + CapabilityCategory, + CapabilityRegistry, + CapabilityRisk, + EntityKind, + ProjectionProfileKind, + RepoGraphConfigError, + TargetScope, + TargetScopeKind, + compute_artifact_hash, +) + +# Direct submodule imports: per-symbol/per-module coverage of the capability +# plane (compiler, models, validation, enums, projection) rather than relying +# only on the top-level re-export surface. +from repograph.capabilities.compiler import ( + compile_capability, + compile_registry, + load_capability_registry, +) +from repograph.capabilities.enums import REPO_TARGETING_EDGE_KINDS +from repograph.capabilities.models import CapabilityInput +from repograph.capabilities.projection import project_capability_registry +from repograph.capabilities.validation import ( + parse_capability_category, + parse_capability_invocation_kind, + parse_capability_risk, + parse_target_scope_kind, + validate_capability_registry, +) + +_ALL_REPOS = { + "operations_center", + "media_forge", + "team_executor", + "context_lifecycle", + "custodian", +} + + +def _asset_audit(**overrides) -> dict: + doc = { + "action_id": "asset_audit", + "name": "MediaForge Audit", + "owner_repo_id": "operations_center", + "target_scope": {"kind": "repo", "repo_id": "media_forge"}, + "executes_on": "team_executor", + "requires": ["context_lifecycle"], + "validated_by": ["custodian"], + "produces": ["investigation_capsule", "audit_report"], + "category": "audit", + "risk": "read_only", + "routing": {"preferred_lane": "review"}, + "invocation": { + "kind": "entrypoint", + "ref": "operations_center.entrypoints.asset_audit", + }, + "visibility": "public", + "disclosure_mode": "public", + } + doc.update(overrides) + return doc + + +def _fleet_node(action_id: str = "x") -> CapabilityNode: + return CapabilityNode( + action_id=action_id, + name=action_id, + category=CapabilityCategory.AUDIT, + risk=CapabilityRisk.READ_ONLY, + invocation=CapabilityInvocation(CapabilityInvocationKind.ENTRYPOINT, "m.ref"), + target_scope=TargetScope(TargetScopeKind.FLEET), + ) + + +# 1. authoring fields compile to the correct typed edges +def test_authoring_compiles_to_typed_edges() -> None: + registry = compile_registry([_asset_audit()]) + edges = {(e.kind, e.target_id) for e in registry.edges_for("asset_audit")} + assert (CapabilityEdgeKind.OWNS, "operations_center") in edges + assert (CapabilityEdgeKind.TARGETS, "media_forge") in edges + assert (CapabilityEdgeKind.EXECUTES, "team_executor") in edges + assert (CapabilityEdgeKind.REQUIRES, "context_lifecycle") in edges + assert (CapabilityEdgeKind.VALIDATES, "custodian") in edges + assert (CapabilityEdgeKind.PRODUCES, "investigation_capsule") in edges + assert (CapabilityEdgeKind.PRODUCES, "audit_report") in edges + assert registry.owner_of("asset_audit") == "operations_center" + assert registry.capability("asset_audit").kind is EntityKind.CAPABILITY + + +# 2. exactly-one owner enforced (0 or 2 -> fail) +def test_zero_owners_fails() -> None: + with pytest.raises(RepoGraphConfigError): + CapabilityRegistry.build([_fleet_node()], []) + + +def test_two_owners_fails() -> None: + with pytest.raises(RepoGraphConfigError): + CapabilityRegistry.build( + [_fleet_node()], + [ + CapabilityEdge("x", "operations_center", CapabilityEdgeKind.OWNS), + CapabilityEdge("x", "custodian", CapabilityEdgeKind.OWNS), + ], + ) + + +def test_single_owner_passes() -> None: + registry = CapabilityRegistry.build( + [_fleet_node()], + [CapabilityEdge("x", "operations_center", CapabilityEdgeKind.OWNS)], + ) + assert registry.owner_of("x") == "operations_center" + + +# 3. unknown enum fails closed +@pytest.mark.parametrize( + "field,bad", + [ + ("category", "bogus"), + ("risk", "bogus"), + ("invocation", {"kind": "bogus", "ref": "r"}), + ("target_scope", {"kind": "bogus"}), + ("visibility", "bogus"), + ], +) +def test_unknown_enum_fails_closed(field: str, bad) -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry([_asset_audit(**{field: bad})]) + + +# 4. repo target resolves (missing -> fail) +def test_repo_targets_resolve_when_known() -> None: + registry = compile_registry([_asset_audit()], known_repo_ids=_ALL_REPOS) + assert registry.owner_of("asset_audit") == "operations_center" + + +def test_unresolved_repo_target_fails() -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry([_asset_audit()], known_repo_ids={"operations_center"}) + + +def test_produces_targets_are_artifacts_not_repo_checked() -> None: + # investigation_capsule / audit_report are artifacts, absent from the repo + # set, yet resolution still passes because PRODUCES is not repo-targeting. + registry = compile_registry([_asset_audit()], known_repo_ids=_ALL_REPOS) + produced = registry.targets_by_kind("asset_audit", CapabilityEdgeKind.PRODUCES) + assert "investigation_capsule" in produced + assert "audit_report" in produced + + +# 5. repo_set selector validates but does NOT expand to membership +def test_repo_set_selector_not_expanded() -> None: + doc = _asset_audit(target_scope={"kind": "repo_set", "selector": {"visibility": "public"}}) + registry = compile_registry([doc]) + node = registry.capability("asset_audit") + assert node.target_scope.kind is TargetScopeKind.REPO_SET + assert node.target_scope.selector == (("visibility", "public"),) + assert not [ + e for e in registry.edges_for("asset_audit") if e.kind is CapabilityEdgeKind.TARGETS + ] + # membership is dynamic: resolution passes without any "public" repo present + compile_registry( + [doc], + known_repo_ids={"operations_center", "team_executor", "context_lifecycle", "custodian"}, + ) + + +# 6. fleet target rejects stray repo_id / selector +def test_fleet_rejects_repo_id() -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry([_asset_audit(target_scope={"kind": "fleet", "repo_id": "x"})]) + + +def test_fleet_rejects_selector() -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry( + [_asset_audit(target_scope={"kind": "fleet", "selector": {"visibility": "public"}})] + ) + + +def test_repo_scope_requires_repo_id() -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry([_asset_audit(target_scope={"kind": "repo"})]) + + +# 7. public projection drops private; capability entity kind is public-safe +def test_public_projection_drops_private() -> None: + public = _asset_audit() + private = _asset_audit( + action_id="secret_audit", + visibility="private", + disclosure_mode="private", + target_scope={"kind": "fleet"}, + ) + registry = compile_registry([public, private]) + + projected = project_capability_registry(registry, ProjectionProfileKind.PUBLIC_SAFE) + ids = {n.action_id for n in projected.list_capabilities()} + assert "asset_audit" in ids + assert "secret_audit" not in ids + # private capability's edges are dropped with it + assert all(e.source_id == "asset_audit" for e in projected.edges) + + +def test_audit_full_projection_retains_private() -> None: + registry = compile_registry( + [ + _asset_audit(), + _asset_audit( + action_id="secret_audit", + visibility="private", + disclosure_mode="private", + target_scope={"kind": "fleet"}, + ), + ] + ) + projected = project_capability_registry(registry, ProjectionProfileKind.AUDIT_FULL) + assert {"asset_audit", "secret_audit"} <= { + n.action_id for n in projected.list_capabilities() + } + + +def test_capability_entity_kind_is_public_safe() -> None: + rules = DEFAULT_PROJECTION_PROFILE_RULES[ProjectionProfileKind.PUBLIC_SAFE] + assert "Capability" in rules.allowed_entity_kinds + + +# 8. boundary hash covers the capability block +def test_boundary_hash_covers_capability_block() -> None: + base = compute_artifact_hash(compile_registry([_asset_audit()]).to_payload()) + changed = compute_artifact_hash( + compile_registry([_asset_audit(risk="mutates_repo")]).to_payload() + ) + assert base != changed + # deterministic across identical inputs + assert base == compute_artifact_hash(compile_registry([_asset_audit()]).to_payload()) + + +# extra invariants +def test_dangerous_risk_requires_explicit_lane() -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry([_asset_audit(risk="mutates_fleet", routing={})]) + compile_registry([_asset_audit(risk="mutates_fleet", routing={"preferred_lane": "migration"})]) + + +def test_duplicate_action_id_fails() -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry([_asset_audit(), _asset_audit()]) + + +def test_load_registry_validates_schema_header() -> None: + payload = { + "schema_kind": "capabilities", + "schema_version": "1.0.0", + "capabilities": [_asset_audit()], + } + registry = load_capability_registry(payload) + assert registry.capability("asset_audit") is not None + + with pytest.raises(RepoGraphConfigError): + load_capability_registry( + {"schema_kind": "ontology", "schema_version": "1.0.0", "capabilities": []} + ) + + +def test_missing_invocation_ref_fails() -> None: + with pytest.raises(RepoGraphConfigError): + compile_registry([_asset_audit(invocation={"kind": "entrypoint", "ref": ""})]) + + +# direct per-symbol coverage (compiler / models / validation / enums) +def test_compile_capability_returns_node_and_edges() -> None: + node, edges = compile_capability(0, _asset_audit()) + assert node.action_id == "asset_audit" + assert sum(1 for e in edges if e.kind is CapabilityEdgeKind.OWNS) == 1 + + +def test_capability_input_is_compiled() -> None: + doc = _asset_audit( + inputs=[{"name": "target_repo", "type": "repo_id", "required": True}] + ) + node = compile_registry([doc]).capability("asset_audit") + assert node.inputs == ( + CapabilityInput(name="target_repo", type="repo_id", required=True), + ) + + +@pytest.mark.parametrize( + "parser", + [ + parse_capability_category, + parse_capability_risk, + parse_capability_invocation_kind, + parse_target_scope_kind, + ], +) +def test_parse_helpers_reject_unknown(parser) -> None: + with pytest.raises(RepoGraphConfigError): + parser(0, "definitely-not-valid") + + +def test_repo_targeting_edge_kinds_exclude_produces() -> None: + assert CapabilityEdgeKind.PRODUCES not in REPO_TARGETING_EDGE_KINDS + assert CapabilityEdgeKind.OWNS in REPO_TARGETING_EDGE_KINDS + + +def test_validate_capability_registry_is_callable_directly() -> None: + registry = compile_registry([_asset_audit()]) + # idempotent: re-validating an already-built registry does not raise + validate_capability_registry( + registry, list(registry.nodes.values()), known_repo_ids=_ALL_REPOS + ) + assert registry.owner_of("asset_audit") == "operations_center" From 16da912fc3f93300ad5293cbd4f645071073bfe0 Mon Sep 17 00:00:00 2001 From: ProtocolWarden <32967198+ProtocolWarden@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:43:25 -0400 Subject: [PATCH 2/2] docs(capabilities): add capability plane charter The "LAST" doc from the capability-registry design: states the capability plane is fleet-level and must not be migrated into topology (it is not a RepoNode) or execution (not a TeamExecutor lane / CoreRunner job / OC task). OC, SwitchBoard, and CoreRunner are consumers of the plane, not owners. Restates the four load-bearing invariants and the language/instances/reality-check layering. Indexed in docs/README.md. Co-Authored-By: Claude Opus 4.8 --- .console/log.md | 10 +++++ docs/README.md | 1 + docs/capability-plane-charter.md | 64 ++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 docs/capability-plane-charter.md diff --git a/.console/log.md b/.console/log.md index d82b950..6997cd9 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,4 +1,14 @@ # Log +## 2026-06-15 — Capability plane charter (docs) + +Added `docs/capability-plane-charter.md` (indexed in docs/README.md) — the +"LAST" doc from the capability-registry design. States the capability plane is +**fleet-level** and must NOT be migrated into topology (it's not a RepoNode) or +execution (not a TeamExecutor lane / CoreRunner job / OC task); OC/SwitchBoard/ +CoreRunner are consumers, not owners. Restates the four load-bearing invariants +(one owns edge; invocation.ref resolves via Custodian CAP1; target_scope +trichotomy; risk gates a lane) and the language/instances/reality-check layering. + ## 2026-06-15 — Capability registry: schema + read-model projection Added the fleet capability plane as a graph-native module (`src/repograph/capabilities/`): diff --git a/docs/README.md b/docs/README.md index f8f049c..c23d40d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,7 @@ Documentation for the RepoGraph graph-semantics library. +- [Capability Plane Charter](capability-plane-charter.md) — what the capability plane is, the fleet-level ownership boundary, and its invariants - [Policy Plane](policy-plane.md) — policy boundary and ownership rules - [Explorer Spec](repograph-explorer-spec.md) — public-safe explorer contract - [Schema Governance](schema-governance.md) — schema versioning and breaking change rules diff --git a/docs/capability-plane-charter.md b/docs/capability-plane-charter.md new file mode 100644 index 0000000..e9ace09 --- /dev/null +++ b/docs/capability-plane-charter.md @@ -0,0 +1,64 @@ +# Capability Plane Charter + +Status: active · Schema: `capabilities` @ 1.0.0 · Module: `src/repograph/capabilities/` + +## What the capability plane is + +The capability plane answers one question: **what can the fleet do, who owns it, +what does it need, how risky is it, and where should it run?** A capability is a +named, ownable *action* — `repo_health_audit`, `board_unblock`, `session_gc` — +modelled as a first-class graph **node** with typed **edges** +(`owns` / `targets` / `executes` / `requires` / `validates` / `produces`). + +It exists to make an already-running fleet **legible**, not to add a runtime. The +first consumer is an operator or agent reading the registry into context at +session start. RepoGraph owns the *language*; PlatformManifest authors the +*instances*; Custodian checks them against *reality* (CAP1). The graph is the +truth — the flat authoring fields are sugar that compile to nodes and edges. + +## What it is NOT — the ownership boundary (the point of this charter) + +Capabilities are a **fleet-level** plane. They are deliberately **not** owned by, +and must **not** be migrated into, any single subsystem: + +- **Not topology.** A capability is not a `RepoNode` and does not belong to the + repo-graph. Repos *host* and *own* capabilities (via the `owns` edge); they do + not contain them. The capability plane sits beside topology, not inside it. +- **Not execution.** A capability is not a TeamExecutor lane, a CoreRunner job, or + an OperationsCenter task. `executes` / `requires` edges and `preferred_lane` + *reference* those subsystems; they do not move the capability into them. + `routing.preferred_lane` is descriptive metadata in v1, not a dispatch hook. +- **Not a behaviour catalogue per repo.** There are no per-behaviour `SKILL.md` + files and no "harness runtime" repo. The runtime already exists; this plane + describes it. + +Concretely: do not relocate `src/repograph/capabilities/` into OperationsCenter, +SwitchBoard, TeamExecutor, or CoreRunner. Those are **consumers** of the plane +(out of scope for v1), not its home. Centralising the definition is what keeps +"who owns this action?" answerable in one place. + +## Load-bearing invariants + +1. **Exactly one `owns` edge per capability.** Accountability is singular; + participation (`executes` / `requires` / `validates`) is plural. This edge is + the answer to "does this action belong in repo X?". +2. **`invocation.ref` must resolve in the owning repo.** RepoGraph keeps the ref + opaque (it never imports the fleet); Custodian's **CAP1** detector binds it to + real code in the owning repo. Read-models rot silently — this check is what + stops the registry from drifting into fiction. +3. **`target_scope` trichotomy.** `repo` (resolves an id) · `repo_set` (validates + a selector, does **not** expand membership) · `fleet` (no id/selector). +4. **Risk gates a lane.** Risk ≥ `mutates_fleet` requires an explicit + `preferred_lane`. Unknown enums fail closed. + +## Layer boundary + +| Layer | Repo | Responsibility | +|-------|------|----------------| +| Language | RepoGraph | capability nodes/edges, schema, validation, projection — **import-free of the fleet** | +| Instances | PlatformManifest | authored capabilities + read-model CLI | +| Reality check | Custodian | CAP1 — `invocation.ref` resolves in the owning repo | +| Consumers (later) | OC / SwitchBoard / CoreRunner | read the registry; **not** owners — see above | + +v1 is a **read-model**: no mutation API, no dispatch. Consumers and any +write-path are explicitly future work.