feat: complete fabric authority cutover contract
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-31 22:18:39 +02:00
parent bbfa2d661b
commit debe67edf0
7 changed files with 344 additions and 8 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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"]

View file

@ -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)