generated from coulomb/repo-seed
feat: compare accountability update deltas
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -76,6 +76,14 @@ railiance-fabric review-identity <stable-key> \
|
||||
--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;
|
||||
|
||||
@@ -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]],
|
||||
*,
|
||||
|
||||
@@ -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:
|
||||
|
||||
120
schemas/accountability-update-delta.schema.yaml
Normal file
120
schemas/accountability-update-delta.schema.yaml
Normal 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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user