Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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; 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
# unrecognised L9_* name is a startup error, not a silent default.
Expand All @@ -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":"<public-key>"}

# 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.
Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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 \
Expand All @@ -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 ─────────────────────────────────────────────
Expand Down
24 changes: 18 additions & 6 deletions chassis/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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"]
21 changes: 21 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
20 changes: 15 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,17 @@ 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.
# 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
L9_NODE_SPEC_PATH: engine/spec.yaml
L9_ENVIRONMENT: local
L9_SERVICE_NAME: graph-engine
L9_SERVICE_VERSION: 1.1.0
Expand All @@ -42,9 +50,11 @@ 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.
# 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
Expand Down
4 changes: 2 additions & 2 deletions docs/contracts/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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.

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
Expand Down
8 changes: 7 additions & 1 deletion engine/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,13 @@ 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
# 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":
Expand Down
153 changes: 153 additions & 0 deletions engine/gate_egress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""
--- 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 or {}), "entity_id": entity_id, "domain": domain}
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."""
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,
"idempotency_key": key,
}

payload = build_enrichment_request(
entity_id=entity_id,
domain=domain,
target_fields=target_fields,
entity=entity,
objective=objective,
)

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",
]
Loading
Loading