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

@@ -45,6 +45,12 @@ Ownership review schema:
schemas/accountability-ownership-review.schema.yaml schemas/accountability-ownership-review.schema.yaml
``` ```
Update delta schema:
```text
schemas/accountability-update-delta.schema.yaml
```
## Required Sections ## Required Sections
- `netkingdom`: root id, name, and king actor. - `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 Reviewer decisions are keyed by stable identity key. Later rescans apply the
latest decision for that key, so ordinary evidence refreshes do not lose latest decision for that key, so ordinary evidence refreshes do not lose
reviewed ownership choices. 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.

View File

@@ -76,6 +76,14 @@ railiance-fabric review-identity <stable-key> \
--fabric-id fabric.railiance.primary --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: The financial export must satisfy these invariants:
- every accepted node has resolvable ownership; - every accepted node has resolvable ownership;

View File

@@ -246,6 +246,105 @@ def build_ownership_review(
return 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) @dataclass(frozen=True)
class AccountabilityEvidenceStore: class AccountabilityEvidenceStore:
path: Path path: Path
@@ -741,6 +840,44 @@ def _ownership_item(
return 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( def _add_identity_candidate(
candidates: dict[str, dict[str, Any]], candidates: dict[str, dict[str, Any]],
*, *,

View File

@@ -19,6 +19,7 @@ from .accountability_roots import (
AccountabilityEvidenceStore, AccountabilityEvidenceStore,
build_identity_projection, build_identity_projection,
build_ownership_review, build_ownership_review,
build_update_delta,
collect_accountability_root_evidence, collect_accountability_root_evidence,
load_accountability_root_manifest, 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("--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("--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("--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.") 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( 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 store = AccountabilityEvidenceStore(args.store_db) if args.store_db else None
decisions = store.latest_review_decisions() if store else {} decisions = store.latest_review_decisions() if store else {}
ownership_review = build_ownership_review(projection, manifest, review_decisions=decisions) 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: if args.store_db:
store.add_evidence_run(payload, projection) 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)) print(json.dumps(output, indent=2, sort_keys=True))
return 0 return 0
@@ -1648,6 +1670,13 @@ def _load_graph_or_exit(paths: list[Path]) -> FabricGraph:
return graph 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: def _print_providers(graph: FabricGraph, capability: str) -> None:
providers = graph.providers(capability) providers = graph.providers(capability)
if not providers: if not providers:

View File

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

View File

@@ -5,6 +5,7 @@ from railiance_fabric.accountability_roots import (
AccountabilityEvidenceStore, AccountabilityEvidenceStore,
build_identity_projection, build_identity_projection,
build_ownership_review, build_ownership_review,
build_update_delta,
collect_accountability_root_evidence, collect_accountability_root_evidence,
load_accountability_root_manifest, 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" 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: def _fixture_manifest(tmp_path: Path) -> Path:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
repo = workspace / "fixture-repo" repo = workspace / "fixture-repo"

View File

@@ -231,7 +231,7 @@ Result:
```task ```task
id: RAIL-FAB-WP-0018-T05 id: RAIL-FAB-WP-0018-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "c2f28b34-de32-4090-8782-5d00541b9018" state_hub_task_id: "c2f28b34-de32-4090-8782-5d00541b9018"
``` ```
@@ -257,6 +257,25 @@ Done when:
changes are highlighted; changes are highlighted;
- unchanged sources are not needlessly promoted. - 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 ## T06 - Bootstrap The Current Railiance Rebuild
```task ```task