From ff1c4ce05b3c8e690fba5f5295ddd1cf19ba3a95 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 24 May 2026 15:55:05 +0200 Subject: [PATCH] feat: add deployment zone overlays --- catalog/capability-types.yaml | 1 + catalog/interface-types.yaml | 8 + docs/type-catalog.md | 1 + .../2026-05-24-deployment-zone-inventory.yaml | 404 ++++++++++++++++++ .../net-kingdom-control-surface-ui.yaml | 35 ++ ...nce-fabric-registry-graph-explorer-ui.yaml | 12 + .../railiance-fabric-registry-http-api.yaml | 12 + .../the-custodian-state-hub-dashboard.yaml | 35 ++ .../the-custodian-state-hub-http-api.yaml | 15 + .../the-custodian-state-hub-mcp-api.yaml | 35 ++ fabric/services/net-kingdom-iam-profile.yaml | 1 + fabric/services/the-custodian-state-hub.yaml | 2 + railiance_fabric/accountability_roots.py | 23 +- railiance_fabric/deployment_overlay.py | 107 +++++ railiance_fabric/financial.py | 31 +- railiance_fabric/financial_baseline.py | 14 + railiance_fabric/graph.py | 5 +- railiance_fabric/graph_explorer.py | 101 +++++ railiance_fabric/graph_explorer_ui.py | 143 ++++++- railiance_fabric/scanner.py | 99 ++++- ...untability-identity-projection.schema.yaml | 28 ++ schemas/interface.schema.yaml | 29 ++ schemas/state-hub-export.schema.yaml | 30 ++ tests/test_deployment_zone_inventory.py | 47 ++ tests/test_graph_explorer.py | 31 +- tests/test_registry.py | 11 + tests/test_scanner.py | 8 + ...deployment-zone-discovery-visualization.md | 40 +- 28 files changed, 1282 insertions(+), 26 deletions(-) create mode 100644 fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml create mode 100644 fabric/interfaces/net-kingdom-control-surface-ui.yaml create mode 100644 fabric/interfaces/the-custodian-state-hub-dashboard.yaml create mode 100644 fabric/interfaces/the-custodian-state-hub-mcp-api.yaml create mode 100644 railiance_fabric/deployment_overlay.py create mode 100644 tests/test_deployment_zone_inventory.py diff --git a/catalog/capability-types.yaml b/catalog/capability-types.yaml index 905ad98..a4e70f5 100644 --- a/catalog/capability-types.yaml +++ b/catalog/capability-types.yaml @@ -117,6 +117,7 @@ spec: default_data_classification: internal expected_interface_types: - http-api + - mcp-api - event-stream tags: [coordination, state-hub, planning] diff --git a/catalog/interface-types.yaml b/catalog/interface-types.yaml index bcf916a..9a3e0cf 100644 --- a/catalog/interface-types.yaml +++ b/catalog/interface-types.yaml @@ -25,6 +25,14 @@ spec: typical_auth_methods: [none, oidc, jwt, mtls, api_key] versioning: URL path, static asset version, and documented user-facing workflow compatibility. + - id: mcp-api + name: MCP API + lifecycle: active + description: Model Context Protocol interface consumed by agents or agent runtimes. + category: api + typical_auth_methods: [none, oidc, jwt, mtls, api_key] + versioning: MCP protocol version, tool schema version, and documented capability compatibility. + - id: oidc-discovery name: OIDC discovery lifecycle: active diff --git a/docs/type-catalog.md b/docs/type-catalog.md index 859f985..3552062 100644 --- a/docs/type-catalog.md +++ b/docs/type-catalog.md @@ -36,6 +36,7 @@ Machine-readable catalog files: |------|-----------|----------|--------------| | `http-api` | active | api | `none`, `oidc`, `jwt`, `mtls`, `api_key` | | `web-ui` | active | ui | `none`, `oidc`, `jwt`, `mtls`, `api_key` | +| `mcp-api` | active | api | `none`, `oidc`, `jwt`, `mtls`, `api_key` | | `oidc-discovery` | active | identity | `none` | | `kubernetes-secret` | active | kubernetes | `kubernetes_service_account` | | `kubernetes-crd` | active | kubernetes | `kubernetes_service_account` | diff --git a/fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml b/fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml new file mode 100644 index 0000000..6379051 --- /dev/null +++ b/fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml @@ -0,0 +1,404 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: DeploymentZoneInventory +generated_at: "2026-05-24T00:00:00+02:00" +source: + repo: railiance-fabric + workplan: RAIL-FAB-WP-0020 + method: source-search-and-declared-surfaces +scope: + note: > + This inventory captures deployment-zone overlay evidence. It does not + define fabric membership, port ownership, live health, or access policy. + deployment_environments: + - id: dev + scenario: bernd-laptop + intended_reachability: private operator workstation + - id: test + scenario: coulombcore + intended_reachability: shared collaborator and early-access test stage + - id: prod + scenario: railiance01 + intended_reachability: production stage, currently alpha-accessible to developers +surfaces: + - id: dev.bernd-laptop.railiance-fabric.registry-api + name: Railiance Fabric registry HTTP API + repo: railiance-fabric + service_id: railiance-fabric.registry + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8765 + host: 127.0.0.1 + port: 8765 + protocol: http + evidence: + - path: fabric/interfaces/railiance-fabric-registry-http-api.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.railiance-fabric.graph-explorer + name: Railiance Fabric graph explorer UI + repo: railiance-fabric + service_id: railiance-fabric.registry + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8765/ui/graph-explorer + host: 127.0.0.1 + port: 8765 + protocol: http + path: /ui/graph-explorer + evidence: + - path: fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.state-hub.api + name: State Hub HTTP API + repo: the-custodian + service_id: the-custodian.state-hub + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8000 + host: 127.0.0.1 + port: 8000 + protocol: http + evidence: + - path: fabric/interfaces/the-custodian-state-hub-http-api.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.state-hub.mcp + name: State Hub MCP API + repo: the-custodian + service_id: the-custodian.state-hub + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8001 + host: 127.0.0.1 + port: 8001 + protocol: http + evidence: + - path: fabric/interfaces/the-custodian-state-hub-mcp-api.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.state-hub.dashboard + name: State Hub dashboard + repo: the-custodian + service_id: the-custodian.state-hub + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:3000 + host: 127.0.0.1 + port: 3000 + protocol: http + evidence: + - path: fabric/interfaces/the-custodian-state-hub-dashboard.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.net-kingdom.control-surface + name: NetKingdom control surface + repo: net-kingdom + service_id: net-kingdom.iam-profile + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8876 + host: 127.0.0.1 + port: 8876 + protocol: http + evidence: + - path: fabric/interfaces/net-kingdom-control-surface-ui.yaml + kind: fabric-interface-declaration + - path: ../net-kingdom/sso-mfa/k8s/keycape/README.md + kind: source-search-hit + note: local OIDC callback lists localhost port 8876 + - id: test.coulombcore.state-hub.http-tunnel + name: State Hub HTTP API tunnel to coulombcore + repo: railiance-infra + service_id: the-custodian.state-hub + deployment_environment: test + deployment_scenario: coulombcore + access_zone: collaborator-test + exposure_class: collaborator-test + routing_authority: ops-bridge + policy_authority: ops-bridge-ssh + route_evidence: + route: http://127.0.0.1:18000 + host: 127.0.0.1 + port: 18000 + protocol: http + tunnel_target: coulombcore + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "127" + kind: source-search-hit + - id: test.coulombcore.state-hub.mcp-tunnel + name: State Hub MCP tunnel to coulombcore + repo: railiance-infra + service_id: the-custodian.state-hub + deployment_environment: test + deployment_scenario: coulombcore + access_zone: collaborator-test + exposure_class: collaborator-test + routing_authority: ops-bridge + policy_authority: ops-bridge-ssh + route_evidence: + route: http://127.0.0.1:18001 + host: 127.0.0.1 + port: 18001 + protocol: http + tunnel_target: coulombcore + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "128" + kind: source-search-hit + - id: test.coulombcore.k3s-api-tunnel + name: k3s API tunnel to coulombcore + repo: railiance-infra + deployment_environment: test + deployment_scenario: coulombcore + access_zone: collaborator-test + exposure_class: collaborator-test + routing_authority: ops-bridge + policy_authority: ops-bridge-ssh + route_evidence: + route: https://127.0.0.1:16443 + host: 127.0.0.1 + port: 16443 + protocol: https + tunnel_target: coulombcore + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "129" + kind: source-search-hit + - path: ../railiance-cluster/SCOPE.md + lines: "127" + kind: source-search-hit + note: cluster scope states it runs on COULOMBCORE + - id: prod.railiance01.gitea + name: Gitea ingress + repo: railiance-apps + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: null + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://gitea.coulomb.social + hostname: gitea.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: access zone and policy authority require operator review + evidence: + - path: ../railiance-apps/manifests/gitea-ingress.yaml + lines: "2,12,14,16,27" + kind: kubernetes-ingress + - path: ../railiance-apps/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md + lines: "612,613" + kind: source-search-hit + note: places Gitea before vergabe-teilnahme on railiance01 + - id: prod.railiance01.vergabe-teilnahme + name: Vergabe Teilnahme ingress + repo: railiance-apps + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: null + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://vergabe-teilnahme.whywhynot.de + hostname: vergabe-teilnahme.whywhynot.de + port: 443 + protocol: https + review: + status: candidate + note: production public classification is inferred from ingress host and workplan + evidence: + - path: ../railiance-apps/manifests/vergabe-teilnahme-ingress.yaml + lines: "2,11,13,15,26" + kind: kubernetes-ingress + - path: ../railiance-apps/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md + lines: "22,40,68,69,163,612,613" + kind: source-search-hit + - id: prod.railiance01.authelia + name: Authelia ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: authelia + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://auth.coulomb.social + hostname: auth.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: railiance01 attribution comes from NetKingdom deployment workplan + evidence: + - path: ../net-kingdom/sso-mfa/k8s/authelia/ingress.yaml + lines: "13,22,24,26,38" + kind: kubernetes-ingress + - path: ../net-kingdom/workplans/NK-WP-0003-keycape-privacyidea-cluster-deployment.md + lines: "29,47,88,101" + kind: source-search-hit + - id: prod.railiance01.keycape + name: Keycape ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: traefik-middleware + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://kc.coulomb.social + hostname: kc.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: middleware is present, but intended audience still needs operator review + evidence: + - path: ../net-kingdom/sso-mfa/k8s/keycape/ingress.yaml + lines: "13,22,23,27,29,41" + kind: kubernetes-ingress + - path: ../net-kingdom/sso-mfa/k8s/keycape/middleware.yaml + lines: "9,24" + kind: traefik-middleware + - id: prod.railiance01.privacyidea + name: privacyIDEA ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-admin + exposure_class: production-admin + routing_authority: traefik + policy_authority: traefik-middleware + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://pink.coulomb.social + hostname: pink.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: admin classification inferred from privacyIDEA role and middleware + evidence: + - path: ../net-kingdom/sso-mfa/k8s/privacyidea/ingress.yaml + lines: "25,34,36,38,40,52,60,69,71,75,77,89" + kind: kubernetes-ingress + - path: ../net-kingdom/sso-mfa/k8s/privacyidea/middleware.yaml + lines: "19,41" + kind: traefik-middleware + - id: prod.railiance01.privacyidea-account + name: privacyIDEA account self-service ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: traefik-middleware + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://pink-account.coulomb.social + hostname: pink-account.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: self-service classification inferred from host name and middleware + evidence: + - path: ../net-kingdom/sso-mfa/k8s/privacyidea/ingress.yaml + lines: "94,103,104,106,108,120" + kind: kubernetes-ingress + - id: prod.railiance01.lldap + name: LLDAP ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-admin + exposure_class: production-admin + routing_authority: traefik + policy_authority: traefik-admin-allowlist + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://lldap.coulomb.social + hostname: lldap.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: admin allowlist middleware indicates intended restricted access + evidence: + - path: ../net-kingdom/sso-mfa/k8s/lldap/ingress.yaml + lines: "12,21,22,24,26,38" + kind: kubernetes-ingress + - path: ../net-kingdom/sso-mfa/k8s/lldap/middleware.yaml + lines: "11" + kind: traefik-middleware +ambiguities: + - id: railiance01-coulombcore-ip-conflict + severity: high + summary: Source documents disagree on which host owns 92.205.130.254. + evidence: + - path: ../railiance-apps/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md + lines: "22,163" + note: says railiance01 and Traefik LoadBalancer use 92.205.130.254 + - path: ../railiance-infra/SCOPE.md + lines: "126" + note: says COULOMBCORE is 92.205.130.254 and Railiance01 is 92.205.62.239 + next: reconcile host inventory before treating IP evidence as authoritative + - id: prod-access-zone-review + severity: medium + summary: Production access zones are candidate classifications. + evidence: + - path: ../railiance-apps/manifests + note: app ingress manifests show routing and TLS but not business audience + - path: ../net-kingdom/sso-mfa/k8s + note: middleware and network policy hint at access intent but do not replace operator review + next: confirm each production host as public, admin, or early-access + - id: test-reachability-is-tunneled + severity: medium + summary: Current coulombcore routes are ops-bridge tunnel evidence, not public ingress evidence. + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "127,128,129" + note: state-hub and k3s API access are tunnel commands + next: add executable test-stage ingress/service discovery when coulombcore manifests exist +missing_policy_authority: + - surface_id: prod.railiance01.gitea + reason: route and TLS are discovered, but access policy authority is not evident in the ingress artifact + - surface_id: prod.railiance01.vergabe-teilnahme + reason: route and TLS are discovered, but access policy authority is not evident in the ingress artifact diff --git a/fabric/interfaces/net-kingdom-control-surface-ui.yaml b/fabric/interfaces/net-kingdom-control-surface-ui.yaml new file mode 100644 index 0000000..92bfa66 --- /dev/null +++ b/fabric/interfaces/net-kingdom-control-surface-ui.yaml @@ -0,0 +1,35 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: InterfaceDeclaration +metadata: + id: net-kingdom.control-surface.ui + name: NetKingdom Control Surface + owner: net-kingdom + repo: net-kingdom + domain: railiance +spec: + lifecycle: active + environments: [dev] + description: Local NetKingdom control surface on the operator workstation. + interface_type: web-ui + version: v1 + service_id: net-kingdom.iam-profile + capability_ids: + - net-kingdom.iam-profile.issuer + endpoint: + url: http://127.0.0.1:8876 + notes: Local workstation endpoint after moving away from the Fabric registry port. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: net-kingdom-local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8876 + protocol: tcp + route: http://127.0.0.1:8876 + auth: + method: none + data_classification: internal diff --git a/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml b/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml index 76130e5..a97a054 100644 --- a/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml +++ b/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml @@ -23,6 +23,18 @@ spec: endpoint: url: http://127.0.0.1:8765/ui/graph-explorer notes: Local workstation UI when the registry service is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: railiance-fabric-registry + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8765 + protocol: tcp + route: http://127.0.0.1:8765/ui/graph-explorer auth: method: none data_classification: internal diff --git a/fabric/interfaces/railiance-fabric-registry-http-api.yaml b/fabric/interfaces/railiance-fabric-registry-http-api.yaml index 6e38b20..bb69ec7 100644 --- a/fabric/interfaces/railiance-fabric-registry-http-api.yaml +++ b/fabric/interfaces/railiance-fabric-registry-http-api.yaml @@ -23,6 +23,18 @@ spec: endpoint: url: http://127.0.0.1:8765 notes: Local workstation endpoint when the registry service is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8765 + protocol: tcp + route: http://127.0.0.1:8765 auth: method: none data_classification: internal diff --git a/fabric/interfaces/the-custodian-state-hub-dashboard.yaml b/fabric/interfaces/the-custodian-state-hub-dashboard.yaml new file mode 100644 index 0000000..9435f3f --- /dev/null +++ b/fabric/interfaces/the-custodian-state-hub-dashboard.yaml @@ -0,0 +1,35 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: InterfaceDeclaration +metadata: + id: the-custodian.state-hub.dashboard + name: State Hub Dashboard + owner: the-custodian + repo: the-custodian + domain: custodian +spec: + lifecycle: active + environments: [dev] + description: Local browser dashboard for State Hub coordination views. + interface_type: web-ui + version: v1 + service_id: the-custodian.state-hub + capability_ids: + - the-custodian.state-hub.coordination + endpoint: + url: http://127.0.0.1:3000 + notes: Local workstation dashboard endpoint when the State Hub frontend is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: vite-dev-server + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 3000 + protocol: tcp + route: http://127.0.0.1:3000 + auth: + method: none + data_classification: internal diff --git a/fabric/interfaces/the-custodian-state-hub-http-api.yaml b/fabric/interfaces/the-custodian-state-hub-http-api.yaml index fbcec5f..6458059 100644 --- a/fabric/interfaces/the-custodian-state-hub-http-api.yaml +++ b/fabric/interfaces/the-custodian-state-hub-http-api.yaml @@ -15,6 +15,21 @@ spec: service_id: the-custodian.state-hub capability_ids: - the-custodian.state-hub.coordination + endpoint: + url: http://127.0.0.1:8000 + notes: Local workstation State Hub REST API. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: state-hub-local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8000 + protocol: tcp + route: http://127.0.0.1:8000 auth: method: none data_classification: internal diff --git a/fabric/interfaces/the-custodian-state-hub-mcp-api.yaml b/fabric/interfaces/the-custodian-state-hub-mcp-api.yaml new file mode 100644 index 0000000..a1ba66d --- /dev/null +++ b/fabric/interfaces/the-custodian-state-hub-mcp-api.yaml @@ -0,0 +1,35 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: InterfaceDeclaration +metadata: + id: the-custodian.state-hub.mcp-api + name: State Hub MCP API + owner: the-custodian + repo: the-custodian + domain: custodian +spec: + lifecycle: active + environments: [dev] + description: Local MCP surface for State Hub coordination tools. + interface_type: mcp-api + version: v1 + service_id: the-custodian.state-hub + capability_ids: + - the-custodian.state-hub.coordination + endpoint: + url: http://127.0.0.1:8001 + notes: Local workstation MCP endpoint when State Hub is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: state-hub-local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8001 + protocol: tcp + route: http://127.0.0.1:8001 + auth: + method: none + data_classification: internal diff --git a/fabric/services/net-kingdom-iam-profile.yaml b/fabric/services/net-kingdom-iam-profile.yaml index 33cac2f..452d12b 100644 --- a/fabric/services/net-kingdom-iam-profile.yaml +++ b/fabric/services/net-kingdom-iam-profile.yaml @@ -15,3 +15,4 @@ spec: - net-kingdom.iam-profile.issuer exposes_interfaces: - net-kingdom.iam-profile.oidc-discovery + - net-kingdom.control-surface.ui diff --git a/fabric/services/the-custodian-state-hub.yaml b/fabric/services/the-custodian-state-hub.yaml index 9044854..688fb70 100644 --- a/fabric/services/the-custodian-state-hub.yaml +++ b/fabric/services/the-custodian-state-hub.yaml @@ -15,3 +15,5 @@ spec: - the-custodian.state-hub.coordination exposes_interfaces: - the-custodian.state-hub.http-api + - the-custodian.state-hub.mcp-api + - the-custodian.state-hub.dashboard diff --git a/railiance_fabric/accountability_roots.py b/railiance_fabric/accountability_roots.py index 4a7a9b6..5b03c72 100644 --- a/railiance_fabric/accountability_roots.py +++ b/railiance_fabric/accountability_roots.py @@ -11,6 +11,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from .deployment_overlay import normalize_deployment_overlay from .discovery import normalize_identity_part, short_fingerprint from .loader import load_yaml, repo_root from .schema_validation import draft202012_validator @@ -619,7 +620,7 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ or declared_slug or Path(str(source.get("path") or "")).name ) - return { + candidate = { "identity_type": "Repository", "label": identity_slug, "graph_id": identity_slug, @@ -635,6 +636,10 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ }, "confidence": 0.9 if evidence_type == "repository_checkout" else 0.85, } + overlay = normalize_deployment_overlay(source, attributes) + if overlay: + candidate["deployment_overlay"] = overlay + return candidate if evidence_type in {"deployment_automation", "infrastructure_manifest"}: path = str(source.get("path") or "") return { @@ -665,7 +670,7 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ } if evidence_type == "endpoint_contract": path = str(source.get("path") or "") - return { + candidate = { "identity_type": "Endpoint", "label": Path(path).name or "endpoint-contract", "graph_id": path, @@ -677,6 +682,10 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ "attributes": {**attributes, "source_evidence_type": evidence_type}, "confidence": 0.75, } + overlay = normalize_deployment_overlay(source, attributes) + if overlay: + candidate["deployment_overlay"] = overlay + return candidate if evidence_type == "host_path_match": path = str(source.get("path") or "") return { @@ -901,6 +910,7 @@ def _add_identity_candidate( evidence_ids: list[str], aliases: list[str], attributes: dict[str, Any], + deployment_overlay: dict[str, Any] | None = None, confidence: float, ) -> None: normalized_type = normalize_identity_part(identity_type) @@ -924,6 +934,9 @@ def _add_identity_candidate( incoming["subfabric_id"] = subfabric_id if owner_actor_id: incoming["owner_actor_id"] = owner_actor_id + overlay = normalize_deployment_overlay(deployment_overlay or {}, attributes) + if overlay: + incoming["deployment_overlay"] = overlay existing = candidates.get(stable_key) if existing is None: @@ -933,6 +946,11 @@ def _add_identity_candidate( existing["aliases"] = _unique_strings([*existing.get("aliases", []), *incoming["aliases"]]) existing["evidence_ids"] = _unique_strings([*existing.get("evidence_ids", []), *incoming["evidence_ids"]]) existing["attributes"] = {**existing.get("attributes", {}), **incoming["attributes"]} + if incoming.get("deployment_overlay"): + existing["deployment_overlay"] = normalize_deployment_overlay( + existing.get("deployment_overlay") if isinstance(existing.get("deployment_overlay"), dict) else {}, + incoming["deployment_overlay"], + ) if incoming.get("owner_actor_id") and existing.get("owner_actor_id") and incoming["owner_actor_id"] != existing["owner_actor_id"]: existing["attributes"]["ambiguous_owner_actor_ids"] = _unique_strings( [existing["owner_actor_id"], incoming["owner_actor_id"], *existing["attributes"].get("ambiguous_owner_actor_ids", [])] @@ -973,6 +991,7 @@ def _candidate_graph(candidates: list[dict[str, Any]], manifest: dict[str, Any]) "fabric_id": candidate.get("fabric_id", ""), "subfabric_id": candidate.get("subfabric_id", ""), "owner_actor_id": candidate.get("owner_actor_id", ""), + "deployment_overlay": candidate.get("deployment_overlay", {}), } for candidate in sorted(candidates, key=lambda item: item["stable_key"]) ] diff --git a/railiance_fabric/deployment_overlay.py b/railiance_fabric/deployment_overlay.py new file mode 100644 index 0000000..e317336 --- /dev/null +++ b/railiance_fabric/deployment_overlay.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import Any + + +OVERLAY_FIELDS = ( + "deployment_environment", + "deployment_scenario", + "routing_authority", + "access_zone", + "policy_authority", + "exposure_class", +) + +ROUTE_EVIDENCE_FIELDS = ( + "host", + "hostname", + "port", + "protocol", + "route", + "route_url", + "path", + "scheme", +) + +_ALIASES = { + "deployment_environment": ("deployment_environment", "environment"), + "deployment_scenario": ("deployment_scenario", "deployment_scenario_id", "scenario"), + "routing_authority": ("routing_authority",), + "access_zone": ("access_zone",), + "policy_authority": ("policy_authority",), + "exposure_class": ("exposure_class", "exposure"), +} + + +def normalize_deployment_overlay(*sources: object) -> dict[str, Any]: + """Collect deployment-zone overlay fields without changing fabric membership.""" + + overlay: dict[str, Any] = {} + route_evidence: dict[str, Any] = {} + + for source in sources: + if not isinstance(source, dict): + continue + nested = source.get("deployment_overlay") + if isinstance(nested, dict): + _merge_overlay_source(overlay, route_evidence, nested) + _merge_overlay_source(overlay, route_evidence, source) + + if route_evidence: + overlay["route_evidence"] = route_evidence + return {key: value for key, value in overlay.items() if _has_value(value)} + + +def graph_explorer_overlay_fields(overlay: dict[str, Any]) -> dict[str, Any]: + route = overlay.get("route_evidence") if isinstance(overlay.get("route_evidence"), dict) else {} + return { + "deploymentOverlay": overlay, + "deploymentEnvironment": str(overlay.get("deployment_environment") or ""), + "deploymentScenario": str(overlay.get("deployment_scenario") or ""), + "routingAuthority": str(overlay.get("routing_authority") or ""), + "accessZone": str(overlay.get("access_zone") or ""), + "policyAuthority": str(overlay.get("policy_authority") or ""), + "exposureClass": str(overlay.get("exposure_class") or ""), + "routeHost": str(route.get("host") or ""), + "routeHostname": str(route.get("hostname") or ""), + "routePort": route.get("port") if route.get("port") not in (None, "") else "", + "routeProtocol": str(route.get("protocol") or ""), + "route": str(route.get("route") or route.get("route_url") or ""), + } + + +def _merge_overlay_source(overlay: dict[str, Any], route_evidence: dict[str, Any], source: dict[str, Any]) -> None: + for field in OVERLAY_FIELDS: + if field in overlay: + continue + value = _first_value(source, _ALIASES[field]) + if _has_value(value): + overlay[field] = value + + nested_route = source.get("route_evidence") + if isinstance(nested_route, dict): + for field in ROUTE_EVIDENCE_FIELDS: + value = nested_route.get(field) + if _has_value(value) and field not in route_evidence: + route_evidence[field] = value + + for field in ROUTE_EVIDENCE_FIELDS: + value = source.get(field) + if _has_value(value) and field not in route_evidence: + route_evidence[field] = value + + +def _first_value(source: dict[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + value = source.get(key) + if _has_value(value): + return value + return "" + + +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/railiance_fabric/financial.py b/railiance_fabric/financial.py index 5f08b16..2979e4e 100644 --- a/railiance_fabric/financial.py +++ b/railiance_fabric/financial.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from typing import Any +from .deployment_overlay import normalize_deployment_overlay + FINANCIAL_API_VERSION = "railiance.fabric/v1alpha2" FINANCIAL_SCHEMA_VERSION = "financial-fabric-v1" @@ -19,7 +21,7 @@ RELATIONSHIP_CATEGORIES = { "evidence", } FABRIC_KINDS = {"Fabric", "Subfabric"} -ENVIRONMENT_LABELS = {"local", "dev", "staging", "prod", "lab", "all"} +ENVIRONMENT_LABELS = {"local", "dev", "test", "staging", "prod", "lab", "all"} def is_financial_graph_export(graph: dict[str, Any]) -> bool: @@ -125,6 +127,7 @@ def financial_graph_errors(graph: dict[str, Any]) -> list[str]: _validate_containment(errors, f"nodes[{index}]", node, netkingdom_id, fabric_kinds, accepted=review_state == "accepted") _validate_ownership(errors, f"nodes[{index}]", node, actor_roles, accepted=review_state == "accepted") _validate_optional_object(errors, f"nodes[{index}].accounting", node, "accounting") + _validate_overlay(errors, f"nodes[{index}].deployment_overlay", node) edges = _indexed_items(errors, graph, "edges") for index, edge in edges: @@ -141,6 +144,7 @@ def financial_graph_errors(graph: dict[str, Any]) -> list[str]: errors.append(f"{path}.relationship_category {category!r} is not valid") _validate_evidence(errors, path, edge) _validate_optional_object(errors, f"{path}.accounting", edge, "accounting") + _validate_overlay(errors, f"{path}.deployment_overlay", edge) if edge_type == "provides_utility_to" and category != "utility": errors.append(f"{path}.relationship_category must be 'utility' for provides_utility_to edges") if category == "utility": @@ -211,6 +215,13 @@ def _materialize_node(node: dict[str, Any]) -> None: for key in ("containment", "ownership", "accounting"): if key not in node and isinstance(attrs.get(key), dict): node[key] = attrs[key] + overlay = normalize_deployment_overlay( + node.get("deployment_overlay") if isinstance(node.get("deployment_overlay"), dict) else {}, + attrs, + node.get("containment") if isinstance(node.get("containment"), dict) else {}, + ) + if overlay: + node["deployment_overlay"] = overlay node.setdefault("evidence", _legacy_evidence(node, attrs)) @@ -218,6 +229,12 @@ def _materialize_edge(edge: dict[str, Any]) -> None: attrs = edge.get("attributes") if isinstance(edge.get("attributes"), dict) else {} if "accounting" not in edge and isinstance(attrs.get("accounting"), dict): edge["accounting"] = attrs["accounting"] + overlay = normalize_deployment_overlay( + edge.get("deployment_overlay") if isinstance(edge.get("deployment_overlay"), dict) else {}, + attrs, + ) + if overlay: + edge["deployment_overlay"] = overlay edge.setdefault("relationship_category", _relationship_category(edge)) edge.setdefault("evidence", _legacy_evidence(edge, attrs)) if edge.get("relationship_category") == "utility": @@ -362,6 +379,18 @@ def _validate_optional_object(errors: list[str], path: str, item: dict[str, Any] errors.append(f"{path} must be an object") +def _validate_overlay(errors: list[str], path: str, item: dict[str, Any]) -> None: + overlay = item.get("deployment_overlay") + if overlay is None: + return + if not isinstance(overlay, dict): + errors.append(f"{path} must be an object") + return + route = overlay.get("route_evidence") + if route is not None and not isinstance(route, dict): + errors.append(f"{path}.route_evidence must be an object") + + def _required_object(errors: list[str], path: str, item: dict[str, Any], key: str) -> dict[str, Any]: value = item.get(key) if not isinstance(value, dict): diff --git a/railiance_fabric/financial_baseline.py b/railiance_fabric/financial_baseline.py index 80ae4d5..6911363 100644 --- a/railiance_fabric/financial_baseline.py +++ b/railiance_fabric/financial_baseline.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from .deployment_overlay import normalize_deployment_overlay from .financial import ( FINANCIAL_API_VERSION, FINANCIAL_SCHEMA_VERSION, @@ -97,6 +98,13 @@ def _financial_node_from_legacy( accounting = _object(result["attributes"].get("accounting")) or accounting_default if _has_value(accounting): result["accounting"] = json.loads(json.dumps(accounting)) + overlay = normalize_deployment_overlay( + node.get("deployment_overlay") if isinstance(node.get("deployment_overlay"), dict) else {}, + result["attributes"], + result["containment"], + ) + if _has_value(overlay): + result["deployment_overlay"] = overlay for key in ("canon_category", "canon_anchor", "mapping_fit"): if node.get(key): result[key] = node[key] @@ -113,6 +121,12 @@ def _financial_edge_from_legacy(edge: dict[str, Any]) -> dict[str, Any]: for key in ("canonical_type", "canon_anchor", "mapping_fit", "display_only", "evidence_state"): if key in edge: result[key] = edge[key] + overlay = normalize_deployment_overlay( + edge.get("deployment_overlay") if isinstance(edge.get("deployment_overlay"), dict) else {}, + result["attributes"], + ) + if _has_value(overlay): + result["deployment_overlay"] = overlay return result diff --git a/railiance_fabric/graph.py b/railiance_fabric/graph.py index f1a6009..b542327 100644 --- a/railiance_fabric/graph.py +++ b/railiance_fabric/graph.py @@ -340,9 +340,12 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]: def _base_export_attributes(declaration: Declaration) -> dict[str, Any]: source_links = declaration.metadata.get("source_links", []) - return { + attributes = { "owner": declaration.metadata.get("owner", ""), "description": declaration.spec.get("description", ""), "source_path": str(declaration.path), "source_links": source_links if isinstance(source_links, list) else [], } + if isinstance(declaration.spec.get("deployment_overlay"), dict): + attributes["deployment_overlay"] = declaration.spec["deployment_overlay"] + return attributes diff --git a/railiance_fabric/graph_explorer.py b/railiance_fabric/graph_explorer.py index f7b71f5..22763d4 100644 --- a/railiance_fabric/graph_explorer.py +++ b/railiance_fabric/graph_explorer.py @@ -7,6 +7,7 @@ from typing import Any from urllib.parse import urlparse from .canon import edge_canon_mapping +from .deployment_overlay import graph_explorer_overlay_fields, normalize_deployment_overlay DISPLAY_STATES = ("show", "blur", "hide", "highlight", "remove") @@ -134,6 +135,12 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "layer", "kind", "environment", + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", "serverHost", "lifecycle", "unresolved", @@ -147,7 +154,17 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "repo", "domain", "environment", + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", "serverHost", + "routeHost", + "routeHostname", + "routePort", + "routeProtocol", "kind", "layer", "edgeType", @@ -163,7 +180,15 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: {"id": "repo", "label": "Repo", "type": "string"}, {"id": "domain", "label": "Domain", "type": "string"}, {"id": "environment", "label": "Environment", "type": "string"}, + {"id": "deploymentEnvironment", "label": "Deployment Environment", "type": "string"}, + {"id": "deploymentScenario", "label": "Deployment Scenario", "type": "string"}, + {"id": "routingAuthority", "label": "Routing Authority", "type": "string"}, + {"id": "accessZone", "label": "Access Zone", "type": "string"}, + {"id": "policyAuthority", "label": "Policy Authority", "type": "string"}, + {"id": "exposureClass", "label": "Exposure Class", "type": "string"}, {"id": "serverHost", "label": "Server Host", "type": "string"}, + {"id": "routeHost", "label": "Route Host", "type": "string"}, + {"id": "routePort", "label": "Route Port", "type": "number"}, {"id": "lifecycle", "label": "Lifecycle", "type": "string"}, {"id": "reviewState", "label": "Review State", "type": "string"}, {"id": "unresolved", "label": "Unresolved", "type": "boolean"}, @@ -193,6 +218,14 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "repo", "domain", "lifecycle", + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", + "routeHost", + "routePort", "canonCategory", "evidenceState", "unresolved", @@ -215,6 +248,31 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: ], }, "modes": [ + { + "id": "by-fabric", + "label": "Fabric", + "description": "Group and filter by fabric/accountability fields.", + }, + { + "id": "by-deployment-environment", + "label": "Environment", + "description": "Group and filter by deployment environment.", + }, + { + "id": "by-deployment-scenario", + "label": "Scenario", + "description": "Group and filter by deployment scenario.", + }, + { + "id": "by-routing-authority", + "label": "Routing", + "description": "Group and filter by routing authority.", + }, + { + "id": "by-access-zone", + "label": "Access Zone", + "description": "Group and filter by intended access zone.", + }, { "id": "full", "label": "Full", @@ -335,6 +393,10 @@ def fabric_graph_explorer_payload( if source_kind == "Repository": continue attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} + overlay_data = _overlay_data( + node.get("deployment_overlay") if isinstance(node.get("deployment_overlay"), dict) else {}, + attributes, + ) kind = _presentation_kind(source_kind, attributes) layer = _layer_for_kind(kind) is_unresolved = node_id in unresolved @@ -353,6 +415,8 @@ def fabric_graph_explorer_payload( "repo": str(node.get("repo", "")), "domain": str(node.get("domain", "")), "lifecycle": str(node.get("lifecycle", "")), + "environment": overlay_data["deploymentEnvironment"], + **overlay_data, "canonCategory": str( node.get("canon_category") or attributes.get("canon_category") or "" ), @@ -522,12 +586,17 @@ def _presentation_edge_type(edge_type: str, source: str, target: str, node_kinds def _edge_metadata(edge: dict[str, Any], edge_type: str) -> dict[str, Any]: canon_mapping = edge_canon_mapping(edge_type) + attributes = edge.get("attributes") if isinstance(edge.get("attributes"), dict) else {} return { "canonical_type": str(edge.get("canonical_type") or canon_mapping.canonical_type), "canon_anchor": str(edge.get("canon_anchor") or canon_mapping.canon_anchor), "mapping_fit": str(edge.get("mapping_fit") or canon_mapping.fit), "display_only": bool(edge.get("display_only", canon_mapping.display_only)), "evidence_state": str(edge.get("evidence_state") or "declared"), + "deployment_overlay": normalize_deployment_overlay( + edge.get("deployment_overlay") if isinstance(edge.get("deployment_overlay"), dict) else {}, + attributes, + ), } @@ -630,6 +699,12 @@ def _append_infrastructure_elements( for endpoint in service_endpoints if _environment_matches(environment, endpoint["environments"]) ] + deployment_overlay = normalize_deployment_overlay( + attributes, + {"deployment_environment": environment}, + *[endpoint["deployment_overlay"] for endpoint in matching_endpoints], + ) + overlay_data = _overlay_data(deployment_overlay) server_hosts = sorted({endpoint["host"] for endpoint in matching_endpoints}) deployment_data = { "id": deployment_id, @@ -643,6 +718,7 @@ def _append_infrastructure_elements( "domain": str(service.get("domain") or ""), "lifecycle": str(service.get("lifecycle") or ""), "environment": environment, + **overlay_data, "serviceId": service_id, "serverHosts": server_hosts, "reviewState": "accepted", @@ -676,6 +752,10 @@ def _append_infrastructure_elements( host = endpoint["host"] port = endpoint["port"] protocol = endpoint["protocol"] + endpoint_overlay_data = _overlay_data( + endpoint["deployment_overlay"], + {"deployment_environment": environment}, + ) server_id = server_ids_by_host.get(host) endpoint_key = _endpoint_key(host, port, protocol) port_id = port_ids_by_endpoint.get(endpoint_key) @@ -695,6 +775,7 @@ def _append_infrastructure_elements( "domain": str(service.get("domain") or ""), "lifecycle": "active", "environment": environment, + **endpoint_overlay_data, "serverHost": host, "reviewState": "accepted", "freshnessState": "current", @@ -706,6 +787,7 @@ def _append_infrastructure_elements( "host": host, "source_interface_id": endpoint["interface_id"], "endpoint_url": endpoint["url"], + "deployment_overlay": endpoint_overlay_data["deploymentOverlay"], }, "displayState": "show", "visibilitySource": "default", @@ -731,6 +813,7 @@ def _append_infrastructure_elements( "domain": str(service.get("domain") or ""), "lifecycle": "active", "environment": environment, + **endpoint_overlay_data, "serverHost": host, "reviewState": "accepted", "freshnessState": "current", @@ -744,6 +827,7 @@ def _append_infrastructure_elements( "protocol": protocol, "source_interface_id": endpoint["interface_id"], "endpoint_url": endpoint["url"], + "deployment_overlay": endpoint_overlay_data["deploymentOverlay"], }, "displayState": "show", "visibilitySource": "default", @@ -783,6 +867,16 @@ def _endpoints_by_service(source_nodes: list[dict[str, Any]]) -> dict[str, list[ "url": url, "interface_id": str(node.get("id") or ""), "environments": _environments(attributes), + "deployment_overlay": normalize_deployment_overlay( + attributes, + endpoint, + { + "host": host, + "port": port, + "protocol": protocol, + "route": url, + }, + ), } ) return endpoints @@ -895,6 +989,7 @@ def _edge_element( mapping_fit: str = "", display_only: bool = False, evidence_state: str = "", + deployment_overlay: dict[str, Any] | None = None, ) -> dict[str, Any]: source_layer = node_layers.get(source, "unknown") target_layer = node_layers.get(target, "unknown") @@ -910,6 +1005,7 @@ def _edge_element( strength = _edge_strength(edge_type) layout = _layout_hints(edge_type, source_layer, target_layer, same_repo) edge_id = f"edge:{edge_index}:{source}:{edge_type}:{target}" + overlay_data = _overlay_data(deployment_overlay or {}) return { "data": { "id": edge_id, @@ -930,6 +1026,7 @@ def _edge_element( "mappingFit": mapping_fit, "displayOnly": display_only, "evidenceState": evidence_state, + **overlay_data, "strength": strength, "edgeWidth": _edge_width(strength), "sameLayer": source_layer == target_layer, @@ -959,6 +1056,10 @@ def _edge_element( } +def _overlay_data(*sources: object) -> dict[str, Any]: + return graph_explorer_overlay_fields(normalize_deployment_overlay(*sources)) + + def _layout_hints( edge_type: str, source_layer: str, diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index c937c4b..5cef4c7 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -574,7 +574,10 @@ def graph_explorer_page() -> str: const elementText = (data) => [ data.id, data.stableKey, data.label, data.name, data.description, - data.repo, data.domain, data.kind, data.layer, data.edgeType + data.repo, data.domain, data.kind, data.layer, data.edgeType, + data.deploymentEnvironment, data.deploymentScenario, data.routingAuthority, + data.accessZone, data.policyAuthority, data.exposureClass, + data.routeHost, data.routeHostname, data.routePort, data.routeProtocol, data.route ].join(" ").toLowerCase(); const overrideKey = (element) => element.data("stableKey") || element.id(); @@ -614,6 +617,37 @@ def graph_explorer_page() -> str: const selectedEdgeTypes = () => checkedValues(edgeTypeFilter); + const hasValue = (value) => value !== undefined && value !== null && value !== ""; + + const overlayRuleAttributes = [ + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", + "routeHost", + "routeHostname", + "routePort", + "routeProtocol", + ]; + + const zoneModeFields = { + "by-fabric": ["domain", "repo", "kind"], + "by-deployment-environment": ["deploymentEnvironment"], + "by-deployment-scenario": ["deploymentScenario"], + "by-routing-authority": ["routingAuthority"], + "by-access-zone": ["accessZone"], + }; + + const zoneModeLabels = { + "by-fabric": "Fabric", + "by-deployment-environment": "Deployment Environment", + "by-deployment-scenario": "Deployment Scenario", + "by-routing-authority": "Routing Authority", + "by-access-zone": "Access Zone", + }; + const summarizeSelection = (selected, allValues, labels, noun) => { if (selected.size === 0) return `No ${noun}`; if (selected.size === allValues.length) return `All ${noun}`; @@ -657,11 +691,21 @@ def graph_explorer_page() -> str: strength: "Strength", sameRepo: "Same repo", layoutAffinity: "Layout affinity", + deploymentEnvironment: "Deployment Environment", + deploymentScenario: "Deployment Scenario", + routingAuthority: "Routing Authority", + accessZone: "Access Zone", + policyAuthority: "Policy Authority", + exposureClass: "Exposure Class", + routeHost: "Route Host", + routeHostname: "Route Hostname", + routePort: "Route Port", + routeProtocol: "Route Protocol", }; const ruleAttributeCandidates = { - node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle"], - edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity"], + node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle", ...overlayRuleAttributes], + edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity", ...overlayRuleAttributes], }; const renderOptions = (select, options, current = "") => { @@ -835,6 +879,79 @@ def graph_explorer_page() -> str: (!type || edge.data("edgeType") === type) ) : []; + const routeLabel = (data) => { + if (hasValue(data.route)) return data.route; + const host = data.routeHost || data.routeHostname || ""; + const protocol = data.routeProtocol || ""; + const port = data.routePort || ""; + if (!hasValue(host) && !hasValue(port)) return ""; + const authority = hasValue(port) ? `${host}:${port}` : host; + return [protocol, authority].filter(hasValue).join(" "); + }; + + const hasRouteEvidence = (data) => + hasValue(data.route) || + hasValue(data.routeHost) || + hasValue(data.routeHostname) || + hasValue(data.routePort); + + const zoneWarningsForData = (data) => { + const warnings = []; + if (hasRouteEvidence(data) && !hasValue(data.policyAuthority)) { + warnings.push("route without policy authority"); + } + return warnings; + }; + + const groupCounts = (elements, field) => { + const counts = new Map(); + elements.forEach((element) => { + const value = element.data(field); + if (hasValue(value)) counts.set(String(value), (counts.get(String(value)) || 0) + 1); + }); + return Array.from(counts.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); + }; + + const renderMapOverview = () => { + const visibleElements = cy + ? cy.elements().filter((element) => element.style("display") !== "none").toArray() + : []; + const visibleNodes = visibleElements.filter((element) => element.isNode()).length; + const visibleEdges = visibleElements.filter((element) => element.isEdge()).length; + const fields = zoneModeFields[activeMode] || []; + const title = zoneModeLabels[activeMode] || "Full"; + detailTitle.textContent = activeMode === "full" ? "Fabric Map" : `${title} View`; + detailSummary.textContent = `${visibleNodes} nodes / ${visibleEdges} edges visible`; + detailPills.innerHTML = activeMode === "full" ? "" : `${escapeHtml(activeMode)}`; + + const rows = [{label: "visible", value: `${visibleNodes} nodes / ${visibleEdges} edges`}]; + fields.forEach((field) => { + const counts = groupCounts(visibleElements, field); + rows.push({ + label: ruleAttributeLabels[field] || humanize(field), + value: counts.length + ? counts.slice(0, 8).map(([value, count]) => `${value} (${count})`).join(", ") + : "no annotated visible entities", + state: counts.length ? "" : "warning", + }); + }); + + const warnings = visibleElements + .flatMap((element) => zoneWarningsForData(element.data()).map((warning) => + `${elementLabel(element)}: ${warning}` + )); + warnings.slice(0, 6).forEach((warning) => rows.push({label: "warning", value: warning, state: "warning"})); + if (warnings.length > 6) { + rows.push({label: "warning", value: `${warnings.length - 6} additional route warnings`, state: "warning"}); + } + + detailList.innerHTML = rows + .filter((row) => row.value) + .map((row) => `
  • ${escapeHtml(row.label)} ${escapeHtml(row.value)}
  • `) + .join(""); + }; + const collectionArray = (collection) => Array.from(collection || []); const orientationStateClass = (state) => @@ -1145,6 +1262,12 @@ def graph_explorer_page() -> str: if (activeMode === "unresolved") { return new Set(cy.elements().filter((element) => element.data("unresolved") === true).map((element) => element.id())); } + const zoneFields = zoneModeFields[activeMode] || []; + if (zoneFields.length && activeMode !== "by-fabric") { + return new Set(cy.elements().filter((element) => + zoneFields.some((field) => hasValue(element.data(field))) + ).map((element) => element.id())); + } if (!selected) return null; if (activeMode === "selected-path") { const collection = selected.union(selected.predecessors()).union(selected.successors()); @@ -1204,6 +1327,7 @@ def graph_explorer_page() -> str: hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`; updateLabelVisibility(); updateSelectionAnchor(); + if (!selected) renderMapOverview(); if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout(); }; @@ -1211,10 +1335,7 @@ def graph_explorer_page() -> str: selected = element || null; if (!element) { orientationContext = null; - detailTitle.textContent = "Fabric Map"; - detailSummary.textContent = "No selection"; - detailPills.innerHTML = ""; - detailList.innerHTML = ""; + renderMapOverview(); orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo."; orientationList.innerHTML = ""; orientationActions.innerHTML = ""; @@ -1241,6 +1362,14 @@ def graph_explorer_page() -> str: ["mapping", data.mappingFit], ["display only", data.displayOnly === true ? "yes" : ""], ["strength", data.strength], + ["deployment environment", data.deploymentEnvironment], + ["deployment scenario", data.deploymentScenario], + ["routing authority", data.routingAuthority], + ["access zone", data.accessZone], + ["policy authority", data.policyAuthority], + ["exposure", data.exposureClass], + ["route", routeLabel(data)], + ...zoneWarningsForData(data).map((warning) => ["warning", warning]), ...Object.entries(links), ...refs.map((ref) => [ref.label || ref.kind || "source", ref.path || ref.url || ref.ref || ""]) ]; diff --git a/railiance_fabric/scanner.py b/railiance_fabric/scanner.py index 02ac64e..7c834d8 100644 --- a/railiance_fabric/scanner.py +++ b/railiance_fabric/scanner.py @@ -15,6 +15,7 @@ import yaml from .canon import edge_canon_mapping, evidence_state_for, node_canon_mapping, source_kind_from_anchor from .connectors import ConnectorConfig, apply_connectors +from .deployment_overlay import normalize_deployment_overlay from .discovery import ( attribute_stable_key, discovery_stable_key, @@ -1146,6 +1147,22 @@ def _add_runtime_endpoint( if not host or port_number is None: return "" + runtime_attributes = { + "host": host, + "runtime_target_type": server_type, + **(attributes or {}), + } + overlay = _runtime_deployment_overlay( + host=host, + port=port_number, + protocol=protocol_value, + domain=domain, + server_type=server_type, + attributes=runtime_attributes, + ) + if overlay: + runtime_attributes["deployment_overlay"] = overlay + target_kind = _runtime_target_kind(host, server_type) target_key = discovery_stable_key(context.repo_slug, target_kind, host) context.accumulator.add_node( @@ -1156,11 +1173,7 @@ def _add_runtime_endpoint( provenance=provenance, source_anchor=anchor, aliases=[host], - attributes={ - "host": host, - "runtime_target_type": server_type, - **(attributes or {}), - }, + attributes=runtime_attributes, confidence=confidence, ) @@ -1175,10 +1188,9 @@ def _add_runtime_endpoint( source_anchor=anchor, aliases=[port_label], attributes={ - "host": host, "port": port_number, "protocol": protocol_value, - **(attributes or {}), + **runtime_attributes, }, confidence=confidence, ) @@ -1333,6 +1345,79 @@ def _add_domain_route( ) +def _runtime_deployment_overlay( + *, + host: str, + port: int, + protocol: str, + domain: str, + server_type: str, + attributes: dict[str, object], +) -> dict[str, Any]: + source = str(attributes.get("source") or server_type or "").strip() + overlay: dict[str, Any] = { + "routing_authority": _routing_authority(source, server_type), + "route_evidence": { + "host": host, + "hostname": _normalize_domain(domain) or host, + "port": port, + "protocol": protocol, + "route": str(attributes.get("endpoint_url") or domain or host), + "scheme": attributes.get("scheme", ""), + }, + } + if _is_loopback_host(host): + overlay.update( + { + "deployment_environment": "dev", + "deployment_scenario": "bernd-laptop", + "access_zone": "private-dev", + "policy_authority": "local-loopback-binding", + "exposure_class": "local-only", + } + ) + elif _mentions_scenario(host, domain, "coulombcore"): + overlay.update( + { + "deployment_environment": "test", + "deployment_scenario": "coulombcore", + "access_zone": "collaborator-test", + "exposure_class": "collaborator-test", + } + ) + elif _mentions_scenario(host, domain, "railiance01"): + overlay.update( + { + "deployment_environment": "prod", + "deployment_scenario": "railiance01", + "access_zone": "production-public" if _looks_like_domain(domain or host) else "production-admin", + "exposure_class": "production-public" if _looks_like_domain(domain or host) else "production-admin", + } + ) + return normalize_deployment_overlay(overlay) + + +def _routing_authority(source: str, server_type: str) -> str: + source_value = source.strip().lower() + if source_value == "docker-compose": + return "docker-compose" + if source_value.startswith("kubernetes-") or server_type == "kubernetes-service-dns": + return "kubernetes" + if server_type == "declared-endpoint": + return "declared-endpoint" + return source_value or server_type + + +def _is_loopback_host(host: str) -> bool: + value = _normalize_host(host) + return value in {"localhost", "127.0.0.1", "::1"} + + +def _mentions_scenario(host: str, domain: str, scenario: str) -> bool: + needle = scenario.strip().lower() + return needle in _normalize_host(host) or needle in _normalize_domain(domain) + + def _compose_port_bindings(service: dict[str, Any]) -> list[dict[str, object]]: ports = service.get("ports") if not isinstance(ports, list): diff --git a/schemas/accountability-identity-projection.schema.yaml b/schemas/accountability-identity-projection.schema.yaml index 7bbd29f..a568fcb 100644 --- a/schemas/accountability-identity-projection.schema.yaml +++ b/schemas/accountability-identity-projection.schema.yaml @@ -87,6 +87,8 @@ $defs: type: string owner_actor_id: type: string + deployment_overlay: + $ref: "#/$defs/deploymentOverlay" review_state: type: string enum: @@ -108,3 +110,29 @@ $defs: attributes: type: object additionalProperties: true + + deploymentOverlay: + type: object + additionalProperties: false + properties: + deployment_environment: + type: string + deployment_scenario: + type: string + routing_authority: + type: string + access_zone: + type: string + policy_authority: + type: string + exposure_class: + type: string + route_evidence: + type: object + additionalProperties: + anyOf: + - type: string + - type: integer + - type: number + - type: boolean + - type: "null" diff --git a/schemas/interface.schema.yaml b/schemas/interface.schema.yaml index 4298b87..4f39fe6 100644 --- a/schemas/interface.schema.yaml +++ b/schemas/interface.schema.yaml @@ -61,9 +61,38 @@ properties: notes: type: string minLength: 1 + deployment_overlay: + $ref: "#/$defs/deploymentOverlay" auth: $ref: "./common.schema.yaml#/$defs/auth" data_classification: $ref: "./common.schema.yaml#/$defs/dataClassification" compatibility: $ref: "./common.schema.yaml#/$defs/compatibility" + +$defs: + deploymentOverlay: + type: object + additionalProperties: false + properties: + deployment_environment: + type: string + deployment_scenario: + type: string + routing_authority: + type: string + access_zone: + type: string + policy_authority: + type: string + exposure_class: + type: string + route_evidence: + type: object + additionalProperties: + anyOf: + - type: string + - type: integer + - type: number + - type: boolean + - type: "null" diff --git a/schemas/state-hub-export.schema.yaml b/schemas/state-hub-export.schema.yaml index 996dd49..14ce8fb 100644 --- a/schemas/state-hub-export.schema.yaml +++ b/schemas/state-hub-export.schema.yaml @@ -273,6 +273,8 @@ $defs: $ref: "#/$defs/ownership" accounting: $ref: "#/$defs/accounting" + deployment_overlay: + $ref: "#/$defs/deploymentOverlay" evidence: $ref: "#/$defs/evidence" canon_category: @@ -336,6 +338,8 @@ $defs: $ref: "#/$defs/utility" accounting: $ref: "#/$defs/accounting" + deployment_overlay: + $ref: "#/$defs/deploymentOverlay" evidence: $ref: "#/$defs/evidence" attributes: @@ -484,6 +488,32 @@ $defs: - type: string - type: "null" + deploymentOverlay: + type: object + additionalProperties: false + properties: + deployment_environment: + type: string + deployment_scenario: + type: string + routing_authority: + type: string + access_zone: + type: string + policy_authority: + type: string + exposure_class: + type: string + route_evidence: + type: object + additionalProperties: + anyOf: + - type: string + - type: integer + - type: number + - type: boolean + - type: "null" + evidence: type: object additionalProperties: false diff --git a/tests/test_deployment_zone_inventory.py b/tests/test_deployment_zone_inventory.py new file mode 100644 index 0000000..6bc9af9 --- /dev/null +++ b/tests/test_deployment_zone_inventory.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + + +def test_deployment_zone_inventory_covers_current_scenarios() -> None: + inventory = yaml.safe_load( + Path("fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml").read_text(encoding="utf-8") + ) + + surfaces = inventory["surfaces"] + scenarios = {surface["deployment_scenario"] for surface in surfaces} + environments = {surface["deployment_environment"] for surface in surfaces} + + assert {"bernd-laptop", "coulombcore", "railiance01"} <= scenarios + assert {"dev", "test", "prod"} <= environments + + dev_routes = [ + surface["route_evidence"] + for surface in surfaces + if surface["deployment_environment"] == "dev" + ] + assert {route["port"] for route in dev_routes} >= {3000, 8000, 8001, 8765, 8876} + + test_routes = [ + surface + for surface in surfaces + if surface["deployment_scenario"] == "coulombcore" + ] + assert all(surface["routing_authority"] == "ops-bridge" for surface in test_routes) + assert all(surface["policy_authority"] == "ops-bridge-ssh" for surface in test_routes) + + prod_hosts = { + surface["route_evidence"]["hostname"] + for surface in surfaces + if surface["deployment_scenario"] == "railiance01" + } + assert {"gitea.coulomb.social", "vergabe-teilnahme.whywhynot.de", "auth.coulomb.social"} <= prod_hosts + + ambiguity_ids = {item["id"] for item in inventory["ambiguities"]} + assert "railiance01-coulombcore-ip-conflict" in ambiguity_ids + assert {item["surface_id"] for item in inventory["missing_policy_authority"]} >= { + "prod.railiance01.gitea", + "prod.railiance01.vergabe-teilnahme", + } diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 53c8887..c51e18c 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -52,6 +52,11 @@ def test_graph_explorer_manifest_and_payload_validate() -> None: } filter_labels = {field["id"]: field["label"] for field in manifest["filter"]["fields"]} assert filter_labels["layer"] == "Node Type" + assert filter_labels["deploymentEnvironment"] == "Deployment Environment" + assert filter_labels["accessZone"] == "Access Zone" + assert {"by-deployment-environment", "by-deployment-scenario", "by-routing-authority", "by-access-zone"} <= { + mode["id"] for mode in manifest["modes"] + } nodes = [element for element in payload["elements"] if "source" not in element["data"]] edges = [element for element in payload["elements"] if "source" in element["data"]] registered_only = next( @@ -164,7 +169,19 @@ def test_graph_explorer_presents_legacy_server_nodes_as_runtime_entities() -> No "repo": "fixture-repo", "domain": "testing", "lifecycle": "active", - "attributes": {"host": "gitea.example.test", "server_type": "ingress-host"}, + "attributes": { + "host": "gitea.example.test", + "server_type": "ingress-host", + "deployment_overlay": { + "deployment_environment": "test", + "deployment_scenario": "coulombcore", + "routing_authority": "kubernetes", + "access_zone": "early-access", + "policy_authority": "netkingdom-iam", + "exposure_class": "early-access", + "route_evidence": {"hostname": "gitea.example.test", "port": 443, "protocol": "tcp"}, + }, + }, }, { "id": "fixture.server.gitea.default.svc.cluster.local", @@ -217,6 +234,12 @@ def test_graph_explorer_presents_legacy_server_nodes_as_runtime_entities() -> No assert nodes_by_id["fixture.server.gitea.example.test"]["kind"] == "ApplicationEndpoint" assert nodes_by_id["fixture.server.gitea.example.test"]["layer"] == "application" + assert nodes_by_id["fixture.server.gitea.example.test"]["deploymentEnvironment"] == "test" + assert nodes_by_id["fixture.server.gitea.example.test"]["deploymentScenario"] == "coulombcore" + assert nodes_by_id["fixture.server.gitea.example.test"]["accessZone"] == "early-access" + assert nodes_by_id["fixture.server.gitea.example.test"]["policyAuthority"] == "netkingdom-iam" + assert nodes_by_id["fixture.server.gitea.example.test"]["routeHostname"] == "gitea.example.test" + assert nodes_by_id["fixture.server.gitea.example.test"]["routePort"] == 443 assert nodes_by_id["fixture.server.gitea.default.svc.cluster.local"]["kind"] == "RuntimeService" assert nodes_by_id["fixture.server.gitea.default.svc.cluster.local"]["layer"] == "runtime_service" edge_types = { @@ -387,6 +410,12 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert "updateLabelVisibility" in page assert "ruleActionFor" in page assert "ruleRemovalSignature" in page + assert "zoneModeFields" in page + assert "renderMapOverview" in page + assert "route without policy authority" in page + assert "deploymentEnvironment" in page + assert "routingAuthority" in page + assert "accessZone" in page assert "Remove and redraw" in page assert "Rules are applied top to bottom" in page assert "showHelp" in page diff --git a/tests/test_registry.py b/tests/test_registry.py index 45751d8..6344790 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -309,6 +309,8 @@ def test_registry_accepts_financial_graph_and_materializes_vnext_fields(tmp_path assert graph["apiVersion"] == "railiance.fabric/v1alpha2" assert graph["schema_version"] == "financial-fabric-v1" assert graph["nodes"][0]["evidence"]["review_state"] == "accepted" + assert graph["nodes"][0]["deployment_overlay"]["deployment_environment"] == "dev" + assert graph["nodes"][0]["deployment_overlay"]["deployment_scenario"] == "bernd-laptop" assert edge["relationship_category"] == "utility" assert edge["boundary"]["crosses_fabric_boundary"] is False assert edge["boundary"]["crosses_subfabric_boundary"] is True @@ -558,6 +560,15 @@ def _financial_graph() -> dict: "subfabric_id": None, "environment": "local", }, + "deployment_overlay": { + "deployment_environment": "dev", + "deployment_scenario": "bernd-laptop", + "routing_authority": "loopback", + "access_zone": "private-dev", + "policy_authority": "local-loopback-binding", + "exposure_class": "local-only", + "route_evidence": {"host": "127.0.0.1", "port": 8000, "protocol": "tcp"}, + }, "ownership": { "owner_actor_id": "actor.railiance.primary-lord", "owner_role": "lord", diff --git a/tests/test_scanner.py b/tests/test_scanner.py index e7259b9..64051df 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -44,6 +44,14 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) -> assert nodes_by_label[("Lockfile", "package-lock.json")]["attributes"]["path"] == "package-lock.json" assert nodes_by_label[("ServiceConfig", "application.yaml")]["attributes"]["format"] == "yaml" assert nodes_by_label[("Server", "127.0.0.1")]["attributes"]["runtime_target_type"] == "compose-host" + dev_overlay = nodes_by_label[("Server", "127.0.0.1")]["attributes"]["deployment_overlay"] + assert dev_overlay["deployment_environment"] == "dev" + assert dev_overlay["deployment_scenario"] == "bernd-laptop" + assert dev_overlay["access_zone"] == "private-dev" + assert dev_overlay["policy_authority"] == "local-loopback-binding" + assert dev_overlay["exposure_class"] == "local-only" + assert dev_overlay["routing_authority"] == "docker-compose" + assert dev_overlay["route_evidence"]["port"] == 8080 assert ( nodes_by_label[("RuntimeService", "fixture-api.testing.svc.cluster.local")]["attributes"]["runtime_target_type"] == "kubernetes-service-dns" diff --git a/workplans/RAIL-FAB-WP-0020-deployment-zone-discovery-visualization.md b/workplans/RAIL-FAB-WP-0020-deployment-zone-discovery-visualization.md index 1c4c316..e2203fc 100644 --- a/workplans/RAIL-FAB-WP-0020-deployment-zone-discovery-visualization.md +++ b/workplans/RAIL-FAB-WP-0020-deployment-zone-discovery-visualization.md @@ -4,7 +4,7 @@ type: workplan title: "Deployment Zone Discovery And Visualization" domain: railiance repo: railiance-fabric -status: ready +status: finished owner: codex topic_slug: railiance created: "2026-05-24" @@ -49,7 +49,7 @@ Railiance currently treats: ```task id: RAIL-FAB-WP-0020-T01 -status: todo +status: done priority: high state_hub_task_id: "b8cf7d91-7743-4e58-9b13-ce99f2d9eef1" ``` @@ -70,11 +70,15 @@ Fields should cover: Done when identity projection, financial export, and graph-explorer payloads have a clear place to carry these fields without changing fabric membership. +Result: added a normalized `deployment_overlay` object and threaded it through +identity candidates, financial exports, State Hub export schema validation, and +graph-explorer payload fields/modes. + ## T02 - Discover Local Dev Routing Evidence ```task id: RAIL-FAB-WP-0020-T02 -status: todo +status: done priority: high state_hub_task_id: "b072e11b-08b5-426f-9f98-001abf8afd70" ``` @@ -95,11 +99,16 @@ Done when local-only surfaces are marked as `deployment_environment: dev`, `deployment_scenario: bernd-laptop`, and `access_zone: private-dev` with provenance. +Result: known local surfaces are declared or discovered with `dev`, +`bernd-laptop`, `private-dev`, local-loopback policy authority, and route +evidence: Fabric registry/explorer `8765`, NetKingdom control surface `8876`, +State Hub API `8000`, State Hub MCP `8001`, and State Hub dashboard `3000`. + ## T03 - Discover Test And Production Routing Authorities ```task id: RAIL-FAB-WP-0020-T03 -status: todo +status: done priority: high state_hub_task_id: "91fc3f28-fbb9-43d2-bb46-44d179f4b485" ``` @@ -118,11 +127,16 @@ Done when test-stage routes can be attributed to `coulombcore` and production routes can be attributed to `railiance01`, with access zones flagged as candidate values for operator review. +Result: published `fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml` +with `coulombcore` test tunnel evidence, `railiance01` Traefik ingress evidence, +candidate access zones, and explicit ambiguity flags for host/IP conflicts and +operator review. + ## T04 - Add Zone Overlay Graph Explorer Modes ```task id: RAIL-FAB-WP-0020-T04 -status: todo +status: done priority: high state_hub_task_id: "664c2688-f45b-47bf-90ff-b17096a326fb" ``` @@ -146,11 +160,15 @@ The UI should make it easy to answer: Done when the graph explorer can group/filter by overlay fields and surface the basic warnings without making policy decisions. +Result: graph-explorer manifests and payloads expose deployment overlay fields; +the UI includes zone modes, overlay search/rule filtering, visible zone summaries, +and route-without-policy-authority warnings. + ## T05 - Preserve State Hub Read-Model Compatibility ```task id: RAIL-FAB-WP-0020-T05 -status: todo +status: done priority: medium state_hub_task_id: "1a5ef6f9-357f-4803-a1f8-ebd1ff5443fb" ``` @@ -161,11 +179,15 @@ Done when Fabric exports remain backward compatible, State Hub keeps importing valid v1alpha2 exports, and overlay fields are visible enough for dashboard or search views. +Result: `schemas/state-hub-export.schema.yaml` accepts optional +`deployment_overlay` objects on financial nodes and edges while preserving the +legacy export shape. Focused compatibility tests pass. + ## T06 - Publish Current Zone Inventory ```task id: RAIL-FAB-WP-0020-T06 -status: todo +status: done priority: medium state_hub_task_id: "a1b208e3-3321-4792-ba44-d32aba682183" ``` @@ -180,3 +202,7 @@ Done when there is a saved artifact answering: - which production services are visible on `railiance01`; - which routes or ports are ambiguous, conflicting, or missing a policy authority. + +Result: saved the current inventory at +`fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml` and added +focused test coverage to keep the dev/test/prod scenario answers present.