feat: resolve accountability ownership reviews

This commit is contained in:
2026-05-24 09:53:44 +02:00
parent c27d71a511
commit a55f1a45d6
7 changed files with 592 additions and 3 deletions

View File

@@ -194,6 +194,58 @@ def build_identity_projection(
return projection
def build_ownership_review(
identity_projection: dict[str, Any],
manifest: dict[str, Any],
*,
review_decisions: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
review_decisions = review_decisions or {}
actor_roles = {
actor.get("id"): actor.get("role", "")
for actor in manifest.get("actors", [])
if isinstance(actor, dict)
}
fabric_owners: dict[str, str] = {}
fabric_kinds: dict[str, str] = {}
for fabric in manifest.get("fabrics", []):
if not isinstance(fabric, dict):
continue
fabric_id = str(fabric.get("id") or "")
fabric_kinds[fabric_id] = str(fabric.get("kind") or "")
fabric_owners[fabric_id] = str(fabric.get("tenant_actor_id") or fabric.get("lord_actor_id") or "")
items = [
_ownership_item(candidate, actor_roles, fabric_owners, fabric_kinds, review_decisions.get(candidate["stable_key"]))
for candidate in identity_projection.get("identity_candidates", [])
if isinstance(candidate, dict)
]
review = {
"apiVersion": "railiance.fabric/v1alpha2",
"kind": "AccountabilityOwnershipReview",
"generated_at": _utc_now(),
"evidence_run": identity_projection.get("evidence_run", {}),
"items": sorted(items, key=lambda item: item["stable_key"]),
"summary": {
"total": len(items),
"accepted": sum(1 for item in items if item["review_state"] == "accepted"),
"needs_review": sum(1 for item in items if item["review_state"] == "needs_review"),
"unresolved_ownership": sum(
1 for item in items if item["ownership"]["resolution"] == "unresolved"
),
"ambiguous_containment": sum(
1 for item in items if item["containment"]["status"] == "ambiguous"
),
},
}
validator = draft202012_validator(repo_root() / "schemas" / "accountability-ownership-review.schema.yaml")
errors = sorted(validator.iter_errors(review), 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 ownership review at {location}: {errors[0].message}")
return review
@dataclass(frozen=True)
class AccountabilityEvidenceStore:
path: Path
@@ -252,6 +304,21 @@ class AccountabilityEvidenceStore:
create index if not exists idx_accountability_identity_candidates_run
on accountability_identity_candidates(run_id);
create table if not exists accountability_review_decisions (
id integer primary key autoincrement,
stable_key text not null,
decision text not null,
reviewer text not null,
owner_actor_id text,
fabric_id text,
subfabric_id text,
note text,
created_at text not null
);
create index if not exists idx_accountability_review_decisions_stable_key
on accountability_review_decisions(stable_key, id desc);
"""
)
@@ -350,6 +417,59 @@ class AccountabilityEvidenceStore:
).fetchone()
return dict(row) if row else None
def add_review_decision(
self,
*,
stable_key: str,
decision: str,
reviewer: str,
owner_actor_id: str = "",
fabric_id: str = "",
subfabric_id: str = "",
note: str = "",
) -> dict[str, Any]:
self.init_schema()
created_at = _utc_now()
with self._connect() as db:
cursor = db.execute(
"""
insert into accountability_review_decisions (
stable_key, decision, reviewer, owner_actor_id, fabric_id,
subfabric_id, note, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(stable_key, decision, reviewer, owner_actor_id, fabric_id, subfabric_id, note, created_at),
)
decision_id = int(cursor.lastrowid)
return {
"id": decision_id,
"stable_key": stable_key,
"decision": decision,
"reviewer": reviewer,
"owner_actor_id": owner_actor_id,
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"note": note,
"created_at": created_at,
}
def latest_review_decisions(self) -> dict[str, dict[str, Any]]:
self.init_schema()
with self._connect() as db:
rows = db.execute(
"""
select *
from accountability_review_decisions
where id in (
select max(id)
from accountability_review_decisions
group by stable_key
)
order by stable_key
"""
).fetchall()
return {row["stable_key"]: dict(row) for row in rows}
def list_evidence(self, run_id: int) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
@@ -491,6 +611,136 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[
return None
def _ownership_item(
candidate: dict[str, Any],
actor_roles: dict[str, str],
fabric_owners: dict[str, str],
fabric_kinds: dict[str, str],
decision: dict[str, Any] | None,
) -> dict[str, Any]:
attributes = candidate.get("attributes") if isinstance(candidate.get("attributes"), dict) else {}
blockers: list[str] = []
fabric_id = str(candidate.get("fabric_id") or "")
subfabric_id = str(candidate.get("subfabric_id") or "")
owner_actor_id = str(candidate.get("owner_actor_id") or "")
resolution = "explicit" if owner_actor_id else "unresolved"
inherited_from = ""
if attributes.get("ambiguous_aliases"):
blockers.append("ambiguous_identity")
if attributes.get("ambiguous_owner_actor_ids"):
blockers.append("ambiguous_ownership")
if candidate.get("identity_type") not in {"Actor", "Netkingdom"}:
if not fabric_id:
blockers.append("containment_unresolved")
elif fabric_id not in fabric_kinds:
blockers.append("unknown_fabric")
if subfabric_id and subfabric_id not in fabric_kinds:
blockers.append("unknown_subfabric")
if not owner_actor_id and subfabric_id and fabric_owners.get(subfabric_id):
owner_actor_id = fabric_owners[subfabric_id]
resolution = "inherited"
inherited_from = subfabric_id
if not owner_actor_id and fabric_id and fabric_owners.get(fabric_id):
owner_actor_id = fabric_owners[fabric_id]
resolution = "inherited"
inherited_from = fabric_id
if candidate.get("identity_type") == "Actor" and not owner_actor_id:
owner_actor_id = str(candidate.get("graph_id") or "")
resolution = "explicit" if owner_actor_id else "unresolved"
decision_payload: dict[str, Any] | None = None
if decision:
decision_payload = {
"decision": decision.get("decision", ""),
"reviewer": decision.get("reviewer", ""),
"note": decision.get("note", ""),
"created_at": decision.get("created_at", ""),
}
if decision.get("fabric_id"):
fabric_id = str(decision["fabric_id"])
if decision.get("subfabric_id"):
subfabric_id = str(decision["subfabric_id"])
if decision.get("owner_actor_id"):
owner_actor_id = str(decision["owner_actor_id"])
resolution = "review_decision"
inherited_from = ""
if decision.get("decision") == "accept":
blockers = [
blocker
for blocker in blockers
if blocker
not in {
"ambiguous_identity",
"ambiguous_ownership",
"containment_unresolved",
"unknown_fabric",
"unknown_subfabric",
"ownership_unresolved",
"unknown_owner_actor",
}
]
if candidate.get("identity_type") not in {"Actor", "Netkingdom"}:
if not fabric_id:
blockers.append("containment_unresolved")
elif fabric_id not in fabric_kinds:
blockers.append("unknown_fabric")
if subfabric_id and subfabric_id not in fabric_kinds:
blockers.append("unknown_subfabric")
if not owner_actor_id:
blockers.append("ownership_unresolved")
resolution = "unresolved"
if owner_actor_id and owner_actor_id not in actor_roles:
blockers.append("unknown_owner_actor")
review_state = "candidate"
if blockers:
review_state = "needs_review"
if decision:
if decision.get("decision") == "reject":
review_state = "rejected"
elif decision.get("decision") == "needs_review":
review_state = "needs_review"
elif decision.get("decision") == "accept" and owner_actor_id and not blockers:
review_state = "accepted"
elif decision.get("decision") == "accept":
review_state = "needs_review"
blockers.append("accepted_without_resolved_owner_or_containment")
containment_status = "resolved"
if "containment_unresolved" in blockers:
containment_status = "unresolved"
elif "unknown_fabric" in blockers or "unknown_subfabric" in blockers:
containment_status = "ambiguous"
item = {
"stable_key": candidate["stable_key"],
"identity_type": candidate["identity_type"],
"label": candidate["label"],
"review_state": review_state,
"ownership": {
"owner_actor_id": owner_actor_id,
"owner_role": actor_roles.get(owner_actor_id, ""),
"resolution": resolution,
},
"containment": {
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"status": containment_status,
},
"blockers": _unique_strings(blockers),
"evidence_ids": candidate.get("evidence_ids", []),
}
if inherited_from:
item["ownership"]["inherited_from"] = inherited_from
if decision_payload:
item["decision"] = decision_payload
return item
def _add_identity_candidate(
candidates: dict[str, dict[str, Any]],
*,
@@ -535,6 +785,11 @@ def _add_identity_candidate(
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"]}
if incoming.get("owner_actor_id") and existing.get("owner_actor_id") and incoming["owner_actor_id"] != existing["owner_actor_id"]:
existing["attributes"]["ambiguous_owner_actor_ids"] = _unique_strings(
[existing["owner_actor_id"], incoming["owner_actor_id"], *existing["attributes"].get("ambiguous_owner_actor_ids", [])]
)
existing["review_state"] = "needs_review"
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]

View File

@@ -18,6 +18,7 @@ from .accountability_roots import (
DEFAULT_ROOT_MANIFEST_PATH,
AccountabilityEvidenceStore,
build_identity_projection,
build_ownership_review,
collect_accountability_root_evidence,
load_accountability_root_manifest,
)
@@ -123,8 +124,22 @@ def build_parser() -> argparse.ArgumentParser:
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("--ownership-review", action="store_true", help="Print ownership resolution and review blockers.")
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",
help="Persist a review decision for a stable accountability identity candidate.",
)
review_identity.add_argument("stable_key")
review_identity.add_argument("--store-db", type=Path, required=True)
review_identity.add_argument("--decision", choices=["accept", "needs_review", "reject"], required=True)
review_identity.add_argument("--owner-actor-id", default="")
review_identity.add_argument("--fabric-id", default="")
review_identity.add_argument("--subfabric-id", default="")
review_identity.add_argument("--reviewer", default="operator")
review_identity.add_argument("--note", default="")
registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.")
registry_sub = registry.add_subparsers(dest="registry_command", required=True)
@@ -345,10 +360,26 @@ def main(argv: list[str] | None = None) -> int:
max_items_per_root=args.max_items_per_root,
)
projection = build_identity_projection(payload, manifest)
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)
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))
output = 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
if args.command == "review-identity":
decision = AccountabilityEvidenceStore(args.store_db).add_review_decision(
stable_key=args.stable_key,
decision=args.decision,
reviewer=args.reviewer,
owner_actor_id=args.owner_actor_id,
fabric_id=args.fabric_id,
subfabric_id=args.subfabric_id,
note=args.note,
)
print(json.dumps(decision, indent=2, sort_keys=True))
return 0
if args.command == "registry":