From ab7e0ccab1df6a9fcf331a9589cebea44915c8a8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 24 May 2026 09:38:57 +0200 Subject: [PATCH] feat: persist accountability evidence identities --- docs/accountability-root-manifest.md | 26 + docs/financial-fabric-operator-guide.md | 7 + railiance_fabric/accountability_roots.py | 522 +++++++++++++++++- railiance_fabric/cli.py | 17 +- ...untability-identity-projection.schema.yaml | 110 ++++ tests/test_accountability_root_adapters.py | 70 ++- ...countability-root-discovery-update-loop.md | 24 +- 7 files changed, 771 insertions(+), 5 deletions(-) create mode 100644 schemas/accountability-identity-projection.schema.yaml diff --git a/docs/accountability-root-manifest.md b/docs/accountability-root-manifest.md index 0057ffa..32e605f 100644 --- a/docs/accountability-root-manifest.md +++ b/docs/accountability-root-manifest.md @@ -33,6 +33,12 @@ Raw evidence run schema: schemas/accountability-root-evidence.schema.yaml ``` +Identity projection schema: + +```text +schemas/accountability-identity-projection.schema.yaml +``` + ## Required Sections - `netkingdom`: root id, name, and king actor. @@ -78,3 +84,23 @@ The output is an `AccountabilityRootEvidenceRun`. Every evidence item carries provenance, source, fingerprint, `durable: true`, and `live_telemetry: false`, preserving the boundary between Fabric evidence and operational telemetry. + +To normalize raw evidence into reviewable identity candidates: + +```bash +railiance-fabric discover-roots \ + --identity-projection \ + --max-items-per-root 200 +``` + +To persist raw evidence and identity candidates in a local SQLite store: + +```bash +railiance-fabric discover-roots \ + --store-db .railiance-fabric/accountability-evidence.sqlite3 \ + --identity-projection +``` + +The store is intentionally separate from accepted registry graph snapshots. It +keeps raw evidence runs, evidence items, and identity candidates available for +inspection before any candidate is promoted. diff --git a/docs/financial-fabric-operator-guide.md b/docs/financial-fabric-operator-guide.md index b071846..27cf1e6 100644 --- a/docs/financial-fabric-operator-guide.md +++ b/docs/financial-fabric-operator-guide.md @@ -56,6 +56,13 @@ To collect raw evidence from those roots without promoting graph state: railiance-fabric discover-roots --max-items-per-root 200 ``` +To inspect normalized identity candidates or persist a local evidence run: + +```bash +railiance-fabric discover-roots --identity-projection +railiance-fabric discover-roots --store-db .railiance-fabric/accountability-evidence.sqlite3 +``` + The financial export must satisfy these invariants: - every accepted node has resolvable ownership; diff --git a/railiance_fabric/accountability_roots.py b/railiance_fabric/accountability_roots.py index f55ded5..ca8bebf 100644 --- a/railiance_fabric/accountability_roots.py +++ b/railiance_fabric/accountability_roots.py @@ -2,14 +2,16 @@ from __future__ import annotations import hashlib import json +import sqlite3 import subprocess import urllib.error import urllib.request +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any -from .discovery import short_fingerprint +from .discovery import normalize_identity_part, short_fingerprint from .loader import load_yaml, repo_root from .schema_validation import draft202012_validator @@ -95,6 +97,512 @@ def collect_accountability_root_evidence( return result +def build_identity_projection( + evidence_run: dict[str, Any], + manifest: dict[str, Any] | None = None, +) -> dict[str, Any]: + if manifest is None: + manifest_path = evidence_run.get("manifest", {}).get("path") + manifest = load_accountability_root_manifest(_resolve_path(manifest_path), validate=True) + + candidates: dict[str, dict[str, Any]] = {} + + netkingdom = manifest.get("netkingdom") if isinstance(manifest.get("netkingdom"), dict) else {} + if netkingdom: + _add_identity_candidate( + candidates, + identity_type="Netkingdom", + label=str(netkingdom.get("name") or netkingdom.get("id")), + graph_id=str(netkingdom.get("id")), + fabric_id=None, + owner_actor_id=str(netkingdom.get("king_actor_id") or ""), + evidence_ids=[], + aliases=[str(netkingdom.get("id") or "")], + attributes={"king_actor_id": netkingdom.get("king_actor_id", "")}, + confidence=1.0, + ) + + for actor in manifest.get("actors", []): + if not isinstance(actor, dict): + continue + _add_identity_candidate( + candidates, + identity_type="Actor", + label=str(actor.get("name") or actor.get("id")), + graph_id=str(actor.get("id")), + fabric_id=None, + owner_actor_id=str(actor.get("id") or ""), + evidence_ids=[], + aliases=[str(actor.get("id") or ""), str(actor.get("role") or "")], + attributes={"role": actor.get("role", "")}, + confidence=1.0, + ) + + for fabric in manifest.get("fabrics", []): + if not isinstance(fabric, dict): + continue + owner_actor_id = str(fabric.get("tenant_actor_id") or fabric.get("lord_actor_id") or "") + _add_identity_candidate( + candidates, + identity_type=str(fabric.get("kind") or "Fabric"), + label=str(fabric.get("name") or fabric.get("id")), + graph_id=str(fabric.get("id")), + fabric_id=str(fabric.get("id") or ""), + subfabric_id=str(fabric.get("id")) if fabric.get("kind") == "Subfabric" else None, + owner_actor_id=owner_actor_id, + evidence_ids=[], + aliases=[str(fabric.get("id") or ""), str(fabric.get("parent_fabric_id") or "")], + attributes={ + "status": fabric.get("status", ""), + "netkingdom_id": fabric.get("netkingdom_id", ""), + "parent_fabric_id": fabric.get("parent_fabric_id", ""), + "boundary": fabric.get("boundary", {}), + }, + confidence=1.0, + ) + + for root in evidence_run.get("roots", []): + if not isinstance(root, dict): + continue + for item in root.get("evidence", []): + if not isinstance(item, dict): + continue + identity = _identity_from_evidence(root, item) + if identity is None: + continue + _add_identity_candidate(candidates, **identity) + + candidate_list = _mark_ambiguous_identities(list(candidates.values())) + candidate_graph = _candidate_graph(candidate_list, manifest) + projection = { + "apiVersion": "railiance.fabric/v1alpha2", + "kind": "AccountabilityIdentityProjection", + "generated_at": _utc_now(), + "evidence_run": { + "manifest_id": evidence_run.get("manifest", {}).get("id", ""), + "manifest_fingerprint": evidence_run.get("manifest", {}).get("fingerprint", ""), + "generated_at": evidence_run.get("generated_at", ""), + }, + "identity_candidates": sorted(candidate_list, key=lambda item: item["stable_key"]), + "candidate_graph": candidate_graph, + } + validator = draft202012_validator(repo_root() / "schemas" / "accountability-identity-projection.schema.yaml") + errors = sorted(validator.iter_errors(projection), key=lambda error: list(error.path)) + if errors: + location = ".".join(str(part) for part in errors[0].path) or "" + raise ValueError(f"invalid accountability identity projection at {location}: {errors[0].message}") + return projection + + +@dataclass(frozen=True) +class AccountabilityEvidenceStore: + path: Path + + def init_schema(self) -> None: + if str(self.path) != ":memory:": + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as db: + db.executescript( + """ + create table if not exists accountability_evidence_runs ( + id integer primary key autoincrement, + manifest_id text not null, + manifest_path text not null, + manifest_fingerprint text not null, + generated_at text not null, + payload_json text not null, + created_at text not null + ); + + create table if not exists accountability_evidence_items ( + id text not null, + run_id integer not null references accountability_evidence_runs(id), + root_id text not null, + evidence_type text not null, + state text not null, + durable integer not null, + live_telemetry integer not null, + fingerprint text not null, + summary text not null, + source_json text not null, + attributes_json text not null, + payload_json text not null, + primary key (id, run_id) + ); + + create index if not exists idx_accountability_evidence_items_run + on accountability_evidence_items(run_id); + + create table if not exists accountability_identity_candidates ( + stable_key text not null, + run_id integer not null references accountability_evidence_runs(id), + identity_type text not null, + label text not null, + fabric_id text, + subfabric_id text, + owner_actor_id text, + review_state text not null, + confidence real not null, + aliases_json text not null, + evidence_ids_json text not null, + attributes_json text not null, + payload_json text not null, + primary key (stable_key, run_id) + ); + + create index if not exists idx_accountability_identity_candidates_run + on accountability_identity_candidates(run_id); + """ + ) + + def add_evidence_run( + self, + evidence_run: dict[str, Any], + identity_projection: dict[str, Any] | None = None, + ) -> dict[str, Any]: + self.init_schema() + created_at = _utc_now() + manifest = evidence_run.get("manifest", {}) + with self._connect() as db: + cursor = db.execute( + """ + insert into accountability_evidence_runs ( + manifest_id, manifest_path, manifest_fingerprint, generated_at, + payload_json, created_at + ) values (?, ?, ?, ?, ?, ?) + """, + ( + manifest.get("id", ""), + manifest.get("path", ""), + manifest.get("fingerprint", ""), + evidence_run.get("generated_at", ""), + json.dumps(evidence_run, sort_keys=True), + created_at, + ), + ) + run_id = int(cursor.lastrowid) + for item in _iter_evidence_items(evidence_run): + db.execute( + """ + insert into accountability_evidence_items ( + id, run_id, root_id, evidence_type, state, durable, live_telemetry, + fingerprint, summary, source_json, attributes_json, payload_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + item.get("id", ""), + run_id, + item.get("root_id", ""), + item.get("evidence_type", ""), + item.get("state", ""), + 1 if item.get("durable") else 0, + 1 if item.get("live_telemetry") else 0, + item.get("fingerprint", ""), + item.get("summary", ""), + json.dumps(item.get("source", {}), sort_keys=True), + json.dumps(item.get("attributes", {}), sort_keys=True), + json.dumps(item, sort_keys=True), + ), + ) + if identity_projection is not None: + for candidate in identity_projection.get("identity_candidates", []): + db.execute( + """ + insert into accountability_identity_candidates ( + stable_key, run_id, identity_type, label, fabric_id, subfabric_id, + owner_actor_id, review_state, confidence, aliases_json, + evidence_ids_json, attributes_json, payload_json + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + candidate.get("stable_key", ""), + run_id, + candidate.get("identity_type", ""), + candidate.get("label", ""), + candidate.get("fabric_id", ""), + candidate.get("subfabric_id", ""), + candidate.get("owner_actor_id", ""), + candidate.get("review_state", ""), + float(candidate.get("confidence") or 0), + json.dumps(candidate.get("aliases", []), sort_keys=True), + json.dumps(candidate.get("evidence_ids", []), sort_keys=True), + json.dumps(candidate.get("attributes", {}), sort_keys=True), + json.dumps(candidate, sort_keys=True), + ), + ) + return { + "run_id": run_id, + "evidence_count": len(list(_iter_evidence_items(evidence_run))), + "identity_candidate_count": len(identity_projection.get("identity_candidates", [])) + if identity_projection + else 0, + } + + def latest_run(self) -> dict[str, Any] | None: + with self._connect() as db: + row = db.execute( + """ + select id, manifest_id, manifest_path, manifest_fingerprint, generated_at, created_at + from accountability_evidence_runs + order by id desc + limit 1 + """ + ).fetchone() + return dict(row) if row else None + + def list_evidence(self, run_id: int) -> list[dict[str, Any]]: + with self._connect() as db: + rows = db.execute( + """ + select payload_json + from accountability_evidence_items + where run_id = ? + order by root_id, evidence_type, id + """, + (run_id,), + ).fetchall() + return [json.loads(row["payload_json"]) for row in rows] + + def list_identity_candidates(self, run_id: int) -> list[dict[str, Any]]: + with self._connect() as db: + rows = db.execute( + """ + select payload_json + from accountability_identity_candidates + where run_id = ? + order by stable_key + """, + (run_id,), + ).fetchall() + return [json.loads(row["payload_json"]) for row in rows] + + def _connect(self) -> sqlite3.Connection: + db = sqlite3.connect(self.path) + db.row_factory = sqlite3.Row + return db + + +def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None: + evidence_type = str(item.get("evidence_type") or "") + source = item.get("source") if isinstance(item.get("source"), dict) else {} + attributes = item.get("attributes") if isinstance(item.get("attributes"), dict) else {} + evidence_ids = [str(item.get("id", ""))] + fabric_id = str(root.get("fabric_id") or "") + subfabric_id = str(root.get("subfabric_id") or "") or None + owner_actor_id = str(root.get("owner_actor_id") or "") + + if evidence_type in {"registered_repository", "repository_checkout"}: + label = str(source.get("repo_slug") or attributes.get("repo_slug") or Path(str(source.get("path") or "")).name) + return { + "identity_type": "Repository", + "label": label, + "graph_id": label, + "fabric_id": fabric_id, + "subfabric_id": subfabric_id, + "owner_actor_id": owner_actor_id, + "evidence_ids": evidence_ids, + "aliases": [label, str(source.get("path") or ""), str(source.get("remote_url") or "")], + "attributes": {**attributes, "source_evidence_type": evidence_type}, + "confidence": 0.9 if evidence_type == "repository_checkout" else 0.85, + } + if evidence_type in {"deployment_automation", "infrastructure_manifest"}: + path = str(source.get("path") or "") + return { + "identity_type": "Deployable", + "label": Path(path).name or evidence_type, + "graph_id": path, + "fabric_id": fabric_id, + "subfabric_id": subfabric_id, + "owner_actor_id": owner_actor_id, + "evidence_ids": evidence_ids, + "aliases": [path, Path(path).stem], + "attributes": {**attributes, "source_evidence_type": evidence_type}, + "confidence": 0.75, + } + if evidence_type == "service_config": + path = str(source.get("path") or "") + return { + "identity_type": "ServiceConfig", + "label": Path(path).name or "service-config", + "graph_id": path, + "fabric_id": fabric_id, + "subfabric_id": subfabric_id, + "owner_actor_id": owner_actor_id, + "evidence_ids": evidence_ids, + "aliases": [path], + "attributes": {**attributes, "source_evidence_type": evidence_type}, + "confidence": 0.7, + } + if evidence_type == "endpoint_contract": + path = str(source.get("path") or "") + return { + "identity_type": "Endpoint", + "label": Path(path).name or "endpoint-contract", + "graph_id": path, + "fabric_id": fabric_id, + "subfabric_id": subfabric_id, + "owner_actor_id": owner_actor_id, + "evidence_ids": evidence_ids, + "aliases": [path], + "attributes": {**attributes, "source_evidence_type": evidence_type}, + "confidence": 0.75, + } + if evidence_type == "host_path_match": + path = str(source.get("path") or "") + return { + "identity_type": "HostPath", + "label": path or "host-path", + "graph_id": path, + "fabric_id": fabric_id, + "subfabric_id": subfabric_id, + "owner_actor_id": owner_actor_id, + "evidence_ids": evidence_ids, + "aliases": [path], + "attributes": {**attributes, "source_evidence_type": evidence_type}, + "confidence": 0.65, + } + if evidence_type in {"secret_root", "backup_recovery"}: + path = str(source.get("path") or "") + return { + "identity_type": "SecretRoot" if evidence_type == "secret_root" else "BackupRecoveryRoot", + "label": Path(path).name or evidence_type, + "graph_id": path or evidence_type, + "fabric_id": fabric_id, + "subfabric_id": subfabric_id, + "owner_actor_id": owner_actor_id, + "evidence_ids": evidence_ids, + "aliases": [path], + "attributes": {**attributes, "source_evidence_type": evidence_type}, + "confidence": 0.65, + } + if evidence_type in {"state_hub_repo_inventory", "gitea_organization", "gitea_repository", "registry_manifest"}: + return { + "identity_type": "CatalogRoot", + "label": str(source.get("url") or source.get("manifest_path") or root.get("id")), + "graph_id": str(root.get("id") or evidence_type), + "fabric_id": fabric_id, + "subfabric_id": subfabric_id, + "owner_actor_id": owner_actor_id, + "evidence_ids": evidence_ids, + "aliases": [str(source.get("url") or ""), str(source.get("manifest_path") or "")], + "attributes": {**attributes, "source_evidence_type": evidence_type}, + "confidence": 0.6, + } + return None + + +def _add_identity_candidate( + candidates: dict[str, dict[str, Any]], + *, + identity_type: str, + label: str, + graph_id: str | None = None, + fabric_id: str | None = None, + subfabric_id: str | None = None, + owner_actor_id: str | None = None, + evidence_ids: list[str], + aliases: list[str], + attributes: dict[str, Any], + confidence: float, +) -> None: + normalized_type = normalize_identity_part(identity_type) + identity_key = graph_id or label + stable_key = f"identity:{normalized_type}:{normalize_identity_part(identity_key)}" + incoming = { + "stable_key": stable_key, + "identity_type": identity_type, + "label": label or identity_key, + "review_state": "candidate", + "confidence": confidence, + "aliases": _unique_strings([identity_key, *aliases]), + "evidence_ids": _unique_strings(evidence_ids), + "attributes": {key: value for key, value in attributes.items() if value not in ("", None, [], {})}, + } + if graph_id: + incoming["graph_id"] = graph_id + if fabric_id: + incoming["fabric_id"] = fabric_id + if subfabric_id: + incoming["subfabric_id"] = subfabric_id + if owner_actor_id: + incoming["owner_actor_id"] = owner_actor_id + + existing = candidates.get(stable_key) + if existing is None: + candidates[stable_key] = incoming + return + existing["confidence"] = max(float(existing.get("confidence", 0)), confidence) + existing["aliases"] = _unique_strings([*existing.get("aliases", []), *incoming["aliases"]]) + existing["evidence_ids"] = _unique_strings([*existing.get("evidence_ids", []), *incoming["evidence_ids"]]) + existing["attributes"] = {**existing.get("attributes", {}), **incoming["attributes"]} + for key in ("fabric_id", "subfabric_id", "owner_actor_id", "graph_id"): + if incoming.get(key) and not existing.get(key): + existing[key] = incoming[key] + + +def _mark_ambiguous_identities(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]: + alias_index: dict[tuple[str, str], list[str]] = {} + for candidate in candidates: + for alias in candidate.get("aliases", []): + key = (str(candidate.get("identity_type")), normalize_identity_part(alias)) + alias_index.setdefault(key, []).append(candidate["stable_key"]) + ambiguous: dict[str, list[str]] = {} + for (_identity_type, alias), keys in alias_index.items(): + unique_keys = sorted(set(keys)) + if len(unique_keys) > 1: + for stable_key in unique_keys: + ambiguous.setdefault(stable_key, []).append(alias) + for candidate in candidates: + aliases = ambiguous.get(candidate["stable_key"]) + if aliases: + candidate["review_state"] = "needs_review" + candidate.setdefault("attributes", {})["ambiguous_aliases"] = sorted(aliases) + return candidates + + +def _candidate_graph(candidates: list[dict[str, Any]], manifest: dict[str, Any]) -> dict[str, Any]: + nodes = [ + { + "id": candidate["stable_key"], + "kind": candidate["identity_type"], + "label": candidate["label"], + "review_state": candidate["review_state"], + "fabric_id": candidate.get("fabric_id", ""), + "subfabric_id": candidate.get("subfabric_id", ""), + "owner_actor_id": candidate.get("owner_actor_id", ""), + } + for candidate in sorted(candidates, key=lambda item: item["stable_key"]) + ] + edges: list[dict[str, Any]] = [] + for fabric in manifest.get("fabrics", []): + if not isinstance(fabric, dict): + continue + fabric_key = f"identity:{normalize_identity_part(fabric.get('kind') or 'Fabric')}:{normalize_identity_part(fabric.get('id'))}" + parent = fabric.get("parent_fabric_id") or manifest.get("netkingdom", {}).get("id") + parent_type = "Fabric" if fabric.get("parent_fabric_id") else "Netkingdom" + parent_key = f"identity:{normalize_identity_part(parent_type)}:{normalize_identity_part(parent)}" + edges.append( + { + "id": f"candidate-edge:{short_fingerprint([parent_key, 'contains', fabric_key], length=16)}", + "from": parent_key, + "to": fabric_key, + "type": "contains", + "review_state": "candidate", + } + ) + return {"nodes": nodes, "edges": edges} + + +def _iter_evidence_items(evidence_run: dict[str, Any]) -> list[dict[str, Any]]: + return [ + item + for root in evidence_run.get("roots", []) + if isinstance(root, dict) + for item in root.get("evidence", []) + if isinstance(item, dict) + ] + + def _collect_root_evidence(root: dict[str, Any], *, include_remote: bool, max_items: int) -> list[dict[str, Any]]: root_type = str(root.get("type") or "") if root.get("status") == "disabled": @@ -381,5 +889,17 @@ def _git_value(repo_path: Path, *args: str) -> str | None: return value or None +def _unique_strings(values: list[object]) -> list[str]: + result: list[str] = [] + seen: set[str] = set() + for value in values: + text = str(value or "").strip() + if not text or text in seen: + continue + result.append(text) + seen.add(text) + return result + + def _utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index 24270be..ac98aee 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -14,7 +14,13 @@ from pathlib import Path from typing import Any from urllib.parse import quote -from .accountability_roots import DEFAULT_ROOT_MANIFEST_PATH, collect_accountability_root_evidence +from .accountability_roots import ( + DEFAULT_ROOT_MANIFEST_PATH, + AccountabilityEvidenceStore, + build_identity_projection, + collect_accountability_root_evidence, + load_accountability_root_manifest, +) from .connectors import ConnectorConfig from .financial_baseline import financial_export_from_legacy from .loader import declaration_files, load_yaml @@ -116,6 +122,8 @@ def build_parser() -> argparse.ArgumentParser: discover_roots.add_argument("--manifest", type=Path, default=DEFAULT_ROOT_MANIFEST_PATH) discover_roots.add_argument("--include-remote", action="store_true", help="Allow HTTP reads from configured remote roots.") discover_roots.add_argument("--max-items-per-root", type=int, default=200) + discover_roots.add_argument("--identity-projection", action="store_true", help="Print normalized identity candidates instead of raw evidence.") + discover_roots.add_argument("--store-db", type=Path, default=None, help="Persist evidence and identity candidates in a SQLite store.") registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.") registry_sub = registry.add_subparsers(dest="registry_command", required=True) @@ -330,12 +338,17 @@ def main(argv: list[str] | None = None) -> int: return _scan_repo(args) if args.command == "discover-roots": + manifest = load_accountability_root_manifest(args.manifest) payload = collect_accountability_root_evidence( args.manifest, include_remote=args.include_remote, max_items_per_root=args.max_items_per_root, ) - print(json.dumps(payload, indent=2, sort_keys=True)) + projection = build_identity_projection(payload, manifest) + if args.store_db: + store = AccountabilityEvidenceStore(args.store_db) + store.add_evidence_run(payload, projection) + print(json.dumps(projection if args.identity_projection else payload, indent=2, sort_keys=True)) return 0 if args.command == "registry": diff --git a/schemas/accountability-identity-projection.schema.yaml b/schemas/accountability-identity-projection.schema.yaml new file mode 100644 index 0000000..7bbd29f --- /dev/null +++ b/schemas/accountability-identity-projection.schema.yaml @@ -0,0 +1,110 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://railiance.local/fabric/schemas/accountability-identity-projection.schema.yaml" +title: "AccountabilityIdentityProjection" +type: object +additionalProperties: false +required: + - apiVersion + - kind + - generated_at + - evidence_run + - identity_candidates + - candidate_graph +properties: + apiVersion: + type: string + const: "railiance.fabric/v1alpha2" + kind: + type: string + const: AccountabilityIdentityProjection + generated_at: + type: string + format: date-time + evidence_run: + type: object + additionalProperties: false + required: + - manifest_id + - manifest_fingerprint + - generated_at + properties: + manifest_id: + type: string + manifest_fingerprint: + type: string + generated_at: + type: string + identity_candidates: + type: array + items: + $ref: "#/$defs/identityCandidate" + candidate_graph: + type: object + additionalProperties: false + required: + - nodes + - edges + properties: + nodes: + type: array + items: + type: object + additionalProperties: true + edges: + type: array + items: + type: object + additionalProperties: true + +$defs: + identityCandidate: + type: object + additionalProperties: false + required: + - stable_key + - identity_type + - label + - review_state + - confidence + - aliases + - evidence_ids + - attributes + properties: + stable_key: + type: string + minLength: 3 + identity_type: + type: string + minLength: 1 + label: + type: string + minLength: 1 + graph_id: + type: string + fabric_id: + type: string + subfabric_id: + type: string + owner_actor_id: + type: string + review_state: + type: string + enum: + - candidate + - accepted + - needs_review + confidence: + type: number + minimum: 0 + maximum: 1 + aliases: + type: array + items: + type: string + evidence_ids: + type: array + items: + type: string + attributes: + type: object + additionalProperties: true diff --git a/tests/test_accountability_root_adapters.py b/tests/test_accountability_root_adapters.py index fb8ebf1..48d0d18 100644 --- a/tests/test_accountability_root_adapters.py +++ b/tests/test_accountability_root_adapters.py @@ -1,7 +1,12 @@ import json from pathlib import Path -from railiance_fabric.accountability_roots import collect_accountability_root_evidence +from railiance_fabric.accountability_roots import ( + AccountabilityEvidenceStore, + build_identity_projection, + collect_accountability_root_evidence, + load_accountability_root_manifest, +) from railiance_fabric.cli import main as cli_main from railiance_fabric.schema_validation import draft202012_validator @@ -32,6 +37,46 @@ def test_collect_accountability_root_evidence_from_manifest(tmp_path: Path) -> N assert "secret-value" not in json.dumps(secret_root) +def test_identity_projection_is_stable_and_reviewable(tmp_path: Path) -> None: + manifest_path = _fixture_manifest(tmp_path) + manifest = load_accountability_root_manifest(manifest_path) + + first = build_identity_projection(collect_accountability_root_evidence(manifest_path), manifest) + second = build_identity_projection(collect_accountability_root_evidence(manifest_path), manifest) + + validator = draft202012_validator(Path("schemas/accountability-identity-projection.schema.yaml")) + assert list(validator.iter_errors(first)) == [] + + first_keys = {candidate["stable_key"] for candidate in first["identity_candidates"]} + second_keys = {candidate["stable_key"] for candidate in second["identity_candidates"]} + assert first_keys == second_keys + assert { + "Actor", + "Fabric", + "Repository", + "Deployable", + "SecretRoot", + } <= {candidate["identity_type"] for candidate in first["identity_candidates"]} + assert first["candidate_graph"]["nodes"] + assert first["candidate_graph"]["edges"] + + +def test_evidence_store_persists_runs_items_and_identities(tmp_path: Path) -> None: + manifest_path = _fixture_manifest(tmp_path) + manifest = load_accountability_root_manifest(manifest_path) + evidence_run = collect_accountability_root_evidence(manifest_path) + projection = build_identity_projection(evidence_run, manifest) + store = AccountabilityEvidenceStore(tmp_path / "evidence.sqlite3") + + stored = store.add_evidence_run(evidence_run, projection) + latest = store.latest_run() + + assert latest is not None + assert latest["id"] == stored["run_id"] + assert stored["evidence_count"] == len(store.list_evidence(stored["run_id"])) + assert stored["identity_candidate_count"] == len(store.list_identity_candidates(stored["run_id"])) + + def test_discover_roots_cli_prints_evidence_json(tmp_path: Path, capsys) -> None: manifest = _fixture_manifest(tmp_path) @@ -42,6 +87,29 @@ def test_discover_roots_cli_prints_evidence_json(tmp_path: Path, capsys) -> None assert payload["roots"] +def test_discover_roots_cli_can_print_identities_and_store(tmp_path: Path, capsys) -> None: + manifest = _fixture_manifest(tmp_path) + store_path = tmp_path / "evidence.sqlite3" + + assert ( + cli_main( + [ + "discover-roots", + "--manifest", + str(manifest), + "--identity-projection", + "--store-db", + str(store_path), + ] + ) + == 0 + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["kind"] == "AccountabilityIdentityProjection" + assert AccountabilityEvidenceStore(store_path).latest_run() is not None + + def _fixture_manifest(tmp_path: Path) -> Path: workspace = tmp_path / "workspace" repo = workspace / "fixture-repo" diff --git a/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md b/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md index e1df82c..26c4af7 100644 --- a/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md +++ b/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md @@ -135,7 +135,7 @@ Result: ```task id: RAIL-FAB-WP-0018-T03 -status: todo +status: done priority: high state_hub_task_id: "2a79938f-13e2-41b4-b692-74420d31bec4" ``` @@ -157,6 +157,28 @@ Done when: - identity normalization produces reviewable candidates; - repeated scans produce deterministic identities for unchanged sources. +Result: + +- Added `schemas/accountability-identity-projection.schema.yaml` for + normalized `AccountabilityIdentityProjection` payloads. +- Extended `railiance_fabric/accountability_roots.py` with deterministic + identity normalization for netkingdoms, actors, fabrics, subfabrics, + repositories, deployables, endpoint/service/config roots, host paths, + catalog roots, secret roots, and backup/recovery roots. +- Added duplicate/ambiguous alias marking on identity candidates and a + candidate graph section that remains separate from accepted registry graph + snapshots. +- Added `AccountabilityEvidenceStore`, a SQLite store for raw evidence runs, + evidence items, and identity candidates. +- Extended `railiance-fabric discover-roots` with `--identity-projection` and + `--store-db`. +- Added focused tests for deterministic identity keys, schema validation, + persistence, CLI output, and store inspection. +- Verified with + `python3 -m pytest tests/test_accountability_roots.py tests/test_accountability_root_adapters.py -q`, + `python3 -m railiance_fabric.cli discover-roots --max-items-per-root 5 --identity-projection --store-db /tmp/railiance-root-evidence.sqlite3`, + and full `python3 -m pytest`. + ## T04 - Add Ownership Resolution And Review Flow ```task