generated from coulomb/repo-seed
feat: resolve accountability ownership reviews
This commit is contained in:
@@ -39,6 +39,12 @@ Identity projection schema:
|
||||
schemas/accountability-identity-projection.schema.yaml
|
||||
```
|
||||
|
||||
Ownership review schema:
|
||||
|
||||
```text
|
||||
schemas/accountability-ownership-review.schema.yaml
|
||||
```
|
||||
|
||||
## Required Sections
|
||||
|
||||
- `netkingdom`: root id, name, and king actor.
|
||||
@@ -104,3 +110,35 @@ railiance-fabric discover-roots \
|
||||
The store is intentionally separate from accepted registry graph snapshots. It
|
||||
keeps raw evidence runs, evidence items, and identity candidates available for
|
||||
inspection before any candidate is promoted.
|
||||
|
||||
## Ownership Review
|
||||
|
||||
To resolve ownership and containment from the normalized identities:
|
||||
|
||||
```bash
|
||||
railiance-fabric discover-roots \
|
||||
--ownership-review \
|
||||
--store-db .railiance-fabric/accountability-evidence.sqlite3
|
||||
```
|
||||
|
||||
The ownership review inherits owners from fabric/subfabric containment when
|
||||
possible, applies explicit owner evidence from discovery roots, and marks
|
||||
unresolved or ambiguous candidates as `needs_review`. Accepted candidates must
|
||||
have a resolved owner and containment unless they are actors or the netkingdom
|
||||
root.
|
||||
|
||||
To persist a reviewer decision for a stable identity candidate:
|
||||
|
||||
```bash
|
||||
railiance-fabric review-identity identity:repository:example-repo \
|
||||
--store-db .railiance-fabric/accountability-evidence.sqlite3 \
|
||||
--decision accept \
|
||||
--owner-actor-id actor.railiance.primary-lord \
|
||||
--fabric-id fabric.railiance.primary \
|
||||
--reviewer operator \
|
||||
--note "accepted from reviewed checkout evidence"
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -63,6 +63,19 @@ railiance-fabric discover-roots --identity-projection
|
||||
railiance-fabric discover-roots --store-db .railiance-fabric/accountability-evidence.sqlite3
|
||||
```
|
||||
|
||||
To inspect ownership blockers and apply review decisions:
|
||||
|
||||
```bash
|
||||
railiance-fabric discover-roots --ownership-review \
|
||||
--store-db .railiance-fabric/accountability-evidence.sqlite3
|
||||
|
||||
railiance-fabric review-identity <stable-key> \
|
||||
--store-db .railiance-fabric/accountability-evidence.sqlite3 \
|
||||
--decision accept \
|
||||
--owner-actor-id actor.railiance.primary-lord \
|
||||
--fabric-id fabric.railiance.primary
|
||||
```
|
||||
|
||||
The financial export must satisfy these invariants:
|
||||
|
||||
- every accepted node has resolvable ownership;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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":
|
||||
|
||||
149
schemas/accountability-ownership-review.schema.yaml
Normal file
149
schemas/accountability-ownership-review.schema.yaml
Normal file
@@ -0,0 +1,149 @@
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema"
|
||||
$id: "https://railiance.local/fabric/schemas/accountability-ownership-review.schema.yaml"
|
||||
title: "AccountabilityOwnershipReview"
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- apiVersion
|
||||
- kind
|
||||
- generated_at
|
||||
- evidence_run
|
||||
- items
|
||||
- summary
|
||||
properties:
|
||||
apiVersion:
|
||||
type: string
|
||||
const: "railiance.fabric/v1alpha2"
|
||||
kind:
|
||||
type: string
|
||||
const: AccountabilityOwnershipReview
|
||||
generated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
evidence_run:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/$defs/reviewItem"
|
||||
summary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- total
|
||||
- accepted
|
||||
- needs_review
|
||||
- unresolved_ownership
|
||||
- ambiguous_containment
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
accepted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
needs_review:
|
||||
type: integer
|
||||
minimum: 0
|
||||
unresolved_ownership:
|
||||
type: integer
|
||||
minimum: 0
|
||||
ambiguous_containment:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
$defs:
|
||||
reviewItem:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- stable_key
|
||||
- identity_type
|
||||
- label
|
||||
- review_state
|
||||
- ownership
|
||||
- containment
|
||||
- blockers
|
||||
- evidence_ids
|
||||
properties:
|
||||
stable_key:
|
||||
type: string
|
||||
minLength: 3
|
||||
identity_type:
|
||||
type: string
|
||||
minLength: 1
|
||||
label:
|
||||
type: string
|
||||
minLength: 1
|
||||
review_state:
|
||||
type: string
|
||||
enum:
|
||||
- candidate
|
||||
- accepted
|
||||
- needs_review
|
||||
- rejected
|
||||
ownership:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- owner_actor_id
|
||||
- owner_role
|
||||
- resolution
|
||||
properties:
|
||||
owner_actor_id:
|
||||
type: string
|
||||
owner_role:
|
||||
type: string
|
||||
resolution:
|
||||
type: string
|
||||
enum:
|
||||
- explicit
|
||||
- inherited
|
||||
- review_decision
|
||||
- unresolved
|
||||
inherited_from:
|
||||
type: string
|
||||
containment:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- fabric_id
|
||||
- subfabric_id
|
||||
- status
|
||||
properties:
|
||||
fabric_id:
|
||||
type: string
|
||||
subfabric_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- resolved
|
||||
- ambiguous
|
||||
- unresolved
|
||||
blockers:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
evidence_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
decision:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- decision
|
||||
- reviewer
|
||||
- note
|
||||
- created_at
|
||||
properties:
|
||||
decision:
|
||||
type: string
|
||||
reviewer:
|
||||
type: string
|
||||
note:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from railiance_fabric.accountability_roots import (
|
||||
AccountabilityEvidenceStore,
|
||||
build_identity_projection,
|
||||
build_ownership_review,
|
||||
collect_accountability_root_evidence,
|
||||
load_accountability_root_manifest,
|
||||
)
|
||||
@@ -77,6 +78,40 @@ def test_evidence_store_persists_runs_items_and_identities(tmp_path: Path) -> No
|
||||
assert stored["identity_candidate_count"] == len(store.list_identity_candidates(stored["run_id"]))
|
||||
|
||||
|
||||
def test_ownership_review_flags_ambiguity_and_applies_review_decisions(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)
|
||||
|
||||
validator = draft202012_validator(Path("schemas/accountability-ownership-review.schema.yaml"))
|
||||
assert list(validator.iter_errors(review)) == []
|
||||
|
||||
repo_key = "identity:repository:fixture-repo"
|
||||
repo_item = next(item for item in review["items"] if item["stable_key"] == repo_key)
|
||||
assert repo_item["review_state"] == "needs_review"
|
||||
assert "ambiguous_ownership" in repo_item["blockers"]
|
||||
|
||||
store = AccountabilityEvidenceStore(tmp_path / "evidence.sqlite3")
|
||||
store.add_review_decision(
|
||||
stable_key=repo_key,
|
||||
decision="accept",
|
||||
reviewer="tester",
|
||||
owner_actor_id="actor.fixture.lord",
|
||||
fabric_id="fabric.fixture.primary",
|
||||
note="fixture checkout owner wins over registry root",
|
||||
)
|
||||
accepted_review = build_ownership_review(
|
||||
projection,
|
||||
manifest,
|
||||
review_decisions=store.latest_review_decisions(),
|
||||
)
|
||||
accepted_item = next(item for item in accepted_review["items"] if item["stable_key"] == repo_key)
|
||||
assert accepted_item["review_state"] == "accepted"
|
||||
assert accepted_item["ownership"]["resolution"] == "review_decision"
|
||||
assert accepted_item["ownership"]["owner_actor_id"] == "actor.fixture.lord"
|
||||
|
||||
|
||||
def test_discover_roots_cli_prints_evidence_json(tmp_path: Path, capsys) -> None:
|
||||
manifest = _fixture_manifest(tmp_path)
|
||||
|
||||
@@ -110,6 +145,52 @@ def test_discover_roots_cli_can_print_identities_and_store(tmp_path: Path, capsy
|
||||
assert AccountabilityEvidenceStore(store_path).latest_run() is not None
|
||||
|
||||
|
||||
def test_review_identity_cli_persists_decision_for_ownership_review(tmp_path: Path, capsys) -> None:
|
||||
manifest = _fixture_manifest(tmp_path)
|
||||
store_path = tmp_path / "evidence.sqlite3"
|
||||
repo_key = "identity:repository:fixture-repo"
|
||||
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"review-identity",
|
||||
repo_key,
|
||||
"--store-db",
|
||||
str(store_path),
|
||||
"--decision",
|
||||
"accept",
|
||||
"--owner-actor-id",
|
||||
"actor.fixture.lord",
|
||||
"--fabric-id",
|
||||
"fabric.fixture.primary",
|
||||
"--reviewer",
|
||||
"tester",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
decision_payload = json.loads(capsys.readouterr().out)
|
||||
assert decision_payload["stable_key"] == repo_key
|
||||
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"discover-roots",
|
||||
"--manifest",
|
||||
str(manifest),
|
||||
"--ownership-review",
|
||||
"--store-db",
|
||||
str(store_path),
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
review_payload = json.loads(capsys.readouterr().out)
|
||||
repo_item = next(item for item in review_payload["items"] if item["stable_key"] == repo_key)
|
||||
assert repo_item["review_state"] == "accepted"
|
||||
assert repo_item["decision"]["reviewer"] == "tester"
|
||||
|
||||
|
||||
def _fixture_manifest(tmp_path: Path) -> Path:
|
||||
workspace = tmp_path / "workspace"
|
||||
repo = workspace / "fixture-repo"
|
||||
|
||||
@@ -183,7 +183,7 @@ Result:
|
||||
|
||||
```task
|
||||
id: RAIL-FAB-WP-0018-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "670be2c2-6bec-4534-ae6a-ab0186ce0a8d"
|
||||
```
|
||||
@@ -205,6 +205,28 @@ Done when:
|
||||
- unresolved or ambiguous nodes are visible before promotion;
|
||||
- review decisions survive ordinary rescans.
|
||||
|
||||
Result:
|
||||
|
||||
- Added `schemas/accountability-ownership-review.schema.yaml` for ownership
|
||||
resolution and review-blocker payloads.
|
||||
- Added `build_ownership_review()` to resolve explicit owners, inherit owners
|
||||
from fabric/subfabric containment, flag unresolved ownership, flag unknown or
|
||||
ambiguous containment, and surface ambiguous owner evidence.
|
||||
- Extended `AccountabilityEvidenceStore` with durable review decisions keyed by
|
||||
stable identity candidate key.
|
||||
- Added `railiance-fabric discover-roots --ownership-review` and
|
||||
`railiance-fabric review-identity` so operators can inspect blockers and
|
||||
persist accept/needs-review/reject decisions across rescans.
|
||||
- Added tests proving ambiguous ownership is visible, review decisions can
|
||||
accept a stable identity, decisions survive a later ownership-review run, and
|
||||
accepted items cannot silently lack a resolved owner.
|
||||
- Documented ownership review and reviewer decisions in the manifest and
|
||||
operator docs.
|
||||
- Verified with
|
||||
`python3 -m pytest tests/test_accountability_roots.py tests/test_accountability_root_adapters.py -q`,
|
||||
`python3 -m railiance_fabric.cli discover-roots --max-items-per-root 5 --ownership-review --store-db /tmp/railiance-root-ownership.sqlite3`,
|
||||
and full `python3 -m pytest`.
|
||||
|
||||
## T05 - Implement Snapshot Deltas And Freshness Triggers
|
||||
|
||||
```task
|
||||
|
||||
Reference in New Issue
Block a user