diff --git a/railiance_fabric/reconciliation.py b/railiance_fabric/reconciliation.py index a40daf3..b4b0e4f 100644 --- a/railiance_fabric/reconciliation.py +++ b/railiance_fabric/reconciliation.py @@ -16,7 +16,32 @@ ORIGIN_PRECEDENCE = { "manual": 5, } -PATH_SCOPED_NODE_KINDS = {"lockfile"} +PATH_SCOPED_NODE_KINDS = { + "container-build", + "deployment-service", + "domain-name", + "kubernetes-config-map", + "kubernetes-cron-job", + "kubernetes-daemon-set", + "kubernetes-deployment", + "kubernetes-horizontal-pod-autoscaler", + "kubernetes-ingress", + "kubernetes-job", + "kubernetes-namespace", + "kubernetes-secret", + "kubernetes-service", + "kubernetes-stateful-set", + "lockfile", + "network-port", + "score-workload", + "service-config", +} +EVIDENCE_AGGREGATE_EDGE_TYPES = { + "exposes_port", + "opens_port", + "resolves_to", + "routes_to_port", +} def reconcile_discovery_snapshots( @@ -186,9 +211,12 @@ def _edge_conflicts(by_key: dict[str, dict[str, Any]]) -> list[dict[str, object] conflicts: list[dict[str, object]] = [] seen: dict[tuple[str, str, str], str] = {} for key, edge in sorted(by_key.items()): + edge_type = str(edge.get("edge_type") or "") + if edge_type in EVIDENCE_AGGREGATE_EDGE_TYPES: + continue match_key = ( str(edge.get("source_key") or ""), - str(edge.get("edge_type") or ""), + edge_type, str(edge.get("target_key") or ""), ) other = seen.get(match_key) @@ -292,11 +320,24 @@ def _path_scoped_nodes_are_distinct(left: dict[str, Any] | None, right: dict[str right_kind = normalize_identity_part(str(right.get("kind") or "")) if left_kind != right_kind or left_kind not in PATH_SCOPED_NODE_KINDS: return False + left_identities = _source_anchor_identities(left) + right_identities = _source_anchor_identities(right) + if left_identities and right_identities: + return left_identities.isdisjoint(right_identities) left_paths = _source_anchor_paths(left) right_paths = _source_anchor_paths(right) return bool(left_paths and right_paths and left_paths.isdisjoint(right_paths)) +def _source_anchor_identities(candidate: dict[str, Any]) -> set[str]: + anchors = candidate.get("source_anchors") if isinstance(candidate.get("source_anchors"), list) else [] + return { + str(anchor.get("fingerprint") or "") + for anchor in anchors + if isinstance(anchor, dict) and anchor.get("fingerprint") + } + + def _source_anchor_paths(candidate: dict[str, Any]) -> set[str]: anchors = candidate.get("source_anchors") if isinstance(candidate.get("source_anchors"), list) else [] return { diff --git a/railiance_fabric/scanner.py b/railiance_fabric/scanner.py index 8817961..76941e5 100644 --- a/railiance_fabric/scanner.py +++ b/railiance_fabric/scanner.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Iterable +from urllib.parse import urlparse import yaml @@ -76,6 +77,14 @@ KUBERNETES_KINDS = { "Service", "StatefulSet", } +DEFAULT_SCHEME_PORTS = { + "http": 80, + "https": 443, + "postgres": 5432, + "postgresql": 5432, + "redis": 6379, +} +COMPOSE_DOMAIN_LABELS = {"VIRTUAL_HOST", "LETSENCRYPT_HOST"} @dataclass(frozen=True) @@ -520,6 +529,25 @@ def _extract_fabric_declarations(context: ScanContext) -> None: _add_declaration_edge(context, scope, provenance, anchor, keys_by_id.get(dependency, ""), keys_by_id, provider, "binds") if dependency and interface: _add_declaration_edge(context, scope, provenance, anchor, keys_by_id.get(dependency, ""), keys_by_id, interface, "uses_interface") + elif kind == "InterfaceDeclaration": + endpoint = spec.get("endpoint") if isinstance(spec.get("endpoint"), dict) else {} + endpoint_url = str(endpoint.get("url") or "").strip() + if endpoint_url: + _add_url_runtime_endpoint( + context, + scope, + provenance, + anchor, + source_key, + endpoint_url, + server_type="declared-endpoint", + attributes={ + "interface_id": graph_id, + "service_id": str(spec.get("service_id") or ""), + "source": "fabric-declaration", + }, + confidence=0.95, + ) def _add_declaration_edge( @@ -829,6 +857,39 @@ def _extract_compose(context: ScanContext) -> None: source_anchor=anchor, confidence=0.9, ) + port_bindings = _compose_port_bindings(service) + domains = _compose_domains(service) + for binding in port_bindings: + port_key = _add_runtime_endpoint( + context, + scope, + provenance, + anchor, + deployment_key, + server_host=binding["host"], + port=binding["published_port"], + protocol=binding["protocol"], + domain="", + server_type="compose-host", + attributes={ + "orchestrator": "docker-compose", + "service_name": str(service_name), + "target_port": binding.get("target_port"), + "source": "docker-compose", + }, + confidence=0.85, + ) + for domain in domains: + _add_domain_route( + context, + scope, + provenance, + anchor, + domain, + port_key, + binding["host"], + confidence=0.8, + ) def _extract_api_contracts(context: ScanContext) -> None: @@ -956,7 +1017,7 @@ def _extract_kubernetes_manifests(context: ScanContext) -> None: replacement_scope=scope, provenance=provenance, source_anchor=anchor, - aliases=[name, relpath], + aliases=[name], attributes={ "api_version": document.get("apiVersion") or "", "manifest_kind": kind, @@ -974,6 +1035,28 @@ def _extract_kubernetes_manifests(context: ScanContext) -> None: source_anchor=anchor, confidence=0.85, ) + if kind == "Service": + _add_kubernetes_service_runtime( + context, + scope, + provenance, + anchor, + manifest_key, + name, + metadata, + document, + ) + elif kind == "Ingress": + _add_kubernetes_ingress_runtime( + context, + scope, + provenance, + anchor, + manifest_key, + name, + metadata, + document, + ) def _extract_service_configs(context: ScanContext) -> None: @@ -1014,6 +1097,429 @@ def _extract_service_configs(context: ScanContext) -> None: ) +def _add_runtime_endpoint( + context: ScanContext, + scope: str, + provenance: dict[str, object], + anchor: dict[str, object], + source_key: str, + *, + server_host: str, + port: object, + protocol: str = "tcp", + domain: str = "", + server_type: str, + attributes: dict[str, object] | None = None, + confidence: float = 0.8, +) -> str: + host = _normalize_host(server_host) + port_number = _int_value(port) + protocol_value = _normalize_protocol(protocol) + if not host or port_number is None: + return "" + + server_key = discovery_stable_key(context.repo_slug, "Server", host) + context.accumulator.add_node( + stable_key=server_key, + kind="Server", + label=host, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + aliases=[host], + attributes={ + "host": host, + "server_type": server_type, + **(attributes or {}), + }, + confidence=confidence, + ) + + port_label = f"{host}:{port_number}/{protocol_value}" + port_key = discovery_stable_key(context.repo_slug, "NetworkPort", port_label) + context.accumulator.add_node( + stable_key=port_key, + kind="NetworkPort", + label=port_label, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + aliases=[port_label], + attributes={ + "host": host, + "port": port_number, + "protocol": protocol_value, + **(attributes or {}), + }, + confidence=confidence, + ) + context.accumulator.add_edge( + edge_type="opens_port", + source_key=server_key, + target_key=port_key, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + confidence=confidence, + ) + if source_key: + context.accumulator.add_edge( + edge_type="exposes_port", + source_key=source_key, + target_key=port_key, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + confidence=confidence, + ) + route_domain = _normalize_domain(domain) + if route_domain: + _add_domain_route(context, scope, provenance, anchor, route_domain, port_key, host, confidence=confidence) + elif _looks_like_domain(host): + _add_domain_route(context, scope, provenance, anchor, host, port_key, host, confidence=confidence) + return port_key + + +def _add_url_runtime_endpoint( + context: ScanContext, + scope: str, + provenance: dict[str, object], + anchor: dict[str, object], + source_key: str, + url: str, + *, + server_type: str, + attributes: dict[str, object] | None = None, + confidence: float = 0.8, +) -> str: + endpoint = _parse_endpoint_url(url) + if not endpoint: + return "" + host, port, scheme = endpoint + return _add_runtime_endpoint( + context, + scope, + provenance, + anchor, + source_key, + server_host=host, + port=port, + protocol="tcp", + domain=host if _looks_like_domain(host) else "", + server_type=server_type, + attributes={ + "endpoint_url": url, + "scheme": scheme, + **(attributes or {}), + }, + confidence=confidence, + ) + + +def _add_domain_route( + context: ScanContext, + scope: str, + provenance: dict[str, object], + anchor: dict[str, object], + domain: str, + port_key: str, + server_host: str, + *, + confidence: float, +) -> None: + domain_value = _normalize_domain(domain) + if not domain_value: + return + domain_key = discovery_stable_key(context.repo_slug, "DomainName", domain_value) + server_key = discovery_stable_key(context.repo_slug, "Server", _normalize_host(server_host)) + context.accumulator.add_node( + stable_key=domain_key, + kind="DomainName", + label=domain_value, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + aliases=[domain_value], + attributes={"domain": domain_value}, + confidence=confidence, + ) + if port_key: + context.accumulator.add_edge( + edge_type="routes_to_port", + source_key=domain_key, + target_key=port_key, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + confidence=confidence, + ) + if server_host: + context.accumulator.add_edge( + edge_type="resolves_to", + source_key=domain_key, + target_key=server_key, + replacement_scope=scope, + provenance=provenance, + source_anchor=anchor, + confidence=confidence, + ) + + +def _compose_port_bindings(service: dict[str, Any]) -> list[dict[str, object]]: + ports = service.get("ports") + if not isinstance(ports, list): + return [] + bindings: list[dict[str, object]] = [] + for item in ports: + binding = _parse_compose_port(item) + if binding: + bindings.append(binding) + return bindings + + +def _parse_compose_port(item: object) -> dict[str, object] | None: + if isinstance(item, dict): + published = _int_value(item.get("published") or item.get("published_port")) + target = _int_value(item.get("target") or item.get("target_port")) + port = published or target + if port is None: + return None + return { + "host": _normalize_host(str(item.get("host_ip") or item.get("host") or "localhost")), + "published_port": port, + "target_port": target or port, + "protocol": _normalize_protocol(str(item.get("protocol") or "tcp")), + } + if not isinstance(item, str): + return None + value, _, protocol_suffix = item.partition("/") + protocol = _normalize_protocol(protocol_suffix or "tcp") + parts = value.split(":") + if len(parts) == 1: + port = _int_value(parts[0]) + host = "localhost" + target = port + elif len(parts) == 2: + host = "localhost" + port = _int_value(parts[0]) + target = _int_value(parts[1]) + else: + host = _normalize_host(parts[-3] or "localhost") + port = _int_value(parts[-2]) + target = _int_value(parts[-1]) + if port is None: + return None + return { + "host": host, + "published_port": port, + "target_port": target or port, + "protocol": protocol, + } + + +def _compose_domains(service: dict[str, Any]) -> list[str]: + labels = service.get("labels") + pairs: list[tuple[str, str]] = [] + if isinstance(labels, dict): + pairs.extend((str(key), str(value)) for key, value in labels.items()) + elif isinstance(labels, list): + for label in labels: + text = str(label) + key, separator, value = text.partition("=") + pairs.append((key, value if separator else "")) + + domains: list[str] = [] + for key, value in pairs: + key_name = key.strip() + if key_name.upper() in COMPOSE_DOMAIN_LABELS: + domains.extend(_split_domains(value)) + domains.extend(_host_rule_domains(value or key_name)) + return _unique_strings(_normalize_domain(domain) for domain in domains) + + +def _host_rule_domains(value: str) -> list[str]: + domains: list[str] = [] + for match in re.finditer(r"Host\(([^)]*)\)", value): + body = match.group(1) + for part in body.split(","): + domain = part.strip().strip("`'\" ") + if domain: + domains.append(domain) + return domains + + +def _split_domains(value: str) -> list[str]: + return [domain.strip() for domain in re.split(r"[,;\s]+", value) if domain.strip()] + + +def _add_kubernetes_service_runtime( + context: ScanContext, + scope: str, + provenance: dict[str, object], + anchor: dict[str, object], + source_key: str, + name: str, + metadata: dict[str, Any], + document: dict[str, Any], +) -> None: + namespace = str(metadata.get("namespace") or "default") + service_host = f"{name}.{namespace}.svc.cluster.local" + spec = document.get("spec") if isinstance(document.get("spec"), dict) else {} + service_type = str(spec.get("type") or "ClusterIP") + ports = spec.get("ports") if isinstance(spec.get("ports"), list) else [] + for port_entry in ports: + if not isinstance(port_entry, dict): + continue + service_port = _int_value(port_entry.get("port")) + if service_port is None: + continue + _add_runtime_endpoint( + context, + scope, + provenance, + anchor, + source_key, + server_host=service_host, + port=service_port, + protocol=str(port_entry.get("protocol") or "tcp"), + domain=service_host, + server_type="kubernetes-service-dns", + attributes={ + "namespace": namespace, + "service_name": name, + "service_type": service_type, + "service_port": service_port, + "target_port": port_entry.get("targetPort") or service_port, + "source": "kubernetes-service", + }, + confidence=0.85, + ) + + +def _add_kubernetes_ingress_runtime( + context: ScanContext, + scope: str, + provenance: dict[str, object], + anchor: dict[str, object], + source_key: str, + name: str, + metadata: dict[str, Any], + document: dict[str, Any], +) -> None: + namespace = str(metadata.get("namespace") or "default") + spec = document.get("spec") if isinstance(document.get("spec"), dict) else {} + tls_hosts = { + _normalize_domain(host) + for tls in spec.get("tls", []) + if isinstance(tls, dict) + for host in _string_list(tls.get("hosts")) + } + for rule in spec.get("rules", []) if isinstance(spec.get("rules"), list) else []: + if not isinstance(rule, dict): + continue + domain = _normalize_domain(str(rule.get("host") or "")) + http = rule.get("http") if isinstance(rule.get("http"), dict) else {} + paths = http.get("paths") if isinstance(http.get("paths"), list) else [] + for path_rule in paths: + backend = path_rule.get("backend") if isinstance(path_rule, dict) else {} + service = backend.get("service") if isinstance(backend, dict) and isinstance(backend.get("service"), dict) else {} + service_name = str(service.get("name") or "") + port_spec = service.get("port") if isinstance(service.get("port"), dict) else {} + service_port = _int_value(port_spec.get("number")) + if not service_name or service_port is None: + continue + service_host = f"{service_name}.{namespace}.svc.cluster.local" + _add_runtime_endpoint( + context, + scope, + provenance, + anchor, + source_key, + server_host=service_host, + port=service_port, + protocol="tcp", + domain=domain, + server_type="kubernetes-service-dns", + attributes={ + "namespace": namespace, + "ingress_name": name, + "backend_service": service_name, + "service_port": service_port, + "tls": domain in tls_hosts, + "source": "kubernetes-ingress", + }, + confidence=0.8, + ) + for host in sorted(tls_hosts): + _add_runtime_endpoint( + context, + scope, + provenance, + anchor, + source_key, + server_host=host, + port=443, + protocol="tcp", + domain=host, + server_type="ingress-host", + attributes={"namespace": namespace, "ingress_name": name, "scheme": "https", "source": "kubernetes-ingress-tls"}, + confidence=0.75, + ) + + +def _parse_endpoint_url(url: str) -> tuple[str, int, str] | None: + text = url.strip() + if not text: + return None + parsed = urlparse(text if "://" in text else f"//{text}", scheme="") + host = _normalize_host(parsed.hostname or parsed.netloc or parsed.path.split("/", 1)[0]) + scheme = str(parsed.scheme or "").lower() + try: + port = parsed.port + except ValueError: + port = None + port = port or DEFAULT_SCHEME_PORTS.get(scheme) + if not host or port is None: + return None + return host, port, scheme + + +def _normalize_host(value: str) -> str: + host = str(value or "").strip().lower() + if host in {"0.0.0.0", "::", ""}: + return "localhost" if host else "" + return host.strip("[]") + + +def _normalize_domain(value: str) -> str: + return str(value or "").strip().strip(".").lower() + + +def _normalize_protocol(value: str) -> str: + protocol = str(value or "tcp").strip().lower() + return protocol or "tcp" + + +def _looks_like_domain(host: str) -> bool: + value = _normalize_domain(host) + if not value or value == "localhost": + return False + if re.fullmatch(r"[0-9.]+", value): + return False + return "." in value + + +def _int_value(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + text = str(value or "").strip() + if not text or not re.fullmatch(r"\d+", text): + return None + return int(text) + + def _source_anchor( source_kind: str, path: str, diff --git a/tests/test_scanner.py b/tests/test_scanner.py index ebfc256..c8eb759 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -40,6 +40,15 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) -> assert nodes_by_label[("ScoreWorkload", "fixture-api")]["attributes"]["container_count"] == 1 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"]["server_type"] == "compose-host" + assert nodes_by_label[("Server", "fixture-api.testing.svc.cluster.local")]["attributes"]["server_type"] == "kubernetes-service-dns" + assert nodes_by_label[("Server", "declared.fixture.test")]["attributes"]["server_type"] == "declared-endpoint" + assert nodes_by_label[("NetworkPort", "127.0.0.1:8080/tcp")]["attributes"]["target_port"] == 8080 + assert nodes_by_label[("NetworkPort", "fixture-api.testing.svc.cluster.local:8080/tcp")]["attributes"]["service_port"] == 8080 + assert nodes_by_label[("NetworkPort", "declared.fixture.test:9443/tcp")]["attributes"]["scheme"] == "https" + assert nodes_by_label[("DomainName", "api.fixture.test")]["attributes"]["domain"] == "api.fixture.test" + assert nodes_by_label[("DomainName", "api.k8s.fixture.test")]["attributes"]["domain"] == "api.k8s.fixture.test" + assert nodes_by_label[("DomainName", "declared.fixture.test")]["attributes"]["domain"] == "declared.fixture.test" edge_types = {edge["edge_type"] for edge in candidates["edges"]} assert edge_types >= { @@ -53,6 +62,10 @@ def test_scan_repo_emits_schema_valid_deterministic_snapshot(tmp_path: Path) -> "uses_config", "provides", "exposes", + "opens_port", + "exposes_port", + "routes_to_port", + "resolves_to", } assert {attribute["name"] for attribute in candidates["attributes"]} >= { "readme_title", @@ -147,7 +160,10 @@ services: api: build: . ports: - - "8080:8080" + - "127.0.0.1:8080:8080" + labels: + - "traefik.http.routers.fixture.rule=Host(`api.fixture.test`)" + - "VIRTUAL_HOST=api-alt.fixture.test" """.lstrip(), ) _write( @@ -236,6 +252,49 @@ metadata: spec: interface_type: http-api lifecycle: active + service_id: fixture.api + endpoint: + url: https://declared.fixture.test:9443/api +""".lstrip(), + ) + _write( + repo / "deploy" / "service.yaml", + """ +apiVersion: v1 +kind: Service +metadata: + name: fixture-api + namespace: testing +spec: + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP +""".lstrip(), + ) + _write( + repo / "deploy" / "ingress.yaml", + """ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fixture-api + namespace: testing +spec: + tls: + - hosts: + - secure.fixture.test + rules: + - host: api.k8s.fixture.test + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: fixture-api + port: + number: 8080 """.lstrip(), ) return repo diff --git a/workplans/RAIL-FAB-WP-0014-runtime-topology-discovery.md b/workplans/RAIL-FAB-WP-0014-runtime-topology-discovery.md new file mode 100644 index 0000000..0f96b18 --- /dev/null +++ b/workplans/RAIL-FAB-WP-0014-runtime-topology-discovery.md @@ -0,0 +1,134 @@ +--- +id: RAIL-FAB-WP-0014 +type: workplan +title: "Runtime Topology Discovery" +domain: railiance +repo: railiance-fabric +status: finished +owner: codex +topic_slug: railiance +planning_priority: high +planning_order: 14 +created: "2026-05-21" +updated: "2026-05-21" +state_hub_workstream_id: "9cb51719-3ef1-400a-916e-959b24c67b79" +--- + +# RAIL-FAB-WP-0014 - Runtime Topology Discovery + +## Goal + +Discover runtime topology before broader projection: servers, ports exposed by +services, and domain names that map onto those ports. + +## Background + +The current scanner already discovers deployment-like evidence from Docker +Compose, Kubernetes manifests, service configs, and Fabric declarations. The +graph explorer can infer some `Server` nodes from accepted interface endpoint +URLs, but that inference is UI-local and misses servers/domains/ports that live +in runtime manifests. + +Before a full discovery/projection pass, Fabric should capture these runtime +facts as normal discovery candidates with source anchors and conservative review +state. + +## Design Principles + +- Keep repo-owned declaration schemas unchanged for this increment. +- Emit discovery candidates for `Server`, `NetworkPort`, and `DomainName`. +- Keep facts source-linked to Compose, Kubernetes, or Fabric declaration files. +- Do not resolve DNS or scan networks; only parse deterministic repo evidence. +- Avoid projection until the broad runtime discovery pass has been reviewed. + +## Tasks + +### T01 - Add Runtime Topology Test Fixture + +```task +id: RAIL-FAB-WP-0014-T01 +status: done +priority: high +state_hub_task_id: "714e3560-cd32-4009-8cdd-8ff410cd2725" +``` + +Extend scanner tests so a fixture repo exposes Compose ports, Kubernetes Service +ports, Kubernetes Ingress domains, and Fabric interface endpoint URLs. + +### T02 - Discover Compose Runtime Ports And Domains + +```task +id: RAIL-FAB-WP-0014-T02 +status: done +priority: high +state_hub_task_id: "20923f1d-6bf7-4f59-a9e5-4898d6f0a699" +``` + +Extract `Server`, `NetworkPort`, and `DomainName` candidates from Compose +published ports and common domain labels such as Traefik `Host(...)`, +`VIRTUAL_HOST`, and `LETSENCRYPT_HOST`. + +### T03 - Discover Kubernetes Runtime Ports And Domains + +```task +id: RAIL-FAB-WP-0014-T03 +status: done +priority: high +state_hub_task_id: "a7664f5e-4530-413d-a1b1-2a1702ac9763" +``` + +Extract ports from Kubernetes `Service` manifests and domains from `Ingress` +rules/TLS hosts, linking domains to the relevant ingress/service/port evidence +where possible. + +### T04 - Discover Fabric Endpoint Runtime Facts + +```task +id: RAIL-FAB-WP-0014-T04 +status: done +priority: medium +state_hub_task_id: "4f5e5c34-453c-4c3b-969e-80d5d2b9b370" +``` + +Extract server/domain/port candidates from `InterfaceDeclaration` endpoint URLs +so declared HTTP/database endpoints participate in the same runtime topology +view. + +### T05 - Verify Broad Runtime Discovery + +```task +id: RAIL-FAB-WP-0014-T05 +status: done +priority: high +state_hub_task_id: "4aae0fa0-39a4-401c-ad5f-8d0f7647cfb6" +``` + +Run the deterministic tests and a broad ingest-only rescan. Confirm server, +port, and domain candidates appear without duplicate conflicts or review-only +blockers. + +Verification result: + +- `python3 -m pytest` passed with 33 tests. +- Broad ingest-only rescan completed with 35 repos scanned, 28 unchanged, 7 + changed, 0 retired, 0 conflicted, 0 review required, and 0 errors. +- Report: + `registry/.fabric-discovery/reports/2026-05-20t222151z-deterministic.rescan-report.json`. +- Latest discovery snapshots now include 20 `Server`, 26 `NetworkPort`, and 14 + `DomainName` candidates across `flex-auth`, `net-kingdom`, `railiance-apps`, + `railiance-cluster`, `railiance-fabric`, `repo-scoping`, and `state-hub`. +- `registry rescan-status --review-only --json` shows 0 review-required repos. + +Projection recommendation: + +- Keep this runtime topology as ingested discovery state for review. +- Do not project candidate-only topology into accepted graph snapshots until we + have reviewed the server/domain/port inventory in the graph explorer or a + focused report. + +## Close Criteria + +- Scanner tests validate server, network port, and domain candidates. +- Runtime topology candidates are schema-valid discovery output. +- Broad ingest-only rescan reports no scanner errors. +- State Hub records the outcome and the next projection recommendation.