feat: complete fabric authority cutover contract
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
parent
bbfa2d661b
commit
debe67edf0
7 changed files with 344 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
28
docs/evidence/FIN-WP-0003-precutover-2026-08-31.md
Normal file
28
docs/evidence/FIN-WP-0003-precutover-2026-08-31.md
Normal 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.
|
||||
68
docs/fabric-authority-consumer-contract-v1.md
Normal file
68
docs/fabric-authority-consumer-contract-v1.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
119
src/fin_hub/fabric_cutover.py
Normal file
119
src/fin_hub/fabric_cutover.py
Normal 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
|
||||
69
tests/test_fabric_cutover.py
Normal file
69
tests/test_fabric_cutover.py
Normal 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"]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue