diff --git a/docs/accountability-root-manifest.md b/docs/accountability-root-manifest.md index 34fff48..95a3a63 100644 --- a/docs/accountability-root-manifest.md +++ b/docs/accountability-root-manifest.md @@ -45,6 +45,12 @@ Ownership review schema: schemas/accountability-ownership-review.schema.yaml ``` +Update delta schema: + +```text +schemas/accountability-update-delta.schema.yaml +``` + ## Required Sections - `netkingdom`: root id, name, and king actor. @@ -142,3 +148,19 @@ railiance-fabric review-identity identity:repository:example-repo \ Reviewer decisions are keyed by stable identity key. Later rescans apply the latest decision for that key, so ordinary evidence refreshes do not lose reviewed ownership choices. + +## Update Deltas + +To compare the current run with previous identity and ownership-review outputs: + +```bash +railiance-fabric discover-roots \ + --delta \ + --previous-identity-projection previous-identities.json \ + --previous-ownership-review previous-ownership.json +``` + +The delta separates candidate graph node changes, candidate graph edge changes, +ownership changes, containment changes, review-state changes, and blocker +changes. When `summary.promotion_needed` is `false`, the update loop can skip +promotion because the durable evidence produced no meaningful Fabric change. diff --git a/docs/financial-fabric-operator-guide.md b/docs/financial-fabric-operator-guide.md index f881b19..e87e937 100644 --- a/docs/financial-fabric-operator-guide.md +++ b/docs/financial-fabric-operator-guide.md @@ -76,6 +76,14 @@ railiance-fabric review-identity \ --fabric-id fabric.railiance.primary ``` +To compare a new run with saved review outputs: + +```bash +railiance-fabric discover-roots --delta \ + --previous-identity-projection previous-identities.json \ + --previous-ownership-review previous-ownership.json +``` + 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 41d4b73..5c53250 100644 --- a/railiance_fabric/accountability_roots.py +++ b/railiance_fabric/accountability_roots.py @@ -246,6 +246,105 @@ def build_ownership_review( return review +def build_update_delta( + current_identity_projection: dict[str, Any], + current_ownership_review: dict[str, Any], + *, + previous_identity_projection: dict[str, Any] | None = None, + previous_ownership_review: dict[str, Any] | None = None, +) -> dict[str, Any]: + previous_identity_projection = previous_identity_projection or {} + previous_ownership_review = previous_ownership_review or {} + current_nodes = { + item["stable_key"]: item + for item in current_identity_projection.get("identity_candidates", []) + if isinstance(item, dict) and item.get("stable_key") + } + previous_nodes = { + item["stable_key"]: item + for item in previous_identity_projection.get("identity_candidates", []) + if isinstance(item, dict) and item.get("stable_key") + } + current_edges = { + item["id"]: item + for item in current_identity_projection.get("candidate_graph", {}).get("edges", []) + if isinstance(item, dict) and item.get("id") + } + previous_edges = { + item["id"]: item + for item in previous_identity_projection.get("candidate_graph", {}).get("edges", []) + if isinstance(item, dict) and item.get("id") + } + current_review = { + item["stable_key"]: item + for item in current_ownership_review.get("items", []) + if isinstance(item, dict) and item.get("stable_key") + } + previous_review = { + item["stable_key"]: item + for item in previous_ownership_review.get("items", []) + if isinstance(item, dict) and item.get("stable_key") + } + + node_delta = _delta_sets(previous_nodes, current_nodes) + edge_delta = _delta_sets(previous_edges, current_edges) + ownership_changes = _field_changes(previous_review, current_review, "ownership") + containment_changes = _field_changes(previous_review, current_review, "containment") + review_state_changes = [ + key + for key in sorted(set(previous_review) & set(current_review)) + if previous_review[key].get("review_state") != current_review[key].get("review_state") + ] + blocker_changes = _field_changes(previous_review, current_review, "blockers") + meaningful_changes = _unique_strings( + [ + *node_delta["added"], + *node_delta["changed"], + *node_delta["removed"], + *edge_delta["added"], + *edge_delta["changed"], + *edge_delta["removed"], + *ownership_changes, + *containment_changes, + *review_state_changes, + *blocker_changes, + ] + ) + delta = { + "apiVersion": "railiance.fabric/v1alpha2", + "kind": "AccountabilityUpdateDelta", + "generated_at": _utc_now(), + "current": current_identity_projection.get("evidence_run", {}), + "previous": previous_identity_projection.get("evidence_run", {}), + "node_delta": node_delta, + "edge_delta": edge_delta, + "change_sets": { + "ownership": ownership_changes, + "containment": containment_changes, + "review_state": review_state_changes, + "blockers": blocker_changes, + }, + "summary": { + "nodes_added": len(node_delta["added"]), + "nodes_changed": len(node_delta["changed"]), + "nodes_removed": len(node_delta["removed"]), + "nodes_unchanged": len(node_delta["unchanged"]), + "edges_added": len(edge_delta["added"]), + "edges_changed": len(edge_delta["changed"]), + "edges_removed": len(edge_delta["removed"]), + "edges_unchanged": len(edge_delta["unchanged"]), + "meaningful_change_count": len(meaningful_changes), + "promotion_needed": bool(meaningful_changes), + }, + } + validator = draft202012_validator(repo_root() / "schemas" / "accountability-update-delta.schema.yaml") + errors = sorted(validator.iter_errors(delta), key=lambda error: list(error.path)) + if errors: + location = ".".join(str(part) for part in errors[0].path) or "" + raise ValueError(f"invalid accountability update delta at {location}: {errors[0].message}") + return delta + + @dataclass(frozen=True) class AccountabilityEvidenceStore: path: Path @@ -741,6 +840,44 @@ def _ownership_item( return item +def _delta_sets(previous: dict[str, dict[str, Any]], current: dict[str, dict[str, Any]]) -> dict[str, list[str]]: + previous_keys = set(previous) + current_keys = set(current) + common = previous_keys & current_keys + changed = [ + key + for key in sorted(common) + if short_fingerprint(_stable_payload(previous[key]), length=16) + != short_fingerprint(_stable_payload(current[key]), length=16) + ] + return { + "added": sorted(current_keys - previous_keys), + "changed": changed, + "removed": sorted(previous_keys - current_keys), + "unchanged": sorted(common - set(changed)), + } + + +def _field_changes( + previous: dict[str, dict[str, Any]], + current: dict[str, dict[str, Any]], + field: str, +) -> list[str]: + return [ + key + for key in sorted(set(previous) & set(current)) + if previous[key].get(field) != current[key].get(field) + ] + + +def _stable_payload(value: dict[str, Any]) -> dict[str, Any]: + return { + key: data + for key, data in value.items() + if key not in {"generated_at", "decision"} + } + + def _add_identity_candidate( candidates: dict[str, dict[str, Any]], *, diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index aefd199..ca172a4 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -19,6 +19,7 @@ from .accountability_roots import ( AccountabilityEvidenceStore, build_identity_projection, build_ownership_review, + build_update_delta, collect_accountability_root_evidence, load_accountability_root_manifest, ) @@ -125,6 +126,9 @@ def build_parser() -> argparse.ArgumentParser: 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("--ownership-review", action="store_true", help="Print ownership resolution and review blockers.") + discover_roots.add_argument("--delta", action="store_true", help="Print a delta against previous identity/ownership review files.") + discover_roots.add_argument("--previous-identity-projection", type=Path, default=None) + discover_roots.add_argument("--previous-ownership-review", type=Path, default=None) discover_roots.add_argument("--store-db", type=Path, default=None, help="Persist evidence and identity candidates in a SQLite store.") review_identity = sub.add_parser( @@ -363,9 +367,27 @@ def main(argv: list[str] | None = None) -> int: store = AccountabilityEvidenceStore(args.store_db) if args.store_db else None decisions = store.latest_review_decisions() if store else {} ownership_review = build_ownership_review(projection, manifest, review_decisions=decisions) + update_delta = build_update_delta( + projection, + ownership_review, + previous_identity_projection=_load_json_file(args.previous_identity_projection) + if args.previous_identity_projection + else None, + previous_ownership_review=_load_json_file(args.previous_ownership_review) + if args.previous_ownership_review + else None, + ) if args.store_db: store.add_evidence_run(payload, projection) - output = ownership_review if args.ownership_review else projection if args.identity_projection else payload + output = ( + update_delta + if args.delta + else ownership_review + if args.ownership_review + else projection + if args.identity_projection + else payload + ) print(json.dumps(output, indent=2, sort_keys=True)) return 0 @@ -1648,6 +1670,13 @@ def _load_graph_or_exit(paths: list[Path]) -> FabricGraph: return graph +def _load_json_file(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"JSON file must contain an object: {path}") + return payload + + def _print_providers(graph: FabricGraph, capability: str) -> None: providers = graph.providers(capability) if not providers: diff --git a/schemas/accountability-update-delta.schema.yaml b/schemas/accountability-update-delta.schema.yaml new file mode 100644 index 0000000..dd8734d --- /dev/null +++ b/schemas/accountability-update-delta.schema.yaml @@ -0,0 +1,120 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://railiance.local/fabric/schemas/accountability-update-delta.schema.yaml" +title: "AccountabilityUpdateDelta" +type: object +additionalProperties: false +required: + - apiVersion + - kind + - generated_at + - current + - previous + - node_delta + - edge_delta + - change_sets + - summary +properties: + apiVersion: + type: string + const: "railiance.fabric/v1alpha2" + kind: + type: string + const: AccountabilityUpdateDelta + generated_at: + type: string + format: date-time + current: + type: object + additionalProperties: true + previous: + type: object + additionalProperties: true + node_delta: + $ref: "#/$defs/deltaSets" + edge_delta: + $ref: "#/$defs/deltaSets" + change_sets: + type: object + additionalProperties: false + required: + - ownership + - containment + - review_state + - blockers + properties: + ownership: + $ref: "#/$defs/stringList" + containment: + $ref: "#/$defs/stringList" + review_state: + $ref: "#/$defs/stringList" + blockers: + $ref: "#/$defs/stringList" + summary: + type: object + additionalProperties: false + required: + - nodes_added + - nodes_changed + - nodes_removed + - nodes_unchanged + - edges_added + - edges_changed + - edges_removed + - edges_unchanged + - meaningful_change_count + - promotion_needed + properties: + nodes_added: + type: integer + minimum: 0 + nodes_changed: + type: integer + minimum: 0 + nodes_removed: + type: integer + minimum: 0 + nodes_unchanged: + type: integer + minimum: 0 + edges_added: + type: integer + minimum: 0 + edges_changed: + type: integer + minimum: 0 + edges_removed: + type: integer + minimum: 0 + edges_unchanged: + type: integer + minimum: 0 + meaningful_change_count: + type: integer + minimum: 0 + promotion_needed: + type: boolean + +$defs: + stringList: + type: array + items: + type: string + + deltaSets: + type: object + additionalProperties: false + required: + - added + - changed + - removed + - unchanged + properties: + added: + $ref: "#/$defs/stringList" + changed: + $ref: "#/$defs/stringList" + removed: + $ref: "#/$defs/stringList" + unchanged: + $ref: "#/$defs/stringList" diff --git a/tests/test_accountability_root_adapters.py b/tests/test_accountability_root_adapters.py index b040062..6dd31c2 100644 --- a/tests/test_accountability_root_adapters.py +++ b/tests/test_accountability_root_adapters.py @@ -5,6 +5,7 @@ from railiance_fabric.accountability_roots import ( AccountabilityEvidenceStore, build_identity_projection, build_ownership_review, + build_update_delta, collect_accountability_root_evidence, load_accountability_root_manifest, ) @@ -191,6 +192,78 @@ def test_review_identity_cli_persists_decision_for_ownership_review(tmp_path: Pa assert repo_item["decision"]["reviewer"] == "tester" +def test_update_delta_detects_ownership_changes_and_unchanged_runs(tmp_path: Path) -> None: + manifest_path = _fixture_manifest(tmp_path) + manifest = load_accountability_root_manifest(manifest_path) + projection = build_identity_projection(collect_accountability_root_evidence(manifest_path), manifest) + review = build_ownership_review(projection, manifest) + + unchanged = build_update_delta( + projection, + review, + previous_identity_projection=projection, + previous_ownership_review=review, + ) + validator = draft202012_validator(Path("schemas/accountability-update-delta.schema.yaml")) + assert list(validator.iter_errors(unchanged)) == [] + assert unchanged["summary"]["promotion_needed"] is False + assert unchanged["node_delta"]["unchanged"] + + store = AccountabilityEvidenceStore(tmp_path / "evidence.sqlite3") + store.add_review_decision( + stable_key="identity:repository:fixture-repo", + decision="accept", + reviewer="tester", + owner_actor_id="actor.fixture.lord", + fabric_id="fabric.fixture.primary", + ) + accepted_review = build_ownership_review( + projection, + manifest, + review_decisions=store.latest_review_decisions(), + ) + changed = build_update_delta( + projection, + accepted_review, + previous_identity_projection=projection, + previous_ownership_review=review, + ) + assert changed["summary"]["promotion_needed"] is True + assert "identity:repository:fixture-repo" in changed["change_sets"]["ownership"] + assert "identity:repository:fixture-repo" in changed["change_sets"]["review_state"] + + +def test_discover_roots_cli_can_emit_delta(tmp_path: Path, capsys) -> None: + manifest = _fixture_manifest(tmp_path) + manifest_data = load_accountability_root_manifest(manifest) + projection = build_identity_projection(collect_accountability_root_evidence(manifest), manifest_data) + review = build_ownership_review(projection, manifest_data) + projection_path = tmp_path / "previous-identities.json" + review_path = tmp_path / "previous-ownership.json" + projection_path.write_text(json.dumps(projection), encoding="utf-8") + review_path.write_text(json.dumps(review), encoding="utf-8") + + assert ( + cli_main( + [ + "discover-roots", + "--manifest", + str(manifest), + "--delta", + "--previous-identity-projection", + str(projection_path), + "--previous-ownership-review", + str(review_path), + ] + ) + == 0 + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["kind"] == "AccountabilityUpdateDelta" + assert payload["summary"]["promotion_needed"] is False + + 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 e143219..f83dce8 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 @@ -231,7 +231,7 @@ Result: ```task id: RAIL-FAB-WP-0018-T05 -status: todo +status: done priority: medium state_hub_task_id: "c2f28b34-de32-4090-8782-5d00541b9018" ``` @@ -257,6 +257,25 @@ Done when: changes are highlighted; - unchanged sources are not needlessly promoted. +Result: + +- Added `schemas/accountability-update-delta.schema.yaml` for + `AccountabilityUpdateDelta` payloads. +- Added `build_update_delta()` to compare current and previous identity + projections plus ownership reviews. +- Deltas distinguish candidate graph node additions/changes/removals, + candidate graph edge additions/changes/removals, ownership changes, + containment changes, review-state changes, blocker changes, and unchanged + nodes/edges. +- Added `railiance-fabric discover-roots --delta` with optional + `--previous-identity-projection` and `--previous-ownership-review` inputs. +- Added tests proving unchanged runs do not require promotion and ownership + review changes are highlighted. +- Documented update deltas in the manifest and operator docs. +- Verified with `python3 -m pytest tests/test_accountability_root_adapters.py -q`, + `python3 -m railiance_fabric.cli discover-roots --max-items-per-root 5 --delta`, + and full `python3 -m pytest`. + ## T06 - Bootstrap The Current Railiance Rebuild ```task