feat: add deployment zone overlays

This commit is contained in:
2026-05-24 15:55:05 +02:00
parent 62236f6453
commit ff1c4ce05b
28 changed files with 1282 additions and 26 deletions

View File

@@ -117,6 +117,7 @@ spec:
default_data_classification: internal
expected_interface_types:
- http-api
- mcp-api
- event-stream
tags: [coordination, state-hub, planning]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,3 +15,4 @@ spec:
- net-kingdom.iam-profile.issuer
exposes_interfaces:
- net-kingdom.iam-profile.oidc-discovery
- net-kingdom.control-surface.ui

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" ? "" : `<span class="pill">${escapeHtml(activeMode)}</span>`;
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) => `<li class="${orientationStateClass(row.state)}"><strong>${escapeHtml(row.label)}</strong> ${escapeHtml(row.value)}</li>`)
.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 || ""])
];

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
}

View File

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

View File

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

View File

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

View File

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