From 999f90dcbeb1ee9fa9d7acbd222d6f21316a739c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 24 May 2026 03:11:47 +0200 Subject: [PATCH] feat: collect accountability root evidence --- docs/accountability-root-manifest.md | 29 ++ docs/financial-fabric-operator-guide.md | 6 + railiance_fabric/accountability_roots.py | 385 ++++++++++++++++++ railiance_fabric/cli.py | 18 + .../accountability-root-evidence.schema.yaml | 187 +++++++++ tests/test_accountability_root_adapters.py | 161 ++++++++ ...countability-root-discovery-update-loop.md | 23 +- 7 files changed, 808 insertions(+), 1 deletion(-) create mode 100644 railiance_fabric/accountability_roots.py create mode 100644 schemas/accountability-root-evidence.schema.yaml create mode 100644 tests/test_accountability_root_adapters.py diff --git a/docs/accountability-root-manifest.md b/docs/accountability-root-manifest.md index e426df4..0057ffa 100644 --- a/docs/accountability-root-manifest.md +++ b/docs/accountability-root-manifest.md @@ -27,6 +27,12 @@ Tenant/subfabric example: examples/discovery/accountability-root-manifest.yaml ``` +Raw evidence run schema: + +```text +schemas/accountability-root-evidence.schema.yaml +``` + ## Required Sections - `netkingdom`: root id, name, and king actor. @@ -49,3 +55,26 @@ still rests on financial and operational accountability. Discovery roots should state `safe_discovery` explicitly. Secret and backup roots should use `metadata_only` or `explicit_review`; adapters must never read secret values or operational telemetry while building Fabric graph evidence. + +## Collecting Root Evidence + +The first adapter slice emits raw evidence without promoting it into accepted +graph snapshots: + +```bash +railiance-fabric discover-roots \ + --manifest fabric/discovery/railiance-accountability-roots.yaml \ + --max-items-per-root 200 +``` + +The command covers manifest-backed repository inventory, repository checkout +identity, host-path evidence, deployment automation and infrastructure files, +State Hub/Gitea metadata roots, endpoint/service-config roots, and safe +metadata-only backup or secret roots. Remote HTTP reads are disabled by default; +pass `--include-remote` only when the operator intentionally wants configured +remote roots such as State Hub inventory endpoints to be fetched. + +The output is an `AccountabilityRootEvidenceRun`. Every evidence item carries +provenance, source, fingerprint, `durable: true`, and +`live_telemetry: false`, preserving the boundary between Fabric evidence and +operational telemetry. diff --git a/docs/financial-fabric-operator-guide.md b/docs/financial-fabric-operator-guide.md index aad334b..b071846 100644 --- a/docs/financial-fabric-operator-guide.md +++ b/docs/financial-fabric-operator-guide.md @@ -50,6 +50,12 @@ fabric/discovery/railiance-accountability-roots.yaml The manifest schema is documented in `docs/accountability-root-manifest.md`. +To collect raw evidence from those roots without promoting graph state: + +```bash +railiance-fabric discover-roots --max-items-per-root 200 +``` + The financial export must satisfy these invariants: - every accepted node has resolvable ownership; diff --git a/railiance_fabric/accountability_roots.py b/railiance_fabric/accountability_roots.py new file mode 100644 index 0000000..f55ded5 --- /dev/null +++ b/railiance_fabric/accountability_roots.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .discovery import short_fingerprint +from .loader import load_yaml, repo_root +from .schema_validation import draft202012_validator + + +EXTRACTOR_VERSION = "0.1.0" +DEFAULT_ROOT_MANIFEST_PATH = repo_root() / "fabric" / "discovery" / "railiance-accountability-roots.yaml" + + +def load_accountability_root_manifest(path: Path | None = None, *, validate: bool = True) -> dict[str, Any]: + manifest_path = path or DEFAULT_ROOT_MANIFEST_PATH + manifest = load_yaml(manifest_path) + if not isinstance(manifest, dict): + raise ValueError(f"accountability root manifest must be a mapping: {manifest_path}") + if validate: + validator = draft202012_validator(repo_root() / "schemas" / "accountability-root-manifest.schema.yaml") + errors = sorted(validator.iter_errors(manifest), key=lambda error: list(error.path)) + if errors: + location = ".".join(str(part) for part in errors[0].path) or "" + raise ValueError(f"invalid accountability root manifest at {location}: {errors[0].message}") + return manifest + + +def collect_accountability_root_evidence( + manifest_path: Path | None = None, + *, + include_remote: bool = False, + max_items_per_root: int = 200, +) -> dict[str, Any]: + manifest_path = manifest_path or DEFAULT_ROOT_MANIFEST_PATH + manifest = load_accountability_root_manifest(manifest_path) + generated_at = _utc_now() + roots: list[dict[str, Any]] = [] + review_artifacts: list[dict[str, Any]] = [] + + for root in manifest.get("discovery_roots", []): + if not isinstance(root, dict): + continue + root_record = { + "root_id": root.get("id", ""), + "root_type": root.get("type", ""), + "status": root.get("status", "planned"), + "fabric_id": root.get("fabric_id", ""), + "owner_actor_id": root.get("owner_actor_id", ""), + "safe_discovery": _source(root).get("safe_discovery", "metadata_only"), + "evidence": [], + } + if root.get("subfabric_id"): + root_record["subfabric_id"] = root["subfabric_id"] + try: + root_record["evidence"] = _collect_root_evidence( + root, + include_remote=include_remote, + max_items=max_items_per_root, + ) + except Exception as exc: # pragma: no cover - defensive boundary for operator runs + review_artifacts.append( + _review_artifact( + root, + "adapter_failed", + "error", + f"{type(exc).__name__}: {exc}", + ) + ) + roots.append(root_record) + + result = { + "apiVersion": "railiance.fabric/v1alpha2", + "kind": "AccountabilityRootEvidenceRun", + "generated_at": generated_at, + "manifest": { + "id": manifest.get("metadata", {}).get("id", ""), + "path": _display_path(manifest_path), + "fingerprint": _file_sha256(manifest_path) or short_fingerprint(manifest), + }, + "roots": roots, + "review_artifacts": review_artifacts, + } + validator = draft202012_validator(repo_root() / "schemas" / "accountability-root-evidence.schema.yaml") + errors = sorted(validator.iter_errors(result), key=lambda error: list(error.path)) + if errors: + location = ".".join(str(part) for part in errors[0].path) or "" + raise ValueError(f"invalid accountability root evidence at {location}: {errors[0].message}") + return result + + +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 "") + if root.get("status") == "disabled": + return [_declared_evidence(root, "root_disabled", "skipped", "Discovery root is disabled.")] + if root_type == "registry_manifest": + return _registry_manifest_evidence(root, max_items=max_items) + if root_type == "repository_checkout": + return _repository_checkout_evidence(root) + if root_type == "host_path": + return _glob_root_evidence(root, "host_path_match", max_items=max_items) + if root_type in {"deployment_automation", "infrastructure_manifest", "service_config", "endpoint_contract"}: + return _glob_root_evidence(root, root_type, max_items=max_items) + if root_type == "state_hub_repo_inventory": + return _state_hub_evidence(root, include_remote=include_remote) + if root_type in {"gitea_organization", "gitea_repository"}: + return [_declared_evidence(root, root_type, "declared", f"{root_type} root declared.")] + if root_type in {"secret_root", "backup_recovery", "manual_review_queue"}: + return _metadata_root_evidence(root) + return [_declared_evidence(root, root_type or "unknown_root", "declared", "Discovery root declared.")] + + +def _registry_manifest_evidence(root: dict[str, Any], *, max_items: int) -> list[dict[str, Any]]: + source = _source(root) + manifest_path = _resolve_path(source.get("manifest_path") or source.get("path")) + if not manifest_path.exists(): + return [_declared_evidence(root, "registry_manifest_missing", "unavailable", f"Manifest missing: {manifest_path}")] + manifest = load_yaml(manifest_path) + repositories = manifest.get("repositories") if isinstance(manifest, dict) else [] + if not isinstance(repositories, list): + return [_declared_evidence(root, "registry_manifest_invalid", "unavailable", "Manifest has no repositories list.")] + + evidence: list[dict[str, Any]] = [ + _file_evidence(root, manifest_path, "registry_manifest", summary=f"Registry manifest with {len(repositories)} repositories.") + ] + for index, repo in enumerate(repositories[:max_items]): + if not isinstance(repo, dict): + continue + repo_source = { + "manifest_path": _display_path(manifest_path), + "json_pointer": f"/repositories/{index}", + "repo_slug": repo.get("slug", ""), + "path": repo.get("path", ""), + "remote_url": repo.get("remote_url", ""), + } + attributes = { + "name": repo.get("name", ""), + "domain": repo.get("domain", ""), + "default_branch": repo.get("default_branch", ""), + "state_hub_repo_id": repo.get("state_hub_repo_id", ""), + "has_local_path": bool(repo.get("path")), + "has_remote_url": bool(repo.get("remote_url")), + } + evidence.append( + _evidence_item( + root, + evidence_type="registered_repository", + state="declared", + source=repo_source, + summary=f"Registered repository {repo.get('slug', '')}.", + attributes={key: value for key, value in attributes.items() if value not in ("", None)}, + ) + ) + if len(repositories) > max_items: + evidence.append(_declared_evidence(root, "registry_manifest_truncated", "skipped", f"Skipped {len(repositories) - max_items} repositories beyond max_items_per_root.")) + return evidence + + +def _repository_checkout_evidence(root: dict[str, Any]) -> list[dict[str, Any]]: + source = _source(root) + checkout = _resolve_path(source.get("path")) + if not checkout.exists(): + return [_declared_evidence(root, "repository_checkout_missing", "unavailable", f"Checkout missing: {checkout}")] + attributes = { + "repo_slug": source.get("repo_slug", ""), + "path_exists": True, + "has_git_dir": (checkout / ".git").exists(), + "has_fabric_dir": (checkout / "fabric").exists(), + "remote_origin": _git_value(checkout, "config", "--get", "remote.origin.url") or source.get("remote_url", ""), + "head": _git_value(checkout, "rev-parse", "HEAD") or "", + "branch": _git_value(checkout, "rev-parse", "--abbrev-ref", "HEAD") or "", + } + return [ + _evidence_item( + root, + evidence_type="repository_checkout", + state="observed", + source={"path": _display_path(checkout), "repo_slug": source.get("repo_slug", "")}, + summary=f"Repository checkout observed at {_display_path(checkout)}.", + attributes={key: value for key, value in attributes.items() if value not in ("", None)}, + ) + ] + + +def _glob_root_evidence(root: dict[str, Any], evidence_type: str, *, max_items: int) -> list[dict[str, Any]]: + source = _source(root) + base = _resolve_path(source.get("path") or ".") + patterns = source.get("patterns") if isinstance(source.get("patterns"), list) else ["*"] + if not base.exists(): + return [_declared_evidence(root, f"{evidence_type}_missing", "unavailable", f"Root path missing: {base}")] + matches: list[Path] = [] + for pattern in patterns: + matches.extend(sorted(base.glob(str(pattern)))) + if len(matches) >= max_items: + break + evidence = [ + _evidence_item( + root, + evidence_type=evidence_type, + state="observed", + source={"path": _display_path(path)}, + summary=f"Observed {evidence_type} at {_display_path(path)}.", + attributes=_file_attributes(path), + ) + for path in matches[:max_items] + ] + if not evidence: + evidence.append(_declared_evidence(root, f"{evidence_type}_empty", "unavailable", f"No files matched under {base}.")) + if len(matches) > max_items: + evidence.append(_declared_evidence(root, f"{evidence_type}_truncated", "skipped", f"Skipped {len(matches) - max_items} matches beyond max_items_per_root.")) + return evidence + + +def _state_hub_evidence(root: dict[str, Any], *, include_remote: bool) -> list[dict[str, Any]]: + source = _source(root) + if not include_remote: + return [_declared_evidence(root, "state_hub_repo_inventory", "declared", "State Hub repo inventory root declared; remote fetch disabled.")] + base_url = str(source.get("base_url") or "").rstrip("/") + evidence: list[dict[str, Any]] = [] + for api_path in source.get("api_paths") or ["/managed-repos/"]: + url = f"{base_url}{api_path}" + try: + with urllib.request.urlopen(url, timeout=5) as response: + payload = json.loads(response.read()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + evidence.append(_declared_evidence(root, "state_hub_fetch_failed", "unavailable", f"{url}: {exc}")) + continue + count = len(payload) if isinstance(payload, list) else len(payload.get("items", [])) if isinstance(payload, dict) else 0 + evidence.append( + _evidence_item( + root, + evidence_type="state_hub_repo_inventory", + state="observed", + source={"url": url}, + summary=f"Fetched State Hub repository inventory from {url}.", + attributes={"item_count": count, "payload_fingerprint": short_fingerprint(payload)}, + ) + ) + return evidence + + +def _metadata_root_evidence(root: dict[str, Any]) -> list[dict[str, Any]]: + source = _source(root) + path = source.get("path") + if path: + resolved = _resolve_path(path) + if resolved.exists(): + return [_file_evidence(root, resolved, str(root.get("type") or "metadata_root"))] + return [_declared_evidence(root, str(root.get("type") or "metadata_root"), "planned" if root.get("status") == "planned" else "declared", "Metadata-only root declared.")] + + +def _file_evidence(root: dict[str, Any], path: Path, evidence_type: str, *, summary: str | None = None) -> dict[str, Any]: + return _evidence_item( + root, + evidence_type=evidence_type, + state="observed", + source={"path": _display_path(path)}, + summary=summary or f"Observed {evidence_type} file at {_display_path(path)}.", + attributes=_file_attributes(path), + ) + + +def _declared_evidence(root: dict[str, Any], evidence_type: str, state: str, summary: str) -> dict[str, Any]: + source = _source(root) + return _evidence_item( + root, + evidence_type=evidence_type, + state=state, + source={key: value for key, value in source.items() if key != "safe_discovery"}, + summary=summary, + attributes={"safe_discovery": source.get("safe_discovery", "metadata_only")}, + ) + + +def _evidence_item( + root: dict[str, Any], + *, + evidence_type: str, + state: str, + source: dict[str, Any], + summary: str, + attributes: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload = { + "root_id": root.get("id", ""), + "evidence_type": evidence_type, + "state": state, + "source": source, + "summary": summary, + "attributes": attributes or {}, + } + fingerprint = short_fingerprint(payload, length=16) + return { + "id": f"evidence:{root.get('id', 'root')}:{fingerprint}", + "root_id": root.get("id", ""), + "evidence_type": evidence_type, + "state": state, + "durable": True, + "live_telemetry": False, + "source": source, + "provenance": { + "extractor_id": "accountability-root-adapter", + "extractor_version": EXTRACTOR_VERSION, + "method": "deterministic", + "origin": "deterministic", + }, + "fingerprint": fingerprint, + "summary": summary, + "attributes": attributes or {}, + } + + +def _review_artifact(root: dict[str, Any], artifact_type: str, severity: str, message: str) -> dict[str, Any]: + return { + "root_id": root.get("id", ""), + "artifact_type": artifact_type, + "severity": severity, + "message": message, + "source": _source(root), + } + + +def _source(root: dict[str, Any]) -> dict[str, Any]: + source = root.get("source") + return source if isinstance(source, dict) else {} + + +def _resolve_path(value: object) -> Path: + path = Path(str(value or ".")) + return path if path.is_absolute() else repo_root() / path + + +def _display_path(path: Path) -> str: + try: + return path.resolve().relative_to(repo_root()).as_posix() + except ValueError: + return str(path.resolve()) + + +def _file_attributes(path: Path) -> dict[str, Any]: + attributes: dict[str, Any] = { + "path_type": "directory" if path.is_dir() else "file", + "exists": path.exists(), + } + if path.is_file(): + attributes["size_bytes"] = path.stat().st_size + attributes["sha256"] = _file_sha256(path) + return attributes + + +def _file_sha256(path: Path) -> str | None: + if not path.is_file(): + return None + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _git_value(repo_path: Path, *args: str) -> str | None: + try: + result = subprocess.run( + ["git", *args], + cwd=repo_path, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=False, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + value = result.stdout.strip() + return value or None + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index 59ba938..24270be 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -14,6 +14,7 @@ from pathlib import Path from typing import Any from urllib.parse import quote +from .accountability_roots import DEFAULT_ROOT_MANIFEST_PATH, collect_accountability_root_evidence from .connectors import ConnectorConfig from .financial_baseline import financial_export_from_legacy from .loader import declaration_files, load_yaml @@ -108,6 +109,14 @@ def build_parser() -> argparse.ArgumentParser: help="Manifest path for the local-fabric-registry connector.", ) + discover_roots = sub.add_parser( + "discover-roots", + help="Collect raw evidence from accountability root manifest entries.", + ) + 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("--max-items-per-root", type=int, default=200) + registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.") registry_sub = registry.add_subparsers(dest="registry_command", required=True) @@ -320,6 +329,15 @@ def main(argv: list[str] | None = None) -> int: if args.command == "scan": return _scan_repo(args) + if args.command == "discover-roots": + payload = collect_accountability_root_evidence( + args.manifest, + include_remote=args.include_remote, + max_items_per_root=args.max_items_per_root, + ) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + if args.command == "registry": if args.registry_command == "sync": return _registry_sync(args) diff --git a/schemas/accountability-root-evidence.schema.yaml b/schemas/accountability-root-evidence.schema.yaml new file mode 100644 index 0000000..4b07c2a --- /dev/null +++ b/schemas/accountability-root-evidence.schema.yaml @@ -0,0 +1,187 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://railiance.local/fabric/schemas/accountability-root-evidence.schema.yaml" +title: "AccountabilityRootEvidenceRun" +type: object +additionalProperties: false +required: + - apiVersion + - kind + - generated_at + - manifest + - roots + - review_artifacts +properties: + apiVersion: + type: string + const: "railiance.fabric/v1alpha2" + kind: + type: string + const: AccountabilityRootEvidenceRun + generated_at: + type: string + format: date-time + manifest: + type: object + additionalProperties: false + required: + - id + - path + - fingerprint + properties: + id: + type: string + minLength: 1 + path: + type: string + minLength: 1 + fingerprint: + type: string + minLength: 8 + roots: + type: array + items: + $ref: "#/$defs/rootEvidence" + review_artifacts: + type: array + items: + $ref: "#/$defs/reviewArtifact" + +$defs: + rootEvidence: + type: object + additionalProperties: false + required: + - root_id + - root_type + - status + - fabric_id + - owner_actor_id + - safe_discovery + - evidence + properties: + root_id: + type: string + minLength: 1 + root_type: + type: string + minLength: 1 + status: + type: string + enum: + - active + - planned + - disabled + fabric_id: + type: string + minLength: 1 + subfabric_id: + type: string + minLength: 1 + owner_actor_id: + type: string + minLength: 1 + safe_discovery: + type: string + minLength: 1 + evidence: + type: array + items: + $ref: "#/$defs/evidenceItem" + + evidenceItem: + type: object + additionalProperties: false + required: + - id + - root_id + - evidence_type + - state + - durable + - live_telemetry + - source + - provenance + - fingerprint + - summary + properties: + id: + type: string + minLength: 1 + root_id: + type: string + minLength: 1 + evidence_type: + type: string + minLength: 1 + state: + type: string + enum: + - observed + - declared + - planned + - skipped + - unavailable + durable: + type: boolean + live_telemetry: + type: boolean + source: + type: object + additionalProperties: true + provenance: + type: object + additionalProperties: false + required: + - extractor_id + - extractor_version + - method + - origin + properties: + extractor_id: + type: string + minLength: 1 + extractor_version: + type: string + minLength: 1 + method: + type: string + minLength: 1 + origin: + type: string + minLength: 1 + fingerprint: + type: string + minLength: 8 + summary: + type: string + minLength: 1 + attributes: + type: object + additionalProperties: true + + reviewArtifact: + type: object + additionalProperties: false + required: + - root_id + - artifact_type + - severity + - message + properties: + root_id: + type: string + minLength: 1 + artifact_type: + type: string + minLength: 1 + severity: + type: string + enum: + - info + - warning + - error + message: + type: string + minLength: 1 + source: + type: object + additionalProperties: true diff --git a/tests/test_accountability_root_adapters.py b/tests/test_accountability_root_adapters.py new file mode 100644 index 0000000..fb8ebf1 --- /dev/null +++ b/tests/test_accountability_root_adapters.py @@ -0,0 +1,161 @@ +import json +from pathlib import Path + +from railiance_fabric.accountability_roots import collect_accountability_root_evidence +from railiance_fabric.cli import main as cli_main +from railiance_fabric.schema_validation import draft202012_validator + + +def test_collect_accountability_root_evidence_from_manifest(tmp_path: Path) -> None: + manifest = _fixture_manifest(tmp_path) + evidence_run = collect_accountability_root_evidence(manifest, max_items_per_root=20) + + validator = draft202012_validator(Path("schemas/accountability-root-evidence.schema.yaml")) + assert list(validator.iter_errors(evidence_run)) == [] + assert evidence_run["kind"] == "AccountabilityRootEvidenceRun" + assert evidence_run["manifest"]["id"] == "fixture.accountability-roots" + + evidence = [ + item + for root in evidence_run["roots"] + for item in root["evidence"] + ] + evidence_types = {item["evidence_type"] for item in evidence} + assert {"registered_repository", "repository_checkout", "deployment_automation", "secret_root"} <= evidence_types + assert all(item["durable"] is True for item in evidence) + assert all(item["live_telemetry"] is False for item in evidence) + + registered_repo = next(item for item in evidence if item["evidence_type"] == "registered_repository") + assert registered_repo["attributes"]["state_hub_repo_id"] == "fixture-state-hub-id" + + secret_root = next(item for item in evidence if item["evidence_type"] == "secret_root") + assert "secret-value" not in json.dumps(secret_root) + + +def test_discover_roots_cli_prints_evidence_json(tmp_path: Path, capsys) -> None: + manifest = _fixture_manifest(tmp_path) + + assert cli_main(["discover-roots", "--manifest", str(manifest), "--max-items-per-root", "20"]) == 0 + + payload = json.loads(capsys.readouterr().out) + assert payload["kind"] == "AccountabilityRootEvidenceRun" + assert payload["roots"] + + +def _fixture_manifest(tmp_path: Path) -> Path: + workspace = tmp_path / "workspace" + repo = workspace / "fixture-repo" + repo.mkdir(parents=True) + (repo / ".git").mkdir() + (repo / "fabric").mkdir() + (repo / "Dockerfile").write_text("FROM python:3.12-slim\n", encoding="utf-8") + (repo / "compose.yaml").write_text("services:\n api:\n image: fixture/api\n", encoding="utf-8") + secret_metadata = repo / "secret-root.txt" + secret_metadata.write_text("secret-value-never-promote\n", encoding="utf-8") + + registry_manifest = tmp_path / "local-repos.yaml" + registry_manifest.write_text( + """ +apiVersion: railiance.fabric/v1alpha1 +kind: RegistryOnboardingManifest +repositories: + - slug: fixture-repo + name: Fixture Repo + domain: testing + path: {repo} + default_branch: main + state_hub_repo_id: fixture-state-hub-id + remote_url: gitea-remote:coulomb/fixture-repo.git +""".format(repo=repo), + encoding="utf-8", + ) + + manifest = tmp_path / "accountability-roots.yaml" + manifest.write_text( + """ +apiVersion: railiance.fabric/v1alpha2 +kind: AccountabilityRootManifest +metadata: + id: fixture.accountability-roots + name: Fixture Accountability Roots +netkingdom: + id: fixture.netkingdom + name: Fixture Netkingdom + king_actor_id: actor.fixture.king +actors: + - id: actor.fixture.king + role: king + name: Fixture King + - id: actor.fixture.lord + role: lord + name: Fixture Lord +fabrics: + - id: fabric.fixture.primary + kind: Fabric + name: Fixture Primary Fabric + netkingdom_id: fixture.netkingdom + lord_actor_id: actor.fixture.lord + parent_fabric_id: null + status: active + boundary: + boundary_type: fabric + criterion: financial_and_operational_accountability +discovery_roots: + - id: root.fixture.registry + type: registry_manifest + status: active + fabric_id: fabric.fixture.primary + owner_actor_id: actor.fixture.king + source: + manifest_path: {registry_manifest} + safe_discovery: local_files + evidence_scope: + - repo_inventory + - repository_identity + - id: root.fixture.checkout + type: repository_checkout + status: active + fabric_id: fabric.fixture.primary + owner_actor_id: actor.fixture.lord + source: + repo_slug: fixture-repo + path: {repo} + safe_discovery: local_files + evidence_scope: + - repository_identity + - local_checkout + - id: root.fixture.deployment + type: deployment_automation + status: active + fabric_id: fabric.fixture.primary + owner_actor_id: actor.fixture.lord + source: + path: {repo} + patterns: + - Dockerfile + - compose.yaml + safe_discovery: local_files + evidence_scope: + - deployment_topology + - id: root.fixture.secret + type: secret_root + status: planned + fabric_id: fabric.fixture.primary + owner_actor_id: actor.fixture.king + source: + path: {secret_metadata} + safe_discovery: metadata_only + evidence_scope: + - secret_metadata +refresh: + cadence: manual + triggers: + - operator_request +""".format( + registry_manifest=registry_manifest, + repo=repo, + secret_metadata=secret_metadata, + ), + encoding="utf-8", + ) + return manifest diff --git a/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md b/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md index f7e68eb..e1df82c 100644 --- a/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md +++ b/workplans/RAIL-FAB-WP-0018-accountability-root-discovery-update-loop.md @@ -86,7 +86,7 @@ Result: ```task id: RAIL-FAB-WP-0018-T02 -status: todo +status: done priority: high state_hub_task_id: "09246f06-10db-4c6c-9cb3-f2808fdbaa38" ``` @@ -110,6 +110,27 @@ Done when: state; - adapters can run against the current local Railiance workspace. +Result: + +- Added `railiance_fabric/accountability_roots.py` to load and validate the + accountability root manifest and collect raw evidence from registered roots. +- Added `schemas/accountability-root-evidence.schema.yaml` for + `AccountabilityRootEvidenceRun` payloads. +- Added `railiance-fabric discover-roots` with `--manifest`, + `--max-items-per-root`, and opt-in `--include-remote`. +- Implemented initial adapters for registry manifests, repository checkouts, + host paths, deployment/infrastructure/service/endpoint file roots, State Hub + metadata roots, Gitea metadata roots, and metadata-only backup/secret roots. +- Raw evidence carries source, provenance, fingerprints, durable evidence + state, and `live_telemetry: false` without promoting candidates into accepted + graph state. +- Added adapter coverage in `tests/test_accountability_root_adapters.py` and + documented the evidence command in the manifest/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`, + and full `python3 -m pytest`. + ## T03 - Build Evidence Store And Identity Normalization ```task