From 1874e0138cef3a1cae2b4ccd3d925d5ac7aad2cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:03:48 +0000 Subject: [PATCH 1/3] fix(seam): make the SDK chassis the default and wire CEG -> Gate -> EIE egress Forensic seam audit repairs (CEG side of EIE <-> Gate <-> CEG): - engine/spec.yaml advertises only the CEG-owned, implemented, Gate-routable actions (match, sync, outcomes, resolve). The 23 unimplemented `graph-*` names are gone and `enrich` is never advertised: Gate owns that action name for EIE, and a CEG replica advertising it would collide on the same route. - chassis/entrypoint.py defaults to the SDK TransportPacket chassis; the legacy dict chassis (api-key auth, no Gate provenance) is a direct-ingress side door and is refused outside dev/local/test. require_sdk_chassis_in_prod now defaults to True. - engine/gate_egress.py is the only CEG -> peer egress: request_enrichment() asks Gate to run EIE's `enrich` with an EIE-shaped payload, one attempt, idempotency-keyed, fail closed when GATE_URL is unset. The ROI health trigger now actually dispatches through it instead of returning a payload it never sent. - Gate_SDK pinned to main a0827f2 (pyproject, requirements, poetry.lock via `poetry lock`, validate_sdk_pin); test fixtures updated for the pinned SDK. - docker-compose (dev + prod), Makefile, .env.template and the OpenAPI note select the SDK chassis, carry GATE_URL / registration / signing config, and drop `enrich` from L9_ALLOWED_ACTIONS. - tests/architecture/test_seam_gate_only.py locks all of the above. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0179fXWHFZQcA47NMj11345X --- .env.template | 13 +- Makefile | 7 +- chassis/entrypoint.py | 24 ++- docker-compose.prod.yml | 21 +++ docker-compose.yml | 15 +- docs/contracts/api/openapi.yaml | 4 +- engine/config/settings.py | 5 +- engine/gate_egress.py | 148 +++++++++++++++++ engine/health/enrichment_trigger.py | 30 +++- engine/spec.yaml | 36 ++-- poetry.lock | 22 +-- pyproject.toml | 2 +- requirements.txt | 2 +- scripts/validate_sdk_pin.py | 2 +- tests/architecture/__init__.py | 0 tests/architecture/test_seam_gate_only.py | 191 ++++++++++++++++++++++ tests/contracts/test_chassis_parity.py | 40 ++++- tests/unit/test_gate_egress.py | 126 ++++++++++++++ tests/unit/test_node_app.py | 25 +-- 19 files changed, 640 insertions(+), 73 deletions(-) create mode 100644 engine/gate_egress.py create mode 100644 tests/architecture/__init__.py create mode 100644 tests/architecture/test_seam_gate_only.py create mode 100644 tests/unit/test_gate_egress.py diff --git a/.env.template b/.env.template index 3760007d..36b7cfd8 100644 --- a/.env.template +++ b/.env.template @@ -65,7 +65,7 @@ KGE_EMBEDDING_DIM=300 ── Gate SDK Configuration ────────────────────────────────── # Required for Gate self-registration and inter-node routing -GATE_URL=http://gate:8000 +GATE_URL=http://gate:9000 GATE_ADMIN_TOKEN=your-gate-admin-token-here L9_NODE_NAME=graph L9_NODE_SPEC_PATH=engine/spec.yaml @@ -77,8 +77,10 @@ GATE_NODE_SPEC_PATH=engine/spec.yaml # ── SDK chassis (L9_CHASSIS=sdk) ───────────────────────────── # Selects the app factory behind chassis.entrypoint:create_app. -# legacy = chassis/chassis_app.py (default), sdk = chassis/node_app.py -L9_CHASSIS=legacy +# sdk = chassis/node_app.py (default; TransportPacket + Gate-only ingress) +# legacy = chassis/chassis_app.py (dict ExecuteRequest; dev/test only — startup +# is refused outside L9_ENV=dev while REQUIRE_SDK_CHASSIS_IN_PROD=true) +L9_CHASSIS=sdk # NodeRuntimeConfig is frozen with extra="forbid" and lru_cached: an # unrecognised L9_* name is a startup error, not a silent default. @@ -104,9 +106,10 @@ L9_SIGNING_KEY_ID=graph-engine-v1 # ed25519 alternative: L9_SIGNING_ALGORITHM=ed25519 + L9_SIGNING_PRIVATE_KEY # L9_VERIFYING_KEYS_JSON={"gate-v1":""} -# Allowed actions — keep in sync with engine.handlers.ACTION_HANDLERS. +# Allowed actions — engine.handlers.ACTION_HANDLERS minus `enrich`: Gate owns +# the `enrich` name for EIE, so CEG must never accept or advertise it. # `make local-api-sdk` generates this from ACTION_HANDLERS so it cannot drift. -L9_ALLOWED_ACTIONS=match,sync,admin,outcomes,resolve,health,healthcheck,enrich +L9_ALLOWED_ACTIONS=match,sync,admin,outcomes,resolve,health,healthcheck # Attachments are unused by this engine. A non-zero max requires # L9_ATTACHMENT_ALLOWED_SCHEMES to be set or preflight fails. diff --git a/Makefile b/Makefile index d3c61b2a..404d7dab 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,10 @@ redis-shell:Redis CLI local-dbs:Start only Neo4j + Redis docker compose up -d neo4j redis -local-api:Run API locally against Dockerized DBs +local-api:Run API locally against Dockerized DBs (SDK chassis; alias of local-api-sdk) + $(MAKE) local-api-sdk + +local-api-legacy:Run the legacy dict chassis locally (dev/test only; refused outside L9_ENV=dev) PLASTICOS_NEO4J_URI=bolt://localhost:7687 \ PLASTICOS_NEO4J_PASSWORD=l9-dev-password \ PLASTICOS_REDIS_URL=redis://localhost:6379/0 \ @@ -103,7 +106,7 @@ local-api-sdk:Run API locally on the SDK chassis (L9_CHASSIS=sdk) L9_REQUIRE_SIGNATURE=false \ L9_MAX_ATTACHMENTS=0 \ L9_MAX_ATTACHMENT_SIZE_BYTES=0 \ - L9_ALLOWED_ACTIONS="$$(python3 -c 'from engine.handlers import ACTION_HANDLERS; print(",".join(ACTION_HANDLERS))')" \ + L9_ALLOWED_ACTIONS="$$(python3 -c 'from engine.handlers import ACTION_HANDLERS; print(",".join(a for a in ACTION_HANDLERS if a != "enrich"))')" \ uvicorn chassis.entrypoint:create_app --factory --reload --port 8000 # ── Production ───────────────────────────────────────────── diff --git a/chassis/entrypoint.py b/chassis/entrypoint.py index 7d57b289..ea4817cc 100644 --- a/chassis/entrypoint.py +++ b/chassis/entrypoint.py @@ -11,12 +11,17 @@ chassis/entrypoint.py Single uvicorn target for both chassis implementations. - L9_CHASSIS=legacy (default) -> chassis.chassis_app.create_app - L9_CHASSIS=sdk -> chassis.node_app.create_app + L9_CHASSIS=sdk (default) -> chassis.node_app.create_app + L9_CHASSIS=legacy -> chassis.chassis_app.create_app (dev/test only) Every launch site (scripts/entrypoint.sh, Dockerfile.prod, Makefile) points at chassis.entrypoint:create_app so switching chassis is a config change, not a command change. + +Seam audit 2026-09-02: the legacy chassis accepts a dict ExecuteRequest with +api-key auth and no Gate provenance — a direct-ingress side door around Gate. +It is therefore refused at startup outside dev/local/test environments while +``settings.require_sdk_chassis_in_prod`` is on (the default). """ from __future__ import annotations @@ -32,20 +37,26 @@ LEGACY = "legacy" SDK = "sdk" +DEFAULT_CHASSIS = SDK _VALID = (LEGACY, SDK) +# Environments in which the legacy dict chassis may still be selected explicitly. +LEGACY_PERMITTED_ENVS: frozenset[str] = frozenset({"dev", "local", "test"}) def resolve_chassis() -> str: """Return the selected chassis name, validating L9_CHASSIS.""" - selected = os.environ.get("L9_CHASSIS", LEGACY).strip().lower() + selected = os.environ.get("L9_CHASSIS", DEFAULT_CHASSIS).strip().lower() if selected not in _VALID: msg = f"L9_CHASSIS must be one of {_VALID}, got {selected!r}" raise ValueError(msg) from engine.config.settings import settings - if settings.require_sdk_chassis_in_prod and settings.is_production and selected != SDK: - msg = f"L9_CHASSIS must be {SDK!r} in production (l9_env=prod); got {selected!r}" + if selected != SDK and settings.require_sdk_chassis_in_prod and settings.l9_env not in LEGACY_PERMITTED_ENVS: + msg = ( + f"L9_CHASSIS must be {SDK!r} outside {sorted(LEGACY_PERMITTED_ENVS)} " + f"(l9_env={settings.l9_env!r}, production={settings.is_production}); got {selected!r}" + ) raise ValueError(msg) return selected @@ -60,9 +71,10 @@ def create_app() -> FastAPI: return build_sdk_app() + logger.warning("Legacy dict chassis selected: direct /v1/execute ingress without Gate provenance (dev/test only)") from chassis.chassis_app import create_app as build_legacy_app return build_legacy_app() -__all__ = ["LEGACY", "SDK", "create_app", "resolve_chassis"] +__all__ = ["DEFAULT_CHASSIS", "LEGACY", "LEGACY_PERMITTED_ENVS", "SDK", "create_app", "resolve_chassis"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 12042217..eba9566b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -25,6 +25,27 @@ services: environment: L9_ENV: prod L9_LIFECYCLE_HOOK: engine.boot:GraphLifecycle + # Seam audit 2026-09-02: SDK chassis is mandatory in prod (legacy chassis + # is refused at startup). Gate is the only peer transport. + L9_CHASSIS: sdk + L9_ENVIRONMENT: production + L9_SERVICE_NAME: graph-engine + L9_SERVICE_VERSION: 1.1.0 + HOST: 0.0.0.0 + GATE_URL: ${GATE_URL:?GATE_URL must be set for production} + GATE_ADMIN_TOKEN: ${GATE_ADMIN_TOKEN:?GATE_ADMIN_TOKEN must be set for production} + GATE_REGISTRATION_ENABLED: "true" + L9_NODE_NAME: graph + L9_NODE_SPEC_PATH: engine/spec.yaml + L9_ENFORCE_GATE_ONLY_INGRESS: "true" + L9_GATE_NODE_NAME: gate + L9_REQUIRE_SIGNATURE: "true" + L9_SIGNING_ALGORITHM: hmac-sha256 + L9_SIGNING_KEY: ${L9_SIGNING_KEY:?L9_SIGNING_KEY must be set for production} + L9_SIGNING_KEY_ID: ${L9_SIGNING_KEY_ID:-graph-engine-v1} + L9_ALLOWED_ACTIONS: match,sync,admin,outcomes,resolve,health,healthcheck + L9_MAX_ATTACHMENTS: "0" + L9_MAX_ATTACHMENT_SIZE_BYTES: "0" NEO4J_URI: bolt://neo4j:7687 NEO4J_USERNAME: ${NEO4J_USERNAME:-neo4j} NEO4J_PASSWORD: ${NEO4J_PASSWORD:?NEO4J_PASSWORD must be set for production} diff --git a/docker-compose.yml b/docker-compose.yml index bfea85f2..8c38dd9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,9 +29,15 @@ services: # Engine lifecycle hook — wires Graph engine to chassis L9_LIFECYCLE_HOOK: engine.boot:GraphLifecycle # Chassis selection (chassis.entrypoint:create_app) - L9_CHASSIS: legacy - # SDK chassis config — inert while L9_CHASSIS=legacy. NodeRuntimeConfig - # is frozen with extra="forbid", so names must match the SDK exactly. + L9_CHASSIS: sdk + # SDK chassis config. NodeRuntimeConfig is frozen with extra="forbid", + # so names must match the SDK exactly. + # Gate is the only peer transport: registration + all egress go to GATE_URL. + GATE_URL: ${GATE_URL:-http://gate:9000} + GATE_ADMIN_TOKEN: ${GATE_ADMIN_TOKEN:-dev-gate-admin-token-not-for-production} + GATE_REGISTRATION_ENABLED: "true" + L9_NODE_NAME: graph + L9_NODE_SPEC_PATH: engine/spec.yaml L9_ENVIRONMENT: local L9_SERVICE_NAME: graph-engine L9_SERVICE_VERSION: 1.1.0 @@ -42,7 +48,8 @@ services: L9_SIGNING_ALGORITHM: hmac-sha256 L9_SIGNING_KEY: dev-shared-hmac-secret-not-for-production L9_SIGNING_KEY_ID: graph-engine-v1 - L9_ALLOWED_ACTIONS: match,sync,admin,outcomes,resolve,health,healthcheck,enrich + # `enrich` deliberately absent: Gate owns that action name for EIE. + L9_ALLOWED_ACTIONS: match,sync,admin,outcomes,resolve,health,healthcheck L9_MAX_ATTACHMENTS: "0" # Required: SDK defaults (10MB attachment vs 256KB packet) are mutually invalid. L9_MAX_ATTACHMENT_SIZE_BYTES: "0" diff --git a/docs/contracts/api/openapi.yaml b/docs/contracts/api/openapi.yaml index a683268a..11a06b59 100644 --- a/docs/contracts/api/openapi.yaml +++ b/docs/contracts/api/openapi.yaml @@ -20,11 +20,11 @@ info: `L9_CHASSIS`; both paths route the same `engine.handlers.ACTION_HANDLERS` (CONTRACT-02). The request/response envelope differs: - L9_CHASSIS=legacy (default) — chassis/chassis_app.py. The ExecuteRequest / + L9_CHASSIS=legacy (dev/test only; refused outside L9_ENV=dev) — chassis/chassis_app.py. The ExecuteRequest / ExecuteResponse schemas below. Auth: `x-api-key`. `/v1/health` returns 200 healthy / 503 degraded. - L9_CHASSIS=sdk — chassis/node_app.py, built by constellation-node-sdk + L9_CHASSIS=sdk (default) — chassis/node_app.py, built by constellation-node-sdk `create_node_app()`. The body is a `TransportPacket`, not ExecuteRequest: `action` and `payload` live under the packet, tenant arrives as `tenant.org_id` (== CEG domain_id), and the response is a TransportPacket diff --git a/engine/config/settings.py b/engine/config/settings.py index 8d599cbe..411b33aa 100644 --- a/engine/config/settings.py +++ b/engine/config/settings.py @@ -148,7 +148,10 @@ class Settings(BaseSettings): strict_tenant_database: bool = ( False # W7-01: require explicit database= on GraphDriver calls; no implicit 'neo4j' fallback ) - require_sdk_chassis_in_prod: bool = False # W7-02: fail startup if L9_CHASSIS != sdk when l9_env == prod + # W7-02 / seam audit 2026-09-02: the legacy chassis (dict ExecuteRequest, no Gate + # provenance, api-key auth) is a direct-ingress side door. Default ON: startup + # fails unless L9_CHASSIS=sdk outside dev/local/test. + require_sdk_chassis_in_prod: bool = True @model_validator(mode="after") def _validate_production_secrets(self) -> "Settings": diff --git a/engine/gate_egress.py b/engine/gate_egress.py new file mode 100644 index 00000000..da0536a4 --- /dev/null +++ b/engine/gate_egress.py @@ -0,0 +1,148 @@ +""" +--- L9_META --- +l9_schema: 1 +origin: engine-specific +engine: graph +layer: [integration] +tags: [gate, transport, outbound, sdk, enrichment] +owner: engine-team +status: active +--- /L9_META --- + +engine/gate_egress.py — the only CEG -> peer egress: CEG -> Gate -> EIE. + +CEG never addresses the enrichment node. It asks Gate to run the `enrich` +action (owned by Enrichment.Inference.Engine in Gate's ownership map) and +receives Gate's response packet. The SDK owns packet construction, signing, +the single HTTP attempt, and the deadline derived from ``timeout_ms``. + +Fail-closed rules (seam audit 2026-09-02): + * no GATE_URL -> ``gate_not_configured``; there is no direct fallback; + * one attempt per call; retry is the caller's decision and requires the + idempotency key returned in the result; + * every SDK error is reported as a typed failure, never swallowed as success. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +from collections.abc import Sequence +from typing import Any + +from constellation_node_sdk import GateClientError + +from engine.gate_client import get_gate_client + +logger = logging.getLogger(__name__) + +ENRICH_ACTION = "enrich" +DEFAULT_ENRICH_TIMEOUT_MS = 25_000 +_SEAM_TAGS: tuple[str, ...] = ("INTER_NODE",) + + +def build_enrichment_request( + *, + entity_id: str, + domain: str, + target_fields: Sequence[str], + entity: dict[str, Any] | None = None, + objective: str | None = None, +) -> dict[str, Any]: + """Shape the payload EIE's `enrich` handler validates (EIE ``EnrichRequest``). + + Keys: ``entity`` (record fields), ``object_type`` (source object name), + ``schema`` ({field: type}), ``objective`` (natural-language instruction), + ``kb_context`` (KB profile selector). EIE owns that model; CEG adapts to it. + """ + fields = [f for f in dict.fromkeys(target_fields) if f] + if not entity_id or not domain: + msg = "entity_id and domain are required for an enrichment request" + raise ValueError(msg) + if not fields: + msg = "at least one target field is required for an enrichment request" + raise ValueError(msg) + record = {"entity_id": entity_id, "domain": domain, **(entity or {})} + return { + "entity": record, + "object_type": domain, + "schema": dict.fromkeys(fields, "string"), + "objective": objective + or ( + f"Fill {len(fields)} gate-critical field(s) for entity {entity_id} in domain {domain}: {', '.join(fields)}" + ), + "kb_context": domain, + } + + +def enrichment_idempotency_key(tenant: str, entity_id: str, target_fields: Sequence[str]) -> str: + digest = hashlib.sha256("|".join([tenant, entity_id, *sorted(set(target_fields))]).encode("utf-8")).hexdigest() + return f"ceg:enrich:{tenant}:{entity_id}:{digest[:16]}" + + +async def request_enrichment( + *, + tenant: str, + entity_id: str, + domain: str, + target_fields: Sequence[str], + entity: dict[str, Any] | None = None, + objective: str | None = None, + timeout_ms: int = DEFAULT_ENRICH_TIMEOUT_MS, + correlation_id: str | None = None, +) -> dict[str, Any]: + """Ask Gate to run EIE's `enrich` for one entity. One attempt, fail closed.""" + if not os.environ.get("GATE_URL", "").strip(): + logger.warning("gate_egress: GATE_URL unset — enrichment request for %s not sent", entity_id) + return {"status": "failed", "error": "gate_not_configured", "action": ENRICH_ACTION} + + payload = build_enrichment_request( + entity_id=entity_id, + domain=domain, + target_fields=target_fields, + entity=entity, + objective=objective, + ) + key = enrichment_idempotency_key(tenant, entity_id, target_fields) + + try: + client = get_gate_client() + response = await client.execute( + action=ENRICH_ACTION, + payload=payload, + tenant=tenant, + idempotency_key=key, + timeout_ms=timeout_ms, + correlation_id=correlation_id, + compliance_tags=_SEAM_TAGS, + ) + except GateClientError as exc: + logger.warning("gate_egress: %s for entity=%s tenant=%s: %s", type(exc).__name__, entity_id, tenant, exc) + return { + "status": "failed", + "error": type(exc).__name__, + "detail": str(exc), + "action": ENRICH_ACTION, + "idempotency_key": key, + } + + failed = response.header.packet_type == "failure" + return { + "status": "failed" if failed else "ok", + "action": ENRICH_ACTION, + "idempotency_key": key, + "packet_id": str(response.header.packet_id), + "packet_type": response.header.packet_type, + "correlation_id": str(response.header.correlation_id) if response.header.correlation_id else None, + "payload": dict(response.payload), + } + + +__all__ = [ + "DEFAULT_ENRICH_TIMEOUT_MS", + "ENRICH_ACTION", + "build_enrichment_request", + "enrichment_idempotency_key", + "request_enrichment", +] diff --git a/engine/health/enrichment_trigger.py b/engine/health/enrichment_trigger.py index 6aa12a64..5c7537ab 100644 --- a/engine/health/enrichment_trigger.py +++ b/engine/health/enrichment_trigger.py @@ -18,6 +18,7 @@ from typing import Any from engine.config.schema import DomainSpec +from engine.gate_egress import request_enrichment from engine.health.field_health import EnrichmentPriority, EntityHealth, MatchQualityDelta logger = logging.getLogger(__name__) @@ -205,9 +206,11 @@ async def trigger_reenrichment_v2( tenant: str, historical_outcomes: list[Any] | None = None, ) -> dict[str, Any]: - """Trigger re-enrichment via PacketEnvelope if ROI justifies it. + """Trigger re-enrichment through Gate (CEG -> Gate -> EIE `enrich`) if ROI justifies it. - Returns the enrichment decision and packet metadata. + Returns the enrichment decision plus the Gate dispatch result. The request + is sent by engine.gate_egress.request_enrichment — one attempt, fail closed; + ``triggered`` is True only when Gate returned a non-failure packet. """ priority = compute_enrichment_priority(entity_health, domain_spec, historical_outcomes) @@ -219,10 +222,13 @@ async def trigger_reenrichment_v2( } # Build enrichment packet payload + target_fields: list[str] = [t.field_name for t in entity_health.enrichment_targets[:10]] or list( + entity_health.critical_gaps + ) enrichment_payload = { "entity_id": entity_health.entity_id, "domain": entity_health.domain, - "target_fields": [t.field_name for t in entity_health.enrichment_targets[:10]], + "target_fields": target_fields, "priority": priority.recommendation, "estimated_cost_tokens": priority.estimated_cost_tokens, "roi": priority.roi, @@ -236,9 +242,25 @@ async def trigger_reenrichment_v2( priority.roi, ) + dispatch = await request_enrichment( + tenant=tenant, + entity_id=entity_health.entity_id, + domain=entity_health.domain, + target_fields=target_fields, + ) + triggered = dispatch.get("status") == "ok" + if not triggered: + logger.warning( + "Re-enrichment for entity=%s was NOT dispatched: %s", + entity_health.entity_id, + dispatch.get("error", "unknown"), + ) + return { - "triggered": True, + "triggered": triggered, + "reason": None if triggered else dispatch.get("error", "dispatch_failed"), "recommendation": priority.recommendation, "priority": priority.model_dump(), "enrichment_payload": enrichment_payload, + "dispatch": dispatch, } diff --git a/engine/spec.yaml b/engine/spec.yaml index 923140f9..d58d5897 100644 --- a/engine/spec.yaml +++ b/engine/spec.yaml @@ -22,27 +22,17 @@ node: max_concurrent: 100 timeout_ms: 30000 + # Advertised actions = the CEG-owned subset of engine.handlers.ACTION_HANDLERS + # that Gate routes (seam audit 2026-09-02). Rules, enforced by + # tests/architecture/test_seam_gate_only.py: + # * every advertised action has a registered handler; + # * `enrich` is NEVER advertised — Gate owns that name for EIE's + # enrichment operation; CEG's local Cypher-property `enrich` is reached + # only through Gate-authored packets that Gate will not route here; + # * `admin`/`health`/`healthcheck` are operator/probe surfaces, not + # collaboration routes, so they are not advertised either. actions: - - graph-query - - graph-traverse - - graph-expand - - graph-neighborhood - - graph-similarity - - graph-path - - graph-match - - graph-score - - graph-rank - - graph-converge - - graph-arbitrate - - graph-infer - - graph-resolve - - graph-enrich - - graph-intake - - graph-upsert - - graph-delete - - graph-feedback - - graph-decay - - graph-reinforce - - graph-health - - graph-audit - - graph-explain + - match + - sync + - outcomes + - resolve diff --git a/poetry.lock b/poetry.lock index fdbf5a1e..24f0d1f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "annotated-doc" @@ -462,7 +462,7 @@ markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win [[package]] name = "constellation-node-sdk" version = "1.0.1" -description = "Canonical TransportPacket SDK for Constellation worker and orchestrator nodes" +description = "L9 Constellation Node SDK — TransportPacket runtime, Gate client, and node factory" optional = false python-versions = ">=3.12" groups = ["main"] @@ -470,23 +470,23 @@ files = [] develop = false [package.dependencies] -cryptography = ">=42.0.0" +cryptography = ">=43.0.0" fastapi = ">=0.115.0" httpx = ">=0.27.0" -prometheus-client = ">=0.20.0" -pydantic = ">=2.8.0" -python-json-logger = ">=2.0.7" +prometheus-client = ">=0.21.0" +pydantic = ">=2.9.0" +python-json-logger = ">=3.2.0" pyyaml = ">=6.0.2" -starlette = ">=0.37.2" +uvicorn = {version = ">=0.32.0", extras = ["standard"]} [package.extras] -dev = ["mypy (>=1.11.0)", "pytest (>=8.3.0)", "pytest-asyncio (>=0.23.8)", "ruff (>=0.6.0)"] +dev = ["build (>=1.2.0)", "mypy (>=1.11.0)", "pytest (>=8.3.0)", "pytest-asyncio (>=0.23.8)", "pytest-cov (>=5.0.0)", "ruff (>=0.6.0)", "types-PyYAML (>=6.0.12)", "types-requests"] [package.source] type = "git" url = "https://github.com/Quantum-L9/Gate_SDK.git" -reference = "a770e8531dc1c59ce01e1dbb0f4162785d9dda89" -resolved_reference = "a770e8531dc1c59ce01e1dbb0f4162785d9dda89" +reference = "a0827f2b94e77a981c6d6be88653e4975cf631ef" +resolved_reference = "a0827f2b94e77a981c6d6be88653e4975cf631ef" [[package]] name = "coverage" @@ -2554,4 +2554,4 @@ dev = ["pytest", "setuptools"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "d82ddb5b9a28225ced1abed6067debd7d4e5a4326b3474b47bc597cad7b41eb7" +content-hash = "4cabf0d5f070e0c3b8fcc127c8af653fb0a8d44d5cd1c0b7d7d231f723e6857e" diff --git a/pyproject.toml b/pyproject.toml index 9cfcd682..e25ee5d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ prometheus-client = ">=0.24.1,<0.27.0" numpy = "^2.4.3" openai = "^1.0.0" asyncpg = "^0.31.0" -constellation-node-sdk = {git = "https://github.com/Quantum-L9/Gate_SDK.git", rev = "a770e8531dc1c59ce01e1dbb0f4162785d9dda89"} +constellation-node-sdk = {git = "https://github.com/Quantum-L9/Gate_SDK.git", rev = "a0827f2b94e77a981c6d6be88653e4975cf631ef"} [tool.poetry.group.dev.dependencies] pytest = "9.0.3" diff --git a/requirements.txt b/requirements.txt index c648bafe..3fed27ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ prometheus-client>=0.24.1,<1.0.0 numpy>=2.4.3,<3.0.0 openai>=1.0.0,<2.0.0 asyncpg>=0.31.0,<1.0.0 -constellation-node-sdk @ git+https://github.com/Quantum-L9/Gate_SDK.git@a770e8531dc1c59ce01e1dbb0f4162785d9dda89 +constellation-node-sdk @ git+https://github.com/Quantum-L9/Gate_SDK.git@a0827f2b94e77a981c6d6be88653e4975cf631ef diff --git a/scripts/validate_sdk_pin.py b/scripts/validate_sdk_pin.py index 516ce994..9bfe61a1 100644 --- a/scripts/validate_sdk_pin.py +++ b/scripts/validate_sdk_pin.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -PIN = "a770e8531dc1c59ce01e1dbb0f4162785d9dda89" +PIN = "a0827f2b94e77a981c6d6be88653e4975cf631ef" errors: list[str] = [] for rel in ["pyproject.toml", "requirements.txt", "poetry.lock"]: diff --git a/tests/architecture/__init__.py b/tests/architecture/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/architecture/test_seam_gate_only.py b/tests/architecture/test_seam_gate_only.py new file mode 100644 index 00000000..d61ccd6d --- /dev/null +++ b/tests/architecture/test_seam_gate_only.py @@ -0,0 +1,191 @@ +"""Seam architecture guards (forensic audit 2026-09-02): CEG <-> Gate <-> EIE. + +Locks the CEG side of the bidirectional seam: + * the node advertises only CEG-owned, implemented actions (never `enrich`); + * the SDK TransportPacket chassis is the default and legacy is dev/test only; + * every outbound packet CEG authors is addressed to Gate; + * no peer URL awareness and no raw HTTP transport to a peer outside the SDK; + * the reverse path (CEG -> Gate -> EIE `enrich`) is wired, not dormant. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +ENGINE = ROOT / "engine" +CHASSIS = ROOT / "chassis" + +# `enrich` is owned by EIE in Gate's action-ownership map; CEG's local handler of +# the same name must never be advertised or accepted from Gate. +_NOT_ADVERTISED = {"enrich", "admin", "health", "healthcheck"} +_PEER_ENV_NAMES = ("EIE_URL", "EIE_BASE_URL", "ENRICHMENT_URL", "ENRICHMENT_ENGINE_URL", "PEER_URL", "NODE_URL") + + +def _spec() -> dict: + return yaml.safe_load((ENGINE / "spec.yaml").read_text(encoding="utf-8")) + + +def _production_sources() -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for base in (ENGINE, CHASSIS): + for path in sorted(base.rglob("*.py")): + out.append((path.relative_to(ROOT).as_posix(), path.read_text(encoding="utf-8"))) + return out + + +def test_spec_node_identity_is_graph(): + node = _spec()["node"] + assert node["id"] == "graph", "Gate's node registry and ownership aliases key CEG under node id 'graph'" + assert node["health_endpoint"] == "/v1/health" + + +def test_spec_advertises_only_implemented_ceg_owned_actions(): + from engine.handlers import ACTION_HANDLERS + + advertised = list(_spec()["node"]["actions"]) + assert advertised, "spec.yaml must advertise at least one action" + assert len(set(advertised)) == len(advertised), "duplicate advertised actions" + unimplemented = sorted(set(advertised) - set(ACTION_HANDLERS)) + assert unimplemented == [], f"spec.yaml advertises actions with no handler: {unimplemented}" + forbidden = sorted(set(advertised) & _NOT_ADVERTISED) + assert forbidden == [], f"spec.yaml must not advertise {forbidden} (EIE-owned name or operator surface)" + assert {"match", "sync", "outcomes"} <= set(advertised), "the EIE->CEG seam actions must be advertised" + + +def test_no_phantom_graph_prefixed_actions(): + """The 23 `graph-*` names were never implemented and made routing readiness a lie.""" + advertised = _spec()["node"]["actions"] + assert not any(a.startswith("graph-") for a in advertised) + + +@pytest.mark.parametrize("path", ["docker-compose.yml", "docker-compose.prod.yml", ".env.template"]) +def test_allowed_actions_never_include_enrich(path: str): + text = (ROOT / path).read_text(encoding="utf-8") + for match in re.finditer(r"L9_ALLOWED_ACTIONS[:=]\s*\"?([^\"\n]+)", text): + actions = {a.strip() for a in match.group(1).split(",")} + assert "enrich" not in actions, f"{path}: CEG must not accept `enrich` (Gate owns it for EIE)" + + +def test_sdk_chassis_is_default_and_legacy_is_dev_only(): + from chassis.entrypoint import DEFAULT_CHASSIS, LEGACY_PERMITTED_ENVS, SDK + from engine.config.settings import Settings + + assert DEFAULT_CHASSIS == SDK + assert "prod" not in LEGACY_PERMITTED_ENVS + assert "staging" not in LEGACY_PERMITTED_ENVS + assert Settings(l9_env="dev").require_sdk_chassis_in_prod is True + + +@pytest.mark.parametrize("path", ["docker-compose.yml", "docker-compose.prod.yml", ".env.template"]) +def test_deployment_surfaces_select_sdk_chassis(path: str): + text = (ROOT / path).read_text(encoding="utf-8") + assert re.search(r"L9_CHASSIS[:=]\s*sdk\b", text), f"{path} must select the SDK chassis" + assert not re.search(r"L9_CHASSIS[:=]\s*legacy\b", text), f"{path} selects the legacy side-door chassis" + + +def test_production_compose_requires_gate_and_signing(): + text = (ROOT / "docker-compose.prod.yml").read_text(encoding="utf-8") + for name in ("GATE_URL", "GATE_ADMIN_TOKEN", "L9_SIGNING_KEY"): + assert re.search(rf"{name}: \$\{{{name}:\?", text), f"{name} must be required (no default) in prod compose" + assert 'L9_REQUIRE_SIGNATURE: "true"' in text + assert 'L9_ENFORCE_GATE_ONLY_INGRESS: "true"' in text + + +def _kw(call: ast.Call, name: str) -> ast.expr | None: + return next((kw.value for kw in call.keywords if kw.arg == name), None) + + +def _is_gate(expr: ast.expr | None, *, param_defaults: dict[str, ast.expr]) -> bool: + if isinstance(expr, ast.Constant): + return expr.value == "gate" + if isinstance(expr, ast.Name): + if expr.id in {"_GATE_NODE", "GATE_NODE"}: + return True + default = param_defaults.get(expr.id) + return default is not None and _is_gate(default, param_defaults={}) + return False + + +def test_every_authored_transport_packet_targets_gate(): + """Every packet CEG authors is addressed to Gate. + + The one legitimate non-Gate destination is a packet addressed to this node + itself (destination_node == "graph"): the audit reconstruction of an inbound + request. A packet CEG addresses to itself cannot be egress, so it is exempt. + """ + offenders: list[str] = [] + for rel, src in _production_sources(): + if "create_transport_packet" not in src: + continue + tree = ast.parse(src) + for fn in ast.walk(tree): + if not isinstance(fn, ast.FunctionDef | ast.AsyncFunctionDef): + continue + args = fn.args + defaults = dict(zip([a.arg for a in args.args][-len(args.defaults) :], args.defaults, strict=False)) + defaults.update( + {a.arg: d for a, d in zip(args.kwonlyargs, args.kw_defaults, strict=False) if d is not None} + ) + for node in ast.walk(fn): + if not isinstance(node, ast.Call): + continue + name = getattr(node.func, "id", None) or getattr(node.func, "attr", None) + if name != "create_transport_packet": + continue + dest = _kw(node, "destination_node") + if _is_gate(dest, param_defaults=defaults): + continue + if isinstance(dest, ast.Constant) and dest.value == "graph": + continue # addressed to this node itself: inbound audit reconstruction, not egress + offenders.append(f"{rel}:{node.lineno}") + assert offenders == [], f"TransportPackets not addressed to Gate: {offenders}" + + +def test_packet_bridge_defaults_to_gate_destination(): + from engine.packet_bridge import build_request_packet + + packet = build_request_packet(action="match", payload={}, tenant="t", trace_id="trace-1") + assert packet.address.destination_node == "gate" + assert packet.address.source_node == "graph" + + +def test_no_peer_url_awareness_in_production_code(): + offenders = [ + f"{rel}: {name}" + for rel, src in _production_sources() + for name in _PEER_ENV_NAMES + if re.search(rf"\b{name}\b", src) + ] + assert offenders == [], f"peer URL awareness found (Gate is the only routing authority): {offenders}" + + +def test_no_raw_http_transport_to_execute_outside_sdk(): + offenders = [ + rel + for rel, src in _production_sources() + if re.search(r"httpx\.(Async)?Client|requests\.(post|get|Session)|aiohttp\.ClientSession", src) + and re.search(r"/v1/(execute|admin/register)", src) + ] + assert offenders == [], f"raw HTTP transport to a Gate/peer endpoint outside the SDK: {offenders}" + + +def test_reverse_path_is_wired_through_gate_egress(): + """CEG -> Gate -> EIE: the health trigger must actually send via engine.gate_egress.""" + src = (ENGINE / "health" / "enrichment_trigger.py").read_text(encoding="utf-8") + assert "from engine.gate_egress import request_enrichment" in src + assert re.search(r"await request_enrichment\(", src) + egress = (ENGINE / "gate_egress.py").read_text(encoding="utf-8") + assert "get_gate_client()" in egress + assert 'ENRICH_ACTION = "enrich"' in egress + assert "GateClientError" in egress, "SDK errors must be reported, not swallowed" + + +def test_gate_client_singleton_is_the_only_gateclient_constructor(): + sites = sorted(rel for rel, src in _production_sources() if re.search(r"\bGateClient\s*\(", src)) + assert sites == ["engine/gate_client.py"], f"GateClient constructed outside the singleton: {sites}" diff --git a/tests/contracts/test_chassis_parity.py b/tests/contracts/test_chassis_parity.py index fb78524f..55131006 100644 --- a/tests/contracts/test_chassis_parity.py +++ b/tests/contracts/test_chassis_parity.py @@ -94,10 +94,48 @@ def test_entrypoint_rejects_legacy_chassis_in_production(monkeypatch: pytest.Mon require_sdk_chassis_in_prod=True, ) with patch("engine.config.settings.settings", prod): - with pytest.raises(ValueError, match="production"): + with pytest.raises(ValueError, match="must be 'sdk'"): resolve_chassis() +def test_entrypoint_defaults_to_sdk_chassis(monkeypatch: pytest.MonkeyPatch) -> None: + """Seam audit: with L9_CHASSIS unset the SDK (TransportPacket) chassis is selected.""" + from chassis.entrypoint import DEFAULT_CHASSIS, SDK, resolve_chassis + + monkeypatch.delenv("L9_CHASSIS", raising=False) + assert DEFAULT_CHASSIS == SDK + assert resolve_chassis() == SDK + + +def test_require_sdk_chassis_is_on_by_default() -> None: + """Seam audit: the legacy side door is refused unless explicitly re-enabled.""" + from engine.config.settings import Settings + + assert Settings(l9_env="dev").require_sdk_chassis_in_prod is True + + +def test_entrypoint_rejects_legacy_chassis_in_staging(monkeypatch: pytest.MonkeyPatch) -> None: + """Legacy is not a staging convenience either: only dev/local/test may select it.""" + from chassis.entrypoint import resolve_chassis + from engine.config.settings import Settings + + monkeypatch.setenv("L9_CHASSIS", "legacy") + staging = Settings(l9_env="staging", neo4j_password="test-pw", api_secret_key="test-key") + with patch("engine.config.settings.settings", staging): + with pytest.raises(ValueError, match="must be 'sdk'"): + resolve_chassis() + + +def test_entrypoint_allows_legacy_chassis_in_dev(monkeypatch: pytest.MonkeyPatch) -> None: + from chassis.entrypoint import resolve_chassis + from engine.config.settings import Settings + + monkeypatch.setenv("L9_CHASSIS", "legacy") + dev = Settings(l9_env="dev") + with patch("engine.config.settings.settings", dev): + assert resolve_chassis() == "legacy" + + def test_entrypoint_allows_sdk_chassis_in_production(monkeypatch: pytest.MonkeyPatch) -> None: """W7-02: sdk chassis in prod is accepted with the flag enabled.""" from chassis.entrypoint import resolve_chassis diff --git a/tests/unit/test_gate_egress.py b/tests/unit/test_gate_egress.py new file mode 100644 index 00000000..922080c7 --- /dev/null +++ b/tests/unit/test_gate_egress.py @@ -0,0 +1,126 @@ +"""Unit tests for engine/gate_egress.py (CEG -> Gate -> EIE `enrich`).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +pytest.importorskip("constellation_node_sdk", reason="constellation-node-sdk not installed") + +from constellation_node_sdk import GateConnectionError, create_transport_packet + +from engine import gate_egress +from engine.gate_egress import ( + build_enrichment_request, + enrichment_idempotency_key, + request_enrichment, +) + +pytestmark = pytest.mark.unit + + +class _FakeClient: + def __init__(self, *, response: Any = None, error: Exception | None = None) -> None: + self.calls: list[dict[str, Any]] = [] + self._response = response + self._error = error + + async def execute(self, **kwargs: Any) -> Any: + self.calls.append(kwargs) + if self._error is not None: + raise self._error + return self._response + + +def _response_packet(packet_type: str = "response") -> Any: + packet = create_transport_packet( + action="enrich", + payload={"state": "completed", "fields": {"polymer": "HDPE"}}, + tenant="acme", + destination_node="graph", + source_node="gate", + ) + return packet.derive(packet_type=packet_type) + + +def test_build_enrichment_request_matches_eie_enrich_request_shape(): + payload = build_enrichment_request( + entity_id="ent-1", domain="plasticos", target_fields=["polymer", "capacity", "polymer"], entity={"name": "Acme"} + ) + assert set(payload) == {"entity", "object_type", "schema", "objective", "kb_context"} + assert payload["entity"] == {"entity_id": "ent-1", "domain": "plasticos", "name": "Acme"} + assert payload["object_type"] == "plasticos" + assert payload["schema"] == {"polymer": "string", "capacity": "string"} + assert payload["kb_context"] == "plasticos" + assert "ent-1" in payload["objective"] + + +@pytest.mark.parametrize( + "kwargs", + [ + {"entity_id": "", "domain": "d", "target_fields": ["f"]}, + {"entity_id": "e", "domain": "", "target_fields": ["f"]}, + {"entity_id": "e", "domain": "d", "target_fields": []}, + ], +) +def test_build_enrichment_request_rejects_incomplete_input(kwargs: dict[str, Any]): + with pytest.raises(ValueError): + build_enrichment_request(**kwargs) + + +def test_idempotency_key_is_stable_and_order_independent(): + a = enrichment_idempotency_key("acme", "ent-1", ["x", "y"]) + b = enrichment_idempotency_key("acme", "ent-1", ["y", "x"]) + assert a == b + assert a.startswith("ceg:enrich:acme:ent-1:") + assert enrichment_idempotency_key("acme", "ent-2", ["x", "y"]) != a + + +async def test_request_enrichment_fails_closed_without_gate_url(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("GATE_URL", raising=False) + fake = _FakeClient(response=_response_packet()) + monkeypatch.setattr(gate_egress, "get_gate_client", lambda: fake) + result = await request_enrichment(tenant="acme", entity_id="ent-1", domain="plasticos", target_fields=["polymer"]) + assert result == {"status": "failed", "error": "gate_not_configured", "action": "enrich"} + assert fake.calls == [], "no direct fallback: nothing may be sent when Gate is not configured" + + +async def test_request_enrichment_sends_enrich_action_through_gate(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("GATE_URL", "http://gate:9000") + fake = _FakeClient(response=_response_packet()) + monkeypatch.setattr(gate_egress, "get_gate_client", lambda: fake) + + result = await request_enrichment( + tenant="acme", entity_id="ent-1", domain="plasticos", target_fields=["polymer"], timeout_ms=5_000 + ) + + assert len(fake.calls) == 1, "exactly one attempt per call" + call = fake.calls[0] + assert call["action"] == "enrich" + assert call["tenant"] == "acme" + assert call["timeout_ms"] == 5_000 + assert call["idempotency_key"] == enrichment_idempotency_key("acme", "ent-1", ["polymer"]) + assert call["payload"]["object_type"] == "plasticos" + assert result["status"] == "ok" + assert result["payload"]["state"] == "completed" + assert result["idempotency_key"] == call["idempotency_key"] + + +async def test_request_enrichment_reports_sdk_errors_as_failure(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("GATE_URL", "http://gate:9000") + fake = _FakeClient(error=GateConnectionError("gate unreachable")) + monkeypatch.setattr(gate_egress, "get_gate_client", lambda: fake) + result = await request_enrichment(tenant="acme", entity_id="ent-1", domain="plasticos", target_fields=["polymer"]) + assert result["status"] == "failed" + assert result["error"] == "GateConnectionError" + assert len(fake.calls) == 1 + + +async def test_request_enrichment_treats_failure_packet_as_failed(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("GATE_URL", "http://gate:9000") + fake = _FakeClient(response=_response_packet(packet_type="failure")) + monkeypatch.setattr(gate_egress, "get_gate_client", lambda: fake) + result = await request_enrichment(tenant="acme", entity_id="ent-1", domain="plasticos", target_fields=["polymer"]) + assert result["status"] == "failed" + assert result["packet_type"] == "failure" diff --git a/tests/unit/test_node_app.py b/tests/unit/test_node_app.py index b50ebf44..6a48d513 100644 --- a/tests/unit/test_node_app.py +++ b/tests/unit/test_node_app.py @@ -317,19 +317,22 @@ def test_max_attachments_without_schemes_is_rejected() -> None: def test_sdk_default_attachment_caps_construct() -> None: - """Pinned SDK rejects mutually invalid default attachment/packet caps. + """Pinned SDK (a0827f2) ships mutually valid default attachment/packet caps. - constellation-node-sdk@a770e853 still enforces - max_attachment_size_bytes <= max_packet_bytes. Bare construct with SDK - field defaults fails closed; tests must pin compatible caps via _config(). + Earlier pins (a770e853) defaulted max_attachment_size_bytes above + max_packet_bytes and failed closed on a bare construct. The current pin + defaults attachments off (0 / 0), so a bare construct is valid and the + invariant max_attachment_size_bytes <= max_packet_bytes still holds. """ - with pytest.raises(ValidationError, match="max_attachment_size_bytes"): - NodeRuntimeConfig( - environment="test", - node_name=NODE_NAME, - service_name=NODE_NAME, - service_version="1.1.0", - ) + bare = NodeRuntimeConfig( + environment="test", + node_name=NODE_NAME, + service_name=NODE_NAME, + service_version="1.1.0", + ) + assert bare.max_attachments == 0 + assert bare.max_attachment_size_bytes == 0 + assert bare.max_attachment_size_bytes <= bare.max_packet_bytes cfg = _config() assert cfg.max_packet_bytes > 0 assert cfg.max_attachment_size_bytes >= 0 From 38fc3defdc8b4334e4248a55fea2839a1a74d8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 20:23:32 +0000 Subject: [PATCH 2/3] chore(sdk): pin Gate_SDK to 69c6c67 (verifying-keys env fix) The real-process seam E2E showed that with L9_REQUIRE_SIGNATURE=true every env-configured node rejected Gate's signed responses, because the SDK's get_gate_client_config_from_env() never loaded L9_VERIFYING_KEYS_JSON. Gate_SDK 69c6c67 (main a0827f2 + that fix) is the one revision the whole EIE <-> Gate <-> CEG rail now runs; poetry.lock regenerated with `poetry lock`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0179fXWHFZQcA47NMj11345X --- poetry.lock | 6 +++--- pyproject.toml | 2 +- requirements.txt | 2 +- scripts/validate_sdk_pin.py | 2 +- tests/unit/test_node_app.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 24f0d1f5..97a69a17 100644 --- a/poetry.lock +++ b/poetry.lock @@ -485,8 +485,8 @@ dev = ["build (>=1.2.0)", "mypy (>=1.11.0)", "pytest (>=8.3.0)", "pytest-asyncio [package.source] type = "git" url = "https://github.com/Quantum-L9/Gate_SDK.git" -reference = "a0827f2b94e77a981c6d6be88653e4975cf631ef" -resolved_reference = "a0827f2b94e77a981c6d6be88653e4975cf631ef" +reference = "69c6c67060b08440734a61473c03663423709964" +resolved_reference = "69c6c67060b08440734a61473c03663423709964" [[package]] name = "coverage" @@ -2554,4 +2554,4 @@ dev = ["pytest", "setuptools"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "4cabf0d5f070e0c3b8fcc127c8af653fb0a8d44d5cd1c0b7d7d231f723e6857e" +content-hash = "b6eb2d0b3511e96d6a10d26b2747cd6d0fa6d3cadc2f1acf9068bb944be0fb3e" diff --git a/pyproject.toml b/pyproject.toml index e25ee5d6..78ad619b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ prometheus-client = ">=0.24.1,<0.27.0" numpy = "^2.4.3" openai = "^1.0.0" asyncpg = "^0.31.0" -constellation-node-sdk = {git = "https://github.com/Quantum-L9/Gate_SDK.git", rev = "a0827f2b94e77a981c6d6be88653e4975cf631ef"} +constellation-node-sdk = {git = "https://github.com/Quantum-L9/Gate_SDK.git", rev = "69c6c67060b08440734a61473c03663423709964"} [tool.poetry.group.dev.dependencies] pytest = "9.0.3" diff --git a/requirements.txt b/requirements.txt index 3fed27ed..bac97f42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,4 @@ prometheus-client>=0.24.1,<1.0.0 numpy>=2.4.3,<3.0.0 openai>=1.0.0,<2.0.0 asyncpg>=0.31.0,<1.0.0 -constellation-node-sdk @ git+https://github.com/Quantum-L9/Gate_SDK.git@a0827f2b94e77a981c6d6be88653e4975cf631ef +constellation-node-sdk @ git+https://github.com/Quantum-L9/Gate_SDK.git@69c6c67060b08440734a61473c03663423709964 diff --git a/scripts/validate_sdk_pin.py b/scripts/validate_sdk_pin.py index 9bfe61a1..0db0466f 100644 --- a/scripts/validate_sdk_pin.py +++ b/scripts/validate_sdk_pin.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -PIN = "a0827f2b94e77a981c6d6be88653e4975cf631ef" +PIN = "69c6c67060b08440734a61473c03663423709964" errors: list[str] = [] for rel in ["pyproject.toml", "requirements.txt", "poetry.lock"]: diff --git a/tests/unit/test_node_app.py b/tests/unit/test_node_app.py index 6a48d513..d2c02d2b 100644 --- a/tests/unit/test_node_app.py +++ b/tests/unit/test_node_app.py @@ -317,7 +317,7 @@ def test_max_attachments_without_schemes_is_rejected() -> None: def test_sdk_default_attachment_caps_construct() -> None: - """Pinned SDK (a0827f2) ships mutually valid default attachment/packet caps. + """Pinned SDK (69c6c67 = main a0827f2 + verifying-keys env fix) ships mutually valid default attachment/packet caps. Earlier pins (a770e853) defaulted max_attachment_size_bytes above max_packet_bytes and failed closed on a bare construct. The current pin From e4fa20591e9177ec79caeb7204247d7e399ff73e Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Fri, 4 Sep 2026 12:09:56 -0400 Subject: [PATCH 3/3] fix(seam): gate auto-enrich and harden Gate egress identity Opt-in auto_enrich_via_gate, keep routing identity authoritative over entity payload fields, and align legacy/docs/compose with runtime contracts. Remediation-Cycle: Cognitive.Engine.Graphs#259/cycle-1 Co-authored-by: Cursor --- .env.template | 4 ++-- Makefile | 2 +- docker-compose.yml | 7 +++++-- docs/contracts/api/openapi.yaml | 2 +- engine/config/settings.py | 3 +++ engine/gate_egress.py | 11 ++++++++--- engine/health/enrichment_trigger.py | 11 +++++++++++ 7 files changed, 31 insertions(+), 9 deletions(-) diff --git a/.env.template b/.env.template index 36b7cfd8..67f80c57 100644 --- a/.env.template +++ b/.env.template @@ -78,8 +78,8 @@ GATE_NODE_SPEC_PATH=engine/spec.yaml # ── SDK chassis (L9_CHASSIS=sdk) ───────────────────────────── # Selects the app factory behind chassis.entrypoint:create_app. # sdk = chassis/node_app.py (default; TransportPacket + Gate-only ingress) -# legacy = chassis/chassis_app.py (dict ExecuteRequest; dev/test only — startup -# is refused outside L9_ENV=dev while REQUIRE_SDK_CHASSIS_IN_PROD=true) +# legacy = chassis/chassis_app.py (dict ExecuteRequest; permitted only in +# L9_ENV=dev|local|test while REQUIRE_SDK_CHASSIS_IN_PROD=true) L9_CHASSIS=sdk # NodeRuntimeConfig is frozen with extra="forbid" and lru_cached: an diff --git a/Makefile b/Makefile index 404d7dab..06f64a9c 100644 --- a/Makefile +++ b/Makefile @@ -83,7 +83,7 @@ local-dbs:Start only Neo4j + Redis local-api:Run API locally against Dockerized DBs (SDK chassis; alias of local-api-sdk) $(MAKE) local-api-sdk -local-api-legacy:Run the legacy dict chassis locally (dev/test only; refused outside L9_ENV=dev) +local-api-legacy:Run the legacy dict chassis locally (permitted in L9_ENV=dev|local|test) PLASTICOS_NEO4J_URI=bolt://localhost:7687 \ PLASTICOS_NEO4J_PASSWORD=l9-dev-password \ PLASTICOS_REDIS_URL=redis://localhost:6379/0 \ diff --git a/docker-compose.yml b/docker-compose.yml index 8c38dd9e..8f12b4b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,9 @@ services: # SDK chassis config. NodeRuntimeConfig is frozen with extra="forbid", # so names must match the SDK exactly. # Gate is the only peer transport: registration + all egress go to GATE_URL. - GATE_URL: ${GATE_URL:-http://gate:9000} + # This compose file does not start Gate — attach an external Gate on :8080 + # (or override GATE_URL) before make dev / docker compose up api. + GATE_URL: ${GATE_URL:-http://gate:8080} GATE_ADMIN_TOKEN: ${GATE_ADMIN_TOKEN:-dev-gate-admin-token-not-for-production} GATE_REGISTRATION_ENABLED: "true" L9_NODE_NAME: graph @@ -51,7 +53,8 @@ services: # `enrich` deliberately absent: Gate owns that action name for EIE. L9_ALLOWED_ACTIONS: match,sync,admin,outcomes,resolve,health,healthcheck L9_MAX_ATTACHMENTS: "0" - # Required: SDK defaults (10MB attachment vs 256KB packet) are mutually invalid. + # Attachments intentionally disabled (0/0); bare NodeRuntimeConfig is valid with + # these zeros — not a workaround for mutually invalid SDK defaults. L9_MAX_ATTACHMENT_SIZE_BYTES: "0" # Neo4j — uses Docker service name, not localhost NEO4J_URI: bolt://neo4j:7687 diff --git a/docs/contracts/api/openapi.yaml b/docs/contracts/api/openapi.yaml index 11a06b59..e61dacb3 100644 --- a/docs/contracts/api/openapi.yaml +++ b/docs/contracts/api/openapi.yaml @@ -20,7 +20,7 @@ info: `L9_CHASSIS`; both paths route the same `engine.handlers.ACTION_HANDLERS` (CONTRACT-02). The request/response envelope differs: - L9_CHASSIS=legacy (dev/test only; refused outside L9_ENV=dev) — chassis/chassis_app.py. The ExecuteRequest / + L9_CHASSIS=legacy (permitted in L9_ENV=dev|local|test; refused elsewhere when require_sdk_chassis_in_prod) — chassis/chassis_app.py. The ExecuteRequest / ExecuteResponse schemas below. Auth: `x-api-key`. `/v1/health` returns 200 healthy / 503 degraded. diff --git a/engine/config/settings.py b/engine/config/settings.py index 411b33aa..4d401115 100644 --- a/engine/config/settings.py +++ b/engine/config/settings.py @@ -152,6 +152,9 @@ class Settings(BaseSettings): # provenance, api-key auth) is a direct-ingress side door. Default ON: startup # fails unless L9_CHASSIS=sdk outside dev/local/test. require_sdk_chassis_in_prod: bool = True + # Seam audit / PR remediation: paid-tier enrich_now Gate dispatch is opt-in. + # Default off so deploy does not immediately spend EIE budget until enabled. + auto_enrich_via_gate: bool = False @model_validator(mode="after") def _validate_production_secrets(self) -> "Settings": diff --git a/engine/gate_egress.py b/engine/gate_egress.py index da0536a4..7d7cbbc9 100644 --- a/engine/gate_egress.py +++ b/engine/gate_egress.py @@ -63,7 +63,7 @@ def build_enrichment_request( if not fields: msg = "at least one target field is required for an enrichment request" raise ValueError(msg) - record = {"entity_id": entity_id, "domain": domain, **(entity or {})} + record = {**(entity or {}), "entity_id": entity_id, "domain": domain} return { "entity": record, "object_type": domain, @@ -93,9 +93,15 @@ async def request_enrichment( correlation_id: str | None = None, ) -> dict[str, Any]: """Ask Gate to run EIE's `enrich` for one entity. One attempt, fail closed.""" + key = enrichment_idempotency_key(tenant, entity_id, target_fields) if not os.environ.get("GATE_URL", "").strip(): logger.warning("gate_egress: GATE_URL unset — enrichment request for %s not sent", entity_id) - return {"status": "failed", "error": "gate_not_configured", "action": ENRICH_ACTION} + return { + "status": "failed", + "error": "gate_not_configured", + "action": ENRICH_ACTION, + "idempotency_key": key, + } payload = build_enrichment_request( entity_id=entity_id, @@ -104,7 +110,6 @@ async def request_enrichment( entity=entity, objective=objective, ) - key = enrichment_idempotency_key(tenant, entity_id, target_fields) try: client = get_gate_client() diff --git a/engine/health/enrichment_trigger.py b/engine/health/enrichment_trigger.py index 5c7537ab..9c795f26 100644 --- a/engine/health/enrichment_trigger.py +++ b/engine/health/enrichment_trigger.py @@ -18,6 +18,7 @@ from typing import Any from engine.config.schema import DomainSpec +from engine.config.settings import settings from engine.gate_egress import request_enrichment from engine.health.field_health import EnrichmentPriority, EntityHealth, MatchQualityDelta @@ -234,6 +235,16 @@ async def trigger_reenrichment_v2( "roi": priority.roi, } + if not settings.auto_enrich_via_gate: + return { + "triggered": False, + "reason": "auto_enrich_via_gate disabled", + "recommendation": priority.recommendation, + "priority": priority.model_dump(), + "enrichment_payload": enrichment_payload, + "dispatch": {"status": "skipped", "error": "auto_enrich_via_gate_disabled"}, + } + logger.info( "Triggering re-enrichment for entity=%s domain=%s recommendation=%s roi=%.1f", entity_health.entity_id,