feat: compare accountability update deltas

This commit is contained in:
2026-05-24 10:05:39 +02:00
parent 071a4c49e3
commit 355b7be66a
7 changed files with 410 additions and 2 deletions

View File

@@ -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 "<root>"
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]],
*,

View File

@@ -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: