feat: persist accountability evidence identities

This commit is contained in:
2026-05-24 09:38:57 +02:00
parent 26f1913d51
commit ab7e0ccab1
7 changed files with 771 additions and 5 deletions

View File

@@ -33,6 +33,12 @@ Raw evidence run schema:
schemas/accountability-root-evidence.schema.yaml schemas/accountability-root-evidence.schema.yaml
``` ```
Identity projection schema:
```text
schemas/accountability-identity-projection.schema.yaml
```
## Required Sections ## Required Sections
- `netkingdom`: root id, name, and king actor. - `netkingdom`: root id, name, and king actor.
@@ -78,3 +84,23 @@ The output is an `AccountabilityRootEvidenceRun`. Every evidence item carries
provenance, source, fingerprint, `durable: true`, and provenance, source, fingerprint, `durable: true`, and
`live_telemetry: false`, preserving the boundary between Fabric evidence and `live_telemetry: false`, preserving the boundary between Fabric evidence and
operational telemetry. operational telemetry.
To normalize raw evidence into reviewable identity candidates:
```bash
railiance-fabric discover-roots \
--identity-projection \
--max-items-per-root 200
```
To persist raw evidence and identity candidates in a local SQLite store:
```bash
railiance-fabric discover-roots \
--store-db .railiance-fabric/accountability-evidence.sqlite3 \
--identity-projection
```
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.

View File

@@ -56,6 +56,13 @@ To collect raw evidence from those roots without promoting graph state:
railiance-fabric discover-roots --max-items-per-root 200 railiance-fabric discover-roots --max-items-per-root 200
``` ```
To inspect normalized identity candidates or persist a local evidence run:
```bash
railiance-fabric discover-roots --identity-projection
railiance-fabric discover-roots --store-db .railiance-fabric/accountability-evidence.sqlite3
```
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

@@ -2,14 +2,16 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import sqlite3
import subprocess import subprocess
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from .discovery import short_fingerprint from .discovery import normalize_identity_part, short_fingerprint
from .loader import load_yaml, repo_root from .loader import load_yaml, repo_root
from .schema_validation import draft202012_validator from .schema_validation import draft202012_validator
@@ -95,6 +97,512 @@ def collect_accountability_root_evidence(
return result return result
def build_identity_projection(
evidence_run: dict[str, Any],
manifest: dict[str, Any] | None = None,
) -> dict[str, Any]:
if manifest is None:
manifest_path = evidence_run.get("manifest", {}).get("path")
manifest = load_accountability_root_manifest(_resolve_path(manifest_path), validate=True)
candidates: dict[str, dict[str, Any]] = {}
netkingdom = manifest.get("netkingdom") if isinstance(manifest.get("netkingdom"), dict) else {}
if netkingdom:
_add_identity_candidate(
candidates,
identity_type="Netkingdom",
label=str(netkingdom.get("name") or netkingdom.get("id")),
graph_id=str(netkingdom.get("id")),
fabric_id=None,
owner_actor_id=str(netkingdom.get("king_actor_id") or ""),
evidence_ids=[],
aliases=[str(netkingdom.get("id") or "")],
attributes={"king_actor_id": netkingdom.get("king_actor_id", "")},
confidence=1.0,
)
for actor in manifest.get("actors", []):
if not isinstance(actor, dict):
continue
_add_identity_candidate(
candidates,
identity_type="Actor",
label=str(actor.get("name") or actor.get("id")),
graph_id=str(actor.get("id")),
fabric_id=None,
owner_actor_id=str(actor.get("id") or ""),
evidence_ids=[],
aliases=[str(actor.get("id") or ""), str(actor.get("role") or "")],
attributes={"role": actor.get("role", "")},
confidence=1.0,
)
for fabric in manifest.get("fabrics", []):
if not isinstance(fabric, dict):
continue
owner_actor_id = str(fabric.get("tenant_actor_id") or fabric.get("lord_actor_id") or "")
_add_identity_candidate(
candidates,
identity_type=str(fabric.get("kind") or "Fabric"),
label=str(fabric.get("name") or fabric.get("id")),
graph_id=str(fabric.get("id")),
fabric_id=str(fabric.get("id") or ""),
subfabric_id=str(fabric.get("id")) if fabric.get("kind") == "Subfabric" else None,
owner_actor_id=owner_actor_id,
evidence_ids=[],
aliases=[str(fabric.get("id") or ""), str(fabric.get("parent_fabric_id") or "")],
attributes={
"status": fabric.get("status", ""),
"netkingdom_id": fabric.get("netkingdom_id", ""),
"parent_fabric_id": fabric.get("parent_fabric_id", ""),
"boundary": fabric.get("boundary", {}),
},
confidence=1.0,
)
for root in evidence_run.get("roots", []):
if not isinstance(root, dict):
continue
for item in root.get("evidence", []):
if not isinstance(item, dict):
continue
identity = _identity_from_evidence(root, item)
if identity is None:
continue
_add_identity_candidate(candidates, **identity)
candidate_list = _mark_ambiguous_identities(list(candidates.values()))
candidate_graph = _candidate_graph(candidate_list, manifest)
projection = {
"apiVersion": "railiance.fabric/v1alpha2",
"kind": "AccountabilityIdentityProjection",
"generated_at": _utc_now(),
"evidence_run": {
"manifest_id": evidence_run.get("manifest", {}).get("id", ""),
"manifest_fingerprint": evidence_run.get("manifest", {}).get("fingerprint", ""),
"generated_at": evidence_run.get("generated_at", ""),
},
"identity_candidates": sorted(candidate_list, key=lambda item: item["stable_key"]),
"candidate_graph": candidate_graph,
}
validator = draft202012_validator(repo_root() / "schemas" / "accountability-identity-projection.schema.yaml")
errors = sorted(validator.iter_errors(projection), 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 identity projection at {location}: {errors[0].message}")
return projection
@dataclass(frozen=True)
class AccountabilityEvidenceStore:
path: Path
def init_schema(self) -> None:
if str(self.path) != ":memory:":
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as db:
db.executescript(
"""
create table if not exists accountability_evidence_runs (
id integer primary key autoincrement,
manifest_id text not null,
manifest_path text not null,
manifest_fingerprint text not null,
generated_at text not null,
payload_json text not null,
created_at text not null
);
create table if not exists accountability_evidence_items (
id text not null,
run_id integer not null references accountability_evidence_runs(id),
root_id text not null,
evidence_type text not null,
state text not null,
durable integer not null,
live_telemetry integer not null,
fingerprint text not null,
summary text not null,
source_json text not null,
attributes_json text not null,
payload_json text not null,
primary key (id, run_id)
);
create index if not exists idx_accountability_evidence_items_run
on accountability_evidence_items(run_id);
create table if not exists accountability_identity_candidates (
stable_key text not null,
run_id integer not null references accountability_evidence_runs(id),
identity_type text not null,
label text not null,
fabric_id text,
subfabric_id text,
owner_actor_id text,
review_state text not null,
confidence real not null,
aliases_json text not null,
evidence_ids_json text not null,
attributes_json text not null,
payload_json text not null,
primary key (stable_key, run_id)
);
create index if not exists idx_accountability_identity_candidates_run
on accountability_identity_candidates(run_id);
"""
)
def add_evidence_run(
self,
evidence_run: dict[str, Any],
identity_projection: dict[str, Any] | None = None,
) -> dict[str, Any]:
self.init_schema()
created_at = _utc_now()
manifest = evidence_run.get("manifest", {})
with self._connect() as db:
cursor = db.execute(
"""
insert into accountability_evidence_runs (
manifest_id, manifest_path, manifest_fingerprint, generated_at,
payload_json, created_at
) values (?, ?, ?, ?, ?, ?)
""",
(
manifest.get("id", ""),
manifest.get("path", ""),
manifest.get("fingerprint", ""),
evidence_run.get("generated_at", ""),
json.dumps(evidence_run, sort_keys=True),
created_at,
),
)
run_id = int(cursor.lastrowid)
for item in _iter_evidence_items(evidence_run):
db.execute(
"""
insert into accountability_evidence_items (
id, run_id, root_id, evidence_type, state, durable, live_telemetry,
fingerprint, summary, source_json, attributes_json, payload_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
item.get("id", ""),
run_id,
item.get("root_id", ""),
item.get("evidence_type", ""),
item.get("state", ""),
1 if item.get("durable") else 0,
1 if item.get("live_telemetry") else 0,
item.get("fingerprint", ""),
item.get("summary", ""),
json.dumps(item.get("source", {}), sort_keys=True),
json.dumps(item.get("attributes", {}), sort_keys=True),
json.dumps(item, sort_keys=True),
),
)
if identity_projection is not None:
for candidate in identity_projection.get("identity_candidates", []):
db.execute(
"""
insert into accountability_identity_candidates (
stable_key, run_id, identity_type, label, fabric_id, subfabric_id,
owner_actor_id, review_state, confidence, aliases_json,
evidence_ids_json, attributes_json, payload_json
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
candidate.get("stable_key", ""),
run_id,
candidate.get("identity_type", ""),
candidate.get("label", ""),
candidate.get("fabric_id", ""),
candidate.get("subfabric_id", ""),
candidate.get("owner_actor_id", ""),
candidate.get("review_state", ""),
float(candidate.get("confidence") or 0),
json.dumps(candidate.get("aliases", []), sort_keys=True),
json.dumps(candidate.get("evidence_ids", []), sort_keys=True),
json.dumps(candidate.get("attributes", {}), sort_keys=True),
json.dumps(candidate, sort_keys=True),
),
)
return {
"run_id": run_id,
"evidence_count": len(list(_iter_evidence_items(evidence_run))),
"identity_candidate_count": len(identity_projection.get("identity_candidates", []))
if identity_projection
else 0,
}
def latest_run(self) -> dict[str, Any] | None:
with self._connect() as db:
row = db.execute(
"""
select id, manifest_id, manifest_path, manifest_fingerprint, generated_at, created_at
from accountability_evidence_runs
order by id desc
limit 1
"""
).fetchone()
return dict(row) if row else None
def list_evidence(self, run_id: int) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
select payload_json
from accountability_evidence_items
where run_id = ?
order by root_id, evidence_type, id
""",
(run_id,),
).fetchall()
return [json.loads(row["payload_json"]) for row in rows]
def list_identity_candidates(self, run_id: int) -> list[dict[str, Any]]:
with self._connect() as db:
rows = db.execute(
"""
select payload_json
from accountability_identity_candidates
where run_id = ?
order by stable_key
""",
(run_id,),
).fetchall()
return [json.loads(row["payload_json"]) for row in rows]
def _connect(self) -> sqlite3.Connection:
db = sqlite3.connect(self.path)
db.row_factory = sqlite3.Row
return db
def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None:
evidence_type = str(item.get("evidence_type") or "")
source = item.get("source") if isinstance(item.get("source"), dict) else {}
attributes = item.get("attributes") if isinstance(item.get("attributes"), dict) else {}
evidence_ids = [str(item.get("id", ""))]
fabric_id = str(root.get("fabric_id") or "")
subfabric_id = str(root.get("subfabric_id") or "") or None
owner_actor_id = str(root.get("owner_actor_id") or "")
if evidence_type in {"registered_repository", "repository_checkout"}:
label = str(source.get("repo_slug") or attributes.get("repo_slug") or Path(str(source.get("path") or "")).name)
return {
"identity_type": "Repository",
"label": label,
"graph_id": label,
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"owner_actor_id": owner_actor_id,
"evidence_ids": evidence_ids,
"aliases": [label, str(source.get("path") or ""), str(source.get("remote_url") or "")],
"attributes": {**attributes, "source_evidence_type": evidence_type},
"confidence": 0.9 if evidence_type == "repository_checkout" else 0.85,
}
if evidence_type in {"deployment_automation", "infrastructure_manifest"}:
path = str(source.get("path") or "")
return {
"identity_type": "Deployable",
"label": Path(path).name or evidence_type,
"graph_id": path,
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"owner_actor_id": owner_actor_id,
"evidence_ids": evidence_ids,
"aliases": [path, Path(path).stem],
"attributes": {**attributes, "source_evidence_type": evidence_type},
"confidence": 0.75,
}
if evidence_type == "service_config":
path = str(source.get("path") or "")
return {
"identity_type": "ServiceConfig",
"label": Path(path).name or "service-config",
"graph_id": path,
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"owner_actor_id": owner_actor_id,
"evidence_ids": evidence_ids,
"aliases": [path],
"attributes": {**attributes, "source_evidence_type": evidence_type},
"confidence": 0.7,
}
if evidence_type == "endpoint_contract":
path = str(source.get("path") or "")
return {
"identity_type": "Endpoint",
"label": Path(path).name or "endpoint-contract",
"graph_id": path,
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"owner_actor_id": owner_actor_id,
"evidence_ids": evidence_ids,
"aliases": [path],
"attributes": {**attributes, "source_evidence_type": evidence_type},
"confidence": 0.75,
}
if evidence_type == "host_path_match":
path = str(source.get("path") or "")
return {
"identity_type": "HostPath",
"label": path or "host-path",
"graph_id": path,
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"owner_actor_id": owner_actor_id,
"evidence_ids": evidence_ids,
"aliases": [path],
"attributes": {**attributes, "source_evidence_type": evidence_type},
"confidence": 0.65,
}
if evidence_type in {"secret_root", "backup_recovery"}:
path = str(source.get("path") or "")
return {
"identity_type": "SecretRoot" if evidence_type == "secret_root" else "BackupRecoveryRoot",
"label": Path(path).name or evidence_type,
"graph_id": path or evidence_type,
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"owner_actor_id": owner_actor_id,
"evidence_ids": evidence_ids,
"aliases": [path],
"attributes": {**attributes, "source_evidence_type": evidence_type},
"confidence": 0.65,
}
if evidence_type in {"state_hub_repo_inventory", "gitea_organization", "gitea_repository", "registry_manifest"}:
return {
"identity_type": "CatalogRoot",
"label": str(source.get("url") or source.get("manifest_path") or root.get("id")),
"graph_id": str(root.get("id") or evidence_type),
"fabric_id": fabric_id,
"subfabric_id": subfabric_id,
"owner_actor_id": owner_actor_id,
"evidence_ids": evidence_ids,
"aliases": [str(source.get("url") or ""), str(source.get("manifest_path") or "")],
"attributes": {**attributes, "source_evidence_type": evidence_type},
"confidence": 0.6,
}
return None
def _add_identity_candidate(
candidates: dict[str, dict[str, Any]],
*,
identity_type: str,
label: str,
graph_id: str | None = None,
fabric_id: str | None = None,
subfabric_id: str | None = None,
owner_actor_id: str | None = None,
evidence_ids: list[str],
aliases: list[str],
attributes: dict[str, Any],
confidence: float,
) -> None:
normalized_type = normalize_identity_part(identity_type)
identity_key = graph_id or label
stable_key = f"identity:{normalized_type}:{normalize_identity_part(identity_key)}"
incoming = {
"stable_key": stable_key,
"identity_type": identity_type,
"label": label or identity_key,
"review_state": "candidate",
"confidence": confidence,
"aliases": _unique_strings([identity_key, *aliases]),
"evidence_ids": _unique_strings(evidence_ids),
"attributes": {key: value for key, value in attributes.items() if value not in ("", None, [], {})},
}
if graph_id:
incoming["graph_id"] = graph_id
if fabric_id:
incoming["fabric_id"] = fabric_id
if subfabric_id:
incoming["subfabric_id"] = subfabric_id
if owner_actor_id:
incoming["owner_actor_id"] = owner_actor_id
existing = candidates.get(stable_key)
if existing is None:
candidates[stable_key] = incoming
return
existing["confidence"] = max(float(existing.get("confidence", 0)), confidence)
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"]}
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]
def _mark_ambiguous_identities(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
alias_index: dict[tuple[str, str], list[str]] = {}
for candidate in candidates:
for alias in candidate.get("aliases", []):
key = (str(candidate.get("identity_type")), normalize_identity_part(alias))
alias_index.setdefault(key, []).append(candidate["stable_key"])
ambiguous: dict[str, list[str]] = {}
for (_identity_type, alias), keys in alias_index.items():
unique_keys = sorted(set(keys))
if len(unique_keys) > 1:
for stable_key in unique_keys:
ambiguous.setdefault(stable_key, []).append(alias)
for candidate in candidates:
aliases = ambiguous.get(candidate["stable_key"])
if aliases:
candidate["review_state"] = "needs_review"
candidate.setdefault("attributes", {})["ambiguous_aliases"] = sorted(aliases)
return candidates
def _candidate_graph(candidates: list[dict[str, Any]], manifest: dict[str, Any]) -> dict[str, Any]:
nodes = [
{
"id": candidate["stable_key"],
"kind": candidate["identity_type"],
"label": candidate["label"],
"review_state": candidate["review_state"],
"fabric_id": candidate.get("fabric_id", ""),
"subfabric_id": candidate.get("subfabric_id", ""),
"owner_actor_id": candidate.get("owner_actor_id", ""),
}
for candidate in sorted(candidates, key=lambda item: item["stable_key"])
]
edges: list[dict[str, Any]] = []
for fabric in manifest.get("fabrics", []):
if not isinstance(fabric, dict):
continue
fabric_key = f"identity:{normalize_identity_part(fabric.get('kind') or 'Fabric')}:{normalize_identity_part(fabric.get('id'))}"
parent = fabric.get("parent_fabric_id") or manifest.get("netkingdom", {}).get("id")
parent_type = "Fabric" if fabric.get("parent_fabric_id") else "Netkingdom"
parent_key = f"identity:{normalize_identity_part(parent_type)}:{normalize_identity_part(parent)}"
edges.append(
{
"id": f"candidate-edge:{short_fingerprint([parent_key, 'contains', fabric_key], length=16)}",
"from": parent_key,
"to": fabric_key,
"type": "contains",
"review_state": "candidate",
}
)
return {"nodes": nodes, "edges": edges}
def _iter_evidence_items(evidence_run: dict[str, Any]) -> list[dict[str, Any]]:
return [
item
for root in evidence_run.get("roots", [])
if isinstance(root, dict)
for item in root.get("evidence", [])
if isinstance(item, dict)
]
def _collect_root_evidence(root: dict[str, Any], *, include_remote: bool, max_items: int) -> list[dict[str, Any]]: def _collect_root_evidence(root: dict[str, Any], *, include_remote: bool, max_items: int) -> list[dict[str, Any]]:
root_type = str(root.get("type") or "") root_type = str(root.get("type") or "")
if root.get("status") == "disabled": if root.get("status") == "disabled":
@@ -381,5 +889,17 @@ def _git_value(repo_path: Path, *args: str) -> str | None:
return value or None return value or None
def _unique_strings(values: list[object]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
text = str(value or "").strip()
if not text or text in seen:
continue
result.append(text)
seen.add(text)
return result
def _utc_now() -> str: def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")

View File

@@ -14,7 +14,13 @@ from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
from .accountability_roots import DEFAULT_ROOT_MANIFEST_PATH, collect_accountability_root_evidence from .accountability_roots import (
DEFAULT_ROOT_MANIFEST_PATH,
AccountabilityEvidenceStore,
build_identity_projection,
collect_accountability_root_evidence,
load_accountability_root_manifest,
)
from .connectors import ConnectorConfig from .connectors import ConnectorConfig
from .financial_baseline import financial_export_from_legacy from .financial_baseline import financial_export_from_legacy
from .loader import declaration_files, load_yaml from .loader import declaration_files, load_yaml
@@ -116,6 +122,8 @@ def build_parser() -> argparse.ArgumentParser:
discover_roots.add_argument("--manifest", type=Path, default=DEFAULT_ROOT_MANIFEST_PATH) discover_roots.add_argument("--manifest", type=Path, default=DEFAULT_ROOT_MANIFEST_PATH)
discover_roots.add_argument("--include-remote", action="store_true", help="Allow HTTP reads from configured remote roots.") 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("--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("--store-db", type=Path, default=None, help="Persist evidence and identity candidates in a SQLite store.")
registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.") registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.")
registry_sub = registry.add_subparsers(dest="registry_command", required=True) registry_sub = registry.add_subparsers(dest="registry_command", required=True)
@@ -330,12 +338,17 @@ def main(argv: list[str] | None = None) -> int:
return _scan_repo(args) return _scan_repo(args)
if args.command == "discover-roots": if args.command == "discover-roots":
manifest = load_accountability_root_manifest(args.manifest)
payload = collect_accountability_root_evidence( payload = collect_accountability_root_evidence(
args.manifest, args.manifest,
include_remote=args.include_remote, include_remote=args.include_remote,
max_items_per_root=args.max_items_per_root, max_items_per_root=args.max_items_per_root,
) )
print(json.dumps(payload, indent=2, sort_keys=True)) projection = build_identity_projection(payload, manifest)
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))
return 0 return 0
if args.command == "registry": if args.command == "registry":

View File

@@ -0,0 +1,110 @@
$schema: "https://json-schema.org/draft/2020-12/schema"
$id: "https://railiance.local/fabric/schemas/accountability-identity-projection.schema.yaml"
title: "AccountabilityIdentityProjection"
type: object
additionalProperties: false
required:
- apiVersion
- kind
- generated_at
- evidence_run
- identity_candidates
- candidate_graph
properties:
apiVersion:
type: string
const: "railiance.fabric/v1alpha2"
kind:
type: string
const: AccountabilityIdentityProjection
generated_at:
type: string
format: date-time
evidence_run:
type: object
additionalProperties: false
required:
- manifest_id
- manifest_fingerprint
- generated_at
properties:
manifest_id:
type: string
manifest_fingerprint:
type: string
generated_at:
type: string
identity_candidates:
type: array
items:
$ref: "#/$defs/identityCandidate"
candidate_graph:
type: object
additionalProperties: false
required:
- nodes
- edges
properties:
nodes:
type: array
items:
type: object
additionalProperties: true
edges:
type: array
items:
type: object
additionalProperties: true
$defs:
identityCandidate:
type: object
additionalProperties: false
required:
- stable_key
- identity_type
- label
- review_state
- confidence
- aliases
- evidence_ids
- attributes
properties:
stable_key:
type: string
minLength: 3
identity_type:
type: string
minLength: 1
label:
type: string
minLength: 1
graph_id:
type: string
fabric_id:
type: string
subfabric_id:
type: string
owner_actor_id:
type: string
review_state:
type: string
enum:
- candidate
- accepted
- needs_review
confidence:
type: number
minimum: 0
maximum: 1
aliases:
type: array
items:
type: string
evidence_ids:
type: array
items:
type: string
attributes:
type: object
additionalProperties: true

View File

@@ -1,7 +1,12 @@
import json import json
from pathlib import Path from pathlib import Path
from railiance_fabric.accountability_roots import collect_accountability_root_evidence from railiance_fabric.accountability_roots import (
AccountabilityEvidenceStore,
build_identity_projection,
collect_accountability_root_evidence,
load_accountability_root_manifest,
)
from railiance_fabric.cli import main as cli_main from railiance_fabric.cli import main as cli_main
from railiance_fabric.schema_validation import draft202012_validator from railiance_fabric.schema_validation import draft202012_validator
@@ -32,6 +37,46 @@ def test_collect_accountability_root_evidence_from_manifest(tmp_path: Path) -> N
assert "secret-value" not in json.dumps(secret_root) assert "secret-value" not in json.dumps(secret_root)
def test_identity_projection_is_stable_and_reviewable(tmp_path: Path) -> None:
manifest_path = _fixture_manifest(tmp_path)
manifest = load_accountability_root_manifest(manifest_path)
first = build_identity_projection(collect_accountability_root_evidence(manifest_path), manifest)
second = build_identity_projection(collect_accountability_root_evidence(manifest_path), manifest)
validator = draft202012_validator(Path("schemas/accountability-identity-projection.schema.yaml"))
assert list(validator.iter_errors(first)) == []
first_keys = {candidate["stable_key"] for candidate in first["identity_candidates"]}
second_keys = {candidate["stable_key"] for candidate in second["identity_candidates"]}
assert first_keys == second_keys
assert {
"Actor",
"Fabric",
"Repository",
"Deployable",
"SecretRoot",
} <= {candidate["identity_type"] for candidate in first["identity_candidates"]}
assert first["candidate_graph"]["nodes"]
assert first["candidate_graph"]["edges"]
def test_evidence_store_persists_runs_items_and_identities(tmp_path: Path) -> None:
manifest_path = _fixture_manifest(tmp_path)
manifest = load_accountability_root_manifest(manifest_path)
evidence_run = collect_accountability_root_evidence(manifest_path)
projection = build_identity_projection(evidence_run, manifest)
store = AccountabilityEvidenceStore(tmp_path / "evidence.sqlite3")
stored = store.add_evidence_run(evidence_run, projection)
latest = store.latest_run()
assert latest is not None
assert latest["id"] == stored["run_id"]
assert stored["evidence_count"] == len(store.list_evidence(stored["run_id"]))
assert stored["identity_candidate_count"] == len(store.list_identity_candidates(stored["run_id"]))
def test_discover_roots_cli_prints_evidence_json(tmp_path: Path, capsys) -> None: def test_discover_roots_cli_prints_evidence_json(tmp_path: Path, capsys) -> None:
manifest = _fixture_manifest(tmp_path) manifest = _fixture_manifest(tmp_path)
@@ -42,6 +87,29 @@ def test_discover_roots_cli_prints_evidence_json(tmp_path: Path, capsys) -> None
assert payload["roots"] assert payload["roots"]
def test_discover_roots_cli_can_print_identities_and_store(tmp_path: Path, capsys) -> None:
manifest = _fixture_manifest(tmp_path)
store_path = tmp_path / "evidence.sqlite3"
assert (
cli_main(
[
"discover-roots",
"--manifest",
str(manifest),
"--identity-projection",
"--store-db",
str(store_path),
]
)
== 0
)
payload = json.loads(capsys.readouterr().out)
assert payload["kind"] == "AccountabilityIdentityProjection"
assert AccountabilityEvidenceStore(store_path).latest_run() is not None
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

@@ -135,7 +135,7 @@ Result:
```task ```task
id: RAIL-FAB-WP-0018-T03 id: RAIL-FAB-WP-0018-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "2a79938f-13e2-41b4-b692-74420d31bec4" state_hub_task_id: "2a79938f-13e2-41b4-b692-74420d31bec4"
``` ```
@@ -157,6 +157,28 @@ Done when:
- identity normalization produces reviewable candidates; - identity normalization produces reviewable candidates;
- repeated scans produce deterministic identities for unchanged sources. - repeated scans produce deterministic identities for unchanged sources.
Result:
- Added `schemas/accountability-identity-projection.schema.yaml` for
normalized `AccountabilityIdentityProjection` payloads.
- Extended `railiance_fabric/accountability_roots.py` with deterministic
identity normalization for netkingdoms, actors, fabrics, subfabrics,
repositories, deployables, endpoint/service/config roots, host paths,
catalog roots, secret roots, and backup/recovery roots.
- Added duplicate/ambiguous alias marking on identity candidates and a
candidate graph section that remains separate from accepted registry graph
snapshots.
- Added `AccountabilityEvidenceStore`, a SQLite store for raw evidence runs,
evidence items, and identity candidates.
- Extended `railiance-fabric discover-roots` with `--identity-projection` and
`--store-db`.
- Added focused tests for deterministic identity keys, schema validation,
persistence, CLI output, and store inspection.
- 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 --identity-projection --store-db /tmp/railiance-root-evidence.sqlite3`,
and full `python3 -m pytest`.
## T04 - Add Ownership Resolution And Review Flow ## T04 - Add Ownership Resolution And Review Flow
```task ```task