diff --git a/fabric/financial/railiance-netkingdom.yaml b/fabric/financial/railiance-netkingdom.yaml new file mode 100644 index 0000000..17fbc79 --- /dev/null +++ b/fabric/financial/railiance-netkingdom.yaml @@ -0,0 +1,65 @@ +apiVersion: railiance.fabric/v1alpha2 +kind: FinancialFabricBaseline +metadata: + id: railiance.netkingdom-baseline + name: Railiance Netkingdom Baseline +spec: + netkingdom: + id: railiance.netkingdom + name: Railiance Netkingdom + king_actor_id: actor.railiance.king + actors: + - id: actor.railiance.king + kind: FabricActor + role: king + name: Railiance King + description: Responsible for the Railiance netkingdom and recovery authority. + authority: + recovery_authority: true + secrets_authority: true + backup_authority: true + termination_authority: true + - id: actor.railiance.primary-lord + kind: FabricActor + role: lord + name: Railiance Primary Lord + description: Pays for the current Railiance infrastructure boundary. + fabrics: + - id: fabric.railiance.primary + kind: Fabric + name: Railiance Primary Fabric + netkingdom_id: railiance.netkingdom + lord_actor_id: actor.railiance.primary-lord + parent_fabric_id: null + status: active + boundary: + boundary_type: fabric + criterion: financial_and_operational_accountability + payment_responsibility: actor.railiance.primary-lord + operational_responsibility: actor.railiance.king + recovery_responsibility: actor.railiance.king + evidence_refs: [] + defaults: + containment: + netkingdom_id: railiance.netkingdom + fabric_id: fabric.railiance.primary + subfabric_id: null + environment: local + deployment_scenario_id: null + ownership: + owner_actor_id: actor.railiance.primary-lord + owner_role: lord + resolution: inherited + inherited_from: fabric.railiance.primary + supporting_actor_ids: + - actor.railiance.king + accounting: + cost_center_id: null + profit_center_id: null + allocation_model: null + future_subfabric_template: + kind: Subfabric + parent_fabric_id: fabric.railiance.primary + netkingdom_id: railiance.netkingdom + tenant_actor_role: tenant + note: Add a tenant actor and subfabric without changing the root fabric criterion. diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index 4436552..59ba938 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -15,6 +15,7 @@ from typing import Any from urllib.parse import quote from .connectors import ConnectorConfig +from .financial_baseline import financial_export_from_legacy from .loader import declaration_files, load_yaml from .graph import FabricGraph, build_graph from .graph_explorer import fabric_graph_explorer_payload @@ -72,9 +73,9 @@ def build_parser() -> argparse.ArgumentParser: blast.add_argument("interface", help="Interface type or interface declaration id.") blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")]) - export = sub.add_parser("export", help="Export graph as JSON, Mermaid, or graph-explorer payload.") + export = sub.add_parser("export", help="Export graph as JSON, Mermaid, graph-explorer, or financial payload.") export.add_argument("paths", nargs="*", type=Path, default=[Path(".")]) - export.add_argument("--format", choices=["json", "mermaid", "graph-explorer"], default="json") + export.add_argument("--format", choices=["json", "mermaid", "graph-explorer", "financial"], default="json") scan = sub.add_parser("scan", help="Scan a repo for deterministic discovery candidates.") scan.add_argument("path", nargs="?", type=Path, default=Path(".")) @@ -310,6 +311,8 @@ def main(argv: list[str] | None = None) -> int: print(graph.to_mermaid()) elif args.format == "graph-explorer": print(json.dumps(fabric_graph_explorer_payload(graph.to_export()), indent=2, sort_keys=True)) + elif args.format == "financial": + print(json.dumps(financial_export_from_legacy(graph.to_export()), indent=2, sort_keys=True)) else: print(graph.to_json()) return 0 diff --git a/railiance_fabric/financial_baseline.py b/railiance_fabric/financial_baseline.py new file mode 100644 index 0000000..915c59a --- /dev/null +++ b/railiance_fabric/financial_baseline.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .financial import ( + FINANCIAL_API_VERSION, + FINANCIAL_SCHEMA_VERSION, + financial_graph_errors, + materialize_financial_graph_export, +) +from .loader import load_yaml, repo_root + +DEFAULT_BASELINE_PATH = repo_root() / "fabric" / "financial" / "railiance-netkingdom.yaml" + + +def load_financial_baseline(path: Path | None = None) -> dict[str, Any]: + baseline_path = path or DEFAULT_BASELINE_PATH + data = load_yaml(baseline_path) + if not isinstance(data, dict): + raise ValueError(f"financial baseline must be a mapping: {baseline_path}") + if data.get("kind") != "FinancialFabricBaseline": + raise ValueError(f"financial baseline kind must be FinancialFabricBaseline: {baseline_path}") + spec = data.get("spec") + if not isinstance(spec, dict): + raise ValueError(f"financial baseline spec must be a mapping: {baseline_path}") + return spec + + +def financial_export_from_legacy( + legacy_graph: dict[str, Any], + baseline: dict[str, Any] | None = None, +) -> dict[str, Any]: + baseline = json.loads(json.dumps(baseline or load_financial_baseline())) + defaults = baseline.get("defaults") if isinstance(baseline.get("defaults"), dict) else {} + containment_default = defaults.get("containment") if isinstance(defaults.get("containment"), dict) else {} + ownership_default = defaults.get("ownership") if isinstance(defaults.get("ownership"), dict) else {} + accounting_default = defaults.get("accounting") if isinstance(defaults.get("accounting"), dict) else {} + + graph = { + "apiVersion": FINANCIAL_API_VERSION, + "kind": "FabricGraphExport", + "schema_version": FINANCIAL_SCHEMA_VERSION, + "source": { + **_object(legacy_graph.get("source")), + "producer": "railiance-fabric", + "generation_reason": "baseline_projection", + }, + "compatibility": { + "legacy_v1alpha1_supported": True, + "breaking_reset": False, + "projected_from_apiVersion": legacy_graph.get("apiVersion"), + }, + "netkingdom": baseline.get("netkingdom", {}), + "actors": baseline.get("actors", []), + "fabrics": baseline.get("fabrics", []), + "nodes": [ + _financial_node_from_legacy(node, containment_default, ownership_default, accounting_default) + for node in legacy_graph.get("nodes", []) + if isinstance(node, dict) + ], + "edges": [ + _financial_edge_from_legacy(edge) + for edge in legacy_graph.get("edges", []) + if isinstance(edge, dict) + ], + "unresolved": [], + } + if legacy_graph.get("generated_at"): + graph["generated_at"] = legacy_graph["generated_at"] + materialized = materialize_financial_graph_export(graph) + errors = financial_graph_errors(materialized) + if errors: + raise ValueError(f"invalid financial baseline projection: {errors[0]}") + return materialized + + +def _financial_node_from_legacy( + node: dict[str, Any], + containment_default: dict[str, Any], + ownership_default: dict[str, Any], + accounting_default: dict[str, Any], +) -> dict[str, Any]: + result: dict[str, Any] = { + "id": node.get("id", ""), + "kind": _node_kind(node), + "name": node.get("name") or node.get("id", ""), + "repo": node.get("repo", ""), + "domain": node.get("domain", ""), + "lifecycle": node.get("lifecycle", ""), + "containment": json.loads(json.dumps(containment_default)), + "ownership": json.loads(json.dumps(ownership_default)), + "evidence_state": node.get("evidence_state", "declared"), + "attributes": _object(node.get("attributes")), + } + accounting = _object(result["attributes"].get("accounting")) or accounting_default + if _has_value(accounting): + result["accounting"] = json.loads(json.dumps(accounting)) + for key in ("canon_category", "canon_anchor", "mapping_fit"): + if node.get(key): + result[key] = node[key] + return result + + +def _financial_edge_from_legacy(edge: dict[str, Any]) -> dict[str, Any]: + result = { + "from": edge.get("from", ""), + "to": edge.get("to", ""), + "type": edge.get("type", ""), + "attributes": _object(edge.get("attributes")), + } + for key in ("canonical_type", "canon_anchor", "mapping_fit", "display_only", "evidence_state"): + if key in edge: + result[key] = edge[key] + return result + + +def _node_kind(node: dict[str, Any]) -> str: + kind = str(node.get("kind") or "") + if kind == "ServiceDeclaration": + return "Service" + if kind == "InterfaceDeclaration": + return "UtilityInterface" + return kind or "DiscoveredEntity" + + +def _object(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _has_value(value: Any) -> bool: + if isinstance(value, dict): + return any(_has_value(item) for item in value.values()) + if isinstance(value, list): + return any(_has_value(item) for item in value) + return value not in (None, "") diff --git a/tests/test_registry.py b/tests/test_registry.py index aa12d27..50bbf28 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -9,6 +9,7 @@ from pathlib import Path from railiance_fabric.cli import main as cli_main from railiance_fabric.graph import build_graph from railiance_fabric.financial import materialize_financial_graph_export +from railiance_fabric.financial_baseline import financial_export_from_legacy from railiance_fabric.registry import ( RESET_CONFIRMATION_TOKEN, RegistryError, @@ -319,6 +320,29 @@ def test_registry_accepts_financial_graph_and_materializes_vnext_fields(tmp_path assert combined["edges"][0]["relationship_category"] == "utility" +def test_current_graph_projects_to_financial_baseline() -> None: + legacy_graph = build_graph([Path(".")]).to_export() + financial_graph = financial_export_from_legacy(legacy_graph) + + validate_graph_export(financial_graph) + validator = draft202012_validator(Path("schemas/state-hub-export.schema.yaml")) + assert list(validator.iter_errors(financial_graph)) == [] + assert financial_graph["netkingdom"]["id"] == "railiance.netkingdom" + assert financial_graph["fabrics"][0]["id"] == "fabric.railiance.primary" + assert financial_graph["unresolved"] == [] + assert all(node["containment"]["fabric_id"] == "fabric.railiance.primary" for node in financial_graph["nodes"]) + assert all(node["ownership"]["owner_actor_id"] == "actor.railiance.primary-lord" for node in financial_graph["nodes"]) + + +def test_export_cli_can_print_financial_baseline(capsys) -> None: + assert cli_main(["export", "--format", "financial", "."]) == 0 + + payload = json.loads(capsys.readouterr().out) + validate_graph_export(payload) + assert payload["apiVersion"] == "railiance.fabric/v1alpha2" + assert payload["compatibility"]["projected_from_apiVersion"] == "railiance.fabric/v1alpha1" + + def test_registry_reset_archive_and_guarded_reset(tmp_path: Path) -> None: store = RegistryStore(tmp_path / "registry.sqlite3") store.init_schema() diff --git a/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md b/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md index 6ffabd2..0af6e9b 100644 --- a/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md +++ b/workplans/RAIL-FAB-WP-0017-financial-fabric-model-reset.md @@ -237,7 +237,7 @@ Result: ```task id: RAIL-FAB-WP-0017-T05 -status: todo +status: done priority: medium state_hub_task_id: "b8430050-94a8-43f6-9b3c-7928c5c6bb69" ``` @@ -262,6 +262,21 @@ Done when: - future tenant subfabrics can be added without changing the root fabric definition. +Result: + +- Added `fabric/financial/railiance-netkingdom.yaml` as the current Railiance + netkingdom baseline with king, lord, one active fabric, inherited default + containment/ownership/accounting, and a future subfabric template. +- Added `railiance_fabric/financial_baseline.py` to load the baseline and + project legacy `v1alpha1` exports into `v1alpha2` financial Fabric exports. +- Added `railiance-fabric export --format financial` so the current graph can + emit a financial baseline projection. +- Added tests proving the current graph projects to the Railiance baseline, + validates as a financial graph, has no unresolved gaps, and gives every node + inherited ownership in `fabric.railiance.primary`. +- Verified with `python3 -m pytest tests/test_registry.py -q` and full + `python3 -m pytest`. + ## T06 - Update Documentation, Fixtures, And Operator Guidance ```task