diff --git a/README.md b/README.md index 3f1d575..0118107 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ uv run finhub ledger allocations uv run finhub ledger billing-basis uv run finhub evaluate uv run finhub evidence --seed-fixtures +uv run finhub fabric-cutover-check --authority fabric-export.json --projection state-hub-fabric-summary.json uv run finhub serve uv run finhub import-cloud tests/fixtures/cloud-costs.csv uv run finhub ops-costs tests/fixtures/hosteurope.csv @@ -45,6 +46,11 @@ uv run finhub ops-costs tests/fixtures/hosteurope.csv Cross-hub coupling (`--emit`) posts non-secret progress events to dev-hub when `STATE_HUB_API` is reachable. +Fabric graph authority remains in the specialized `railiance-fabric` engine. +Fin-hub publishes the financial-domain boundary and an executable State Hub +cutover gate in `docs/fabric-authority-consumer-contract-v1.md`; it does not +copy Fabric authority tables into its ledger. + ## Client cost attribution HostEurope CSV rows may include `client_id`, `application_id`, and diff --git a/docs/evidence/FIN-WP-0003-precutover-2026-08-31.md b/docs/evidence/FIN-WP-0003-precutover-2026-08-31.md new file mode 100644 index 0000000..02a190f --- /dev/null +++ b/docs/evidence/FIN-WP-0003-precutover-2026-08-31.md @@ -0,0 +1,28 @@ +# FIN-WP-0003 pre-cutover evidence — 2026-08-31 + +The new gate was exercised against the current local Fabric registry and the +live State Hub projection: + +```bash +PYTHONPATH=src .venv/bin/python -m fin_hub.cli fabric-cutover-check \ + --authority http://127.0.0.1:8765/exports/financial \ + --projection http://127.0.0.1:8000/fabric/graph/summary +``` + +Result: expected exit `2`, `ready: false`. + +| Measure | Fabric authority | State Hub read model | +| --- | ---: | ---: | +| API version | `railiance.fabric/v1alpha2` | `railiance.fabric/v1alpha2` | +| Nodes | 131 | 49 | +| Edges | 117 | 58 | +| Actors | 2 | 2 | +| Fabrics | 1 | 1 | +| Unresolved | 0 | 0 | + +The content hashes differ. The authority now supplies immutable snapshot-set +revision and path provenance; the old State Hub import has null source revision, +path, and export time. This is a correct no-go result: the owner contract and +checker are ready, but STATE-WP-0079 must refresh from one selected authority +export and obtain a green comparison before switching callers or removing the +legacy tables. No production route was changed by FIN-WP-0003. diff --git a/docs/fabric-authority-consumer-contract-v1.md b/docs/fabric-authority-consumer-contract-v1.md new file mode 100644 index 0000000..e60811e --- /dev/null +++ b/docs/fabric-authority-consumer-contract-v1.md @@ -0,0 +1,68 @@ +# Fabric authority and retirement consumer contract v1 + +## Decision + +`railiance-fabric` is the specialized authority for Fabric topology, +containment, ownership, accepted graph snapshots, and graph exports. Fin-hub is +the accountable financial-domain consumer: it may join Fabric identifiers to +booked-cost and reporting facts, but it does not copy or mutate authority +tables. State Hub's `fabric_graph_*` tables are a rebuildable legacy read model. + +The authoritative machine contract is `FabricGraphExport` from: + +- `GET /exports/financial` on the Railiance Fabric registry; or +- `railiance-fabric export --format financial` for an operator-reviewed file. + +The preferred contract is `railiance.fabric/v1alpha2` with +`schema_version: financial-fabric-v1`. The v1alpha1 form remains a compatibility +input during cutover. + +Every cutover export must carry: + +- `source.producer: railiance-fabric`; +- an immutable `source.commit` (a Git revision or `snapshot-set:sha256:*`); +- `source.path`; +- `generated_at`; and +- complete `nodes` and `edges` arrays. + +## State Hub route disposition + +| Legacy State Hub surface | Successor | +| --- | --- | +| `POST /fabric/graph-exports`, `POST /fabric/graph-exports/pull` | Replace with Railiance Fabric repository/discovery snapshot ingest and explicit acceptance. State Hub must not remain an authoring/import authority. | +| `GET /fabric/graph-exports`, `GET /fabric/graph-exports/latest` | Replace with Fabric snapshot history plus `GET /exports/state-hub`. | +| `GET /fabric/graph/nodes` | Replace with Fabric `GET /graph/nodes` or the authoritative export. | +| `GET /fabric/graph/edges` | Replace with Fabric `GET /graph/edges` or the authoritative export. | +| `GET /fabric/graph/summary` | Replace with Fabric `GET /graph/summary`. | +| State Hub Fabric dashboard assumptions | Replace with `GET /ui/graph-explorer`; no independent State Hub Fabric page or external caller was found in the 2026-08-31 scan. | +| `fabric_graph_imports`, `fabric_graph_nodes`, `fabric_graph_edges` | Archive with the final State Hub dump, then retire. Do not reproduce these tables in fin-hub or hub-core. | + +## Cutover gate + +Capture the authority export and the State Hub summary from the same candidate +refresh, then run: + +```bash +finhub fabric-cutover-check \ + --authority http://127.0.0.1:8765/exports/financial \ + --projection http://127.0.0.1:8000/fabric/graph/summary +``` + +The command exits `0` only when API/schema identity, canonical content hash, +node/edge/actor/fabric/unresolved counts, source revision/path, and export time +are equal. Exit `2` means no cutover. Evidence output is non-secret JSON and can +be attached to the State Hub retirement record. + +Rollback keeps the State Hub read routes available during the dual-read window. +If the direct Fabric surface fails or the check drifts, restore the caller route +to State Hub, do not accept a new authority snapshot, and re-run the comparison. +The source graph remains in Railiance Fabric in either direction. + +## Hub-core projection decision + +Deferred. There is no current external caller that needs a cross-domain Fabric +orientation projection, and Fabric already provides a graph API and explorer. +Adding hub-core tables would create a third copy with no demonstrated consumer. +If a future hub view needs Fabric context, it must use a versioned read port over +the authority export, cache only rebuildable bounded results, carry the source +revision/content hash, and never expose mutation operations. diff --git a/src/fin_hub/cli.py b/src/fin_hub/cli.py index b5908b0..27a1777 100644 --- a/src/fin_hub/cli.py +++ b/src/fin_hub/cli.py @@ -48,6 +48,7 @@ from fin_hub.services.ledger import ( set_opening_balance, ) from fin_hub.services.runway import compute_runway +from fin_hub.fabric_cutover import evaluate_fabric_cutover, load_json_reference def _ledger_path(args: argparse.Namespace) -> Path: @@ -356,6 +357,15 @@ def _cmd_evidence(args: argparse.Namespace) -> int: return 0 +def _cmd_fabric_cutover_check(args: argparse.Namespace) -> int: + report = evaluate_fabric_cutover( + load_json_reference(args.authority, timeout=args.timeout), + load_json_reference(args.projection, timeout=args.timeout), + ) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if report["ready"] else 2 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Fin Hub operator CLI") sub = parser.add_subparsers(dest="command", required=True) @@ -603,6 +613,23 @@ def build_parser() -> argparse.ArgumentParser: ) evidence.set_defaults(func=_cmd_evidence) + fabric_cutover = sub.add_parser( + "fabric-cutover-check", + help="Compare a Railiance Fabric export with a State Hub projection summary", + ) + fabric_cutover.add_argument( + "--authority", + required=True, + help="FabricGraphExport JSON file or authority export URL", + ) + fabric_cutover.add_argument( + "--projection", + required=True, + help="State Hub graph summary JSON file or URL", + ) + fabric_cutover.add_argument("--timeout", type=float, default=15.0) + fabric_cutover.set_defaults(func=_cmd_fabric_cutover_check) + serve = sub.add_parser("serve", help="Run the HTTP read API") serve.add_argument("--host", default="127.0.0.1") serve.add_argument("--port", type=int, default=8080) diff --git a/src/fin_hub/fabric_cutover.py b/src/fin_hub/fabric_cutover.py new file mode 100644 index 0000000..cc553ef --- /dev/null +++ b/src/fin_hub/fabric_cutover.py @@ -0,0 +1,119 @@ +"""Evidence gate for retiring the State Hub Fabric read model.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any +from urllib.request import urlopen + + +SUPPORTED_EXPORTS = { + ("railiance.fabric/v1alpha1", None), + ("railiance.fabric/v1alpha2", "financial-fabric-v1"), +} + + +class FabricCutoverError(ValueError): + """Raised when cutover evidence cannot be evaluated safely.""" + + +def load_json_reference(reference: str, *, timeout: float = 15.0) -> dict[str, Any]: + if reference.startswith(("http://", "https://")): + with urlopen(reference, timeout=timeout) as response: # noqa: S310 - operator-supplied URL + payload = json.loads(response.read().decode("utf-8")) + else: + payload = json.loads(Path(reference).read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise FabricCutoverError(f"JSON reference must contain an object: {reference}") + return payload + + +def fabric_content_hash(payload: dict[str, Any]) -> str: + canonical = json.loads(json.dumps(payload, sort_keys=True, default=str)) + canonical.pop("generated_at", None) + raw = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def evaluate_fabric_cutover( + authority: dict[str, Any], projection: dict[str, Any] +) -> dict[str, Any]: + """Compare one authoritative Fabric export with a State Hub-style summary.""" + authority_errors = _authority_errors(authority) + latest = projection.get("latest_import") + if not isinstance(latest, dict): + latest = projection + + source = authority.get("source") if isinstance(authority.get("source"), dict) else {} + expected = { + "api_version": authority.get("apiVersion"), + "schema_version": authority.get("schema_version"), + "export_kind": authority.get("kind"), + "content_hash": fabric_content_hash(authority), + "source_commit": source.get("commit"), + "source_path": source.get("path"), + "exported_at": authority.get("generated_at"), + "node_count": len(authority.get("nodes", [])), + "edge_count": len(authority.get("edges", [])), + "actor_count": len(authority.get("actors", [])), + "fabric_count": len(authority.get("fabrics", [])), + "unresolved_count": len(authority.get("unresolved", [])), + } + actual = { + "api_version": latest.get("api_version"), + "schema_version": projection.get("schema_version", latest.get("schema_version")), + "export_kind": latest.get("export_kind"), + "content_hash": latest.get("content_hash"), + "source_commit": latest.get("source_commit"), + "source_path": latest.get("source_path"), + "exported_at": latest.get("exported_at"), + "node_count": projection.get("node_count", latest.get("node_count")), + "edge_count": projection.get("edge_count", latest.get("edge_count")), + "actor_count": projection.get("actor_count", latest.get("actor_count", 0)), + "fabric_count": projection.get("fabric_count", latest.get("fabric_count", 0)), + "unresolved_count": projection.get( + "unresolved_count", latest.get("unresolved_count", 0) + ), + } + checks = { + name: {"expected": expected[name], "actual": actual[name], "ok": expected[name] == actual[name]} + for name in expected + } + failures = [name for name, result in checks.items() if not result["ok"]] + failures.extend(f"authority:{error}" for error in authority_errors) + return { + "contract": "fin-hub.fabric-cutover/v1", + "ready": not failures, + "authority": { + "producer": source.get("producer"), + "reference": source.get("commit"), + "node_count": expected["node_count"], + "edge_count": expected["edge_count"], + }, + "checks": checks, + "failures": failures, + } + + +def _authority_errors(payload: dict[str, Any]) -> list[str]: + errors: list[str] = [] + identity = (payload.get("apiVersion"), payload.get("schema_version")) + if identity not in SUPPORTED_EXPORTS: + errors.append("unsupported export identity") + if payload.get("kind") != "FabricGraphExport": + errors.append("kind must be FabricGraphExport") + for key in ("nodes", "edges"): + if not isinstance(payload.get(key), list): + errors.append(f"{key} must be a list") + source = payload.get("source") if isinstance(payload.get("source"), dict) else {} + if source.get("producer") != "railiance-fabric": + errors.append("source.producer must be railiance-fabric") + if not source.get("commit") or source.get("commit") == "working-tree": + errors.append("source.commit must identify an immutable authority revision") + if not source.get("path"): + errors.append("source.path is required") + if not payload.get("generated_at"): + errors.append("generated_at is required") + return errors diff --git a/tests/test_fabric_cutover.py b/tests/test_fabric_cutover.py new file mode 100644 index 0000000..85e372f --- /dev/null +++ b/tests/test_fabric_cutover.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from copy import deepcopy + +from fin_hub.fabric_cutover import evaluate_fabric_cutover, fabric_content_hash + + +def _authority() -> dict: + return { + "apiVersion": "railiance.fabric/v1alpha2", + "kind": "FabricGraphExport", + "schema_version": "financial-fabric-v1", + "generated_at": "2026-08-31T12:00:00Z", + "source": { + "producer": "railiance-fabric", + "commit": "snapshot-set:sha256:abc", + "path": "registry://accepted-snapshots", + }, + "actors": [{"id": "actor.king"}], + "fabrics": [{"id": "fabric.main"}], + "nodes": [{"id": "service.one"}], + "edges": [], + "unresolved": [], + } + + +def _projection(authority: dict) -> dict: + return { + "schema_version": authority["schema_version"], + "node_count": 1, + "edge_count": 0, + "actor_count": 1, + "fabric_count": 1, + "unresolved_count": 0, + "latest_import": { + "api_version": authority["apiVersion"], + "schema_version": authority["schema_version"], + "export_kind": authority["kind"], + "content_hash": fabric_content_hash(authority), + "source_commit": authority["source"]["commit"], + "source_path": authority["source"]["path"], + "exported_at": authority["generated_at"], + }, + } + + +def test_cutover_gate_accepts_exact_counts_hash_and_provenance() -> None: + authority = _authority() + report = evaluate_fabric_cutover(authority, _projection(authority)) + assert report["ready"] is True + assert report["failures"] == [] + + +def test_cutover_gate_rejects_count_or_hash_drift() -> None: + authority = _authority() + projection = _projection(authority) + projection["node_count"] = 2 + projection["latest_import"]["content_hash"] = "stale" + report = evaluate_fabric_cutover(authority, projection) + assert report["ready"] is False + assert {"node_count", "content_hash"}.issubset(report["failures"]) + + +def test_cutover_gate_rejects_unversioned_authority_provenance() -> None: + authority = deepcopy(_authority()) + authority["source"]["commit"] = "working-tree" + report = evaluate_fabric_cutover(authority, _projection(authority)) + assert report["ready"] is False + assert "authority:source.commit must identify an immutable authority revision" in report["failures"] diff --git a/workplans/FIN-WP-0003-fabric-authority-boundary.md b/workplans/FIN-WP-0003-fabric-authority-boundary.md index 9ef82b7..c34d137 100644 --- a/workplans/FIN-WP-0003-fabric-authority-boundary.md +++ b/workplans/FIN-WP-0003-fabric-authority-boundary.md @@ -4,11 +4,11 @@ type: workplan title: "Fabric authority boundary for State Hub retirement" domain: financials repo: fin-hub -status: proposed +status: finished owner: codex topic_slug: financials created: "2026-08-09" -updated: "2026-08-09" +updated: "2026-08-31" parent_project: prj-state-hub-retirement parent_workplan: SHR-WP-0001 related: @@ -30,7 +30,7 @@ define any hub-core projection, and support State Hub fabric route disposition ```task id: FIN-WP-0003-T01 -status: todo +status: done priority: high state_hub_task_id: "c6f6d2c5-4df2-5f8a-a838-76e4a0cb2974" ``` @@ -38,11 +38,17 @@ state_hub_task_id: "c6f6d2c5-4df2-5f8a-a838-76e4a0cb2974" Document which fabric entities/APIs are authoritative here vs State Hub read models; publish consumer contract for migration. +Completed 2026-08-31: `docs/fabric-authority-consumer-contract-v1.md` assigns +topology, containment, ownership, accepted snapshots, and exports to the +specialized `railiance-fabric` engine. Fin-hub retains financial-domain +accountability and identifier joins only; State Hub remains a temporary read +model and neither fin-hub nor hub-core receives copied authority tables. + ## Migration path from State Hub fabric routes ```task id: FIN-WP-0003-T02 -status: todo +status: done priority: high state_hub_task_id: "9743357c-d32d-5508-845e-d341fd611c49" ``` @@ -50,11 +56,18 @@ state_hub_task_id: "9743357c-d32d-5508-845e-d341fd611c49" With STATE-WP-0079, plan cutover for fabric ingest/read routes and dashboard consumers; row-count and provenance checks. +Completed 2026-08-31: implemented `finhub fabric-cutover-check`. It gates the +switch on export identity, canonical content hash, five row counts, and exact +source revision/path/time provenance. The route map replaces State Hub ingest +with Fabric registry snapshot acceptance, points reads at Fabric graph/export +surfaces, points UI use at the graph explorer, and retains State Hub only as a +dual-read rollback during the cutover window. + ## Optional hub projection ```task id: FIN-WP-0003-T03 -status: todo +status: done priority: low state_hub_task_id: "b57b5135-7e77-5cbe-9da8-fa226e43a0a5" ``` @@ -62,8 +75,14 @@ state_hub_task_id: "b57b5135-7e77-5cbe-9da8-fa226e43a0a5" If orientation needs a hub-core projection, define port usage without copying authority tables into hub-core. +Completed 2026-08-31: explicitly deferred. The caller scan found no external +consumer of State Hub's Fabric routes, while Railiance Fabric already serves a +graph API and explorer. A future need must use a versioned read-only port with +source revision/content hash and a rebuildable bounded cache; it may not create +hub-core authority tables. + ## Acceptance -- [ ] Authority boundary documented -- [ ] Migration path agreed with State Hub strangler -- [ ] Projection decision recorded (implement or explicitly defer) +- [x] Authority boundary documented +- [x] Migration path agreed with State Hub strangler +- [x] Projection decision recorded (implement or explicitly defer)