Light-Fabric

Light-Fabric is a high-performance, unified platform for managing the lifecycle, governance, and orchestration of enterprise AI services including agentic services, agents, tools, skills, memories, MCP servers, APIs, gateways and workflows.

Why Light-Fabric?

We chose the name Light-Fabric because it embodies the "Unified Governance" required for enterprise-grade AI:

  • Unified Control Plane: Light-Fabric provides a single point of truth for discovering, governing, and auditing agents, MCP servers, and APIs via the light-portal.
  • Enterprise Governance: It prioritizes security and policy enforcement (such as fine-grained authorization) over pure decentralized autonomy, making it safe for corporate environments.
  • Integrated Ecosystem: It "weaves" together distributed components—from memory units (Hindsight) to centralized skills—into a cohesive, observable system.
  • Durable Identity: The name emphasizes the platform's role as the infrastructure foundation, remaining relevant regardless of the underlying implementation details.

Technical Advantages

By building Light-Fabric on a Rust foundation, we achieve:

  • Performance: Built on top of tokio and axum for maximum throughput and memory safety.
  • Native Intelligence: Specialized crates for Hindsight memory, tool calling, and workflow orchestration.
  • Production Ready: Includes robust features like retries, failover, and observability out of the box.

Core Components

The Light-Fabric is composed of modular crates, infrastructure frameworks, and reference applications:

Crates

  • crates/model-provider: A unified interface for multiple LLM providers (Ollama, etc.).
  • crates/hindsight-client: Client for the Hindsight biomimetic memory system.
  • crates/mcp-client: Implementation of the Model Context Protocol (MCP) for tool discovery and execution.
  • crates/portal-registry: Integration with the Light-Portal for service registration and discovery.
  • crates/light-runtime: Core runtime foundation for building agentic and microservice components.
  • crates/light-rule: High-performance rule engine for fine-grained authorization and data filtering.
  • crates/workflow-core & workflow-builder: Core engine and builder for complex agentic workflows.
  • crates/config-loader: Flexible configuration management for enterprise environments.
  • crates/asymmetric-decryptor & symmetric-decryptor: Security utilities for sensitive data handling.

Frameworks

  • frameworks/light-axum: A specialized microservice & agentic framework built on top of the Axum web ecosystem.
  • frameworks/light-pingora: High-performance proxy and gateway framework built on top of Cloudflare's Pingora.

Applications

  • apps/light-agent: A managed AI agent capable of using tools, accessing memory, and executing complex tasks.
  • apps/light-gateway: An enterprise-grade gateway for securing and governing API and agent traffic.
  • apps/light-workflow: A service for orchestrating and executing long-running agentic workflows.

Getting Started with Light-Fabric

This guide will help you set up a local development environment for Light-Fabric, including the AI Gateway, Agent Engine, and the management Portal.

Prerequisites

  • Rust: Latest stable version.
  • Docker: For running database and backend services.
  • Node.js: For running the portal-view UI.
  • Git: To clone the necessary repositories.

Local Development Setup

To run the entire ecosystem locally, we use the portal-config-loc and service-asset repositories to manage configuration and pre-built assets.

1. Initialize Workspace

Create a unified workspace directory (e.g., ~/lightapi) and clone the core management repositories:

cd ~
mkdir -p lightapi
cd lightapi

# Clone configuration and assets
git clone [email protected]:lightapi/portal-config-loc.git
git clone [email protected]:lightapi/service-asset.git

2. Deploy Local Services

Light-Fabric services are orchestrated via Docker Compose scripts in portal-config-loc. The following command starts the PostgreSQL database and the core services (including the Rust-based components):

cd ~/lightapi/portal-config-loc
./scripts/deploy-local.sh pg rust

3. Import Initial Data

Use the importer script in service-asset to populate the local database with initial events, users, and configurations:

cd ~/lightapi/service-asset
./importer.sh -f events.json

4. Update /etc/hosts

The platform uses virtual hosts for local routing. Add the following entry to your /etc/hosts file (replace with your actual local IP if necessary):

127.0.0.1  local.lightapi.net locsignin.lightapi.net

Running the Management Portal

The Light-Portal provides a unified UI for onboarding MCP servers, configuring AI Gateways, and interacting with agents.

cd ~/lightapi
git clone [email protected]:lightapi/portal-view.git
cd portal-view
npm install
npm run dev

Navigate to https://localhost:3000 and log in with your developer credentials.


Cloud Development (Coming Soon)

We are currently preparing a Cloud Development Server. This will allow developers to:

  • Connect to a shared, high-performance AI Gateway.
  • Onboard and test MCP servers without a full local installation.
  • Collaborate on shared agentic workflows and Hindsight memory banks.

Stay tuned for the connection details and onboarding guide for the cloud environment.


Contributing to Light-Fabric

If you are developing for the Rust crates specifically:

cd ~/lightapi
git clone [email protected]:networknt/light-fabric.git
cd light-fabric
cargo build

Model Providers

Light-Fabric provides a unified, high-performance interface for interacting with diverse Large Language Model (LLM) providers. This abstraction is centered around the Provider trait, allowing applications to remain model-agnostic while leveraging advanced capabilities like native tool calling and prompt caching.

The Provider Trait

All model integrations implement the Provider trait, which supports:

  • One-shot and Multi-turn Chat: Simplified APIs for simple prompts and full conversation histories.
  • Structured Tool Calling: Native integration for function calling (OpenAI-style).
  • Capabilities Detection: Programmatic checks for vision, native tool support, and prompt caching.

Supported Cloud Providers

Light-Fabric supports all major LLM providers. Because the Provider trait is model-agnostic, the framework is compatible with the latest flagship releases as soon as they are available.

  • OpenAI: Native support for the GPT-5 series (5.4, mini, nano), the o4 reasoning models, and full legacy support for GPT-4o and GPT-4 Turbo.
  • Anthropic: Support for the Claude 4 generation, including Opus 4.7, Sonnet, and Haiku.
  • Google Gemini: Support for Gemini 3.1 Pro and Flash, leveraging Vertex AI or AI Studio for multi-modal and long-context tasks.
  • Azure OpenAI: Enterprise-grade OpenAI deployments with support for the latest model deployments.
  • AWS Bedrock: Access to the latest Claude and Titan models hosted on Amazon Web Services.
  • OpenRouter: Access to hundreds of open-source and proprietary models via a single unified API.
  • Telnyx: Support for models hosted on the Telnyx platform.
  • GLM (Zhipu AI): Support for the ChatGLM/GLM-5 series of models.

Local & Specialized Providers

  • Ollama: Seamless integration with local models running on your machine.
  • OpenAI-Compatible: A generic CompatibleProvider for any service implementing the OpenAI REST API.
  • GitHub Copilot: Integration with GitHub Copilot Chat for developer-centric workflows.

Meta-Providers (Orchestration)

These providers wrap other providers to add resilient or intelligent behavior:

  • ReliableProvider: Enhances any base provider with retries, exponential backoff, and automatic failover to fallback models.
  • RouterProvider: Dynamically routes requests to different models based on hints or input complexity.

CLI & Tooling Integrations

Light-Fabric includes specialized integrations for developer tools and terminal environments:

  • Claude Code CLI: Integration with Anthropic's Claude Code environment.
  • Gemini CLI: Terminal-based access to Google's Gemini models.
  • KiloCLI: Light-Fabric's native CLI integration for rapid testing and automation.

Key Capabilities

Providers can be queried for their support of advanced features:

  • Native Tool Calling: Efficiently generate structured function calls.
  • Vision: Process images alongside text prompts.
  • Prompt Caching: Leverage provider-side caching to reduce latency and costs for long contexts.

Agentic Workflow Design

Hybrid Agentic Workflow Specification

Agentic Workflow in Light-Fabric implements a hybrid orchestration model for enterprise business processes. The workflow is deterministic, auditable, and stateful, while selected steps can be executed by agents, API calls, rule engine checks, or humans.

The design goal is not to replace enterprise process control with an open-ended agent loop. The goal is to let agents work inside a managed process that has clear state, clear ownership, repeatable execution, and human approval where needed.

Enterprise Challenge

In regulated or operationally sensitive environments, a purely autonomous AI agent is not enough for long-running business work.

  • Compliance requires deterministic process paths, approval records, and audit history.
  • Reliability requires long-running state to survive process restarts, UI disconnects, and agent failures.
  • Safety requires human-in-the-loop checkpoints for decisions with business, security, or financial impact.
  • Coordination requires multiple humans and roles to participate in the same process.
  • Testing requires the same workflow to run interactively with humans or headlessly with example data.

Light-Fabric solves this by separating orchestration from execution.

Hybrid Model

The workflow is the deterministic process manager. It defines the ordered steps, conditions, retries, error handling, human checkpoints, and outputs.

Agents are workers inside that process. They can reason, call tools, ask for missing data, and use skills, but they do not own the overall process state.

FeatureTraditional WorkflowPure Agent LoopLight-Fabric Hybrid
PathFixedDynamicFixed path with flexible task execution
StateDurableOften transientDurable workflow and task state
Human inputForms and approvalsAd hoc chatFirst-class waiting tasks
AuditStrongWeakStep-level audit and agent trace
API callsBuilt into codeTool callsSpec-described endpoint invocations
TestingSeparate test harnessPrompt replaySame workflow can run live tests

Core Separation

There are two related specifications:

  1. Agentic Workflow Specification Describes orchestration: task order, branching, human input, assertions, API calls, retries, errors, exports, and state transitions.

  2. LightAPI Description Specification Describes API capabilities at the endpoint level: how an endpoint is invoked, what inputs it accepts, what result shape it returns, examples, behavior notes, and result expectations.

This separation is important. The workflow should not duplicate every endpoint contract. It should reference endpoint descriptions and use them to invoke calls, guide agents, and verify results.

Endpoint-Level Consumption

Light-Portal manages API descriptions at the endpoint level, not only at the whole API level.

This is necessary because real workflows often combine one endpoint from one API with one endpoint from another API. For example, onboarding an API to an AI gateway may involve:

  1. register an API
  2. create an API version from a specification
  3. create a development API instance
  4. configure the API through config server
  5. link the API instance to a gateway instance
  6. select endpoints to expose as MCP tools
  7. create a gateway config snapshot
  8. reload the gateway through controller
  9. run MCP tests against the gateway

Each step may come from a different API surface. The workflow consumes only the endpoints it needs.

The recommended model is:

  • API-level descriptions can be authored for convenience and consistency.
  • Endpoint-level descriptions are published and consumed by agents and workflows.
  • Endpoint descriptions inherit shared context such as authentication, environments, sources, and secrets from an API catalog.
  • Agents progressively load endpoint information by disclosure level instead of receiving the entire catalog up front.

Progressive Disclosure

Endpoint descriptions should be disclosed to agents in layers:

  • index: operation id, title, tags, visibility
  • summary: purpose, capability group, lifecycle
  • invocation: input shape, request mapping, auth, examples
  • behavior: result cases, errors, edge cases, assertions
  • full: complete description for debugging or generation

This allows the agent to discover capabilities cheaply, load invocation details only for selected endpoints, and load behavior details only when verification or failure analysis needs it.

Workflow Task Types

The updated workflow specification adds first-class support for the task types needed by agentic API workflows.

Ask Task

ask pauses the workflow and waits for human input. It supports prompts, choices, validation, defaults, timeouts, and sensitive input.

The task returns the user's answer as task output. The normal export block should move the answer into workflow context.

Example:

- ask-authz:
    ask:
      prompt: Do you want to configure endpoint authorization?
      mode: choice
      options:
        - label: Configure authorization
          value: configure
        - label: Skip
          value: skip
    export:
      as:
        authzChoice: ${ .result }

Assert Task

assert validates workflow state or API results. It is used for both live tests and interactive workflows.

It supports simple comparisons, JSONPath-style checks, length checks, regex checks, and rule-engine-backed assertions for complex business logic.

Assertion failures should produce structured, catchable errors so workflows can route failures to remediation, task creation, or agent investigation. Complex business assertions can delegate to Light-Rule.

API Call Tasks

The workflow supports direct and description-backed API calls:

  • HTTP / OpenAPI
  • JSON-RPC
  • OpenRPC
  • gRPC
  • MCP tool/resource/prompt calls

For direct internal calls, jsonrpc can be used with an endpoint, method, params, id, notification flag, and error policy.

For cataloged JSON-RPC, openrpc references an OpenRPC document and method.

For MCP, the workflow references a tool, resource, or prompt and passes arguments. MCP capability descriptions belong in the API description layer; the workflow only selects and invokes them.

Explanation Metadata

Tasks can include explain metadata to help an agent or UI explain what is happening.

Useful fields include:

  • purpose
  • visible
  • before
  • success
  • failure
  • requires

Example:

explain:
  purpose: Link the API instance to the development gateway.
  visible: true
  requires:
    - portal-command-token authentication
    - apiInstanceId from prior step

Human Task State

Human-in-the-loop behavior must be represented as durable workflow state.

Recommended task states:

A = active
W = waiting for input
C = completed
F = failed
X = canceled

When an ask or approval task reaches W, the process remains active but the task is no longer picked up by the executor. A user, CLI, scheduler, or agent must complete the task through the workflow API.

Waiting tasks should carry:

  • prompt
  • input mode
  • options
  • validation rules
  • default value
  • sensitive flag
  • assignment metadata
  • explanation metadata
  • timeout policy

Assignment And Worklist

Enterprise workflows need more than chat. Some tasks must be assigned to roles or users and coordinated across multiple humans.

Human tasks should support:

  • assigned user
  • assigned role
  • candidate roles
  • claimed by
  • claimed timestamp
  • due timestamp
  • priority
  • comments
  • audit trail

A role-based task appears in the worklist for users with a matching role. Once claimed, it belongs to the claiming user until completed, released, delegated, or timed out.

Client Architecture

light-workflow should run as a containerized backend service alongside other portal services. It owns workflow execution and state. Portal chat, worklist, CLI, scheduler, and agents are all clients of the same workflow APIs.

The client surfaces are:

  • Portal Chat: conversational guidance for a single user.
  • Worklist: role-based task inbox for approvals, reviews, and coordination.
  • CLI: developer, CI/CD, live test, and automation interface.
  • Scheduler: periodic headless execution, such as hourly live integration tests.
  • Agent: task executor that can call APIs, use skills, and report results back to the workflow.

See Workflow Client Architecture for the dedicated client design.

Workflow Service API

The workflow service should expose one stable API boundary for all clients.

Core operations:

workflow.start
workflow.getInstance
workflow.listInstances
workflow.getEvents
workflow.listTasks
workflow.getTask
workflow.claimTask
workflow.releaseTask
workflow.completeTask
workflow.delegateTask
workflow.cancelInstance

Streaming clients should subscribe to workflow events through Server-Sent Events, WebSocket, or another portal-standard event mechanism.

Important event types:

  • workflow started
  • task started
  • task completed
  • task failed
  • task waiting for input
  • task assigned
  • task claimed
  • task completed by human
  • agent started
  • agent completed
  • workflow completed
  • workflow failed

Live Testing

The same workflow runtime should support interactive runs and headless live tests.

Interactive workflows use ask tasks when decisions or missing values are needed.

Live tests should use example data from LightAPI endpoint descriptions and workflow input fixtures instead of asking the user. Assertions should verify results through assert tasks or rule-engine checks.

This lets the scheduler run workflows every hour against the latest deployed services. When a test fails, the workflow can create a task with the failure detail and assign an agent or human to investigate.

Example: API Onboarding To AI Gateway

An API onboarding workflow can guide a user through a complex multi-endpoint process without requiring a dedicated UI for every operation.

The workflow can:

  1. ask for or infer the API metadata
  2. call the register API endpoint
  3. create an API version from an OpenAPI specification
  4. create a development API instance
  5. configure the API
  6. ask whether fine-grained authorization should be configured
  7. route to create or select authorization rules
  8. link the API instance to the development AI gateway
  9. select endpoints to expose as MCP tools
  10. create a gateway config snapshot
  11. reload the gateway through controller
  12. run MCP tests through the gateway
  13. assert expected results
  14. report success or create remediation tasks

The same workflow can run interactively through portal chat, be managed through the worklist, or run headlessly with examples as a live test.

Technical Implementation

The Light-Fabric implementation is split across:

  • workflow-core: Rust models for the workflow specification.
  • workflow-builder: fluent builders for programmatic workflow construction.
  • light-workflow: runtime service and executor.
  • light-agent: agent execution surface for delegated agent tasks.
  • light-rule: rule engine used by workflow and assertion tasks. See Light-Rule Design.

Runtime responsibilities include:

  • deserializing workflow definitions
  • claiming active tasks
  • executing supported task types
  • storing task output
  • applying exports into process context
  • creating next tasks
  • pausing waiting tasks
  • resuming after human completion
  • failing or completing process instances
  • exposing workflow APIs to clients

The current executable slice supports API invocation and verification tasks such as HTTP, JSON-RPC, OpenRPC, MCP over enterprise HTTP transports, rules, assertions, and waiting human input. MCP stdio transport is intentionally not a priority for enterprise deployment.

Design Rule

There must be one workflow runtime and one task state model.

Chat, worklist, CLI, scheduler, and agents should never implement their own workflow execution. They should all use the same light-workflow service APIs.

This keeps enterprise workflow behavior auditable, testable, and consistent regardless of how a process is started, resumed, or observed.

Workflow Client Architecture

Light-Fabric workflow execution should run as a containerized backend service, not as logic embedded in a portal screen, CLI, scheduler, or agent. The workflow service owns process state, task state, audit records, API invocation, agent invocation, and human-in-the-loop transitions. Clients are thin interaction surfaces over the same service APIs.

This separation lets the same workflow instance be driven by a portal chat session, a worklist user, a CLI command, a scheduler, or an AI agent without creating multiple execution models.

Goals

  • Provide one authoritative workflow runtime for long-running enterprise processes.
  • Support human-in-the-loop tasks from both conversational and worklist interfaces.
  • Support headless execution for live tests, scheduled runs, and CI/CD.
  • Keep all clients stateless or lightly stateful; workflow state lives in light-workflow.
  • Make role assignment, audit, and retry behavior consistent across UI, CLI, scheduler, and agent use.

Runtime Service

light-workflow should be deployed as a portal service in a container alongside the other portal services. It should expose APIs for workflow definitions, workflow instances, task claiming, task completion, event streaming, and operational control.

The service is responsible for:

  • loading workflow definitions
  • starting workflow instances
  • persisting process_info_t and task_info_t
  • executing API calls and assertions
  • invoking agents for agent-owned tasks
  • pausing on ask and approval tasks
  • assigning human tasks to users or roles
  • resuming workflows when a human answer is submitted
  • emitting workflow and task events
  • recording audit history

Clients should never execute workflow steps themselves. They should only start workflows, inspect workflow state, and complete assigned tasks.

Client Surfaces

Portal Chat

The portal chat client is the guided conversational interface for a single user working through a process. It is useful when the workflow needs to ask clarifying questions, explain the next action, or guide a user through a complex multi-endpoint operation.

Typical uses:

  • API onboarding
  • API endpoint publication to an AI gateway
  • guided configuration
  • troubleshooting and remediation workflows
  • interactive approval with explanation

The chat client should call the workflow service for current state and submit answers to waiting tasks. It may stream workflow events and render agent explanations, but it should not own workflow state.

Worklist

The worklist is the enterprise task inbox. It is the right interface for multi-user coordination, role-based assignment, approvals, escalations, and audit-sensitive operations.

Typical uses:

  • approval tasks
  • compliance review
  • operations handoff
  • role-based queue processing
  • task claim and release
  • delegated work
  • due-date and priority management

The worklist should be built around waiting human tasks. A task may have:

  • assigned user
  • candidate roles
  • assigned role
  • priority
  • due time
  • claim status
  • comments
  • completion payload
  • audit trail

The worklist is especially important because many enterprise workflows are not purely conversational. They need accountable ownership and coordination between multiple humans.

CLI

The CLI is a developer and automation client. It should use the same workflow service APIs as portal-view and should not contain separate execution logic.

Typical uses:

  • local workflow testing
  • live parity tests
  • CI/CD automation
  • scheduled headless runs
  • debugging stuck workflow instances
  • submitting test data
  • completing simple waiting tasks from scripts

Example commands:

light-workflow start portal.onboard-api --input input.yaml
light-workflow status <instance-id>
light-workflow tasks --role portal-admin
light-workflow claim <task-id>
light-workflow answer <task-id> --value approve
light-workflow logs <instance-id>
light-workflow cancel <instance-id>

The CLI should be added after the workflow APIs stabilize. It will be valuable for developers and automation, but the worklist and portal chat should drive the primary enterprise UX.

API Boundary

The workflow service should expose a stable API boundary that all clients use. The API can be HTTP, JSON-RPC, or both, but the concepts should remain the same.

Core operations:

workflow.start
workflow.getInstance
workflow.listInstances
workflow.getEvents
workflow.listTasks
workflow.getTask
workflow.claimTask
workflow.releaseTask
workflow.completeTask
workflow.delegateTask
workflow.cancelInstance

For streaming clients, the service should expose workflow events through Server-Sent Events, WebSocket, or another portal-standard event mechanism.

Important event types:

  • workflow started
  • task started
  • task completed
  • task failed
  • task waiting for input
  • task assigned
  • task claimed
  • task completed by human
  • agent started
  • agent completed
  • workflow completed
  • workflow failed

Human Task State

ask and approval-style tasks should enter a waiting state. While waiting, the workflow instance remains active, but the task is no longer executable by the worker loop until a human answer is submitted.

Recommended states:

A = active
W = waiting for input
C = completed
F = failed
X = canceled

The waiting task should include enough metadata for all clients:

  • prompt
  • input mode
  • options
  • validation rules
  • default value
  • sensitivity flag
  • assignment metadata
  • explanation metadata
  • timeout policy

The completion API should validate submitted input against the task definition before resuming the workflow.

Assignment Model

Human tasks should support both direct assignment and role-based queues.

Recommended fields:

assigned_user
assigned_role
candidate_roles
claimed_by
claimed_ts
due_ts
priority
comments

A role-based task can appear in the worklist for all users with a matching role. Once a user claims it, the task becomes owned by that user until completed, released, delegated, or timed out.

  1. Implement stable workflow service APIs for start, status, events, task list, task claim, and task completion.
  2. Harden the ask resume path and waiting task state machine.
  3. Build the worklist because it forces the assignment, audit, and state model to be correct.
  4. Build the portal chat workflow interaction on top of the same task APIs.
  5. Add the CLI after the API shape stabilizes.
  6. Add scheduler integration for hourly live tests and headless workflow runs.

Design Rule

There must be one workflow runtime and one task state model. Chat, worklist, CLI, scheduler, and agents are only clients of that runtime.

This keeps enterprise workflow behavior auditable, testable, and consistent regardless of how a workflow is started or resumed.

LightAPI Description Design

lightapi-description-specification

LightAPI Description is the endpoint capability specification used by Light-Fabric agents, workflows, live tests, and portal API administration.

It describes how an API endpoint is discovered, invoked, explained, and verified. It is intentionally separate from the Agentic Workflow Specification. Workflow describes process orchestration. LightAPI describes endpoint capability.

Why LightAPI

OpenAPI is useful for REST APIs, and OpenRPC is useful for JSON-RPC APIs, but Light-Fabric needs a common description model across multiple enterprise protocols:

  • REST / HTTP
  • OpenAPI-described HTTP
  • JSON-RPC 2.0
  • OpenRPC-described JSON-RPC
  • gRPC
  • MCP tools, resources, and prompts

LightAPI provides a single agent-facing and workflow-facing description layer over these protocols.

The goal is not to replace OpenAPI or OpenRPC. The goal is to reference them where they exist and add the missing information needed by agents and workflow live tests.

API-Level Authoring, Endpoint-Level Consumption

Light-Portal may let teams author descriptions at the API level for convenience. However, workflows and agents consume descriptions at the endpoint level.

This distinction is important because real workflow processes rarely use a whole API. They usually combine selected endpoints from multiple APIs.

For example, onboarding an API to an AI gateway may consume:

  • one endpoint from API registration
  • one endpoint from API version management
  • one endpoint from API instance management
  • one endpoint from config server
  • one endpoint from gateway linking
  • one endpoint from controller reload
  • one or more MCP tools exposed through the gateway

Each consumed operation should have an endpoint-level description with a stable endpointId.

API-level descriptions are still useful as catalogs. Endpoint-level descriptions may inherit shared API context such as:

  • environments
  • authentication
  • secrets
  • sources
  • common tags
  • lifecycle metadata

Relationship To Agentic Workflow

Agentic Workflow and LightAPI have different responsibilities.

ConcernAgentic WorkflowLightAPI Description
Process orderYesNo
Branching and retriesYesNo
Human-in-the-loopYesNo
Endpoint invocation contractReference onlyYes
Input and result examplesOptional workflow fixturesYes
Result verification expectationsCalls assertDescribes expected result cases
Agent progressive disclosureUses selected endpointsDefines disclosure levels
Live testingOrchestrates executionSupplies examples and expected results

In live tests, the workflow should use example data from LightAPI descriptions and workflow fixtures instead of asking for user input.

In interactive runs, the workflow may ask the user for missing values, then invoke endpoints described by LightAPI.

Relationship To Centralized Agent Skills

LightAPI endpoint descriptions are a source of agent skills.

The centralized skill registry should not require every API operation to be manually rewritten as a separate skill. Instead, Light-Portal can publish selected LightAPI endpoint descriptions into the skill registry as invokable capabilities.

The skill registry adds:

  • permission-aware discovery
  • semantic search
  • skill grouping
  • agent persona scoping
  • audit around skill disclosure and execution

LightAPI provides:

  • endpoint identity
  • protocol details
  • input schema
  • request mapping
  • result shape
  • examples
  • behavior notes
  • result cases

Together, they allow an agent to discover a capability as a skill, progressively load only the endpoint details it needs, and execute through the workflow or controller runtime.

See Centralized Agentic Skill Registry for the skill registry design.

Core Document Concepts

A LightAPI document should support both API-level catalogs and endpoint-level documents.

Important top-level concepts:

  • lightapi: specification version
  • profile: api or endpoint
  • info: name, title, version, namespace, owner, contact
  • context: inherited catalog context for endpoint-level documents
  • sources: OpenAPI, OpenRPC, protobuf, MCP, or raw protocol references
  • environments: environment-specific server details
  • secrets: required secret names
  • authentications: reusable authentication policies
  • operations: endpoint operation descriptions
  • testSequences: linear endpoint test sequences
  • agent: progressive disclosure and skill metadata

For profile: endpoint, the document should describe at most one operation.

Operation Model

Each operation represents one endpoint-level capability.

Common fields include:

  • operationId: local operation identifier
  • endpointId: globally stable endpoint identifier
  • title
  • summary
  • description
  • visibility
  • lifecycle
  • tags
  • capability
  • agent
  • input
  • request
  • result
  • examples

The input section describes the logical interface the agent or workflow sees.

The request section describes how logical input maps to the wire protocol.

The result section describes expected output, result cases, and failure shapes.

Protocol Coverage

HTTP And OpenAPI

For raw HTTP, the operation describes method, endpoint, headers, query, path, and body mappings.

For OpenAPI, LightAPI references the OpenAPI document and operation, then adds agent-oriented behavior, examples, and result expectations.

JSON-RPC And OpenRPC

For direct JSON-RPC, the operation describes endpoint, method, params, id behavior, notification behavior, and error policy.

For OpenRPC, LightAPI references the OpenRPC document and method. The workflow runtime can use the OpenRPC document to validate that the method exists and that required params are present before calling it.

gRPC

For gRPC, the operation describes service, method, protobuf source, transport, metadata, request mapping, and result mapping.

For browser or gateway-mediated enterprise deployments, gRPC over WebSocket can be represented as a transport on the structured protocol operation.

MCP

For MCP, the operation describes tool, resource, or prompt invocation.

Tool listing alone is not enough. The description must also include:

  • input schema
  • result shape
  • examples
  • behavior differences for important input cases
  • error cases
  • verification expectations

MCP stdio is not a priority for enterprise portal deployment. HTTP and streamable HTTP transports should be the main runtime targets.

Result Cases And Verification

LightAPI should describe expected result behavior, but Agentic Workflow should execute the actual assertions.

This keeps verification orchestration in one place.

Recommended model:

  • LightAPI operation result cases describe expected outputs, failure shapes, and examples.
  • Workflow test steps invoke the operation.
  • Workflow assert tasks verify actual output against expected result cases.
  • Complex business checks can call the rule engine.

This allows the same endpoint description to support:

  • agent skill usage
  • workflow execution
  • live integration testing
  • failure diagnosis

Progressive Disclosure For Agents

A LightAPI document should support progressive disclosure so an agent can load only the information needed at each stage.

Recommended levels:

  • index: endpoint id, title, tags, visibility
  • summary: purpose, capability group, lifecycle
  • invocation: input schema, request mapping, authentication, examples
  • behavior: result cases, edge cases, errors, assertions
  • full: complete endpoint description

The portal can expose query APIs such as:

lightapi.listOperations
lightapi.getOperation
lightapi.getCapabilityGroup

Agents should start with index or summary data, load invocation details only for selected endpoints, and load behavior details only for testing, troubleshooting, or failure repair.

Portal Publishing Flow

Light-Portal should manage endpoint descriptions as part of API endpoint administration.

Recommended flow:

  1. API owner creates or imports API metadata.
  2. Portal extracts initial endpoint descriptions from OpenAPI, OpenRPC, protobuf, MCP, or raw endpoint configuration.
  3. API owner enriches endpoint descriptions with examples, behavior notes, result cases, and visibility.
  4. Portal stores endpoint-level LightAPI descriptions.
  5. Authorized agents and workflows query descriptions by endpoint, tag, lifecycle, visibility, or capability.
  6. Selected endpoints can be published into the centralized skill registry.
  7. Workflow instances reference endpoint descriptions during execution and live testing.

Live Test Use

Live tests should be workflow-driven.

LightAPI supplies:

  • example input data
  • expected result cases
  • protocol invocation details
  • error behavior

Agentic Workflow supplies:

  • sequence
  • fixtures
  • environment selection
  • endpoint invocation
  • assertions
  • failure routing
  • task creation
  • agent assignment

This avoids building a second test runner model outside the workflow engine.

Design Rule

LightAPI describes endpoint capability. Agentic Workflow orchestrates endpoint use. Centralized Skills expose selected capabilities to agents.

Keeping these responsibilities separate lets Light-Fabric support API administration, agent skill discovery, workflow execution, and live integration testing without duplicating endpoint definitions across multiple systems.

Light-Rule Design

rule-specification

Light-Rule is the local YAML rule engine used by Light-Fabric services and workflows for deterministic business checks, transformations, authorization decisions, and workflow assertions.

It complements agentic workflow by keeping critical decisions explicit, repeatable, and auditable. Agents can propose or select rules, but the rule engine executes the deterministic logic.

Purpose

Light-Rule is designed for enterprise services that need fast local policy and transformation logic without a database call on every request.

Primary uses:

  • fine-grained authorization
  • request transformation
  • response transformation
  • response row and column filtering
  • workflow assertions
  • business validation
  • permission and filter injection
  • reusable rule templates selected from Light-Portal

The rule configuration is loaded locally by the target service. When permissions or rule mappings change, the controller can trigger a config reload so the service swaps to the latest rules.

Relationship To Agentic Workflow

Agentic Workflow orchestrates process steps. Light-Rule evaluates deterministic logic inside those steps.

Workflow uses Light-Rule in two main ways:

  1. Rule call task A workflow task can call a named rule to validate or mutate workflow context.

  2. Assert task extension Simple checks can be handled directly by assert, while complex business checks can delegate to Light-Rule.

This separation keeps workflows readable. The workflow says when a check happens; Light-Rule defines the reusable business logic for the check.

Example workflow responsibilities:

  • decide when authorization configuration is needed
  • select or create a rule
  • invoke a rule during live testing
  • route failures to a human or agent

Example Light-Rule responsibilities:

  • evaluate role, group, position, or attribute checks
  • inject endpoint permissions into the context
  • compute row or column filters
  • execute transformation plugins
  • return pass/fail for business assertions

See Agentic Workflow Design for the workflow orchestration model.

Relationship To LightAPI

LightAPI endpoint descriptions describe endpoint invocation and expected result behavior. Light-Rule can implement complex result checks that are too business-specific for simple schema assertions.

Recommended model:

  • LightAPI describes endpoint result cases and expected behavior.
  • Agentic Workflow invokes the endpoint and runs assert tasks.
  • assert handles simple checks directly.
  • Light-Rule handles complex checks, authorization logic, row filters, column filters, and reusable business policies.

See LightAPI Description Design for endpoint capability descriptions.

Rule Specification

Rules are described by the rule specification in rule-specification/schema/rule.yaml.

The top-level configuration contains:

  • ruleBodies: named rule definitions
  • endpointRules: endpoint-to-rule mappings

Each rule can contain:

  • ruleId
  • ruleDesc
  • ruleType
  • version
  • author
  • updatedAt
  • conditions
  • conditionLanguage
  • conditionSecurityProfile
  • expression
  • actions

Each endpoint mapping can contain:

  • req-tra: request transformation rules
  • res-tra: response transformation rules
  • req-acc: request access rules
  • res-fil: response filter rules
  • access-control: legacy or compatibility access control rules
  • permission: permission values injected into context
  • x-*: extension rule phases

Rule Conditions

Conditions evaluate fields in the input context.

Light-Fabric supports CEL rule conditions only. A Light-Fabric rule uses conditionLanguage: cel and a single boolean expression, with an optional conditionSecurityProfile.

The native condition-row format with operand, operator, expected, and joinCode is a legacy format from the Java yaml-rule implementation. It can be documented for migration and compatibility, but it is not supported by Light-Fabric rule execution. Keep the detailed CEL contract in CEL Rule Conditions; this page documents how CEL fits into the broader Light-Rule model.

Legacy Native Conditions

Supported operand forms:

  • direct field: role
  • dotted path: user.role
  • JSON Pointer: /user/role
  • JSONPath-like path: $.user.roles[0]

Supported operators:

==
!=
>
<
>=
<=
eq
ne
contains
matches
startsWith
endsWith
exists
notExists

expected is typed and may be a string, number, boolean, array, object, or null.

Flat condition arrays are evaluated left-to-right. joinCode combines the current condition with the previous result.

A AND B OR C

is evaluated as:

(A AND B) OR C

If explicit grouping is required, split logic into multiple rules and combine them through endpoint mapping or workflow orchestration.

These native condition rows are included here only to document the legacy Java yaml-rule shape. New Light-Fabric rules must use CEL.

CEL Conditions

CEL rules use conditionLanguage: cel and store the predicate in expression. The expression must evaluate to a boolean. If it evaluates to false, the rule actions do not run.

ruleBodies:
  allowOfferSearch:
    ruleId: allowOfferSearch
    ruleType: req-acc
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      auditInfo.subject_claims.ClaimsMap.role != null
      && toolArguments.category == "travel"
    actions:
      - actionClassName: com.networknt.rule.RoleBasedAccessControlAction

CEL expressions should be used for rule eligibility and business predicates. Endpoint-specific role lists, row filters, column filters, and similar policy values should still live under permission so API owners can change policy data without editing the reusable rule body.

CEL must not be used as a general JSON mutation language. A CEL expression can answer whether a rule applies, or whether a specific row should be kept when an action provides a row-scoped CEL context. It should not directly rewrite responseBody, add or remove fields, or execute side effects.

Rule Actions

Actions execute plugin logic after conditions pass.

An action contains:

  • actionId
  • actionClassName
  • actionValues

actionClassName identifies the registered plugin. actionValues carries plugin-specific configuration.

Typical action plugins:

  • add values to request context
  • inject permission attributes
  • compute filters
  • transform request body
  • transform response body
  • call a local business function

Actions are intentionally plugin-based so the schema remains stable while implementation logic can evolve.

For response filtering, standard actions remain the transformation boundary. ResponseRowFilterAction and ResponseColumnFilterAction own row and column mutation, shape-specific handling, failure behavior, and audit logging. If a use case needs richer row predicates, add a CEL-aware action such as ResponseCelRowFilterAction that evaluates a CEL predicate for each row. Do not expose raw response-body mutation functions to CEL.

Response filtering should parse the response JSON once for the whole res-fil phase, pass the mutable JSON value through the ordered action pipeline, and serialize once at the end. Individual actions should not repeatedly parse and serialize responseBody when multiple filters are configured on the same endpoint.

Endpoint Rule Phases

Endpoint mappings define when rules run.

Request Transformation

req-tra rules run before the service handles the request. They can enrich or transform request context.

Response Transformation

res-tra rules run after the service produces a response. They can filter, redact, or reshape response data.

Request Access

req-acc rules validate whether a request is allowed before the target handler or backend service runs. These rules normally run in parallel because they should not mutate shared state.

access-control can be accepted as a compatibility phase by runtimes that need to load older configuration, but new endpoint mappings should use req-acc.

Response Filtering

res-fil rules run after the target service produces a response and before the caller receives it. They are used for row filters, column filters, response masking, and other response-reduction policies that should not be implemented inside the business API.

res-fil rules always execute as a sequential pipeline in the order listed on the endpoint. accessRuleLogic applies only to req-acc; it does not change response-filter execution semantics.

Permission Injection

permission values are injected into the evaluation context before rule execution. This lets API owners configure roles, groups, attributes, row filters, or column filters without editing the technical rule body.

Extension Phases

Custom phases must use the x-* prefix. This avoids silent typos in standard phase names while preserving controlled extensibility.

Execution Model

The Rust implementation lives in crates/light-rule.

Core components:

  • RuleConfig: top-level config model
  • Rule: rule definition
  • RuleCondition: condition model
  • RuleAction: action model
  • RuleEngine: evaluates one rule
  • ActionRegistry: maps action class names to plugins
  • MultiThreadRuleExecutor: executes rule lists and endpoint phase mappings

Sequential phases such as req-tra and res-tra should run with all semantics so transformations happen in order.

Access control can run in parallel because it should be a validation step rather than a mutation step.

Response filtering should run sequentially when multiple filters can depend on the same fields. For example, a row filter that checks active == true must run before a column filter that removes the active field from the final response.

Why Not Replace With Cedar Or Casbin

Cedar and Casbin are strong policy engines, but Light-Rule has a different role in this platform.

Light-Rule supports:

  • local YAML configuration
  • request and response transformation
  • permission injection
  • row and column filters
  • endpoint-specific rule selection
  • technical-team-authored reusable rules
  • API-owner-selected rule parameters
  • config reload through controller

Cedar is excellent for authorization policy, but it does not naturally cover transformation, row filter, and column filter use cases. Casbin is strong for policy enforcement, but it introduces a different policy storage and matching model.

Light-Rule should remain the built-in rule engine for Light-Fabric service configuration and workflow assertions. External policy engines can still be integrated as action plugins if needed.

Governance

Rule bodies should be authored and reviewed like code or controlled configuration.

Recommended governance metadata:

  • version
  • author
  • updatedAt
  • ruleDesc

Recommended operational controls:

  • validate rule YAML against the schema before publishing
  • reject endpoint phase typos
  • keep ruleId equal to the ruleBodies map key
  • audit rule publication and reload events
  • test rules with representative input contexts
  • use workflow live tests to verify rules in integrated environments

Workflow Live Testing

Light-Rule is useful in live tests because it can express business checks that are more specific than generic JSON assertions.

Example flow:

  1. Workflow invokes an endpoint using LightAPI description.
  2. Workflow captures the endpoint response.
  3. assert verifies simple fields.
  4. A rule task validates business-specific behavior.
  5. On failure, workflow creates a task for a human or agent to investigate.

This keeps live test orchestration in workflow while preserving reusable business rules in Light-Rule.

Design Rule

Use workflow for process control. Use LightAPI for endpoint capability. Use Light-Rule for deterministic business logic.

Agents may select, explain, or help author rules, but the rule engine should execute the final deterministic decision.

CEL Rule Conditions

Light-Fabric supports CEL rule conditions only. A Light-Fabric rule uses conditionLanguage: cel and one rule-level CEL boolean expression.

The old native condition schema with condition rows, operators, and joinCode is a legacy Java yaml-rule format. It can still be documented for migration and Java compatibility, but it is not a supported Light-Fabric runtime condition format.

Each Light-Fabric rule should therefore use CEL. Mixing native condition rows and CEL expressions inside the same rule is not a canonical model because it makes portal authoring, validation, and runtime dispatch harder to reason about.

Goals

  • support CEL expressions as the Light-Fabric rule-level condition language
  • reuse the existing rule context for gateway, workflow, and test execution
  • preserve existing actions, endpointRules, and rule phase semantics
  • let Light-Portal choose the correct editor from rule metadata without parsing arbitrary rule bodies
  • validate CEL before publishing or reloading rules where possible
  • keep CEL execution deterministic and side-effect free

Non-Goals

  • replacing actions with CEL
  • allowing CEL expressions to perform I/O, network calls, mutation, or service lookups
  • allowing CEL expressions to directly mutate responseBody or perform general JSON transformations
  • supporting the legacy Java yaml-rule native condition-row format in Light-Fabric
  • supporting mixed native and CEL condition blocks in the canonical portal authoring flow

Current Model

The legacy Java yaml-rule model contains an optional flat list of native conditions:

ruleBodies:
  allowMcpReader:
    common: Y
    ruleId: allowMcpReader
    ruleName: Allow MCP reader
    ruleType: req-acc
    conditions:
      - operatorCode: isNotNull
        propertyPath: auditInfo.subject_claims.ClaimsMap.role
    actions:
      - actionClassName: com.networknt.rule.RoleBasedAccessControlAction

Each legacy native condition contains:

  • operator
  • operand
  • expected
  • joinCode

The Java yaml-rule engine evaluates conditions left-to-right. joinCode combines each condition with the accumulated result. This format is shown here only as migration context.

Portal persistence stores rule metadata in rule_t and the executable rule JSON in rule_t.rule_body. Today there is no dedicated column that tells the portal which condition editor to render, so the UI would have to inspect rule_body.

Proposed Rule Shape

Use a rule-level condition language flag. Light-Fabric accepts cel for a single CEL expression. native is reserved for legacy Java yaml-rule data and must not be emitted to Light-Fabric runtime configuration.

Persist the flag in both places:

  • rule_t.condition_language: indexed/listable portal metadata
  • ruleBody.conditionLanguage: self-contained exported runtime configuration

Recommended Light-Fabric value:

cel

Legacy Java yaml-rule native rule body:

ruleBodies:
  allowMcpReader:
    common: Y
    ruleId: allowMcpReader
    ruleName: Allow MCP reader
    ruleType: req-acc
    conditionLanguage: native
    conditions:
      - operatorCode: isNotNull
        propertyPath: auditInfo.subject_claims.ClaimsMap.role
    actions:
      - actionClassName: com.networknt.rule.RoleBasedAccessControlAction

CEL rule body:

ruleBodies:
  allowApprovedTransfer:
    common: Y
    ruleId: allowApprovedTransfer
    ruleName: Allow approved transfer
    ruleType: req-acc
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      auditInfo.subject_claims.ClaimsMap.role != null
      && 'roles' in permission
      && permission.roles != null
      && permission.roles.exists(r, r == auditInfo.subject_claims.ClaimsMap.role)
    actions:
      - actionClassName: com.networknt.rule.RoleBasedAccessControlAction

Recommended database shape:

ALTER TABLE rule_t
ADD COLUMN condition_language VARCHAR(16) DEFAULT 'cel' NOT NULL;

ALTER TABLE rule_t
ADD COLUMN condition_security_profile VARCHAR(32);

ALTER TABLE rule_t
ADD CONSTRAINT rule_t_condition_language_check
CHECK (condition_language IN ('cel'));

ALTER TABLE rule_t
ADD CONSTRAINT rule_t_condition_security_profile_check
CHECK (
  condition_security_profile IS NULL
  OR condition_security_profile IN ('strict', 'standard', 'internal-admin')
);

Recommended schema rules:

  • conditionLanguage is optional and defaults to cel
  • conditionLanguage: cel requires expression and rejects conditions
  • conditionSecurityProfile is optional and names a runtime-defined profile
  • conditionLanguage: native is rejected by Light-Fabric runtime config
  • unknown rule and condition fields should continue to be rejected by the schema
  • command handlers should reject requests where the DB metadata and rule body condition language disagree

This can be represented with conditional validation in rule-specification/schema/rule.yaml:

allOf:
  - if:
      properties:
        conditionLanguage:
          const: cel
      required: [conditionLanguage]
    then:
      required: [expression]
      not:
        required: [conditions]
    else:
      properties:
        conditionLanguage:
          const: cel

The Rust model can add optional fields to Rule:

#![allow(unused)]
fn main() {
pub condition_language: Option<String>,
pub condition_security_profile: Option<String>,
pub expression: Option<String>,
}

This is less disruptive than changing RuleCondition into an enum and keeps old rule bodies valid.

Cross-Repository Scope

This change crosses the rule specification, runtime engines, portal services, and portal UI. The implementation should be tracked as a coordinated change rather than a light-fabric-only feature.

AreaRequired work
rule-specificationAdd conditionLanguage, conditionSecurityProfile, expression, CEL rule schema validation, and explicit rejection of native condition rows for Light-Fabric runtime config.
portal-dbAdd rule_t.condition_language with default cel, optional rule_t.condition_security_profile, check constraints, and pending rule-change approval state if workflow task payloads are not sufficient.
light-portalUpdate persistence and projection code so rule create/update/read/export/import paths carry conditionLanguage and conditionSecurityProfile; ensure endpoint rule config generation emits only approved, self-contained rule bodies; integrate stronger-profile requests with worklist and assistant-task approval.
rule-commandAccept conditionLanguage, conditionSecurityProfile, and expression, reject native condition-row payloads for Light-Fabric rules, validate mode/profile-specific shape, publish strict changes immediately, route stronger profile requests through approval, and write both DB metadata and rule body consistently after approval.
rule-queryReturn conditionLanguage, conditionSecurityProfile, and approval status for list/detail APIs, include selected/effective profiles in test-case execution payloads, and surface CEL parse/type/missing-field/profile errors from Java and Rust runners.
portal-viewRender the CEL expression editor for Light-Fabric rules; keep any native condition builder scoped to legacy Java yaml-rule authoring; show a controlled profile selector for CEL rules; submit strict directly and route standard or internal-admin to worklist approval; do not require the UI to infer mode from ruleBody.
workflow and assistant taskUse the existing human-in-the-loop worklist flow for stronger profile approval, route tasks to admin and rule-admin, and attach an advisory assistant-task risk summary for the approver.
light-fabricAdd conditionLanguage, conditionSecurityProfile, and expression to crates/light-rule, dispatch in RuleEngine, add policy-driven CEL evaluator/caching, and update gateway/workflow tests.
yaml-ruleAdd Java runtime parity for conditionLanguage: cel and named profile enforcement if Java services need to execute the same rules; otherwise reject CEL rules explicitly with a clear runtime-capability error.

portal-db is listed even though it is not a rule engine because rule_t lives there. Without the DB column, portal-view would need to parse the compact rule body to choose the editor, which is the coupling this design is trying to avoid.

Operator Alias Alternative

Another possible shape is to add operatorCode: cel and store the CEL expression in expected inside conditions:

conditions:
  - operatorCode: cel
    expected: >
      context.toolArguments.amount < 1000
      || ('roles' in context.permission
          && context.permission.roles != null
          && context.permission.roles.exists(r, r == "approver"))

This has one advantage for legacy Java yaml-rule imports: operator, operand, and expected already exist. It is not useful as the Light-Fabric runtime contract because Light-Fabric does not support native condition rows.

It should not be the canonical schema because:

  • CEL is a full boolean expression, not a comparison operator
  • overloading expected makes validation and portal rendering less clear
  • operand becomes ignored or artificial
  • the UI still has to draw a condition-row editor even though the rule is really a single expression
  • future expression languages would continue overloading legacy native condition fields

The recommended contract is therefore:

  • canonical form: conditionLanguage: cel plus rule-level expression
  • reject operatorCode: cel for Light-Fabric runtime config
  • normalize any legacy import to the canonical rule-level CEL model before persistence or runtime export

Mixed Conditions Alternative

Another possible shape is to allow native and CEL conditions in the same conditions array. Light-Fabric should not support this. Native condition rows belong to the legacy Java yaml-rule model only.

Reasons to avoid canonical mixed rules:

  • Light-Portal would need a hybrid editor that switches row-by-row
  • validation errors become harder to explain to non-technical users
  • joinCode semantics across native and CEL expressions are correct but subtle
  • users may expect CEL operator precedence inside the whole rule even though native joinCode remains left-to-right
  • runtime dispatch is simpler and faster when the rule selects one evaluator

If mixed rules are accepted from an import path, they must be normalized to a single rule-level CEL expression before they are persisted or exported to Light-Fabric runtime configuration.

Execution Model

Rule execution should dispatch by conditionLanguage once per rule:

RuleEngine::execute_rule
  -> conditionLanguage == cel
     -> evaluate rule expression
  -> execute actions when conditions pass

The outer behavior stays unchanged:

  • rules with no conditions continue to run actions
  • CEL rules without an expression fail validation before runtime
  • failed conditions skip actions
  • failed action execution fails the rule
  • endpoint rule ordering and access-control logic stay unchanged
  • req-tra and res-tra continue to run sequentially
  • access-control rules can still be evaluated independently

Runtime should treat a missing conditionLanguage as cel only when an expression is present. Native condition-row payloads must be rejected by Light-Fabric config validation.

CEL And Response Filtering

CEL is the rule predicate language, not the response mutation engine. A rule-level CEL expression decides whether a res-fil rule applies. The response body is then transformed by a standard action.

This keeps the contract narrow:

  • CEL expressions are side-effect free and return booleans.
  • Actions own mutation of responseBody.
  • Actions decide how JSON arrays, JSON objects, and malformed payloads are handled.
  • Actions provide stable audit and failure behavior.
  • The runtime can compile and cache rule-level CEL independently from response-body parsing.
  • The response-filter pipeline parses JSON once, lets actions mutate the same in-memory value, and serializes once after all res-fil actions complete.
  • The parsed mutable response value is action-owned state and must not be exposed as a rule-level CEL mutation target.

The default response-filter actions should stay declarative:

  • ResponseRowFilterAction: applies permission-defined row filters.
  • ResponseColumnFilterAction: applies permission-defined column keep or remove lists.

Rule bodies should keep using the actions[].actionClassName field even when the runtime is Rust. In Rust this value is not a Java class name. It is a stable action registry key that selects the Rust action implementation. The Rust gateway registers both the Java-compatible fully qualified names and short aliases:

  • com.networknt.rule.ResponseRowFilterAction
  • ResponseRowFilterAction
  • com.networknt.rule.ResponseColumnFilterAction
  • ResponseColumnFilterAction
  • com.networknt.rule.ResponseCelRowFilterAction
  • ResponseCelRowFilterAction

For portal-authored and exported rules, prefer the fully qualified Java-compatible names. They preserve compatibility with existing yaml-rule configuration, schemas, import/export flows, and any Java runtime that reads the same rule bodies:

ruleBodies:
  rowFilterByJwtClaims:
    common: Y
    ruleId: rowFilterByJwtClaims
    ruleName: Row filter by JWT claims
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      row != null
      && (
        ("role" in row && "role" in auditInfo.subject_claims.ClaimsMap)
        || ("group" in row
            && ("grp" in auditInfo.subject_claims.ClaimsMap
                || "group" in auditInfo.subject_claims.ClaimsMap))
        || ("position" in row
            && ("pos" in auditInfo.subject_claims.ClaimsMap
                || "position" in auditInfo.subject_claims.ClaimsMap))
        || ("attribute" in row
            && ("att" in auditInfo.subject_claims.ClaimsMap
                || "attribute" in auditInfo.subject_claims.ClaimsMap))
      )
    actions:
      - actionClassName: com.networknt.rule.ResponseRowFilterAction

  colFilterByJwtClaims:
    common: Y
    ruleId: colFilterByJwtClaims
    ruleName: Column filter by JWT claims
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      col != null
      && (
        ("role" in col && "role" in auditInfo.subject_claims.ClaimsMap)
        || ("group" in col
            && ("grp" in auditInfo.subject_claims.ClaimsMap
                || "group" in auditInfo.subject_claims.ClaimsMap))
        || ("position" in col
            && ("pos" in auditInfo.subject_claims.ClaimsMap
                || "position" in auditInfo.subject_claims.ClaimsMap))
        || ("attribute" in col
            && ("att" in auditInfo.subject_claims.ClaimsMap
                || "attribute" in auditInfo.subject_claims.ClaimsMap))
      )
    actions:
      - actionClassName: com.networknt.rule.ResponseColumnFilterAction

The rule-level CEL expression only decides whether the response-filter action runs. The action reads the endpoint permission.row or permission.col configuration and matches it against JWT claims. The response-filter action understands these permission dimensions:

  • role: matched against the JWT role claim
  • group: matched against grp or group
  • position: matched against pos or position
  • attribute: matched against att or attribute
  • user: matched against uid, user_id, or sub

For example:

endpointRules:
  /v1/accounts@get:
    res-fil:
      - rowFilterByJwtClaims
      - colFilterByJwtClaims
    permission:
      row:
        role:
          manager:
            - colName: status
              operator: =
              colValue: ACTIVE
        group:
          finance:
            - colName: department
              operator: =
              colValue: FIN
        position:
          director:
            - colName: level
              operator: ">="
              colValue: "5"
        attribute:
          region-east:
            - colName: region
              operator: =
              colValue: EAST
      col:
        role:
          manager: id,name,status,department
        group:
          finance: id,name,balance,status
        position:
          director: id,name,balance,status,level
        attribute:
          region-east: id,name,region,status

If a row predicate needs CEL, add an explicit CEL-aware action rather than turning rule-level CEL into a JSON transformation DSL:

ruleBodies:
  filterOfferRows:
    ruleId: filterOfferRows
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200 && responseBody != ""
    actions:
      - actionClassName: com.networknt.rule.ResponseCelRowFilterAction
        actionValues:
          rowExpression: >
            auditInfo.subject_claims.ClaimsMap.role == "offer-admin"
            || (row.priority < 50 && row.active == true)

ResponseCelRowFilterAction would compile rowExpression at rule-load time and evaluate it once per candidate row with a curated context containing row, auditInfo, headers, endpoint, permission, and request metadata. The implementation should avoid deep-cloning the full base context for every row. Use a child CEL context that shadows row, or reuse one mutable evaluation context and replace only the row variable before each evaluation.

Row-level CEL evaluation errors should be row-local by default. If a row is missing a referenced field or its predicate evaluation returns an error, drop that row and emit debug or trace diagnostics with the rule id and field error. Only configuration errors, such as an invalid rowExpression that fails to compile, should fail the entire action closed.

Column filtering should remain declarative unless there is a proven use case for dynamic column predicates. Role, group, attribute, user, and position based field lists are easier to review, safer to render in Portal, and cheaper to execute than arbitrary column-level CEL.

Column filtering must also support top-level JSON objects, not only arrays or objects containing items. Single-object responses such as GET /offers/123 still need field hiding. Row filtering also treats a top-level JSON object as a single candidate row. HTTP filtering replaces a denied object with an empty object, while MCP filtering returns a tool error with isError: true.

Rule Context

CEL should evaluate against the rule engine JSON context. For gateway access-control and response filtering, this includes fields such as:

  • auditInfo
  • headers
  • endpoint
  • toolName
  • toolArguments
  • correlationId
  • responseBody
  • statusCode

Endpoint permission values are merged into the root context as their configured keys. For example, permission.roles in endpointRules is available to conditions as roles, response row filters are available as row, and column filters are available as col. A future runtime can also expose a namespaced permission object as an additive convenience, but CEL support should not require that shape to preserve compatibility with existing actions.

For standard and internal-admin profiles, the CEL environment can expose variables in two ways:

  • top-level context fields as direct CEL variables, such as auditInfo, toolArguments, and roles
  • the full root object as context, so expressions can use explicit paths such as context.toolArguments.amount

Direct variables keep expressions concise. The context variable is safer for generated expressions, collision avoidance, and future fields that are not valid CEL identifiers.

For the strict profile, the runtime should expose only curated root variables such as auditInfo, headers, toolArguments, endpoint metadata, and permission values needed by the rule phase. It should not expose the full context object by default. This prevents future internal runtime metadata from becoming visible to tenant-authored CEL just because it was appended to the root request context.

The context contract should be documented as part of Light-Rule because CEL expressions depend on stable field names. Adding fields is compatible. Renaming or changing field shapes is a breaking change for CEL rules.

Type Mapping

The CEL evaluator should receive deterministic values converted from serde_json::Value:

  • JSON object to CEL map
  • JSON array to CEL list
  • JSON string to CEL string
  • JSON number to CEL integer or double
  • JSON boolean to CEL bool
  • JSON null to CEL null

Missing fields should evaluate according to the chosen CEL implementation's standard behavior. The rule test API should expose these failures clearly so authors can distinguish "expression false" from "expression invalid".

Authors should guard optional fields explicitly. Depending on the selected CEL runtime and the field shape, this can use presence checks such as has(...) or map membership checks such as:

"role" in auditInfo.subject_claims.ClaimsMap
  && auditInfo.subject_claims.ClaimsMap.role == "admin"

The portal rule tester should surface missing-field evaluation errors and suggest guarded expressions instead of letting these failures look like ordinary denied rules.

Context Injection Performance

CEL expressions run on request paths, so context conversion must be controlled. The implementation should not recursively deep-clone and convert large JSON payloads separately for every CEL rule evaluation.

Recommended approach:

  • compile expressions once at rule load
  • build the rule context once per request or response phase
  • reuse converted CEL variables across evaluations in the same request or response phase when possible
  • prefer lazy or reference-backed variable resolution if the selected CEL crate supports it
  • if eager conversion is required, convert only the variables exposed to CEL and avoid parsing large string fields such as responseBody unless an expression explicitly needs structured access to them
  • for per-row CEL, avoid cloning the full base context for each row; use a child context or reusable mutable context that changes only the row binding
  • benchmark access-control and response-filter scenarios before enabling CEL by default in high-throughput paths

The initial implementation can be pragmatic, but performance tests should guard against accidentally making CEL expression evaluation proportional to the full response body size when the expression only needs claims or endpoint metadata.

Validation

CEL should be validated earlier than request execution.

Recommended validation points:

  • portal rule editor
  • rule command create/update handler
  • rule test API
  • runtime config reload

Validation must enforce the Light-Fabric rule shape:

  • cel: expression is required, conditions is rejected
  • native: rejected by Light-Fabric runtime config
  • persisted rule_t.condition_language must match ruleBody.conditionLanguage
  • persisted rule_t.condition_security_profile must match ruleBody.conditionSecurityProfile when either side is present

Runtime reload should reject invalid CEL when strict validation is enabled. If a service must preserve availability, it can keep the last known-good rule set and report the new config as rejected.

Approval workflow should not bypass validation. For profile escalation requests, the command path should validate the submitted rule shape and expression before creating the approval task. Final approval should revalidate the exact submitted rule body before emitting the active rule event.

Validation output should include:

  • rule id
  • condition language
  • parse or type error
  • source offset when provided by the CEL implementation

Compilation And Caching

Do not compile CEL on every request. Compile once per rule load and cache the compiled program with the loaded rule set.

Recommended cache key:

ruleId + expression hash + effective profile

The compiled expression cache should be replaced atomically when the rule config reloads. It should not outlive the rule version it was compiled from. Old compiled entries must be evicted during reload so repeated rule updates cannot leak memory through stale expression hashes.

Rust CEL Library

Light-Rule uses the cel crate for the Rust implementation. It provides Program::compile(...), Program::execute(...), a Context for variables and functions, and compiled Program values that are Send + Sync.

Implementation should still be isolated behind a small internal trait:

CelEvaluator
  -> compile(ruleId, expression) -> compiled expression
  -> evaluate(compiled expression, serde_json::Value context) -> bool

This keeps Light-Rule from leaking third-party crate types through its public model and allows the implementation to change if CEL crate maturity, feature flags, or Java parity requirements change.

Legacy Operator Migration

Legacy Java yaml-rule native conditions include operators that may not map one-to-one to the selected CEL runtime. Examples include:

  • containsIgnoreCase
  • matches and notMatch
  • inList and notInList
  • containsAny, containsAll, and containsNone
  • date-style comparisons such as before, after, and on

Before importing legacy native rules into Light-Fabric, the implementation should define a small compatibility function registry for any gaps and convert the rule to CEL. Candidate pure helper functions include:

contains_ignore_case(value, substring)
matches(value, pattern)
in_list(value, values)
contains_any(value, values)
contains_all(value, values)

These functions must be deterministic, side-effect free, and shared by the rule tester and runtime evaluator. If Java parity is required, the same function names and edge-case behavior should be implemented in the Java runtime.

Safety

CEL support should be deterministic and sandboxed.

The evaluator does not need an operating-system sandbox for normal trusted/admin-authored rule configuration. CEL is an interpreted expression language, not arbitrary Rust or JavaScript execution, and expressions can only resolve variables and functions registered in the CEL context. The CEL context is therefore the primary sandbox boundary.

For the Rust cel integration, context construction should be explicit. Context::default() exposes standard pure CEL functions such as size, contains, string helpers, type conversions, regex matches, and time parsing helpers depending on enabled crate features. If a service accepts tenant-authored or otherwise untrusted CEL, prefer Context::empty() and add only platform-approved helper functions.

Security policy should be engine-owned. A rule may request a named condition security profile, but it must not define its own function allowlist, size limits, resource limits, or isolation mode. If a rule author controls the rule body, then inline security settings are also attacker-controlled.

Recommended policy model:

runtime config defines profiles:
  strict
  standard
  internal-admin

rule optionally requests:
  conditionSecurityProfile: strict

effective policy:
  runtime maximum profile intersected with requested profile

If a rule omits conditionSecurityProfile, the runtime default applies. If a rule requests a profile that the service, tenant, or rule phase does not allow, the rule config should be rejected during validation or runtime reload. The engine may choose a stricter profile than requested, but it must never choose a weaker one because the rule requested it.

Recommended profiles:

  • strict: default for tenant-authored, portal self-service, imported, or marketplace-style CEL. Use an empty CEL context, expose only approved variables, add only pure helper functions, and enforce tight size and expression-shape limits. Do not expose the full context root, and disable regex until both Java and Rust provide matching bounded or linear-time behavior.
  • standard: default for internal business rules. Keep allowlists and resource limits, but permit common pure helpers such as size, contains, startsWith, endsWith, contains_ignore_case, and bounded regex support if needed.
  • internal-admin: limited to trusted operator-maintained rules. This may be closer to the selected CEL runtime's default behavior, but should still compile during rule load, validate references, enforce maximum input size, and protect reloads with the last known-good rule set.

Allowed:

  • boolean logic
  • comparisons
  • arithmetic supported by the CEL implementation
  • string operations
  • list and map predicates
  • approved pure helper functions

Not allowed:

  • file access
  • network access
  • database access
  • current time unless explicitly added as an input field
  • random values
  • mutation of the rule context
  • action execution from inside CEL
  • response-body mutation or field removal from CEL

Custom functions should be added conservatively. Standard Light-Rule actions remain the extension point for side effects and transformations.

The core runtime object should be a policy-driven condition evaluator rather than ad hoc logic embedded directly in RuleEngine:

RuleEngineOptions
  -> ConditionExecutionPolicy
      -> defaultCelProfile
      -> allowRuleProfileSelection
      -> profiles[name] = CelSecurityProfile

CelSecurityProfile
  -> allowedFunctions
  -> allowedRootVariables
  -> exposeContextRoot
  -> exposeTopLevelAliases
  -> maxExpressionBytes
  -> maxContextBytes
  -> maxStringBytes
  -> maxCollectionItems
  -> allowRegex
  -> allowTimeParsing
  -> allowComprehensions
  -> maxComprehensionNesting

CEL still needs resource and robustness controls because expressions run on request paths and can iterate over input data. Runtime and publish-time validation should:

  • allow-list functions and variables, using compiled expression references where available
  • reject functions that perform I/O, mutation, service lookup, action execution, random generation, or implicit current-time access
  • cap expression length and input context size
  • reject or limit expensive access to large request or response bodies
  • compile during rule load and fail invalid expression shapes before request execution
  • keep the last known-good rule set if reload validation fails

Phase ceilings should be enforced by runtime policy. Response phases such as res-tra and res-fil should default to a strict ceiling or tight maxContextBytes limits because they can include large response payloads. Access-control phases may allow standard only when the exposed context is small and bounded. A rule request for a stronger profile than the phase ceiling must be rejected or downgraded to the stricter effective profile.

For fully untrusted public input, evaluate CEL in a separate worker, process, or another resource-isolated execution path with CPU and memory limits. A Tokio timeout alone is not a complete guard for synchronous CPU-bound expression evaluation.

Portal Experience

Light-Portal should use conditionLanguage to choose the rule editor. For Light-Fabric, the only supported editor is the CEL editor. Any native condition builder must be scoped to legacy Java yaml-rule authoring and must not export native condition rows to Light-Fabric runtime config.

Recommended authoring modes:

  • CEL: advanced text area for one rule-level CEL expression.
  • Builder: legacy Java yaml-rule-only condition rows with operand, operator, expected, and join controls.

Recommended behavior:

  • default new Light-Fabric rules to cel
  • render a CEL expression text area only for conditionLanguage: cel
  • reject conditionLanguage: native for Light-Fabric rule publishing
  • require confirmation when switching modes if the existing mode has content
  • do not try to round-trip arbitrary CEL into native builder rows
  • store the selected mode in rule_t.condition_language and in the JSON rule body as conditionLanguage
  • for CEL rules, store only the selected profile name in rule_t.condition_security_profile and in the JSON rule body as conditionSecurityProfile; do not expose raw policy limits in the form
  • do not show internal-admin in standard self-service forms; allow it only through checked-in runtime configuration or an explicitly authorized internal admin JWT/role path

The CEL editor should provide:

  • syntax validation
  • test context input
  • expression result preview
  • visible context field reference
  • selected and effective security profile display
  • rule test execution against the same backend evaluator used by runtime

Profile Approval Workflow

Light-Portal may allow a user to select a CEL security profile, but the selected profile is only a request. Runtime policy still computes the effective profile from the requested profile, the service maximum, the tenant maximum, and the rule phase ceiling.

Recommended publish behavior:

  • strict: direct publish. If schema, CEL validation, and command authorization pass, create or update the rule immediately.
  • standard: approval required. Submit the proposed rule change, create a worklist task for rule-admin and admin, and keep the change pending until approval.
  • internal-admin: hidden from standard self-service authoring. If exposed to an operator-only flow, require stronger approval and never allow ordinary self-service users to request it.

For approval-required changes, the command side should not emit the final active RuleCreated or RuleUpdated event at submission time. It should emit a submission event such as RuleChangeSubmittedEvent or RuleApprovalRequestedEvent, store the proposed rule body and requested profile, and create the human-in-the-loop worklist task. Only approval should emit the active rule event. Rejection should emit a rejection event and leave the active rule unchanged.

Assistant tasks can help the approver by summarizing the CEL expression, rule phase, requested profile, referenced context roots, use of response body fields, regex usage, and any runtime ceiling that would downgrade the effective profile. The assistant output is advisory only; the human approver remains responsible for the approval decision.

Recommended approval rules:

  • changing the expression, action list, rule phase, requested profile, or exposed context assumptions invalidates prior approval
  • downgrading from standard to strict can publish directly after validation
  • upgrading from strict to standard or internal-admin requires approval
  • requester and approver should be different users except for an explicit break-glass workflow
  • approval audit should record requested profile, effective profile, requester, approver, approval time, assistant-task summary id, and approval comments
  • pending rules must not be exported to runtime endpoint rule config until approved

Compatibility

Existing Light-Fabric rule YAML must use CEL conditions.

Rules without conditionLanguage can be treated as cel only when a valid expression is present. Rules containing legacy native conditions must be rejected by Light-Fabric runtime config validation or converted to CEL before publish. The database migration should add rule_t.condition_language with default cel.

Rules without conditionSecurityProfile use the runtime default CEL profile. The field is meaningful only for CEL rules.

Native condition aliases are legacy Java yaml-rule import details only:

  • operatorCode as alias for operator
  • propertyPath as alias for operand
  • actionClassName as alias for actionRef

CEL is the Light-Fabric capability. If the Java yaml-rule runtime needs to execute the same rules, it must implement the same CEL rule shape. Until then, Java runtimes must fail closed with a clear capability error, such as UnsupportedConditionLanguageException, when loading or executing a rule with conditionLanguage: cel. A runtime must not silently ignore a CEL rule because that can fail open for access-control rules.

Java parity is feasible because Google maintains CEL-Java under the dev.cel Maven group, including the dev.cel:cel artifact with compiler and runtime APIs. The compatibility requirement is therefore mostly about aligning the rule schema, context shape, custom functions, and error handling across the Rust and Java runtimes.

Example: Access Control

ruleBodies:
  allowEndpointClaims:
    common: Y
    ruleId: allowEndpointClaims
    ruleName: Allow request when endpoint permission matches JWT claims
    ruleType: req-acc
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      (
        !("role" in permission)
        || (
          ("roles" in auditInfo.subject_claims.ClaimsMap
            && permission.role in auditInfo.subject_claims.ClaimsMap.roles)
          || ("role" in auditInfo.subject_claims.ClaimsMap
            && permission.role == auditInfo.subject_claims.ClaimsMap.role)
        )
      )
      && (
        !("group" in permission)
        || (
          ("groups" in auditInfo.subject_claims.ClaimsMap
            && permission.group in auditInfo.subject_claims.ClaimsMap.groups)
          || ("scp" in auditInfo.subject_claims.ClaimsMap
            && permission.group in auditInfo.subject_claims.ClaimsMap.scp)
          || ("group" in auditInfo.subject_claims.ClaimsMap
            && permission.group == auditInfo.subject_claims.ClaimsMap.group)
          || ("grp" in auditInfo.subject_claims.ClaimsMap
            && permission.group == auditInfo.subject_claims.ClaimsMap.grp)
        )
      )
      && (
        !("position" in permission)
        || (
          ("positions" in auditInfo.subject_claims.ClaimsMap
            && permission.position in auditInfo.subject_claims.ClaimsMap.positions)
          || ("position" in auditInfo.subject_claims.ClaimsMap
            && permission.position == auditInfo.subject_claims.ClaimsMap.position)
          || ("pos" in auditInfo.subject_claims.ClaimsMap
            && permission.position == auditInfo.subject_claims.ClaimsMap.pos)
        )
      )
      && (
        !("attribute" in permission)
        || (
          ("attributes" in auditInfo.subject_claims.ClaimsMap
            && permission.attribute.key
              in auditInfo.subject_claims.ClaimsMap.attributes
            && auditInfo.subject_claims.ClaimsMap.attributes[
              permission.attribute.key
            ] == permission.attribute.value)
          || ("attribute" in auditInfo.subject_claims.ClaimsMap
            && permission.attribute.value
              == auditInfo.subject_claims.ClaimsMap.attribute)
          || ("att" in auditInfo.subject_claims.ClaimsMap
            && permission.attribute.value == auditInfo.subject_claims.ClaimsMap.att)
        )
      )
    actions: []

endpointRules:
  /v1/claims/{claimId}@post:
    req-acc:
      - allowEndpointClaims
    permission:
      role: claims-approver
      group: claims.write
      position: adjuster
      attribute:
        key: region
        value: east

The endpoint above matches a caller JWT with claims like:

{
  "roles": ["claims-approver"],
  "groups": ["claims.write"],
  "positions": ["adjuster"],
  "attributes": {
    "region": "east"
  }
}

With this reusable req-acc rule, the technical rule body stays stable and API owners define the required authorization dimensions at the endpoint. The example above allows the request only when all configured endpoint permissions match claims from the caller JWT.

Example: Response Filter Guard

ruleBodies:
  filterAccountsForPortalUsers:
    common: Y
    ruleId: filterAccountsForPortalUsers
    ruleName: Filter accounts for portal users
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200
      && responseBody != ""
      && auditInfo.subject_claims.ClaimsMap.role != null
    actions:
      - actionClassName: com.networknt.rule.ResponseRowFilterAction

Rollout Plan

  1. Add rule_t.condition_language with default cel, optional rule_t.condition_security_profile, and check constraints.
  2. Extend the rule specification with CEL rule validation plus optional conditionSecurityProfile, and reject native condition rows for Light-Fabric runtime config.
  3. Add conditionLanguage, conditionSecurityProfile, and expression fields to the Rust Rule model.
  4. Update command/query APIs so the portal can persist and read the condition language, security profile, and approval state without parsing ruleBody.
  5. Reject operatorCode: cel in runtime config and normalize any legacy import to the rule-level CEL shape before publishing.
  6. Choose and pin the Rust CEL crate behind an internal evaluator abstraction.
  7. Add runtime-owned CEL security profiles and policy-driven context building.
  8. Add approval workflow integration for standard and internal-admin profile requests, including worklist and assistant-task support.
  9. Dispatch inside RuleEngine::execute_rule based on conditionLanguage.
  10. Compile and cache CEL expressions during rule config load.
  11. Add unit tests for CEL true, CEL false, invalid expression, mode validation, and missing-field behavior.
  12. Add tests for custom legacy-operator compatibility helper functions.
  13. Add performance tests for context conversion with large toolArguments and response payloads.
  14. Add gateway integration tests using the existing rule context and the context root variable.
  15. Add rule test API support so Light-Portal can validate CEL before publish.
  16. Add CEL rule editing, a controlled CEL profile selector, and approval UX for stronger profile requests.
  17. Document runtime compatibility and Java parity requirements.

Decision

Support CEL conditions as the only Light-Fabric rule condition language. Native condition rows remain a legacy Java yaml-rule format and must not be emitted to Light-Fabric runtime configuration. A Light-Fabric rule should use conditionLanguage: cel; mixed native/CEL condition arrays are not a supported authoring or runtime model.

Debugging CEL Rules

Status

The short-term referenced-context trace logging described here is implemented. Structured decision outcomes and the rule-test API remain proposed long-term work.

Problem

The Light-Gateway access-control handler and MCP router both evaluate CEL rules through the shared access-control runtime. When a request is denied, a rule author usually sees only a generic access-denied response. That response does not distinguish among these cases:

  • the CEL expression evaluated successfully and returned false
  • CEL compilation or evaluation failed
  • the expression returned a non-boolean value
  • the endpoint referenced a missing rule
  • an action rejected the rule after its CEL condition matched
  • accessRuleLogic: all or accessRuleLogic: any produced the final denial
  • defaultDeny applied because no endpoint or req-acc rule matched
  • MCP tools/list hid a tool because of policy, an unknown rule, or the maxCelEvaluations limit

These cases must remain fail-closed, but they should not be indistinguishable to an authorized operator or rule author.

Printing the complete request on every denial is not an acceptable solution. Access control runs after security, so its CEL context should contain normalized identity claims and policy inputs rather than raw authentication credentials. Diagnostics can then project only the properties referenced by the expression instead of copying unrelated claims, headers, tool arguments, request data, or response data.

Current Behavior

The current implementation already provides a useful starting point:

  • RuleEngine catches CEL execution errors and interpreter panics.
  • Failed CEL evaluations and false results can emit a separate TRACE event containing only statically referenced context properties. Metadata is logged by default; logFullCelContext: true logs bounded values.
  • Context diagnostics bound depth, node count, collection size, string length, key length, and null-path traversal.
  • Access-control context construction excludes authorization, proxy authorization, cookie, set-cookie, and API-key headers.
  • Successful CEL evaluation returns only a boolean.
  • The shared access-control runtime converts a missing rule or any rule-engine error into false for request authorization.
  • The final HTTP and MCP denial responses deliberately avoid exposing internal policy details.

The main gap is therefore not only context visibility. It is loss of structured decision information between CEL execution and the final access decision.

Goals

  • Explain why an HTTP request, MCP tool call, or MCP tool-list entry was allowed, denied, hidden, or filtered.
  • Distinguish a valid false result from a malformed rule or runtime error.
  • Show the effective CEL context during local and development rule testing.
  • Use the exact runtime evaluator for pre-deployment rule testing.
  • Correlate diagnostics with the request, policy revision, endpoint, tool, and rule version that produced the decision.
  • Preserve current fail-closed authorization behavior.
  • Keep diagnostic overhead negligible when debugging is disabled.
  • Share the implementation between HTTP access control and MCP routing.
  • Keep raw credentials and other security-handler inputs out of the CEL context.

Non-Goals

  • Return policy internals or request context to ordinary API or MCP callers.
  • Log complete request, response, token, or tool-argument payloads by default.
  • Rewrite CEL expressions into simpler expressions for diagnostic evaluation. Rewriting could change short-circuiting, macros, presence behavior, or types.
  • Turn CEL into a mutation or general scripting language.
  • Guarantee a natural-language proof for every false result. The first implementation should report facts and outcomes rather than speculate.
  • Weaken strict profile field exposure to make debugging easier.
  • Support CEL rules that inspect raw authorization headers, cookies, API keys, tokens, or other authentication credentials.

Design Principles

Separate enforcement from explanation

Authorization still maps every non-matching or erroneous outcome to deny when the policy requires fail-closed behavior. A separate diagnostic result retains the reason for authorized consumers.

Prefer pre-deployment testing

The best production diagnostic is a rule that was tested before publication. Runtime diagnostics remain necessary because live tokens, headers, endpoint resolution, and tool arguments can differ from test fixtures.

Report facts, not invented explanations

For a false result, report the expression, statically referenced paths, structural metadata or full values according to logFullCelContext, profile, and rule-combination behavior. Do not claim that a particular clause caused the result because the current CEL evaluator has no execution observer that can prove it.

Outcome Model

The boolean returned by the current rule path should be replaced internally by a structured outcome. The exact Rust types can evolve, but the semantic model should be stable:

#![allow(unused)]
fn main() {
enum RuleConditionOutcome {
    Matched,
    NotMatched,
    CompileError { message: String, source: Option<SourceLocation> },
    EvaluationError { message: String },
    NonBoolean { actual_type: String },
    SecurityProfileRejected { message: String },
}

enum RuleExecutionOutcome {
    Matched,
    ConditionNotMatched,
    ConditionError(RuleConditionOutcome),
    ActionRejected { action_ref: String },
    ActionError { action_ref: String, message: String },
    ActionNotFound { action_ref: String },
    RuleNotFound,
}
}

RuleEngine::execute_rule should return a structured result. Compatibility wrappers can continue returning bool where callers do not need diagnostics.

The shared access-control runtime should then build an aggregate decision:

#![allow(unused)]
fn main() {
struct AccessEvaluation {
    decision: AccessDecision,
    reason: AccessDecisionReason,
    rules: Vec<RuleEvaluation>,
    skipped_rule_ids: Vec<String>,
}
}

AccessDecision remains the enforcement projection. AccessEvaluation is the diagnostic projection.

Rule Aggregation Trace

The trace must preserve accessRuleLogic behavior:

  • With all, evaluation stops at the first rule that does not match or errors. Remaining rule IDs are recorded as skipped because of short-circuiting.
  • With any, evaluation stops at the first matching rule. Remaining rule IDs are recorded as skipped because of short-circuiting.
  • A rule-engine error is recorded as an error outcome even when the enforcement projection treats it like false.
  • A missing rule body is recorded as rule_not_found, not not_matched.
  • defaultDeny decisions are recorded without fabricating a rule evaluation.

Actions can mutate the rule context, so the existing candidate-context behavior for any must remain unchanged. Diagnostic collection must observe the same execution and must not evaluate a rule a second time.

Decision Trace

A decision trace should use a stable structured shape suitable for JSON logs and the future rule-test API:

{
  "timestamp": "2026-07-24T15:42:11.184Z",
  "correlationId": "request-123",
  "serviceId": "com.networknt.gateway-1.0.0",
  "policyRevision": "sha256...",
  "surface": "mcp-tools-call",
  "endpoint": "/config/query@post",
  "toolName": "queryConfig",
  "ruleType": "req-acc",
  "ruleLogic": "all",
  "decision": "denied",
  "reason": "condition_not_matched",
  "rules": [
    {
      "ruleId": "allow-config-read",
      "expressionHash": "sha256...",
      "requestedProfile": "strict",
      "effectiveProfile": "strict",
      "outcome": "condition_not_matched",
      "contextMode": "full",
      "referencedPaths": [
        "permission.roles",
        "auditInfo.subject_claims.ClaimsMap.roles"
      ],
      "referencedValues": {
        "permission.roles": ["config-admin"],
        "auditInfo.subject_claims.ClaimsMap.roles": ["developer"]
      },
      "contextTruncated": false
    }
  ],
  "skippedRuleIds": [],
  "referenceAnalysisIncomplete": false,
  "traceTruncated": false
}

Trace logs can include the expression text because this feature is intended for local and development use. They should also include ruleId, expression hash, and policy revision so that a diagnostic can be tied to the exact loaded policy.

Diagnostic Context Modes

The runtime has two context projections:

ModeContentsIntended Use
metadatareferenced paths plus presence, JSON type, null state, and collection or string size without property valuesdefault trace behavior
fullactual values for statically referenced CEL propertieslocal and development environments only

Full context must never mean an unbounded raw dump. Both modes use the same CEL-profile projection and diagnostic budgets as evaluator-error diagnostics.

CEL Reference Discovery

light-rule currently pins cel 0.14.0. That crate exposes two relevant APIs:

  • Program::references() returns the root variables and functions referenced by the compiled expression. For auditInfo.subject_claims.ClaimsMap.roles, it reports the root variable auditInfo.
  • Program::expression() exposes the public parsed AST. Expr::Select nodes contain their operand and selected field, so Light-Fabric can walk the AST and recover the complete static member path auditInfo.subject_claims.ClaimsMap.roles.

The crate does not expose an evaluation observer or a list of properties actually read at runtime. Reference discovery is therefore static: it includes properties in branches that short-circuit evaluation and cannot always resolve computed map keys.

The compiled-program cache should store a reference projection alongside each program:

#![allow(unused)]
fn main() {
struct CelProgramEntry {
    program: Arc<CelProgram>,
    referenced_roots: Vec<String>,
    referenced_paths: Vec<String>,
    reference_analysis_incomplete: bool,
}
}

Static dot selections and indexes with literal string keys should produce exact paths. For dynamic indexing, the projection should fall back to the smallest known root or static prefix and set referenceAnalysisIncomplete: true. Macro and comprehension-local variables must not be mistaken for root context variables.

This fallback deliberately broadens full mode. For example, ClaimsMap[claimName] cannot identify the selected claim statically, so full mode emits the bounded ClaimsMap parent object as well as claimName. Metadata mode emits only the parent's type and size. Rule authors should prefer literal indexes or dot selections when they want the narrowest diagnostic projection.

The reference walker is coupled to the public AST and operator names in the pinned cel 0.14.0 crate. A CEL dependency upgrade must revalidate the walker and its literal-index, dynamic-index, and comprehension tests.

Access-Control Context Boundary

Security authenticates the request before access control runs. The security handler should expose normalized identity and authorization facts through auditInfo.subject_claims.ClaimsMap; it should not forward the credential used to establish those facts into CEL.

The access-control CEL context must therefore exclude raw values such as:

  • Authorization and Proxy-Authorization headers
  • cookies and session tokens
  • API keys and client secrets
  • private keys or credential material owned by an earlier handler

If a rule needs an identity fact derived from one of these inputs, the security handler should expose the normalized claim instead. For example, a rule should read a roles claim from auditInfo, not parse the bearer token.

Other CEL inputs should be policy-oriented: endpoint and tool identity, permissions, selected non-sensitive headers, correlation metadata, referenced tool arguments, and the request or response properties required by the rule phase.

Referenced Context Projection

Diagnostics start from the variables exposed by the effective CEL security profile and keep only the statically referenced properties. A diagnostic cannot include an unrelated property merely because it exists in the root context.

The projection then keeps only the statically referenced properties. Most current request-access rules reference JWT claims below auditInfo.subject_claims.ClaimsMap, so a rule that reads only the caller's roles should not cause unrelated headers, claims, or tool arguments to be logged.

The diagnostic path does not add header or JSON-path masking. Sensitive credentials are excluded when the access-control context is constructed, and reference projection removes unrelated properties. logFullCelContext: true can therefore emit the actual values of referenced policy properties. It is intentionally a local and development-only setting and must emit a startup warning.

Values that require special handling

  • JWT claims and toolArguments include only statically referenced properties.
  • responseBody and responseBodyJson include only statically referenced properties.
  • A row-level CEL filter captures at most the current bounded row and should not repeat the shared context for every rejected row.
  • Binary data is represented by type and length, not encoded into the trace.

Bounds

Reuse the existing limits for diagnostic context depth, nodes, collection items, string characters, key characters, null paths, and null-path traversal. Add an overall serialized trace byte limit. Every limit must have a corresponding truncation field so an operator can distinguish absent data from omitted data.

Runtime Configuration

The short-term configuration should be one root-level property in access-control.yml:

logFullCelContext: false

This property does not enable trace logging. The logging filter still controls whether CEL trace events are emitted, for example:

RUST_LOG=light_rule::cel=trace,info

The property controls only the context projection used by those events:

  • false or absent: emit referenced paths and structural metadata without property values at TRACE
  • true: emit actual values for the referenced CEL properties at TRACE for local or development use

No mode performs diagnostic masking. The serialized trace remains size-limited and reports truncation, but full-mode values are otherwise emitted as they appear in the credential-free access-control context.

The runtime should emit a prominent startup warning when logFullCelContext: true is loaded:

Full CEL context logging is enabled. This setting is intended only for local or development environments.

Trace events should cover CEL evaluation errors and successful evaluations that return false. A per-rule false event must be labeled as a rule outcome, not as a final access denial, because accessRuleLogic: any can allow the request through a later rule.

This changes earlier evaluator-error logging behavior: bounded context and candidate-null-path diagnostics previously appeared with the WARN or ERROR event. They now appear only in the separate light_rule::cel TRACE event; the warning or error retains the expression and failure details without request context. Operators who relied on warning-level context must enable the trace target while diagnosing CEL failures.

Rule-Test API

Rule authors should be able to test before publishing. The test path must use the same light-rule evaluator, profile enforcement, context conversion, and action registry as the target runtime.

Suggested request:

{
  "rule": {
    "ruleId": "allow-config-read",
    "ruleType": "req-acc",
    "conditionLanguage": "cel",
    "conditionSecurityProfile": "strict",
    "expression": "permission.roles.exists(r, r in auditInfo.subject_claims.ClaimsMap.roles)"
  },
  "context": {
    "auditInfo": {
      "subject_claims": {
        "ClaimsMap": {
          "roles": ["developer"]
        }
      }
    },
    "permission": {
      "roles": ["config-admin"]
    },
    "endpoint": "/config/query@post",
    "toolName": "queryConfig",
    "toolArguments": {}
  }
}

Suggested response:

{
  "outcome": "condition_not_matched",
  "requestedProfile": "strict",
  "effectiveProfile": "strict",
  "referencedPaths": [
    "permission.roles",
    "auditInfo.subject_claims.ClaimsMap.roles"
  ],
  "referencedValues": {
    "permission.roles": ["config-admin"],
    "auditInfo.subject_claims.ClaimsMap.roles": ["developer"]
  },
  "warnings": [],
  "contextTruncated": false
}

The API should support two modes:

  • isolated rule evaluation for the editor
  • endpoint-policy evaluation using a selected configuration snapshot, including rule ordering, all or any, permissions, and default-deny behavior

The endpoint-policy mode is essential because a rule can work in isolation but still not be selected for the deployed endpoint.

The test API must not accept arbitrary production credentials or fetch live requests. Test contexts are explicit input, access-controlled, size-limited, and excluded from ordinary logs.

Portal Experience

The CEL editor should present:

  • the documented context schema for the selected rule phase
  • requested and effective security profiles
  • syntax and profile validation before publication
  • an editable constructed test context
  • matched, not-matched, or error as distinct states
  • per-rule endpoint-policy results and short-circuiting
  • referenced paths beside their constructed test values
  • missing-field, null-receiver, type, and non-boolean errors

A production denial shown to an ordinary caller remains generic. Rule testing uses a constructed context in the Portal rather than retrieving a live request context from the gateway.

MCP-Specific Behavior

The same trace model applies to MCP with a surface field that identifies:

  • mcp-tools-call
  • mcp-tools-list
  • mcp-response-filter

For tools/call, include the resolved configured tool name and endpoint, but include only tool-argument properties referenced by the CEL expression.

For CEL-based tools/list, one request can evaluate many tools. Do not emit one large context event per hidden tool by default. Emit a bounded aggregate summary with counts and keep any per-tool trace detail bounded. Distinguish:

  • hidden by a CEL false result
  • hidden by CEL error
  • hidden by unknown-rule fallback
  • skipped after maxCelEvaluations
  • served from the tools-list visibility cache

If a result came from cache, include the policy revision and cache outcome. Do not claim that CEL was evaluated for that request.

Response-Filter Behavior

Response filtering needs separate outcomes because false can mean different things at different layers:

  • rule-level CEL condition did not select the filter
  • a response-filter action rejected execution
  • a row-level CEL expression excluded a row
  • a row-level expression failed and the row was excluded fail-closed
  • the complete top-level object was denied

Row filtering can evaluate the same expression hundreds or thousands of times. Capture the first bounded failure sample, total matched/excluded/error counts, and the number of suppressed diagnostics. Never emit the entire response body.

Logs, Metrics, and Audit

Use stable tracing targets and event names rather than prose-only messages:

target: light_rule::decision
event: cel_rule_decision

Summary events should contain scalar fields that remain useful in text and JSON logging. Detailed context can be a bounded JSON field.

Recommended counters:

  • CEL evaluations by outcome, rule type, and security profile
  • access decisions by reason and surface
  • diagnostic traces captured, truncated, sampled out, or rate-limited
  • rule-test requests by outcome
  • tools-list evaluations skipped by maxCelEvaluations

Do not use rule IDs, endpoints, tool names, correlation IDs, or user identities as unbounded metric labels. Those belong in logs or traces.

Individual rule evaluations normally remain operational tracing events, not durable security audit records, unless deployment policy requires otherwise.

Performance and Abuse Controls

  • When TRACE is disabled for the CEL tracing target, avoid cloning or serializing context solely for diagnostics.
  • Build a context projection only after confirming that the trace event is enabled.
  • Reuse the compiled CEL program and any compile-time reference analysis.
  • Never reevaluate a CEL expression to explain its first result.
  • Bound trace size, row samples, and tools-list samples.
  • Rate-limit rule-test execution.
  • Apply existing CEL profile and expression-complexity limits to test requests.
  • Record when sampling or limits omitted diagnostic data.

Failure Behavior

Diagnostics must never change the access decision. If reference analysis, serialization, or log emission fails:

  1. preserve the original allow or deny result
  2. emit a bounded diagnostic-system error without request context
  3. increment a diagnostic failure counter
  4. do not retry on the request path

The diagnostic system itself should be panic-contained where it processes untrusted context values.

Implementation Phases

Phase 1: Preserve outcomes

  • Introduce structured condition and rule outcomes in light-rule.
  • Stop collapsing every RuleEngine error into an unexplained false inside the shared access-control runtime.
  • Preserve boolean compatibility wrappers for unrelated callers.
  • Add aggregate outcomes for all, any, missing rules, and default deny.
  • Emit safe summary tracing events for errors and denials.

Phase 2: Referenced diagnostic projection

Implemented for the short-term runtime diagnostic path:

  • Exclude raw credentials when constructing the access-control CEL context.
  • Centralize context reference analysis, projection, and bounds.
  • Apply the same safe projection to existing CEL error and panic logs.
  • Use Program::references() and the public AST to extract referenced roots and static member paths.
  • Add the root-level logFullCelContext configuration to access-control.yml.

Remaining enhancements:

  • Add expression hashes, policy revisions, and stable reason codes.
  • Add MCP tools-list aggregation and response-row sampling.

Phase 3: Authoring workflow

  • Add isolated-rule and endpoint-policy test APIs.
  • Integrate the APIs into the Portal CEL editor.
  • Validate rules with the target runtime evaluator before publication.

Testing Strategy

Outcome tests

  • CEL true and false
  • compile error, evaluation error, panic, and non-boolean result
  • missing rule and missing action
  • action rejection and action error
  • all and any short-circuit traces
  • default allow and default deny
  • HTTP, MCP tools/call, MCP tools/list, and response-filter surfaces

Security tests

  • raw authorization headers, cookies, API keys, and credentials never enter the access-control CEL context
  • strict diagnostics never expose roots unavailable to strict CEL
  • unrelated headers, claims, tool arguments, and response fields are absent
  • metadata mode reports structure without property values
  • full mode reports values only for referenced properties
  • request input cannot enable full context logging
  • truncation flags are correct

Parity tests

  • rule-test and runtime execution return the same outcome for the same rule, context, profile, and policy revision
  • diagnostic collection does not change action mutation or short-circuiting
  • cached MCP tools-list decisions are labeled as cached and are not reported as fresh evaluations

Performance tests

  • disabled trace logging has no material request-path allocation regression
  • metadata and full trace logging remain within defined latency budgets
  • large contexts, large rows, and tools-list fan-out remain bounded

Resolved Decisions

  1. The pinned cel 0.14.0 crate exposes referenced root variables and a public AST, so Light-Fabric will statically extract related context paths and log only those properties. It does not expose actual runtime property reads.
  2. The diagnostic path will not add masking. Access-control context construction excludes raw credentials, and full mode logs the actual bounded values of referenced policy properties.
  3. The design does not add log retention or audit requirements. Local logging and its existing rotation policy own trace retention.

The referenced-context trace logging and root-level logFullCelContext switch are the short-term implementation. Next, add structured outcomes, then the rule-test API and Portal editor support.

This sequence fixes the information-loss problem, gives authors a safe way to inspect rule context during local development and test rules before deployment, and can adopt runtime property-read tracing later if the CEL evaluator adds a trustworthy observer API.

Access Control Handler Design

The access-control handler enforces fine-grained authorization for normal HTTP API endpoints. It should reuse the same Light-Rule policy model that the MCP router uses for tool authorization:

  • access-control.yml controls whether policy is enabled, whether missing endpoint rules deny by default, how multiple request access rules combine, and which endpoint prefixes are skipped.
  • rule.yml contains CEL rule bodies and endpoint mappings.
  • req-acc rules run before the upstream API endpoint is called.
  • res-fil rules run after the upstream API endpoint responds and before the response is returned to the caller.

The access-control handler and the MCP router should share the same frameworks/light-pingora/src/access_control.rs runtime. The difference is the boundary where that runtime is applied. The MCP router protects MCP tools. The access-control handler protects API endpoints in the normal handler chain.

Goals

  • Enforce fine-grained access control for REST or HTTP API endpoints.
  • Reuse the existing req-acc and res-fil rule phases.
  • Reuse the built-in action classes: RoleBasedAccessControlAction, ResponseRowFilterAction, and ResponseColumnFilterAction.
  • Keep rule definitions portable between gateway products when the endpoint key and context fields are equivalent.
  • Support exact endpoint rules, Java-style path templates, and parent path entries.
  • Keep the business API unaware of caller-specific row and column filtering.

Non-Goals

  • Do not create a second rule engine for HTTP APIs.
  • Do not support the legacy native condition-row format in Light-Fabric. Light-Fabric rules must use conditionLanguage: cel.
  • Do not replace base authentication. The access-control handler assumes an earlier security handler has already built the caller principal.
  • Do not push row or column filtering into business handlers.

Handler Placement

The access-control handler should run after authentication and before routing to the upstream API service:

request
  -> TLS / CORS / rate-limit / header handlers
  -> security or unified-security handler
  -> access-control req-acc
  -> proxy or route handler
  -> access-control res-fil
  -> response

If no authenticated principal is available, a req-acc rule can still evaluate headers and endpoint metadata, but role, group, user, and claim-based rules will normally fail closed.

Shared Runtime

The existing runtime already models the common policy engine:

#![allow(unused)]
fn main() {
AccessControlRuntime
  -> authorize_tool(...)
  -> filter_mcp_response(...)
}

For API endpoints, these functions should be generalized rather than duplicated. The MCP-specific names can remain as compatibility wrappers, but the shared runtime should expose endpoint-neutral operations:

#![allow(unused)]
fn main() {
authorize_request(
  endpoint,
  headers,
  auth,
  request_context,
  correlation_id
)

filter_response(
  endpoint,
  headers,
  auth,
  request_context,
  response_status,
  response_body,
  correlation_id
)
}

The MCP router can keep passing toolName and toolArguments. The API handler should pass API-oriented values such as path parameters, query parameters, request method, and request body metadata.

Endpoint Keys

Endpoint rule keys should use the same stable format as the MCP router:

{path}@{method}

Examples:

/offers@get
/v1/accounts/{accountId}@get
/v1/accounts@post

The query string must not be part of the endpoint key. Query parameters belong in the rule context so CEL can inspect them without multiplying endpoint rule entries.

Endpoint matching order should remain:

  1. Exact endpoint key.
  2. Java-style path template match, such as /v1/accounts/{id}@get.
  3. Parent path entry, such as /v1/accounts@get for /v1/accounts/123@get.

Configuration

access-control.yml is the handler-level switch:

enabled: true
accessRuleLogic: any
defaultDeny: true
defaultInclude: false
skipPathPrefixes:
  - /health
  - /adm
claimMappings: {}

Fields:

  • enabled: when false, the handler allows requests and does not filter responses.
  • accessRuleLogic: any allows a request if any req-acc rule passes; all requires every listed req-acc rule to pass. This setting applies only to req-acc; it does not apply to res-fil.
  • defaultDeny: when true, a request with no matching endpoint rule or no req-acc rule is denied.
  • defaultInclude: controls response row-filter behavior when a row filter is configured but no caller claim matches any configured row-filter entry. When false, the row filter returns no rows. When true, the row filter preserves the legacy include-all behavior.
  • skipPathPrefixes: endpoint prefixes that bypass access-control entirely.
  • claimMappings: maps permission dimensions to JWT claim names for built-in request-access, row-filter, column-filter, and MCP tool-visibility behavior. Standard keys are roles, groups, positions, attributes, and users. Custom row or column dimensions use the dimension name as the mapping key.

For example, a deployment with custom role and tenant claims can use:

claimMappings:
  roles:
    - custom_roles
  tenant:
    - tenant_id

Standard aliases remain active when a dimension has no configured mapping. Existing toolsListAccessControl.claimMappings configuration remains supported as a compatibility fallback, but top-level claimMappings takes precedence and applies consistently to authorization and response filtering.

rule.yml contains the reusable rules and endpoint policy:

ruleBodies:
  allowOfferRead:
    common: Y
    ruleId: allowOfferRead
    ruleName: Allow offer read
    ruleType: req-acc
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      auditInfo.subject_claims.ClaimsMap.role != null
    actions:
      - actionClassName: com.networknt.rule.RoleBasedAccessControlAction

  filterOfferRows:
    common: Y
    ruleId: filterOfferRows
    ruleName: Filter offer rows
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200
      && responseBody != ""
      && auditInfo.subject_claims.ClaimsMap.role != null
    actions:
      - actionClassName: com.networknt.rule.ResponseRowFilterAction

  filterOfferColumns:
    common: Y
    ruleId: filterOfferColumns
    ruleName: Filter offer columns
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200
      && responseBody != ""
      && auditInfo.subject_claims.ClaimsMap.role != null
    actions:
      - actionClassName: com.networknt.rule.ResponseColumnFilterAction

endpointRules:
  /offers@get:
    req-acc:
      - allowOfferRead
    res-fil:
      - filterOfferRows
      - filterOfferColumns
    permission:
      roles: offer-viewer offer-admin
      row:
        role:
          offer-viewer:
            - colName: priority
              operator: "<"
              colValue: 50
            - colName: active
              operator: "="
              colValue: true
      col:
        role:
          offer-viewer: offerId,title,segment,state,category,priority

In this example, offer-viewer can call GET /offers but only receives active offers with priority below 50, and the active field is removed from the final payload. With defaultInclude: false, offer-admin can call the same endpoint only if a matching row-filter entry exists or the endpoint omits row filtering. If an endpoint has a row block but no row entry matches the caller's role, group, position, attribute, or user claim, the filtered result is empty. Set defaultInclude: true only when a deployment intentionally wants the legacy include-all behavior for unmatched row-filter claims.

Rule Context

The access-control handler should build the same core context shape as the MCP router so existing CEL rules and actions stay reusable:

FieldDescription
auditInfoNormalized authenticated principal claims and correlation id.
headersLower-cased request headers.
endpointStable endpoint key, such as /offers@get.
permissionThe endpoint permission object.
correlationIdCorrelation id when present.
statusCodeResponse status code during res-fil.
responseBodyResponse body string during res-fil.

For API endpoints, add API-specific fields:

FieldDescription
requestMethodHTTP method.
requestPathPath without query string.
queryParametersParsed query parameter map.
pathParametersValues captured from a path template when available.
requestBodyParsed JSON request body when available and within size limits.
requestBodyTextRaw request body string when parsing is not enabled.

The existing MCP fields can remain optional:

FieldUsage
toolNamePresent for MCP router calls, absent or empty for API endpoints.
toolArgumentsPresent for MCP router calls. API endpoints should prefer queryParameters, pathParameters, and requestBody.

Permission values should continue to be injected twice:

  • as the namespaced permission object
  • as top-level convenience fields, such as roles, row, and col

This preserves compatibility with existing rule bodies and built-in action classes. Runtime-owned context fields are the exception: permission keys named auditInfo, headers, endpoint, toolName, toolArguments, correlationId, permission, responseBody, responseBodyJson, statusCode, or accessControl remain available under permission but are not promoted to the top level. This prevents endpoint configuration from replacing verified identity, request, or response context.

Request Access

req-acc runs before the upstream API call.

The handler should:

  1. Build the endpoint key from request path and method.
  2. Skip the request if the endpoint matches skipPathPrefixes.
  3. Find endpoint rules by exact, template, or parent match.
  4. Deny when defaultDeny: true and no matching req-acc rule exists.
  5. Build the rule context from auth, headers, endpoint, request fields, and endpoint permissions.
  6. Execute the listed req-acc rules with accessRuleLogic.
  7. Return 403 when access is denied.

When accessRuleLogic: any, each candidate rule should receive a cloned context, and the first passing rule should win. When accessRuleLogic: all, rules should run sequentially against the same context and all must pass.

Response Filtering

res-fil runs after the upstream API response returns.

The handler should:

  1. Only filter response payloads that are safe and useful to parse, starting with JSON arrays, JSON objects containing an items array, and single JSON objects for column filtering.
  2. Buffer the full response body before filtering.
  3. Decode or avoid upstream compression before JSON parsing.
  4. Add statusCode, responseBody, and the parsed mutable JSON value to the same rule context shape.
  5. Execute res-fil rules sequentially in the order listed on the endpoint.
  6. Serialize the filtered JSON once after all res-fil actions complete.
  7. Replace the response body with the final filtered JSON.
  8. Recompute response headers that depend on body size, such as content-length.

Ordering matters. Row filters must run before column filters when the row predicate depends on a field that should be hidden in the final response. For example, a row filter can use active == true, and the later column filter can remove active from the returned rows.

res-fil is always a sequential all pipeline. accessRuleLogic: any applies only to req-acc; response filters never use any semantics.

Response filtering requires a full payload. It is not compatible with streaming or indefinite responses unless the gateway buffers the entire response first. For Transfer-Encoding: chunked, the gateway must buffer and then emit a normal filtered response. Server-Sent Events and other long-lived streaming responses should bypass res-fil or be rejected when an endpoint requires response filtering.

Compressed upstream responses need explicit handling. The gateway should either strip or normalize Accept-Encoding on the upstream request so the backend returns plaintext JSON, or it must decompress before filtering and recompress afterward. Filtering compressed gzip, br, or deflate bytes as JSON must fail closed.

If a res-fil rule is missing, fails, or returns false, the handler should fail closed for protected API endpoints. For early rollout, a deployment can choose a fail-open compatibility mode only if it is explicit in configuration and emits a high-severity log or module-registry status.

CEL must not directly rewrite the HTTP response body. A res-fil rule-level CEL expression decides whether the filter action should run. The response-filter pipeline owns JSON parsing, final serialization, response body replacement, and header updates such as content-length. Actions own row or column mutation of the parsed response value.

The default model should remain declarative:

  • ResponseRowFilterAction applies permission-defined row filters.
  • ResponseColumnFilterAction applies permission-defined field keep or remove lists.

Row Filter Default Behavior

Row filtering must fail closed by default. If ResponseRowFilterAction runs for an endpoint with a configured permission.row block, but the caller has no matching entry under any supported dimension (role, group, position, attribute, or user), the action must return an empty row set when defaultInclude: false.

This prevents a common policy gap:

permission:
  row:
    role:
      teller:
        - colName: accountType
          operator: "="
          colValue: C

In the legacy include-all behavior, a caller without the teller role would match no row-filter entry and receive every row. With defaultInclude: false, the same caller receives no rows. A caller with the teller role receives only rows where accountType == "C".

defaultInclude applies only to row-filter miss behavior:

  • false: unmatched row-filter dimensions retain no rows. This is the secure default and should be used for new deployments.
  • true: unmatched row-filter dimensions retain all rows. This is a compatibility mode for deployments that relied on the old behavior.

If a row-filter entry matches the caller, normal row predicate evaluation still applies. If multiple dimensions match, the configured filter groups are combined with the existing sequential all behavior so a row must satisfy every matched group.

If an API needs a richer row predicate, add a CEL-aware action rather than making rule-level CEL mutate JSON:

ruleBodies:
  filterOfferRowsWithCel:
    ruleId: filterOfferRowsWithCel
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200 && responseBody != ""
    actions:
      - actionClassName: com.networknt.rule.ResponseCelRowFilterAction
        actionValues:
          rowExpression: >
            auditInfo.subject_claims.ClaimsMap.role == "offer-admin"
            || (row.priority < 50 && row.active == true)

ResponseCelRowFilterAction should compile rowExpression during rule load and evaluate it once per row with a curated context containing row, auditInfo, headers, endpoint, permission, and API request metadata. It owns row retention and failure handling for the parsed response value. It should not deep-clone the full base context for every row; use a child context that shadows row, or reuse one mutable context and update only the row binding.

If a row-level CEL evaluation fails for one row, for example because priority is missing, the action should drop that row and continue. Compile errors, invalid actionValues, or other configuration errors should fail the whole action closed.

MCP Router Comparison

The MCP router and access-control handler should share configuration and runtime semantics:

ConcernMCP routerAccess-control handler
Protected targetMCP toolsHTTP API endpoints
Endpoint keyTool endpoint or derived {path}@{method}Request {path}@{method}
Request inputtoolArgumentsquery parameters, path parameters, request body
Request access phasereq-acc before backend tool callreq-acc before upstream API call
Response filter phaseres-fil before JSON-RPC resultres-fil before HTTP response
Response body targetMCP structuredContent or text contentHTTP response body
Rule languageCEL onlyCEL only

This split keeps MCP behavior specialized for JSON-RPC tool calls while letting API endpoint authorization use the same policy and action implementation.

Reload And Observability

The loader should continue to support both standalone files and values.yml projection:

  • access-control.yml or access-control.yaml
  • rule.yml or rule.yaml
  • access-control.* values in values.yml
  • rule.ruleBodies and rule.endpointRules values in values.yml

The root-level access-control setting below controls CEL context trace detail:

logFullCelContext: false

At the default false, failed or rejected CEL expressions log referenced paths and structural metadata at TRACE. When set to true, the same event includes bounded values for only the referenced properties. Full mode is intended only for local or development environments. Access-control context construction excludes credential headers in both modes.

The module registry should report:

  • whether access control is enabled
  • whether rule config is loaded
  • number of rule bodies
  • number of endpoint mappings
  • last reload status
  • validation errors for rejected CEL, missing rule ids, or invalid endpoint keys

Config reload should build a new immutable runtime and swap it atomically after validation succeeds. If validation fails, the handler should keep the last known-good runtime.

Implementation Notes

The current AccessControlRuntime is already close to the shared runtime. The main implementation work is to remove MCP-specific naming from the reusable API and add an HTTP response-body adapter:

  • Keep authorize_tool and filter_mcp_response as wrappers for the MCP router.
  • Add endpoint-neutral authorization and response-filter methods.
  • Add API-specific request context fields without changing the existing auditInfo, headers, endpoint, permission, responseBody, and statusCode fields.
  • Reuse find_service_entry, rule_ids_for, permission_for, and the default action registry.
  • Reuse ResponseRowFilterAction for JSON arrays, object payloads with an items array, and single top-level JSON objects. A denied single object is replaced with an empty object for HTTP responses; the MCP adapter returns a tool error with isError: true.
  • Reuse ResponseColumnFilterAction for JSON arrays, object payloads with an items array, and single top-level JSON objects.
  • Add handler-level tests for exact endpoint, path template endpoint, parent path endpoint, default deny, skip prefixes, row filtering, and column filtering.

Design Document: Centralized Agentic Skill Registry

Subject: Transitioning from File-Based Markdown Skills to a Database-Backed Skill Registry


1. Executive Summary

Currently, most AI agent frameworks rely on localized Markdown (.md) files to define agent "skills." While Markdown is highly LLM-native and human-readable, it creates significant bottlenecks at an enterprise scale regarding strict typing, API integration, and context window limits.

This document proposes transitioning to an Agentic Control Plane (Centralized Skill Registry) backed by a database. By decoupling skill metadata, schemas, and instructions, and by utilizing dynamic routing, we will achieve hierarchical structuring, strict schema enforcement, and progressive disclosure of tools to agents.

The registry serves enterprise business agents, native workflow agents, coding agents, and personal assistants. It stores governed content and immutable package references; profile-specific runtime hosts materialize the selected skill. See Light-Agent Execution for those runtime and isolation boundaries.


2. Problem Statement

Managing agent skills as flat Markdown files introduces several scaling challenges:

  1. Lack of Strict Typing: Markdown cannot enforce data types (e.g., ensuring a parameter is an integer vs. string), leading to hallucinated or malformed tool inputs.
  2. Context Window Exhaustion: Loading dozens or hundreds of skill definitions at startup overwhelms the LLM context window, increasing latency, token costs, and tool-misuse.
  3. Static Deployments: Updating a skill or changing access permissions requires a full application redeploy.
  4. Poor Discoverability: Flat file structures offer no native mechanism for progressive disclosure or tool search.

3. Data Models & Formats

To solve the limitations of purely text-based skills, we will adopt a hybrid, structured format stored within a database (e.g., PostgreSQL/MongoDB). The architecture uses the right format for the right job:

  • JSON Schema: Used strictly for defining parameters, inputs, and tool shapes. Natively supported by OpenAI/Anthropic/Google tool-calling APIs.
  • LightAPI Description (YAML/JSON): Used to map endpoint-level API capabilities to skills across REST, JSON-RPC, gRPC, and MCP.
  • OpenAPI / OpenRPC / Protobuf: Referenced by LightAPI where protocol-native specifications already exist.
  • Immutable Execution Artifact / Endpoint Reference: API skills reference a governed endpoint. Scripted skills reference a signed, content-addressed package and entrypoint; mutable source code is not executed directly from a database row.
  • Markdown: Retained only for the instructions or prompt fields, as LLMs excel at parsing markdown headers and lists for constraints and persona instructions.

LightAPI is the preferred source format for API-backed skills because it describes endpoint identity, protocol invocation, input schema, request mapping, result shape, examples, and behavior notes in one agent-oriented document. See LightAPI Description Design for the endpoint description model.

YAML and JSON are the external skill document formats. In the portal database, they should not replace the Markdown instruction field. The normalized model is structured columns and relationships for identity, versioning, taxonomy, tools, and execution metadata, plus content_markdown for the LLM-facing instruction body. If the portal later needs to persist a full structured skill document, add a nullable JSONB skill-spec column beside content_markdown and normalize YAML imports to JSON.

3.1 Proposed Database Schema Structure

Light Portal stores skills in structured catalog tables. Below is a representation of the skill payload:

{
  "skill_id": "sk_finance_001",
  "name": "generate_financial_report",
  "version": "1.2.0",
  "tags": ["finance", "reporting"],
  "tool_schema": {
    "type": "function",
    "function": {
      "name": "generate_financial_report",
      "description": "Generates a Q3 report based on ticker symbol.",
      "parameters": {
        "type": "object",
        "properties": {
          "ticker": {"type": "string", "description": "The stock ticker"}
        },
        "required": ["ticker"]
      },
      "response_schema": {
        "type": "object",
        "properties": {
          "report_url": {"type": "string"},
          "status": {"type": "string"}
        }
      }
    }
  },
  "execution": {
    "type": "rest_api",
    "endpoint_id": "ep_finance_report_001",
    "endpoint": "https://internal-api.company.com/v1/finance/report",
    "method": "POST"
  },
  "instructions": "## Role\nYou are a financial analyst.\n## Constraints\n- Never hallucinate financial data.\n- Always return exact numbers."
}

3.2 Skill Authority And Executable Packages

A skill is discovery and guidance content. Assignment of a skill never grants tools, credentials, network, filesystem, workflow, or model-provider access. The effective capability is always the intersection of caller authority, agent-definition policy, skill policy, live gateway/controller policy, and the selected execution profile.

Each tool link resolves to a stable internal tool reference with a server-owned execution placement (gateway, runner, workflow, or fixed-service), model-facing alias, and schema digest. Materialization cannot change that placement. Gateway tools intersect live gateway tools/list; runner tools intersect execution policy, lease allowedTools, the approved runtime-tool manifest, and live local availability. The independently authorized sets may be combined only after alias collisions are rejected or resolved by deterministic server-owned aliases. One placement never grants another placement's tool by name coincidence.

Keep skill_t.content_markdown as the instruction source. Add a separate skill_package_t only for skills that require scripts, binaries, templates, or other runtime assets. A package record should contain:

  • host, skill, semantic version, package ID, and immutable artifact URI;
  • SHA-256 digest, media type, size, and entrypoint;
  • supported runtime profiles and required capability names;
  • minimum sandbox boundary, network, workspace, and credential requirements;
  • provenance/attestation reference, signer, scanner result, and review state;
  • created, deprecated, revoked, and retention state.

The package bytes belong in immutable artifact storage, not a TEXT column. The portal may store authoring source separately, but only a reviewed, signed, scanned, active package can be materialized for execution. The runtime host verifies the package, lease, policy digest, and entrypoint before mounting it read-only.

Existing tool_t.script_content is a legacy authoring/runtime shortcut. It must not become the production execution path for untrusted Python or JavaScript. Publication should compile or package that source into an immutable artifact and require runner placement.


4. Hierarchical Structure & Progressive Disclosure

Dumping 500 JSON schemas into an LLM's context window will cause system failure. The Centralized Controller will act as a mediator, enforcing hierarchy and progressive disclosure (giving the agent only the schemas it needs, exactly when it needs them).

4.1 Implementing Hierarchy & Tagging

Because JSON Schema does not have built-in folders, hierarchy and categorization are enforced via the platform's global entity management system:

  1. Namespacing: Tool names follow a strict convention: [domain]_[subdomain]_[action] (e.g., aws_rds_provision).
  2. Tags & Categories: Instead of hardcoded columns, the registry utilizes the entity_tag_t and entity_category_t tables (with entity_type = 'skill'). This allows for unlimited flat tagging and deep hierarchical folder structures that are consistent across the entire portal.
  3. Discovery API: Portal-query filters by these tags/categories to scoped skill sets for specific agent personas. Agents cache the effective catalog locally and reload it when runtime cache-management invalidation is triggered.

4.2 Progressive Disclosure Patterns

Agents should not load every executable tool into the LLM context. Instead, they should load their assigned skill/tool catalog from the portal API, cache it locally, and use one of the following progressive disclosure patterns:

Phase 5 starts with the Rust light-agent. The agent loads genai-query/getEffectiveAgentCatalog, keeps a local cache keyed by hostId + agentDefId + serviceId + envTag, ranks cached skill/tool entries with keyword and routing-field matching, and intersects the selected tool names with the live gateway tools/list result before giving schemas to the model. Execution remains gateway tools/call.

Pattern A: Meta-Tools (Dynamic Injection)

The agent is booted with only two "meta-tools" designed for discovery.

  1. Local catalog search: Agent searches its cached assigned skills. The cache contains lightweight summaries and mapped tool names.
  2. Schema loading: Once the agent identifies the correct tool, it loads the schema from the local catalog cache or refreshes the cache from portal-query.

Pattern B: Semantic Tool RAG (Zero-Shot Discovery)

For highly complex systems with thousands of skills:

  1. Tool descriptions are embedded into a Vector Database (e.g., pgvector).
  2. When the user prompts the system (e.g., "Reset my AWS password"), portal-query or the agent's local cache performs semantic search and retrieves the Top-3 most relevant JSON Schemas.
  3. The agent boots with only those 3 tools in its context.

Pattern C: Multi-Agent Orchestration (Supervisor / Worker)

Hierarchy is mapped to agent teams.

  1. A Supervisor Agent holds routing tools (e.g., delegate_to_finance, delegate_to_devops).
  2. When delegate_to_devops is triggered, the supervisor routes to a DevOps Worker Agent, loading only the specific DevOps JSON schemas into its context.

4.3 Runtime Profiles And Materialization

One centrally assigned skill can serve several agent products without forcing every runtime to consume the same physical format.

Runtime profileMaterialized skill inputExecution boundary
Enterprise business agentBounded Markdown instructions and selected API/MCP schemasLong-lived light-agent plus light-gateway
Native workflow agentInstructions, structured task input, and output schemalight-workflow, with no local tools
Coding agentRead-only SKILL.md, references, and verified package assetslight-agent-worker inside a runner sandbox
Personal assistantInstructions, connector mappings, schedule/notification policy, and optional reviewed packagelight-agent plus gateway or personal edge runner
External agent adapterAdapter-specific files generated from the immutable skill versionSame sandbox as the selected runtime adapter

Materializers are deterministic and versioned. Their output digest becomes part of the turn/runtime policy snapshot. Runtime-specific rendering may adapt file names or metadata, but it cannot add a tool or capability absent from the effective catalog.

For sandboxed profiles, the materializer emits immutable skill-package references, digests, sizes, and mount/entrypoint policy; it does not fetch from inside the sandbox. Trusted light-workflow-runner code downloads each package before sandbox creation, verifies digest/signature/provenance/scan bindings and archive safety, and stages it as a read-only mount with nodev, nosuid, and noexec unless a reviewed entrypoint requires execution. The worker may revalidate the mounted manifest, but neither the worker nor generated code receives artifact-store credentials or package-download egress. Verification or staging failure prevents the sandbox from starting.

Use the following content precedence, from strongest to weakest:

  1. server and execution policy;
  2. signed platform/tenant skill versions assigned to the agent;
  3. reviewed user-specific skill configuration;
  4. repository or workspace-local instructions;
  5. prompts, retrieved data, messages, and tool output.

Repository-local and user-generated skills are useful context but untrusted. An agent-generated skill is stored as an inactive proposal. It becomes usable only after schema validation, security scanning, human or policy review, immutable packaging, and explicit assignment. Self-modification never hot-activates new authority in the current turn.


5. Example Flow: Dynamic Loading in Action

User: "I need to provision a new database for the marketing team."

  1. Turn 1: Discovery
    • Agent Context: Has a local cache of assigned skill summaries.
    • Agent Action: Searches the local cache for provision database.
  2. Turn 2: High-Level Awareness
    • Local Cache Result: Returns token-efficient summaries from the portal catalog: [{"name": "aws_rds_provision", "description": "Creates AWS RDS DB"}, {"name": "mongo_atlas_create", "description": "Creates Mongo cluster"}]
    • Agent Action: Decides AWS is needed and loads the cached schema for aws_rds_provision.
  3. Turn 3: Strict Execution
    • Agent Catalog: Provides the full JSON schema (requiring instance_type, storage_gb).
    • Agent Action: Understands parameters and safely executes aws_rds_provision through the gateway tools/call path.

6. Operational Benefits & Security

By centralizing skills in a database, the platform gains enterprise-grade operational capabilities:

  • Dynamic Updates: API endpoints, instructions, and schemas can be updated in the database without restarting agents.
  • Permission-Aware Discovery (RBAC): By linking skills to LightAPI endpoint descriptions and api_endpoint_t, portal-query can limit catalog disclosure to the current agent or tenant, while runtime gateway policy still authorizes execution.
  • A/B Testing: Portal catalog metadata can route 50% of an agent's requests to skill_v1 and 50% to skill_v2 to measure prompt/tool efficacy.
  • Audit Logging: Catalog disclosure and gateway execution can be logged separately, preserving a compliance trail without moving tool execution into the registry.
  • Distilled Memory RAG: Following the "Hindsight" pattern, raw conversation history (agent_session_history_t) is separated from RAG-optimized memory (session_memory_t). This prevents the "noisy context" problem while maintaining a perfect audit trail.
  • Profile Reuse Without Privilege Reuse: The same logical skill can be rendered for enterprise, coding, workflow, or personal-assistant runtimes, while each runtime receives only its independently authorized tools and execution capabilities.
  • Supply-Chain Controls: Executable packages are content-addressed, signed, scanned, reviewable, revocable, staged by the trusted runner before sandbox creation, mounted read-only, and always run through an approved sandbox profile.

7. LightAPI As Skill Source

API-backed skills should be generated from endpoint-level LightAPI descriptions whenever possible.

The skill registry should store skill metadata, access control, grouping, and agent-facing instructions. The LightAPI description should remain the source of truth for endpoint invocation and verification details.

Recommended flow:

  1. Light-Portal creates or imports endpoint-level LightAPI descriptions.
  2. API owners enrich endpoint descriptions with examples, behavior notes, result cases, and visibility.
  3. Approved endpoint descriptions are published as agent skills.
  4. The agent loads assigned skill summaries from portal-query and caches them locally.
  5. When the agent selects a skill, it loads the relevant LightAPI disclosure level from the local cache or refreshes from portal-query.
  6. Execution goes through the gateway tools/call path, preserving runtime policy and downstream authorization.

This avoids manually duplicating every API endpoint as a separate hand-written skill while still giving agents strict schemas and progressive disclosure.

8. Workflow-Backed Skills

Some skills need more than instructions and a curated tool set. A skill that must orchestrate several tools, wait for human approval, retry failed steps, run assertions, or preserve a durable audit trail should be backed by light-workflow.

The boundary should stay clear:

LayerResponsibility
SkillDiscovery metadata, taxonomy, instructions, allowed tools, and agent guidance.
WorkflowOrdered execution, branching, retries, assertions, human tasks, durable state, and audit events.
GatewayRuntime tool execution through tools/list and tools/call.

Workflow backing should be optional. Simple skills can stay as instructions plus tool mappings. Durable or regulated processes should link to workflow definitions and let light-workflow own execution.

Recommended storage:

  1. Keep wf_definition_t.definition as the canonical workflow YAML.
  2. Keep skill_t.content_markdown as the LLM-facing skill instruction body.
  3. Add skill_workflow_t to link skills to workflow definitions with a role such as primary, validation, remediation, or test.
  4. Treat skill_tool_t as the allowed tool set for a workflow-backed skill. Validation should flag workflow tool-call steps that are not linked to the skill.

The Portal Skill Workspace should embed a generic Workflow Editor instead of creating a skill-specific workflow runtime. The editor provides YAML editing, step preview, reference lookup, validation, and test runs. Skill authoring provides the surrounding context: skill metadata, taxonomy, allowed tools, effective prompt preview, and workflow link configuration.

9. Next Steps

  1. Complete phase 3 by adding category and tag assignment to existing skill create/update forms, backed by entity_category_t and entity_tag_t with entity_type = 'skill'.
  2. Save skill taxonomy through a composite skill command so the skill row and selected taxonomy associations are emitted from the same user action.
  3. Move the richer authoring workspace, effective prompt preview, skill_tool_t.config formalization, workflow-backed skills, and "create skill from LightAPI/tool" flows to phase 3.5.
  4. Build the generic Workflow Editor for YAML editing, parsed step preview, catalog references, validation, and workflow test runs.
  5. Complete phase 4 agent assignment by improving the agent_skill_t UI, adding an Agent Definition assignment context, and adding a batch assignment composite command that emits one AgentSkillCreatedEvent per selected skill.
  6. Enforce phase 4 assignment validation in command handlers and UI preflight: assigned skills must be active and must have at least one active direct skill_tool_t link. Workflow-backed skills still rely on skill_tool_t as the allowed tool set.
  7. Keep live gateway tools/list runtime executability checks as a diagnostics or governance concern, not as phase 4 persistence validation.
  8. Complete phase 5 for the Rust agent with the genai-query getEffectiveAgentCatalog endpoint, claim checks against host, sid, and env, local catalog caching, keyword/routing search, gateway tools/list intersection, and controller-driven cache invalidation.
  9. Complete phase 6 governance for the Rust agent only: normalize sensitivity tiers to public, internal, confidential, and restricted; filter blocked tools before catalog disclosure; compare the effective catalog with gateway tools/list through /diagnostics/tools; and keep execution through gateway tools/call.
  10. Enforce destructive, approval-required, and sensitivity metadata at the gateway with debug/auditInfo fields when a call is blocked. Do not use workflow audit_log_t for catalog disclosure; use auditInfo/file logging until a generic governance audit table is introduced.
  11. Keep current active row plus aggregate version as the approval/version boundary until workflow-owned approval state is implemented.
  12. Add publishing from LightAPI endpoint descriptions into the skill registry.
  13. Migrate existing file-based skills into structured catalog payloads, keeping instructions in Markdown and converting parameters to JSON Schema.
  14. Implement Pattern B (Semantic Tool RAG) after indexed catalog fields and embeddings are ready for production search.
  15. Add runtime-profile compatibility, stable tool references, server-owned gateway/runner/workflow/fixed-service placement, schema/alias digests, and deterministic materializer metadata without overloading content_markdown or relying on a tool name as authority.
  16. Add skill_package_t, immutable artifact publication, signature and scan verification, revocation, trusted runner-side download/safe extraction, read-only staging, and runner-only package execution. Workers receive no artifact-store credential or package-download authority.
  17. Add reviewed proposal lifecycle for repository-local and agent-generated skills; never activate generated content automatically.
  18. Add materializer conformance fixtures proving that enterprise, workflow, coding, personal-assistant, and external-adapter outputs preserve the same skill version and cannot widen its effective capability set.

Skill Workflow Orchestration

Status

Proposed demo design.

Executive Summary

This design describes a focused demo for agent-driven orchestration in Light-Fabric. The demo uses one agent with two skills:

  1. A skill that starts a workflow which calls two REST APIs directly.
  2. A skill that starts a workflow which calls the same two REST APIs through the MCP router.

Both paths solve the same business use case and return the same output. The visible difference is the execution trace:

  • The REST workflow shows light-workflow invoking HTTP endpoints directly.
  • The MCP workflow shows light-workflow invoking MCP tools/call, with light-gateway routing each tool call to the same backend REST APIs.

This demonstrates that skills provide agent-facing guidance and discovery, workflows provide durable orchestration, and the gateway provides the MCP data plane for tool execution.

Goals

  • Show one agent selecting between two assigned skills.
  • Show a workflow that orchestrates multiple REST APIs directly.
  • Show a second workflow that orchestrates the same APIs through MCP tools.
  • Keep the input and output contract identical across both workflows.
  • Keep the demo small enough to explain in a few minutes.
  • Preserve the runtime boundary: skills guide, workflows orchestrate, gateway executes MCP tool calls.

Non-Goals

  • Do not benchmark REST versus MCP latency.
  • Do not claim that MCP replaces REST. The demo shows two supported access patterns over the same backend capabilities.
  • Do not require every skill to be workflow-backed. Simple skills can remain instructions plus allowed tools.
  • Do not move MCP tool execution into the portal registry or agent catalog. Runtime tool execution stays on the gateway tools/call path.
  • Do not make the demo depend on a large endpoint catalog.

Recommendation

Use two APIs, not one.

A one-API demo can show sequencing, but it does not clearly prove cross-service orchestration. Two APIs show a more realistic enterprise shape: the workflow has to collect data from one business capability and make a decision through another capability.

Use four endpoints for the base demo.

Demo sizeEndpoint countRecommendationWhy
Smoke test2Optional onlyShows a happy path, but not enough variation.
Base demo4RecommendedCovers path parameters, query parameters, arrays, request bodies, branching, and transformation.
Advanced demo6Later phaseAdds parallel enrichment, compensation, or audit callbacks.

The base demo should be small enough to run repeatedly while still proving meaningful orchestration behavior.

General Agent And Workflow Boundary

The demo illustrates one direction of a bidirectional integration. The same boundary applies to enterprise, coding, and personal-assistant profiles:

HandoffOwner after handoffIntended use
Agent starts workflowlight-workflowDurable branching, retries, assertions, human tasks, long waits, regulated business processing
Workflow performs native agent calllight-workflowBounded model reasoning with structured input/output and no interactive session or local tools
Workflow submits agent-service joblight-agentInteractive or tool-using work, coding/research jobs, memory-aware work, or runner-agent placement

The existing call.agent behavior remains the backward-compatible native-workflow mode. It runs inside light-workflow and validates schema-bound JSON. A future explicit agent-service mode submits a typed job to light-agent. The selected agent definition and policy—not the workflow prompt—decide whether light-agent handles the job in its service or through a runner sandbox.

The handoff includes authenticated caller and tenant context, correlation ID, input and output schemas, deadline, idempotency key, cost/action budget, cancellation behavior, and bounded delegation depth. light-workflow never spawns Codex, Pi, Claude Code, Hermes, OpenClaw, or another external agent binary directly. Those products can only run behind a registered light-agent runtime adapter in an approved execution profile.

Do not convert every agent turn into a workflow. Conversation history, streaming, interruptions, tool correction, personal channel delivery, and workspace-aware model loops remain agent-domain concerns. Conversely, an agent that starts a workflow stores the workflow reference and observes its public result; it does not reproduce the workflow state machine in its own context.

Reject cyclic or unbounded agent/workflow delegation. A child handoff inherits or narrows the initiating deadline, budget, data boundary, and authorization.

Demo Scenario

The demo domain is personalized offer recommendation.

The agent receives a prompt such as:

Recommend an offer for customer CUST-1001.

The agent can use either skill:

  • Personalized Offer via REST Workflow
  • Personalized Offer via MCP Router

If the prompt does not specify REST or MCP, the demo agent should not pick a path at random. It should ask a short clarification question:

Do you want to run this through the direct REST workflow or through the MCP
router workflow?

Scripted demos can avoid the clarification by naming the path in the prompt.

Both skills start a workflow that:

  1. Loads the customer profile.
  2. Loads customer preferences and consent.
  3. Stops if the customer has not consented.
  4. Searches for eligible offers.
  5. Selects the best offer.
  6. Records the offer decision.
  7. Returns a normalized decision payload.

APIs And Endpoints

Customer Profile API

The Customer Profile API owns customer data and preferences.

EndpointShapePurpose
GET /customers/{customerId}Path parameter, object responseLoad customer identity, segment, region, and account status.
GET /customers/{customerId}/preferences?channel=portalPath parameter plus query parameterLoad consent, preferred categories, and contact channel rules.

Offer Decision API

The Offer Decision API owns eligible offer lookup and decision recording.

EndpointShapePurpose
GET /offers?segment={segment}&state={state}&category={category}Query parameters, array responseSearch active offers matching the customer profile and preferences.
POST /offer-decisionsJSON request body, object responsePersist the selected offer decision and return a decision id.

Demo API Runtime Services

The two business APIs should be implemented as real Rust services using the light-axum framework, not as ad hoc mocks. This keeps the demo aligned with normal Light-Fabric service lifecycle behavior:

  • load runtime configuration from config-server
  • bind HTTP using configured server settings
  • register with controller through portal-registry
  • appear in the control panel service-discovery view
  • support gateway service discovery by serviceId and envTag

Recommended demo apps:

AppService idDefault HTTP portPurpose
demo-customer-profile-apicom.networknt.demo.customer-profile-1.0.08085Serves customer profile and preference data.
demo-offer-decision-apicom.networknt.demo.offer-decision-1.0.08086Serves offer lookup and decision recording.

The ports are config defaults only. They must be configurable through config-server values so local, Docker, Kubernetes, and shared demo environments can choose different ports without recompiling.

Both services should expose:

GET /health

The API endpoints should return deterministic demo data. A database is not required for the first demo; in-memory seed data is enough as long as the data is stable and documented. If later demos need persistence, keep it behind the same endpoint contract.

Light-Axum Bootstrap

Each demo API should follow the normal light-axum pattern: implement AxumApp, return an axum::Router, and let LightRuntimeBuilder own binding, configuration, shutdown, and controller registration.

The service should read config from the same runtime config files used by other Light-Fabric services:

startup.yml
server.yml
portal-registry.yml

Example config-server values for the Customer Profile API:

startup.host: dev.lightapi.net
startup.externalConfigDir: /var/lib/demo-customer-profile-api/config-cache

light-config-server-uri: https://config-server.lightapi.svc.cluster.local:8435

server.serviceId: com.networknt.demo.customer-profile-1.0.0
server.environment: demo
server.ip: 0.0.0.0
server.advertisedAddress: demo-customer-profile-api
server.httpPort: 8085
server.enableHttp: true
server.enableHttps: false
server.enableRegistry: true
server.startOnRegistryFailure: true

portalRegistry.portalUrl: https://controller.lightapi.svc.cluster.local:8438

Example config-server values for the Offer Decision API:

startup.host: dev.lightapi.net
startup.externalConfigDir: /var/lib/demo-offer-decision-api/config-cache

light-config-server-uri: https://config-server.lightapi.svc.cluster.local:8435

server.serviceId: com.networknt.demo.offer-decision-1.0.0
server.environment: demo
server.ip: 0.0.0.0
server.advertisedAddress: demo-offer-decision-api
server.httpPort: 8086
server.enableHttp: true
server.enableHttps: false
server.enableRegistry: true
server.startOnRegistryFailure: true

portalRegistry.portalUrl: https://controller.lightapi.svc.cluster.local:8438

server.advertisedAddress must be a reachable address, not 0.0.0.0. In Kubernetes, use the Service DNS name. In local Docker Compose, use the Compose service name. In a native VM demo, use the VM hostname or another reachable address.

Controller Registration

The services should register with controller using the runtime's portal-registry integration. The controller registration payload must include at least:

  • serviceId
  • envTag
  • protocol
  • advertised address
  • port
  • discovery token or portal registry token, according to environment policy

After startup, the control panel should show two registered service instances:

com.networknt.demo.customer-profile-1.0.0 / demo
com.networknt.demo.offer-decision-1.0.0 / demo

The MCP router configuration should prefer these service IDs over fixed targetHost values where service discovery is available. Fixed targetHost values are still useful for a minimal local smoke test.

Optional Advanced Endpoints

The base demo should start with four endpoints. If we later want to demonstrate more workflow shapes, add one or two optional endpoints:

EndpointShape DemonstratedUse
GET /customers/{customerId}/riskParallel enrichmentRun profile, preferences, and risk lookup before offer selection.
POST /offer-decisions/{decisionId}/auditFollow-up side effectRecord a compliance audit event after the decision is created.
POST /offer-decisions/{decisionId}/cancelCompensationCancel the decision if a later step fails.

Agent, Skills, And Workflows

Use one agent so the demo highlights skill selection rather than agent handoff.

ObjectNameResponsibility
AgentDemo Orchestration AgentReceives the user request and selects one of the assigned skills.
SkillPersonalized Offer via REST WorkflowGuides the agent to start the direct REST workflow.
SkillPersonalized Offer via MCP RouterGuides the agent to start the MCP-backed workflow.
Workflowpersonalized-offer-rest-v1Orchestrates direct HTTP calls to the two REST APIs.
Workflowpersonalized-offer-mcp-v1Orchestrates MCP tool calls through the gateway router.

The skill registry should link each skill to its workflow definition through skill_workflow_t. The workflow definition remains canonical in wf_definition_t.definition. The skill content_markdown remains agent-facing guidance, not the executable workflow source.

Execution Paths

Direct REST Workflow

User prompt
  -> Demo Orchestration Agent
  -> Personalized Offer via REST Workflow skill
  -> light-workflow
  -> Customer Profile API
  -> Offer Decision API
  -> normalized decision result

This path is useful for showing direct, durable API orchestration.

MCP Router Workflow

User prompt
  -> Demo Orchestration Agent
  -> Personalized Offer via MCP Router skill
  -> light-workflow
  -> MCP tools/call
  -> light-gateway MCP router
  -> Customer Profile API
  -> Offer Decision API
  -> normalized decision result

This path is useful for showing MCP protocol orchestration over the same backend API capabilities.

Common Workflow Contract

Both workflows should accept the same input:

{
  "customerId": "CUST-1001",
  "channel": "portal"
}

Both workflows should return the same successful output shape:

{
  "status": "APPROVED",
  "customerId": "CUST-1001",
  "selectedOfferId": "OFFER-TRAVEL-01",
  "decisionId": "DEC-1001"
}

Both workflows should return comparable business outcomes for known edge cases:

{
  "status": "NO_CONSENT",
  "customerId": "CUST-3003",
  "reason": "Customer has not consented to personalized offers."
}
{
  "status": "NO_ELIGIBLE_OFFER",
  "customerId": "CUST-2002",
  "reason": "No active offer matches the customer profile and preferences."
}

Workflow Shape

The REST and MCP workflows should have the same logical steps.

StepREST workflow actionMCP workflow action
Load profileGET /customers/{customerId}tools/call customer_get_profile
Load preferencesGET /customers/{customerId}/preferencestools/call customer_get_preferences
Check consentWorkflow conditionWorkflow condition
Search offersGET /offerstools/call offer_search
Select offerWorkflow expression or ruleWorkflow expression or rule
Record decisionPOST /offer-decisionstools/call offer_record_decision
Return resultWorkflow output mappingWorkflow output mapping

The workflow should own branching, retries, and output normalization. The agent should not manually sequence each API call after the workflow starts.

Error Handling And Retries

Business outcomes and technical failures should be treated differently.

Business outcomes are expected workflow results and should not be retried:

  • NO_CONSENT
  • NO_ELIGIBLE_OFFER

Technical failures should use bounded workflow retries:

FailureRecommended behavior
Customer Profile API timeoutRetry the profile step with exponential backoff.
Offer Decision API returns 503Retry the affected offer step with exponential backoff.
Gateway MCP tools/call timeoutRetry the MCP tool-call step with the same workflow policy.
Persistent downstream failureEnd with a controlled technical failure result and preserve the workflow trace.

Recommended transient retry status codes:

408, 429, 502, 503, 504

The POST /offer-decisions step should include an idempotency key derived from the workflow instance id and selected offer id. This prevents duplicate decisions when a retry happens after the backend processed the first request but the response was lost.

For parity, the REST and MCP workflows should use the same retry policy. In the MCP path, the gateway should preserve enough error detail for the workflow trace to show the tool name, mapped backend endpoint, status code, and correlation id.

MCP Tool Mapping

The MCP workflow should use a small, explicit tool set.

MCP toolBackend endpointArguments
customer_get_profileGET /customers/{customerId}customerId
customer_get_preferencesGET /customers/{customerId}/preferencescustomerId, channel
offer_searchGET /offerssegment, state, category
offer_record_decisionPOST /offer-decisionscustomerId, offerId, channel, source, reason

The MCP tool input schemas should be normalized JSON objects. The gateway router maps those objects to path parameters, query parameters, or request bodies for the backend REST APIs.

The MCP skill should list these tools in skill_tool_t as its allowed runtime tool set. Workflow validation should flag an MCP tool-call step if it references a tool that is not linked to the skill.

Gateway Tool Configuration Example

Current gateway HTTP tool execution maps GET arguments to query parameters and sends non-GET arguments as JSON request bodies. To support endpoint shapes such as GET /customers/{customerId} without changing the backend API, the demo should add or configure explicit path-template substitution before the request is sent.

Recommended minimal mapping shape:

mcp-router.tools:
  - name: customer_get_profile
    description: Get a customer profile by id.
    protocol: http
    serviceId: com.networknt.demo.customer-profile-1.0.0
    envTag: demo
    path: /customers/{customerId}
    method: GET
    apiType: http
    inputSchema:
      type: object
      required:
        - customerId
      properties:
        customerId:
          type: string
    toolMetadata:
      pathParams:
        - customerId

With this mapping, the MCP tool call:

{
  "name": "customer_get_profile",
  "arguments": {
    "customerId": "CUST-1001"
  }
}

should be routed to:

GET /customers/CUST-1001

The path parameter should not also be appended as a query parameter. Arguments not listed under pathParams can still be appended as query parameters for GET requests or sent as JSON body fields for POST requests.

Skill Content Markdown Guidance

The skill content_markdown should explain when and how the agent should use the skill. It should not duplicate the workflow definition or the full API contract.

Example REST skill content:

## Purpose
Use this skill when the user asks for a personalized offer decision through the
direct REST workflow.

## Inputs
- customerId: customer identifier, such as CUST-1001
- channel: request channel, default portal

## Behavior
- Start workflow personalized-offer-rest-v1.
- Return the workflow result as the answer.
- Do not manually call offer APIs outside the workflow.
- If the user does not specify REST or MCP, ask which execution path they want.

Example MCP skill content:

## Purpose
Use this skill when the user asks to demonstrate MCP router orchestration for a
personalized offer decision.

## Inputs
- customerId: customer identifier, such as CUST-1001
- channel: request channel, default portal

## Behavior
- Start workflow personalized-offer-mcp-v1.
- The workflow will call MCP tools through the gateway.
- Return the workflow result as the answer.
- If the user does not specify REST or MCP, ask which execution path they want.

Structured execution metadata belongs in registry rows and workflow definitions, not only in markdown. The markdown is the LLM-facing explanation.

Output Normalization

The workflows should not pass raw endpoint responses directly to the agent. They should normalize backend responses into a stable business result.

Example raw POST /offer-decisions response:

{
  "decisionId": "DEC-1001",
  "customerId": "CUST-1001",
  "offerId": "OFFER-TRAVEL-01",
  "decision": "approved",
  "createdAt": "2026-05-25T14:12:00Z",
  "auditRef": "AUD-7788"
}

Normalized workflow output:

{
  "status": "APPROVED",
  "customerId": "CUST-1001",
  "selectedOfferId": "OFFER-TRAVEL-01",
  "decisionId": "DEC-1001"
}

The workflow should own this transformation so the REST and MCP variants produce identical final results even if their intermediate transport envelopes are different.

Demo Data

Use deterministic seed data so the demo is repeatable.

CustomerProfilePreferencesExpected result
CUST-1001Premium segment, active, OntarioConsent true, travel preferredAPPROVED with OFFER-TRAVEL-01.
CUST-2002Standard segment, active, OntarioConsent true, travel preferredNO_ELIGIBLE_OFFER.
CUST-3003Premium segment, active, OntarioConsent falseNO_CONSENT.

Seed offers:

OfferMatch conditionResult
OFFER-TRAVEL-01segment=premium, state=ON, category=travelEligible for CUST-1001.
OFFER-CASHBACK-01segment=premium, state=BC, category=shoppingNot eligible for Ontario travel scenario.

Demo Script

Run the REST workflow path first:

Use the REST workflow skill to recommend an offer for CUST-1001.

Expected observation:

  • The agent selects Personalized Offer via REST Workflow.
  • The workflow trace shows direct HTTP calls to the Customer Profile API and Offer Decision API.
  • The final response contains status=APPROVED and a decision id.

Run the MCP workflow path second:

Use the MCP router skill to recommend an offer for CUST-1001.

Expected observation:

  • The agent selects Personalized Offer via MCP Router.
  • The workflow trace shows MCP tools/call invocations.
  • The gateway trace shows those tool calls routed to the same backend REST endpoints.
  • The final response uses the same output shape as the REST workflow.

Then run one edge case:

Use either skill to recommend an offer for CUST-3003.

Expected observation:

  • The workflow stops after the consent check.
  • No offer decision is recorded.
  • The result is NO_CONSENT.

Run one ambiguity case:

Recommend an offer for CUST-1001.

Expected observation:

  • The agent asks whether to use the direct REST workflow or the MCP router workflow.
  • After the user chooses, the agent starts the selected workflow.

Run one technical failure case:

Use the MCP router skill to recommend an offer for CUST-1001 while the Offer
Decision API returns one transient 503.

Expected observation:

  • The workflow retries the failed tool-call step.
  • The gateway trace records the failed offer_record_decision call and the successful retry.
  • The final response still uses the normalized APPROVED output shape.

Portal Authoring Flow

The portal should make the demo visible from the existing GenAI and workflow surfaces:

  1. Create or import the two REST APIs and four endpoint descriptions.
  2. Implement the two APIs as light-axum services.
  3. Add config-server values for both API services.
  4. Start both services and verify controller registration.
  5. Publish MCP router tools for the same four endpoints.
  6. Create personalized-offer-rest-v1 in the workflow catalog.
  7. Create personalized-offer-mcp-v1 in the workflow catalog.
  8. Create the two skills in the skill registry.
  9. Link each skill to its primary workflow through skill_workflow_t.
  10. Link the MCP skill to its allowed tool set through skill_tool_t.
  11. Assign both skills to Demo Orchestration Agent.
  12. Use Skill Workspace preview and test panels to validate the effective prompt, workflow link, allowed tools, and sample test input.

Validation Rules

The authoring experience should validate the following before the demo is considered complete:

  • Each skill has exactly one primary workflow link.
  • The REST workflow does not require MCP tools.
  • The MCP workflow references only MCP tools linked through skill_tool_t.
  • Both workflows declare the same input schema.
  • Both workflows declare the same normalized output shape.
  • The four backend endpoint descriptions are active.
  • Both demo API services load config from config-server.
  • Both demo API services register with controller and appear in the control panel service-discovery view.
  • The MCP router tools/list result includes the four expected tool names.
  • MCP router tools resolve the demo APIs by serviceId and envTag in the service-discovery environment.
  • MCP path-parameter mappings are validated before the workflow test run.
  • POST /offer-decisions includes an idempotency key for retry safety.
  • Test runs for CUST-1001, CUST-2002, and CUST-3003 produce the expected outcomes.

Observability

The demo should show three different traces:

  1. Agent trace: which skill the agent selected and what workflow it started.
  2. Workflow trace: step order, branches, retries, and final output.
  3. Gateway trace: MCP tool name, mapped backend endpoint, status, duration, and correlation id for the MCP path.

Use the same correlation id across the agent request, workflow instance, and gateway calls where possible. This makes the REST and MCP execution paths easy to compare.

Security And Authorization

Authorization should be enforced at each layer:

  • The agent can discover only assigned skills.
  • The workflow can start only definitions visible to the authenticated caller or service identity.
  • The MCP skill can expose only tools linked to the skill and allowed for the agent.
  • The gateway still performs runtime MCP access checks before executing tools/call.
  • Backend REST APIs continue to enforce their own authorization policies.

The skill registry is not a runtime bypass. It narrows discovery and guidance, while the workflow and gateway remain responsible for execution-time controls.

Context And Auth Propagation

The demo should explicitly show that caller context is preserved.

For direct REST workflow steps:

  1. The workflow start request records the initiating user, host, tenant, correlation id, and authorization context.
  2. The workflow executor exchanges the initiating authorization for a short-lived audience- and operation-scoped delegation token, or uses a workload identity whose on-behalf-of claims preserve the initiating subject, workflow instance, task, policy digest, and data boundary. It does not forward the caller's unrestricted bearer token.
  3. Backend APIs enforce their normal authorization policies.

For MCP workflow steps:

  1. light-workflow calls the gateway MCP endpoint with the same correlation, tenant, locale, and a short-lived workflow-task-scoped delegation token.
  2. light-gateway validates the MCP request and runtime tool authorization.
  3. The MCP router forwards only approved identity/delegation context to the backend REST API while regenerating transport-specific headers such as Host, Content-Length, and connection management headers.
  4. Backend APIs see the same business identity context they would see on the direct REST path.

The trace should show this propagation without exposing sensitive token values.

Acceptance Criteria

  • One demo agent has both skills assigned.
  • The REST skill starts personalized-offer-rest-v1.
  • The MCP skill starts personalized-offer-mcp-v1.
  • Both workflows accept the same input JSON.
  • Both workflows return the same normalized output shape.
  • The REST workflow trace shows direct REST calls to two APIs.
  • The MCP workflow trace shows MCP tools/call routed through the gateway to the same two APIs.
  • The two APIs run as light-axum services with config-server supplied HTTP ports.
  • The two APIs register with controller and are visible in the control panel service-discovery view.
  • The demo succeeds for CUST-1001.
  • The demo returns controlled business outcomes for CUST-2002 and CUST-3003.
  • Ambiguous user prompts trigger a clarification question instead of random skill selection.
  • A transient 503 from the Offer Decision API is retried and appears in the workflow trace.
  • The MCP path preserves caller context through workflow, gateway, and backend REST calls.
  • Neither workflow path can directly launch an external agent binary; a future service-mode agent task must enter light-agent through the typed job contract.
  • Agent/workflow delegation preserves correlation and cannot exceed the initiating deadline, budget, authorization, or maximum delegation depth.

Hindsight Memory

Hindsight Memory is the core memory system for light-rs, designed to move beyond simple chat logs. Instead of just remembering what was said, the agent learns and forms mental models over time.

This design is strongly inspired by the paper Hindsight is 20/20: Building Agent Memory that Retains, Recalls, and Reflects and extends it with multi-tenant support.


1. Core Concepts

Hindsight memory organizes information into three distinct "Pathway" types:

  1. World Facts: Objective truths about the environment (e.g., "The production server is in US-East-1").
  2. Experiences: The agent's own history of actions and results (e.g., "I tried to deploy to US-East-1 and it failed due to a timeout").
  3. Mental Models: Synthesized understandings formed by reflecting on facts and experiences (e.g., "Deployments to US-East-1 are unstable during peak hours").

2. The Three Operations

Interaction with the memory system is standardized into three primary operations:

Retain (Storage)

The retain operation ingests information. Behind the scenes, the system:

  • Extracts entities and relationships.
  • Normalizes time and temporal data.
  • Stores the data in agent_memory_unit_t.

Recall (Retrieval)

The recall operation retrieves relevant context using a hybrid strategy:

  • Semantic: Vector similarity using the hnsw index.
  • Graph: Following links in agent_memory_link_t (causes, enables, prevents).
  • Temporal: Time-series filtering.

Reflect (Synthesis)

The reflect operation performs "deep thinking." It analyzes existing memories to generate new insights, which are stored in agent_memory_reflection_t.


3. Database Architecture

The Hindsight system is fully integrated into the portal's multi-tenant schema:

Table NameDescription
agent_memory_bank_tThe primary container. Defines personality and disposition (skepticism, empathy).
agent_memory_doc_tSource documents (logs, files, transcripts) that provide the raw text for memory units.
agent_memory_unit_tSentence-level "atoms" of thought. Stores content, embeddings, and fact types (world, experience, etc.).
agent_memory_entity_tResolved Knowledge Graph nodes, optionally linked to platform users (user_t).
agent_memory_unit_entity_tThe join table linking individual memories to the entities they mention.
agent_memory_entity_cooccur_tAssociation graph tracking concept relationships and co-occurrence counts.
agent_memory_link_tDefines causal and semantic relationships between memories (causes, enables, etc.).
agent_memory_directive_t"Hard rules" that override probabilistic learning.
agent_memory_reflection_tSynthesized high-level insights generated during the "Reflect" phase.
agent_session_history_tThe materialized conversation context for active sessions, linked to a specific bank. Effectful action attempts and append-only session events remain authoritative when history projection is delayed or conflicted.

4. Privacy & Multi-Tenancy

Isolation is managed at the Bank level using three scoping tiers:

  1. Global Host Bank (user_id IS NULL, agent_def_id IS NULL):
    • Knowledge shared across all users and all agents within a specific host_id.
    • Ideal for organization-wide SOPs, common facts, and shared documentation.
  2. Shared Agent Bank (user_id IS NULL, agent_def_id IS NOT NULL):
    • Knowledge shared by all users interacting with a specific agent type.
    • Used for maintaining a consistent agent "Persona" or specialized domain expertise.
  3. Private User Bank (user_id IS NOT NULL):
    • Knowledge unique to a specific user.
    • Can be scoped further by agent_def_id to provide user-specific memory within a particular agent persona.
    • Used for personal preferences, private history, and individualized learning.

Memory-bank identity is not proof of access. Every recall, retain, reflection, export, resume, and deletion operation must derive host, principal, agent, and retention authority from authenticated server-side state. Personal-assistant profiles should default to a private user-and-agent bank unless an explicit sharing policy selects a broader scope.

Recalled memory is untrusted model context, not a system instruction or authorization grant. It cannot add tools, skills, credentials, network access, filesystem access, channel destinations, or execution capabilities.


5. Implementation Guide

To implement a "Learning Agent," follow this sequence in your application logic:

  1. Ingestion: After an interaction or action result is durably accepted into the agent session event stream, call retain with its source class and correlation. Never let a memory-write failure retry an effectful action.
  2. Context Loading: Before calling the LLM, call recall to fetch the most relevant 3-5 memories for the current prompt.
  3. Scheduled Reflection: Run the reflect operation during idle time to compress raw experiences into high-level mental models.

Fine-Grained Authorization

The following is the raw material collected when I chat with AI about migrating the light-4j fine-grained authorization to light-rs to secure light-agent and light-gateway.

The information needs to be organized and documented into a design document for light-rs fine-grained authorization.

Question: We have a Java implementation of fine-grained authorization that we want to migrate to light-rs. In our java implementation, we have a middleware handler to verify the custom claims like role, group, attribute etc in the jwt token against endpoint specific rules defined in database and cached at the service. How should we handle the same requirement in rust?

Response:

In Rust, bridging the gap between dynamic, database-driven rules and high-performance web requests is a well-solved problem. If you are coming from Java, the architecture translates very cleanly, but takes advantage of Rust's async runtime and thread-safe concurrency primitives.

To handle fine-grained authorization (Attribute-Based Access Control / ABAC or Role-Based Access Control / RBAC) where rules are cached locally but backed by a database, you have two primary paths in Rust:

  1. The Custom Middleware Approach (Closest to your current Java implementation)
  2. The Policy Engine Approach (Using an industry-standard Rust authorization crate)

Here is how you handle this requirement idiomatically in Rust, assuming you are using Axum (the current standard web framework in Rust).


Approach 1: Custom Middleware with Async Caching

In Java, you likely used something like Guava or Caffeine for your local cache. In Rust, the exact equivalent for high-performance, concurrent, async-aware caching is the moka crate.

Here is the architectural blueprint:

  1. Define your custom claims using serde.
  2. Setup a shared Application State holding your Database connection pool (e.g., sqlx) and your local Cache (moka).
  3. Create an Axum Middleware to intercept the request, parse the JWT, consult the cache based on the route, and authorize the request.

1. Dependencies (Cargo.toml)

axum = "0.7"
jsonwebtoken = "9.0"
moka = { version = "0.12", features = ["future"] } # Async cache (Rust's Caffeine)
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio"] }
serde = { version = "1.0", features = ["derive"] }

2. Defining Claims and State

#![allow(unused)]
fn main() {
use axum::{extract::State, http::Request, middleware::Next, response::Response};
use moka::future::Cache;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::sync::Arc;

// 1. Define your custom JWT claims
#[derive(Debug, Serialize, Deserialize, Clone)]
struct CustomClaims {
    sub: String,
    roles: Vec<String>,
    groups: Vec<String>,
    attributes: std::collections::HashMap<String, String>,
    exp: usize,
}

// 2. Define the Endpoint Rule (what you fetch from DB)
#[derive(Clone)]
struct EndpointRule {
    required_roles: Vec<String>,
    // add required_groups, attributes, etc.
}

// 3. Define the Application State to share across your app
struct AppState {
    db: PgPool,
    // Cache keyed by String (e.g., "GET:/api/data") returning an EndpointRule
    rule_cache: Cache<String, EndpointRule>,
}
}

3. The Authorization Middleware

In Axum, middleware is written as an asynchronous function that takes the request, does some work, and either rejects it or passes it to the Next handler.

#![allow(unused)]
fn main() {
use axum::http::StatusCode;
use axum::response::IntoResponse;

async fn fine_grained_authz_middleware(
    State(state): State<Arc<AppState>>,
    req: Request<axum::body::Body>,
    next: Next,
) -> Result<Response, StatusCode> {
    
    // 1. Extract and decode the JWT from Authorization header
    let auth_header = req.headers().get("Authorization").and_then(|h| h.to_str().ok());
    let claims = decode_jwt(auth_header).ok_or(StatusCode::UNAUTHORIZED)?;

    // 2. Identify the endpoint being accessed (e.g., "GET:/api/users/123")
    // Note: In practice, use req.extensions() to get the matched generic route template
    // rather than the raw URI so "/api/users/123" matches "/api/users/{id}"
    let endpoint_key = format!("{}:{}", req.method(), req.uri().path());

    // 3. Check Cache or Fallback to DB
    let rule = match state.rule_cache.get(&endpoint_key).await {
        Some(cached_rule) => cached_rule,
        None => {
            // Cache miss: Fetch from database
            let db_rule = fetch_rule_from_db(&state.db, &endpoint_key)
                .await
                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
            
            // Populate cache
            state.rule_cache.insert(endpoint_key, db_rule.clone()).await;
            db_rule
        }
    };

    // 4. Execute Fine-Grained Authorization Logic
    let is_authorized = rule.required_roles.iter().any(|role| claims.roles.contains(role));
    // ... check groups, evaluate custom attribute expressions, etc.

    if !is_authorized {
        return Err(StatusCode::FORBIDDEN);
    }

    // 5. Pass to the actual route handler!
    Ok(next.run(req).await)
}

// Dummy helper functions to make the example compile mentally
fn decode_jwt(_header: Option<&str>) -> Option<CustomClaims> { /* ... */ unimplemented!() }
async fn fetch_rule_from_db(_db: &PgPool, _key: &str) -> Result<EndpointRule, ()> { /* ... */ unimplemented!() }
}

4. Wiring it up

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        db: PgPool::connect("postgres://...").await.unwrap(),
        rule_cache: Cache::builder()
            .max_capacity(10_000)
            .time_to_live(std::time::Duration::from_secs(300)) // 5 minute TTL
            .build(),
    });

    let app = Router::new()
        .route("/api/secure-data", get(|| async { "Secret Data" }))
        // Apply the middleware to all routes above this line
        .route_layer(axum::middleware::from_fn_with_state(state.clone(), fine_grained_authz_middleware))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

If your endpoint-specific rules are highly complex (e.g., "User must be in Group X AND have Attribute Y = 'finance', UNLESS Role is 'Admin'"), writing that evaluation logic manually in Rust gets messy.

Instead of writing custom evaluation logic, the Rust ecosystem heavily relies on external Policy Engines for ABAC/RBAC. The two leaders are:

Developed by Amazon Web Services in Rust, Cedar is a blazing-fast language for defining permissions.

  • You store your Cedar policies in your database as text.
  • Your Rust middleware fetches the Cedar policy string from the DB, compiles it (and caches the compiled policy using moka).
  • You pass the JWT CustomClaims into the Cedar engine as a context entity.
  • Cedar evaluates the claims against the policy in microseconds.

2. Casbin (casbin)

Casbin is a very popular authorization library that exists in Java, Go, and Rust.

  • It supports ACL, RBAC, and ABAC.
  • It has native adapters for sqlx (Postgres/MySQL). You don't even need to write the caching logic yourself; casbin-rs handles reading the rules from the database and keeping them cached in memory automatically.

Summary

  • For simple Role/Group matching: Stick to the Custom Middleware approach using moka for async caching and axum::middleware to intercept requests. It is functionally identical to your Java approach but extremely fast.
  • For complex Attribute evaluation: Use your middleware to parse the JWT, but delegate the actual rule evaluation to cedar-policy or casbin.

Question: Could you please provide some examples for cedar-policy and casbin? For example, what is the format in database? In our system, we are using a yaml rule engine to evaluate the fine-grained authorization. We create some rules users can choose and they can also create their own rules if necessary.

Response:

Transitioning from a custom YAML rule engine to an industry-standard policy engine is a fantastic move. Both AWS Cedar and Casbin are designed precisely for this kind of "user-defined fine-grained rules" requirement, but they take very different approaches to how rules are written and stored.

Here is a breakdown of how both engines work, what their database format looks like, and how you map your JWT claims to them in Rust.


1. AWS Cedar (cedar-policy) - Best for Complex/Custom Rules

Cedar uses a custom, highly readable Domain Specific Language (DSL). It is specifically built to evaluate ABAC (Attribute-Based Access Control) using contextual data like JWT claims.

The Format (In the Database)

In your database, you do not need complex tables. You simply store the Cedar policies as plain text strings in a standard TEXT or VARCHAR column.

If a user wants to create a custom rule saying, "Allow access if the user's JWT has the 'finance' group and a clearance level greater than 3," the Cedar policy stored in the DB looks like this:

// Database column: policy_text
permit (
    principal,
    action == Action::"read_data",
    resource == Resource::"secure_endpoint"
)
when {
    context.jwt.groups contains "finance" &&
    context.jwt.attributes.clearance_level > 3
};

For "predefined rules that users can choose," Cedar has Policy Templates. You store the template once, and users just link their parameters to it.

// Template: "Require specific group"
permit(principal, action, resource)
when { context.jwt.groups contains ?required_group };

How you execute it in Rust

When a request comes in, you fetch the relevant Cedar text strings from your database, pass in the JWT claims as the Context, and let Cedar evaluate it.

#![allow(unused)]
fn main() {
use cedar_policy::{Authorizer, Context, Decision, Entities, PolicySet, Request};
use serde_json::json;
use std::str::FromStr;

fn evaluate_cedar_rule(db_policy_text: &str, jwt_claims: &serde_json::Value) {
    // 1. Parse the text policy from the database
    let policies = PolicySet::from_str(db_policy_text).expect("Invalid policy format");

    // 2. Map your JWT claims directly into the Cedar "Context" using JSON
    let context_json = json!({
        "jwt": jwt_claims
    });
    let context = Context::from_json_value(context_json, None).unwrap();

    // 3. Create the evaluation request
    let request = Request::new(
        Some("User::\"Alice\"".parse().unwrap()), 
        Some("Action::\"read_data\"".parse().unwrap()), 
        Some("Resource::\"secure_endpoint\"".parse().unwrap()), 
        context,
        None,
    ).unwrap();

    // 4. Evaluate
    let authorizer = Authorizer::new();
    let entities = Entities::empty(); // Used if fetching hierarchical data
    let answer = authorizer.is_authorized(&request, &policies, &entities);

    if answer.decision() == Decision::Allow {
        println!("Access Granted!");
    } else {
        println!("Access Denied. Failed policies: {:?}", answer.diagnostics().errors());
    }
}
}

2. Casbin (casbin-rs) - Best for Tabular/Database-Native Rules

Casbin works fundamentally differently. It splits authorization into two parts: the Model (a config file defining the structure of your rules) and the Policies/Rules (the actual data stored in the database).

The Format (In the Database)

Casbin doesn't store a DSL string. Instead, it natively connects to your database using an adapter (like sqlx-adapter) and creates a table usually called casbin_rule.

The table structure looks exactly like a CSV file, with columns: ptype, v0, v1, v2, v3, v4, v5.

If a user selects a predefined rule to allow the finance_admin role to read /api/data, Casbin inserts a row into your database like this:

ptypev0 (Subject)v1 (Object)v2 (Action)v3 (Condition - optional)
pfinance_admin/api/dataread

If a user writes a custom ABAC rule using a UI (e.g., clearance > 3), Casbin can store the evaluation expression as a string in one of the columns (evaluated internally via the rhai scripting language):

ptypev0 (Subject/Condition)v1 (Object)v2 (Action)
pr.sub.groups.contains("finance") && r.sub.clearance > 3/api/dataread

How you execute it in Rust

To make Casbin evaluate JWT attributes dynamically, you define a model.conf file that tells Casbin to evaluate (eval) the string stored in the database.

The model.conf File:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub_rule, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
# This matcher says: check if the object and action match, 
# AND evaluate the ABAC rule stored in the database (p.sub_rule) against the request subject (r.sub)
m = r.obj == p.obj && r.act == p.act && eval(p.sub_rule) 

The Rust Code:

#![allow(unused)]
fn main() {
use casbin::{CoreApi, Enforcer, DefaultModel};
use sqlx_adapter::SqlxAdapter;
use std::collections::HashMap;

// A struct representing your JWT Claims
#[derive(Clone)]
struct JwtClaims {
    groups: Vec<String>,
    clearance: i32,
}

async fn evaluate_casbin() {
    // 1. Connect to DB. The adapter automatically fetches the rules from the `casbin_rule` table.
    let adapter = SqlxAdapter::new("postgres://user:pass@localhost/db", 8).await.unwrap();
    let mut enforcer = Enforcer::new("model.conf", adapter).await.unwrap();

    // 2. Parse the JWT for the incoming request
    let claims = JwtClaims {
        groups: vec!["finance".to_string()],
        clearance: 5,
    };

    // 3. Casbin in Rust uses `rhai` under the hood for dynamic object evaluation.
    // You map your JWT fields into a format the engine understands.
    let mut sub_map = HashMap::new();
    sub_map.insert("groups", claims.groups);
    sub_map.insert("clearance", claims.clearance);

    // 4. Enforce the rules against the DB
    // Request is: Subject (JWT Claims Map), Object (/api/data), Action (read)
    if enforcer.enforce((sub_map, "/api/data", "read")).unwrap() {
        println!("Access Granted!");
    } else {
        println!("Access Denied!");
    }
}
}

Which one maps better to your YAML Rule Engine?

AWS Cedar is highly recommended for your use case.

Because you currently use a YAML rule engine where users define complex logical rules, your users are already accustomed to defining logical expressions.

  1. Translation: It is very easy to write a script that takes your existing YAML definitions and converts them into Cedar text policies.
  2. Safety: Cedar is statically typed and formally verified. If a user writes an invalid custom rule, Cedar will fail to parse it instantly and give you a helpful error message to show in your UI. Casbin's eval() logic can sometimes be brittle at runtime if the user types bad syntax.
  3. Database Schema: With Cedar, you control your database schema. You just add a policy_text column to your existing "Rules" table. Casbin requires taking over a table (casbin_rule) to map to its strict CSV-like format.

Question: In our current implementation, we define all the persmissions and filters from UI and save the permissions and picked up rule in database. During server startup, it load everything locally in yaml format like the following. One of the pros is that our technical team write the rules, and the user or api owner just define the roles, groups, positions, attributes for the endpoint. The rule will be executed locally on the target server without going to the database so the performance is very good. If any permission changed for the target server, we can trigger a config reload from controller to ask the server to reload the config/permission. I am not sure if we should replace this implementation with Cedar. One of the feature we have is the column and row filter and I don't think Cedar support it. What do you think?

rule.endpointRules:
  echo@call:
    req-acc:
    - allow-role-based-access-control.lightapi.net
    permission:
      roles: mcp-reader
  /v1/pets@get:
    req-acc:
    - allow-role-based-access-control.lightapi.net
    permission:
      roles: host-admin
  /v1/accounts@get:
    req-acc:
    - allow-role-based-access-control.lightapi.net
    res-fil:
    - response-column-filter.lightapi.net
    - response-row-filter.lightapi.net
    permission:
      col:
        role:
          teller: '["accountNo","userId","accountType","firstName","lastName"]'
          account-manager: '["accountNo","userId","accountType","firstName","lastName","status"]'
      row:
        role:
          teller:
          - colName: accountType
            colValue: C
            operator: =
      roles: account-manager teller

Response:

You have excellent architectural instincts. You are absolutely right to hesitate, and your concern about column and row filtering is the exact reason you probably should not replace your current system with Cedar.

Your current design is actually highly sophisticated. Let’s break down why your current implementation is so strong, why Cedar would struggle with your specific requirements, and how you should idiomatically build your current design in Rust.


Why Cedar is NOT a drop-in replacement for your system

AWS Cedar (and most standard policy engines like Casbin or OPA) are designed as Binary Decision Engines. You give them a Request, and they return an Allow or Deny.

The Missing Feature: Data Filtering (Masking) Your system handles two fundamentally different concerns in one elegant YAML file:

  1. Access Control (req-acc): Can the user access the /v1/accounts endpoint? (Cedar does this perfectly).
  2. Data Filtering (res-fil): Which rows and columns can the user see in the response? (Cedar does not do this natively).

If you forced Cedar to do column filtering, you would have to misuse its "Annotations" feature to attach custom metadata to policies, read the output, and then write a custom Rust engine to parse that metadata and filter the JSON anyway. You would essentially be rebuilding your existing system, just with Cedar awkwardly wedged in the middle.

Strengths of your current architecture

What you have built is essentially a custom API Gateway / Sidecar Data Masking Engine.

  1. Performance: Loading rules into memory at startup means zero network latency. (Matches Cedar).
  2. Hot Reloading: Fetching updates via a controller is standard cloud-native practice. (Matches Cedar).
  3. Separation of Concerns: Tech team writes the underlying engine logic (allow-role-based-access-control), while API owners just write YAML configs. (Matches Cedar’s Policy Templates).
  4. Row/Column filtering: Natively understood by your engine. (Far exceeds Cedar).

Verdict: Do not switch to Cedar. Your current YAML-based, locally cached evaluation engine is a perfect fit for your business requirements.


How to implement your architecture in Rust

Since you are migrating to or building in Rust, your current Java-based architecture translates beautifully. In fact, Rust will make this system significantly faster and more memory-efficient.

Here is how you replicate your exact Java architecture—including the hot-reloading and filtering—in Rust.

1. Hot-Reloading using arc-swap

In Java, hot-reloading configuration safely across threads can be tricky. In Rust, the industry standard for "read-heavy, occasionally-updated" memory structures is the arc-swap crate. It allows your web server to read the YAML rules lock-free at millions of requests per second, while your controller can swap out the rules in the background instantly.

#![allow(unused)]
fn main() {
use arc_swap::ArcSwap;
use serde::Deserialize;
use std::sync::Arc;
use std::collections::HashMap;

// 1. Map your YAML exactly to Rust Structs
#[derive(Deserialize, Debug, Clone)]
struct EndpointRule {
    #[serde(rename = "req-acc")]
    req_acc: Option<Vec<String>>,
    #[serde(rename = "res-fil")]
    res_fil: Option<Vec<String>>,
    permission: PermissionConfig,
}

#[derive(Deserialize, Debug, Clone)]
struct PermissionConfig {
    roles: String,
    col: Option<HashMap<String, HashMap<String, String>>>, // Map for roles to column JSON strings
}

// 2. Wrap your entire config in an ArcSwap
lazy_static::lazy_static! {
    static ref CONFIG: ArcSwap<HashMap<String, EndpointRule>> = ArcSwap::from_pointee(HashMap::new());
}

// 3. Background task to listen to the Controller and Hot-Reload
async fn reload_config_from_controller(new_yaml_str: &str) {
    let new_config: HashMap<String, EndpointRule> = serde_yaml::from_str(new_yaml_str).unwrap();
    // Swaps the pointer atomically. 
    // All new requests see the new rules instantly. Old requests finish using the old rules.
    CONFIG.store(Arc::new(new_config));
}
}

2. The Request Access (req-acc) Middleware

You would write an Axum middleware that intercepts the request, looks up the current endpoint in the arc-swap configuration, checks the JWT roles against permission.roles, and rejects the request if it fails.

#![allow(unused)]
fn main() {
// Inside your Axum middleware
let current_config = CONFIG.load();
if let Some(rule) = current_config.get("/v1/accounts@get") {
    let required_roles: Vec<&str> = rule.permission.roles.split_whitespace().collect();
    if !user_roles.iter().any(|r| required_roles.contains(&r.as_str())) {
        return Err(StatusCode::FORBIDDEN); // req-acc fails
    }
}
}

3. The Response Filtering (res-fil) Middleware

This is where Rust shines. Because Axum and Actix allow you to intercept the response body after the handler completes but before it goes to the user, you can apply your column filtering dynamically.

Using serde_json, you can strip out columns that the user isn't allowed to see based on the col mapping in your YAML.

#![allow(unused)]
fn main() {
use serde_json::Value;

fn apply_column_filter(mut response_json: Value, allowed_columns_json: &str) -> Value {
    // 1. Parse the allowed columns from the YAML string (e.g., '["accountNo","firstName"]')
    let allowed_cols: Vec<String> = serde_json::from_str(allowed_columns_json).unwrap();

    // 2. Filter the JSON object
    if let Value::Object(ref mut map) = response_json {
        // Retain only the keys that are in the allowed_cols list
        map.retain(|key, _| allowed_cols.contains(key));
    }
    
    response_json
}
}

Summary

Keep your current system!

  1. The YAML approach is great. It acts as a perfect declarative contract between your API owners and your technical rules engine.
  2. Cedar is the wrong tool for Data Masking. Cedar is for strict Allow/Deny decisions, not mutating response bodies or filtering data.
  3. Rust will supercharge this. By using serde_yaml to parse your config, arc-swap for lock-free hot-reloading, and serde_json for column masking inside an Axum middleware, you will get a system that does exactly what your Java system does, but with significantly less memory overhead and higher throughput.

Agent Engine Pattern

The Agent Engine Pattern is the architectural standard for building industrial-grade, metadata-driven AI platforms within the Light-Fabric ecosystem.

In this model, the Rust Runtime acts as a high-performance Orchestrator, while the Application Logic resides in externalized metadata (JSON/YAML) and the Hindsight Memory database.

1. Why the Metadata-Driven Approach?

  • Separation of Concerns: Complex platform logic (security, retries, database connectivity, LLM integration) is implemented once in Rust. Business logic—defining agent personas, goals, and steps—is "programmed" via JSON or Database records.
  • Hot-Reloading: Using the arc-swap crate and YAML-based rule engines, agent personas, model parameters, and tool access can be updated in real-time without a server restart.
  • Elastic Scalability: Deploy one shared agent engine and specialize it from registry metadata. The public light-agent service owns sessions and reasoning, light-agent-worker hosts sandboxed coding/runtime adapters, and the optional light-agent-channel owns messaging connections. These are thin trust-boundary executables over shared domain crates, not separate persona engines.
  • High Performance: Rust's asynchronous tokio runtime allows a single engine instance to manage thousands of concurrent agentic sessions with minimal memory overhead.

2. The Core Architecture: Engine vs. Content

To function as a generic interpreter, the Light-Fabric Engine relies on four primary components:

A. The Tool & Skill Registry (The "Hands")

The engine maps string identifiers in the workflow JSON (e.g., "call": "get_customer_data") to governed API/MCP capabilities, fixed actions, or immutable sandbox packages.

  • Implementation: Uses a ToolRegistry with trait objects (Box<dyn Tool>) or dynamic dispatch to MCP (Model Context Protocol) servers.
  • Logic: When the LLM requests a tool call, the engine verifies permissions via Fine-Grained Authorization, executes the tool, and feeds the result back into the context.

The registry is not an authorization or execution boundary. Mutable script source is never trusted because it appears in metadata; executable packages must be content-addressed, reviewed, and run through an approved ExecutionBackend.

B. Hindsight State Manager (The "Memory")

Unlike simple session storage, the state manager persists every step of the agentic interaction into biomimetic memory banks.

  • Implementation: Every "turn" in the conversation is saved as a unit_t in the Hindsight database.
  • Benefit: Provides fault tolerance (resuming from a crashed step) and "Recall" capabilities, allowing agents to remember past interactions across different sessions.

C. Prompt Templating (The "Mind")

System prompts and instructions are stored as templates rather than hardcoded strings.

  • Implementation: Uses the tera or rinja engines for high-performance string interpolation.
  • Example: "You are a {{agent_role}}. Your current objective is to {{agent_goal}}."
  • Rust Logic: The engine merges runtime context (user input, memory recall, tool results) into the template before calling the LLM.

D. Policy Engine (The "Shield")

Before any tool execution or data retrieval, the engine consults the Light-Rule middleware.

  • Logic: Ensures the agent has the authority to access specific data or execute specific functions, preventing "prompt injection" from leading to unauthorized actions.

3. Conceptual Implementation in Rust

The AgentEngine in Light-Fabric follows a non-blocking, async loop:

#![allow(unused)]
fn main() {
pub struct AgentEngine {
    registry: Arc<ToolRegistry>,
    memory: Arc<HindsightClient>,
    rules: Arc<RuleEngine>,
}

impl AgentEngine {
    pub async fn execute_step(&self, session_id: Uuid, task: Task) -> anyhow::Result<()> {
        // 1. Fetch current context from Hindsight Memory
        let mut context = self.memory.get_context(session_id).await?;

        // 2. Resolve Task Type (Agentic vs. Tool Call)
        match task {
            Task::LlmCall { agent_id, prompt_template } => {
                // Render prompt with Tera
                let prompt = self.render_prompt(prompt_template, &context)?;

                // Call LLM Provider
                let response = self.llm_provider.chat(prompt, &context).await?;

                // Retain turn in Hindsight
                self.memory.retain_turn(session_id, response).await?;
            },
            Task::ToolCall { tool_name, params } => {
                // 3. Enforce Fine-Grained Authorization
                if self.rules.authorize(session_id, &tool_name).await? {
                    let result = self.registry.call(&tool_name, params).await?;
                    context.add_result(tool_name, result);
                }
            }
        }

        // 4. Update Session State
        self.memory.checkpoint(session_id, context).await
    }
}
}

4. Operational Challenges & Solutions

  1. Tool Versioning: As the platform evolves, tools may change. Light-Fabric handles this by versioning tool definitions in the Registry, ensuring old workflows remain compatible with the tools they were designed for.
  2. Safe Execution: A logical agent does not automatically own a sandbox. Remote model and gateway-only work can remain in the long-lived service; shell, filesystem, browser, local MCP, CLI-model, or untrusted execution uses an approved ExecutionBackend such as a microVM, rootless container, Kubernetes Job, dedicated VM, or fixed external action. The effective policy must match the backend's proven boundary.
  3. Observability: Because the engine is generic, tracing is built into light-runtime. Traces record session, turn, model-call, tool-action, policy, lease, and result metadata without treating private hidden reasoning as an observable platform contract.

The Recommendation

Light-Fabric adopts this "Engine-first" philosophy to keep one durable agent model across enterprise, coding, and personal-assistant profiles. Agent definitions and skills are data; shared Rust crates implement sessions, policy, memory, runtime protocols, and audit; thin service, sandbox-worker, and channel binaries enforce their distinct lifecycles and trust boundaries.

See Light-Agent Execution for the concrete service, session, turn, tool, runner, and sandbox boundaries.

Light-Agent Execution

Status

Proposed.

This design defines how interactive Light agents are hosted, how agent turns and tool actions are authorized and recovered, and when execution must move from a long-lived agent service into a runner-managed sandbox.

It complements:

Decision

A logical agent is not an isolation unit.

Do not create a container, VM, or sandbox for every agent definition by default. Use a hybrid model:

  • run interactive API-based agents in a long-lived light-agent service;
  • group service instances by tenant, trust, model-provider, network, and data boundary;
  • execute local CLI providers, code, shell, browser, filesystem, private local MCP, and other effectful work through the shared controller/runner execution substrate;
  • select the sandbox backend from server-owned policy and runner capabilities;
  • use a dedicated VM or external fixed service only when the workload, credential, regulatory, or host-exposure requirement justifies it.

The same agent session may use more than one execution boundary. A remote model call can remain in the service, an HTTP or MCP API tool can run through light-gateway, a code-repair action can run in a Cube or Docker sandbox, and a publish action can run in a separate fixed service.

Support three product profiles through the same agent control plane:

  • enterprise business agents use remote model providers and typed API/MCP tools through light-gateway;
  • coding agents run a workspace-aware model/tool loop in a runner-managed sandbox;
  • personal assistants use the same session, memory, policy, and skill model, but receive messages and proactive triggers through a separately deployed channel gateway and use an edge runner for local-device effects.

These are runtime profiles, not forks of the agent engine. Share the durable agent domain model, policy evaluator, skill resolver, runtime protocol, and audit vocabulary. Add a separate executable only where the trust boundary or process lifecycle is materially different.

Problem

Agent definitions, chat sessions, model calls, tool calls, and local execution have different lifecycles and trust boundaries. Treating all of them as one process creates two bad extremes:

  1. one shared process receives every tenant credential, workspace, tool, and side effect; or
  2. every logical agent permanently owns a container or VM even when it only makes a bounded remote model call.

The first is unsafe. The second is expensive, slow to scale, and ties metadata to infrastructure unnecessarily.

Agent execution also differs from a workflow task:

  • a session can contain many turns;
  • a turn can contain several model calls and tool actions;
  • the client expects interactive streaming and cancellation;
  • multiple turns may share conversation memory;
  • a coding session may optionally reuse a workspace;
  • an agent can ask for human approval without keeping compute alive.

The execution substrate can be shared with workflows, but session and turn orchestration remain owned by light-agent.

Current Runtime Boundary

The current apps/light-agent executable is a long-lived Axum service.

At startup it creates one process-wide AgentState containing:

  • one model provider and model;
  • one MCP gateway client;
  • one portal query client and portal credential;
  • one PostgreSQL pool and memory store;
  • one host identity;
  • one optional agent definition ID;
  • one catalog cache.

The service exposes a WebSocket chat route. Each connection:

  1. accepts or creates a session ID;
  2. uses the session UUID as its memory-bank ID;
  3. loads conversation history;
  4. accepts user messages sequentially on that socket;
  5. recalls memory;
  6. selects portal catalog tools and, because every currently executable entry is gateway-placed, intersects them with gateway tools/list;
  7. runs up to ten model/tool iterations;
  8. calls selected tools through light-gateway;
  9. persists the final conversation history and experience.

Docker Compose and Kubernetes deploy light-agent as a persistent service. The current account, advisor, and technical-support scripts use distinct service identities, which makes one deployment per configured agent profile the practical short-term model.

This implementation is a useful service foundation, but it is not yet a durable or strongly isolated agent execution engine.

Current Gaps

The first implementation work must close these gaps before broad multi-user or effectful use:

  1. A caller-supplied sessionId is not bound inside light-agent to an authenticated user and agent definition before memory is loaded.
  2. The existing memory schema can store user_id and agent_def_id, but current session-bank creation does not populate those ownership fields.
  3. Catalog selection limits the tool specifications shown to the model, but a returned tool name is not revalidated against the accepted set immediately before tools/call.
  4. Tool arguments fall back to an empty object on malformed JSON instead of failing closed and being checked against the selected input schema.
  5. There is no durable agent-turn or tool-attempt record. A crash after an effectful tool call and before history persistence can leave an unknown outcome that a reconnect may repeat.
  6. Concurrent connections using the same session can race history updates.
  7. Turn-level deadlines, token/cost budgets, tool-call budgets, cancellation, output limits, and concurrency quotas are incomplete.
  8. Tool results are inserted into model context without a strict byte/token limit or an explicit untrusted-content boundary.
  9. CLI providers spawn local child processes. A child inherits the service environment unless explicitly scrubbed and can therefore see process-wide credentials.
  10. Claude Code agent mode currently requests its permission-bypass mode. It must never run inside a shared credential-bearing agent service.
  11. Local helper scripts contain default bearer-token literals. Those values must be removed and rotated regardless of whether they were intended only for development.
  12. Portal-command is the production memory-write default. Direct PostgreSQL writes are retained only as an explicitly enabled local/development compatibility mode.
  13. The current MCP client path forwards the caller Authorization header to light-gateway. It does not yet exchange it for a token narrowed to the agent, turn/action, tool, data boundary, and policy digest.

Goals

  • Preserve low-latency interactive chat and streaming.
  • Keep logical agent definitions independent from deployment units.
  • Bind every session and turn to authenticated tenant, host, user, agent, and policy identities.
  • Serialize concurrent same-session prompts through a bounded durable queue.
  • Provide durable, idempotent turn and tool-action state.
  • Reuse runner registration, scheduling, leases, fencing, watchdog, artifact, credential, and backend contracts.
  • Keep workflow tasks and agent turns under their respective orchestrators.
  • Route remote API tools through light-gateway.
  • Downscope caller authority to the exact agent turn/action and data boundary before gateway dispatch.
  • Route local or effectful execution through an approved ExecutionBackend.
  • Support task-scoped and bounded session-scoped sandboxes.
  • Support enterprise, coding, and personal-assistant profiles without forking the agent domain model.
  • Treat Codex, Pi, Claude Code, Gemini CLI, Kilo, and similar products as agent runtime adapters rather than ordinary model providers.
  • Materialize one centrally governed skill into profile-specific prompt, schema, package, and sandbox inputs.
  • Normalize messaging channels and proactive triggers into authenticated, idempotent agent turns.
  • Allow typed agent-to-workflow and workflow-to-agent handoffs without moving interactive turn ownership into light-workflow.
  • Fail closed when a deployment cannot satisfy the required boundary.
  • Release action leases, model channels, and action credentials while waiting for human approval. Clean task sandboxes; retain a non-secret session workspace only through a distinct bounded hold/checkpoint policy.

Non-Goals

  • Do not convert every chat message into a workflow instance.
  • Do not give every agent definition a permanent container or VM.
  • Do not make controller-rs the owner of conversation or workflow state.
  • Do not let the model choose its isolation boundary or credentials.
  • Do not let local runner configuration weaken server-owned policy.
  • Do not treat a Docker container, Kubernetes pod, or Toolbx as a universal security boundary.
  • Do not expose publish, signing, deployment, or unrestricted shell credentials to a general agent loop.
  • Do not require a sandbox for a bounded remote model call with no local effects.
  • Do not rely on the UI disabling the composer to serialize session mutations.
  • Do not use a workflow instance as the inner loop for every chat message, coding command, or personal-assistant action.
  • Do not let light-workflow or a channel gateway directly spawn an external agent CLI.
  • Do not treat a skill package, repository instruction, plugin, or generated skill as authorization to gain tools, credentials, network, or filesystem access.
  • Do not place messaging-channel credentials, model-provider credentials, tenant API credentials, and unrestricted local-device access in one shared process.

Concepts

Agent Definition

Versioned metadata describing instructions, skills, model policy, tool policy, memory policy, data boundary, and default execution profile.

An agent definition is content. It does not own a process.

Agent Product Profile

A server-owned profile selecting the turn lifecycle, ingress surfaces, runtime placement, default tools, memory policy, sandbox requirements, and deployment boundary for an agent definition.

The initial values are enterprise, coding, and personal-assistant. A profile narrows the effective policy; it does not grant authority by itself.

Agent Runtime Adapter

A versioned adapter that runs one model/tool loop and emits normalized runtime events. Native light-agent reasoning, Pi RPC/SDK, Codex, Claude Code, Gemini CLI, and other external harnesses implement this boundary.

An agent runtime is not a model provider. A model provider performs inference; an agent runtime may own a session, tools, local state, approvals, and repeated model calls.

Agent Runtime Host

The small light-agent-worker executable launched inside a runner-managed sandbox. It verifies the leased runtime specification, materializes approved skills and context, starts exactly one runtime adapter, streams normalized events, and exits or checkpoints at the lease boundary.

It does not authenticate end users, own conversation history, choose policy, or accept arbitrary executable paths from a prompt.

Channel Gateway

The optional light-agent-channel executable that owns messaging-platform connections, webhook verification, user/channel pairing, delivery receipts, and channel credentials. It converts inbound messages, scheduled triggers, and connector events into authenticated idempotent turn requests.

It is an ingress and delivery adapter, not an agent engine and not a general execution environment.

Agent Service Instance

A long-lived light-agent process or replica serving compatible agent definitions and sessions. An instance has one deployment trust boundary, network zone, service identity, and set of provider/credential capabilities.

The current implementation has one model/provider and one optional agent definition per instance. Supporting multiple definitions in one pool requires request-time immutable definition resolution and a cache keyed by host and agent definition.

Agent Session

An authenticated conversation scope bound to:

  • tenant and host;
  • user or service principal;
  • agent definition and version;
  • memory policy and bank;
  • data boundary;
  • optional sandbox session;
  • creation, idle, and maximum lifetime;
  • optimistic version or active-turn fence.

A session ID is an opaque server-issued reference, not proof of access.

Agent Turn

One accepted user or service request and its resulting model/tool loop. A turn has a durable ID, idempotency key, policy snapshot, budgets, state, timestamps, and terminal result.

Agent Action Attempt

One effectful tool or local execution attempt within a turn. It has an idempotency key, attempt number, lease, fencing token, approval state, result, and reconciliation state.

Read-only remote gateway calls may use a lighter audit record, but side-effecting or sandboxed actions require a durable attempt.

Execution Subject

The origin-neutral identity carried by the controller/runner protocol:

subject.kind = workflow-task | agent-turn | agent-action
subject.id
subject.attempt
origin.service
origin.instance

Workflow correlation and agent-session correlation are optional typed extensions. They are not mandatory fields in the runner transport.

Sandbox Session

An optional backend environment reused across related turns or actions under one immutable policy, principal, agent definition, workspace base, and expiry. It is separate from the chat session. Most chat sessions need no sandbox.

Ownership

ComponentAuthority
light-agentAgent session, turn, model loop, memory policy, action intent, approval wait, final response
light-workflowWorkflow instance, workflow task, workflow retry, workflow approval, workflow transition
controller-rsRunner admission, capacity, reservation, lease transport, heartbeat, quarantine
light-workflow-runnerLease validation, local journal, backend lifecycle, bounded execution, cleanup evidence
light-agent-workerLeased sandbox-side runtime hosting, skill/context materialization, normalized event streaming, process-tree shutdown
light-agent-channelMessaging connection, webhook verification, channel/principal binding, trigger normalization, response delivery
ExecutionBackendBackend-specific preparation, inspection, execution, logs, artifacts, cancellation, cleanup
light-gatewayAPI/MCP authentication, authorization, routing, network policy, and response controls
model providerModel inference only; its output is untrusted input to policy enforcement
agent runtime adapterOne bounded model/tool loop behind the normalized runtime protocol; no authority to widen its lease
fixed action/serviceStructured publish, signing, deploy, push, or other high-value operation

controller-rs and the runner are origin-neutral. They do not advance an agent turn or workflow task. The origin service reconciles the fenced result into its own state.

Execution Modes

Use explicit modes. Do not silently redirect one mode to another.

ModeOwnerIntended useLocal execution
native-workflowlight-workflowClassification, summarization, branching, schema-bound JSONNone
agent-servicelight-agentInteractive chat, memory, remote model, gateway tool loopNone by default
runner-agentlight-agent or light-workflowFiles, shell, code, browser, local MCP, private tenant toolsExecutionBackend
channel-agentlight-agent-channel plus light-agentMessaging, scheduled triggers, personal-assistant ingress and deliveryNone in channel gateway
fixed-actiondedicated typed service or runner templatePublish, sign, deploy, branch/PR, high-value credentialsFixed structured operation

Native Workflow Agent

Keep bounded workflow reasoning in light-workflow. It receives workflow-safe context, calls an approved remote model, validates structured output, and returns control to explicit workflow tasks.

It receives no filesystem, local shell, dynamic tools, tenant workspace, or release credentials.

Agent Service

Use a long-lived light-agent service for interactive sessions:

  • WebSocket or streaming chat;
  • Hindsight memory;
  • remote model providers;
  • portal catalog caching;
  • dynamic gateway tools/list and tools/call;
  • independently scaled specialist agents.

The service container is an application isolation boundary, not a safe place to execute arbitrary code. It should have no workspace mount, host container socket, build tools, browser automation, or unrestricted local MCP server.

Runner Agent

Use runner-agent mode when a turn needs:

  • checked-out repositories or mutable files;
  • shell or language runtimes;
  • browser automation;
  • CLI-based model agents;
  • local MCP servers;
  • private tenant network access;
  • code generation, repair, or tests;
  • untrusted tool packages or scripts.

For these cases, either:

  1. keep the model loop in light-agent and lease individual local actions; or
  2. place the entire model/tool loop in the sandbox when a CLI agent or workspace-aware model must observe and mutate local state.

The second model is required for Codex-, Pi-, and Claude Code-style execution. Host it with light-agent-worker; do not start a second copy of the public light-agent service inside the sandbox. The shared service must not spawn an external agent CLI with its own environment.

Per-action leasing remains useful for a native service-side loop that needs one isolated command. A workspace-aware external runtime receives one bounded agent-turn lease, with optional policy-compatible session reuse, because its filesystem observations, command sequence, and model context form one local execution loop.

Fixed Action

Publishing, signing, deployment, final tags, branch push, and pull-request creation use fixed actions with structured inputs. They consume immutable artifacts or an accepted canonical patch and receive a fresh scoped credential.

They do not execute arbitrary commands from the agent or mutable workspace.

Portal / CLI / API                 Messaging / schedule / connector event
         |                                      |
         |                         light-agent-channel
         |                                      |
         +------------- authenticated turn ----+
                                |
                                v
                          light-agent
                       session and policy
               +-------------+-------------+
               |             |             |
               v             v             v
        model provider  light-gateway  light-workflow
                          API / MCP       durable process
                                |
                                v
                          controller-rs
                                |
                                v
                     light-workflow-runner
                                |
                                v
                        ExecutionBackend
                                |
                                v
                   task/session sandbox
                                |
                                v
                     light-agent-worker
                     + runtime adapter

Fixed high-value effects remain separate typed services/actions.

The normal interactive path does not allocate a sandbox. A sandbox is allocated only when effective policy and the requested action require local execution.

Product Profiles

Enterprise Business Agent

Use the enterprise profile for API- and MCP-centered business processing. The model loop stays in the long-lived light-agent service. The effective catalog exposes only assigned and currently executable gateway tools. Durable or regulated multi-step processing is delegated to light-workflow.

This profile has no workspace mount, local shell, browser process, external agent CLI, or personal channel credential.

Coding Agent

Use the coding profile for repository inspection, code changes, builds, tests, local MCP, and developer tooling. The whole workspace-aware loop runs through light-agent-worker in a task-scoped sandbox by default. A bounded session-scoped workspace is an optimization that requires the same principal, agent definition, repository base, policy, runtime adapter, backend, and expiry.

The runtime adapter may be native or may wrap an external harness such as Pi, Codex, or Claude Code. The adapter is selected by immutable server policy and image/package identity, never by a prompt-supplied command. Provider access is brokered by a runner-owned service outside the untrusted payload boundary; raw provider keys and reusable proxy bearer tokens are not copied into the sandbox. The worker receives only a peer-bound local channel for the current attempt, model allowlist, data-boundary and policy digests, token/cost budget, rate, and expiry. The generated-code process runs under a different identity and process/mount namespace and cannot inspect, reconnect to, or inherit that channel.

The untrusted workspace can produce a patch and diagnostic artifacts. Trusted runner code computes the canonical diff, enforces protected paths, and exports immutable artifacts. Branch, pull-request, push, publish, signing, and deploy remain fixed actions over the accepted patch or commit.

Personal Assistant

Use the personal-assistant profile for long-lived user memory, messaging channels, proactive schedules, personal connectors, browser tasks, and optional local-device access.

light-agent-channel owns platform-specific connections and principal pairing. It does not hold model-provider or general tenant credentials. Typed remote connectors execute through light-gateway. Browser, filesystem, desktop, home-automation, or other user-local effects execute through a dedicated or user-owned edge runner with an explicit capability policy.

A logical personal assistant does not require a permanent VM. A dedicated service or runner is required when personal OAuth grants, private-network access, legal boundaries, or local-device capabilities cannot share a service pool safely.

Scheduled and connector-triggered turns enter the same durable per-session queue as user prompts, carry an origin and idempotency key, and obey quiet hours, rate, cost, approval, and notification policy. A proactive trigger cannot interrupt an active turn or silently act as the user.

Agent Runtime Protocol

Define a versioned agent-runtime-protocol shared by light-agent, light-agent-worker, runner adapters, and test fixtures. It is separate from the model-provider trait and from the controller/runner lease protocol.

The runtime specification includes:

  • runtime adapter ID, version, immutable image/package digest, and capability digest;
  • agent, session, turn, and execution correlation;
  • bounded context and selected skill-package digests;
  • workspace base, writable roots, protected paths, and change policy;
  • model, tool, network, credential, approval, resource, artifact, and deadline policy;
  • optional checkpoint/session identity and compatibility digest;
  • a one-time event-stream authentication handle.

The runtime emits ordered, bounded events such as:

  • runtime.started and runtime.ready;
  • model.started, model.delta, model.completed, and usage;
  • tool.requested, tool.started, tool.result, and tool.failed;
  • approval.requested and approval.resolved;
  • workspace.changed and artifact.proposed;
  • checkpoint.created;
  • turn.completed, turn.failed, turn.cancelled, or turn.unknown.

Each event carries the execution ID, turn/action identity, monotonically increasing sequence, event ID, policy digest, timestamp, and bounded payload or artifact reference. Duplicate events are idempotent. Missing sequences can be resumed from the worker journal. An event is evidence; only light-agent can accept it into agent-domain state.

The protocol supports start, cancel, inspect, checkpoint, resume, and bounded input/approval responses. It does not expose a generic remote shell endpoint.

Runtime Capabilities And Adapters

A runtime capability document declares whether the adapter supports workspace mutation, native tools, streaming, interruption, approval suspension, checkpoint/resume, project-local instructions, local MCP, and session reuse. Server-owned compatibility policy maps a tested adapter version to the capabilities it may claim.

For local execution it also carries an immutable runtime-tool manifest: stable internal tool reference, model-facing alias, input/output schema digests, effect class, required capability, and dispatch adapter for each shell, filesystem, browser, or local-MCP operation. At turn admission, server policy intersects that manifest with the execution profile and lease allowedTools; where the runtime supports live enumeration, the worker intersects it again with the current local tool set. A runtime self-report can narrow availability but cannot add authority absent from the server-owned compatibility record.

The first adapters should be:

  1. a deterministic mock adapter for protocol, recovery, and fencing tests;
  2. a native bounded adapter using shared Light-Agent core logic;
  3. one SDK/RPC-based coding adapter, with Pi as the preferred first candidate;
  4. subprocess adapters for Codex, Claude Code, Gemini CLI, or Kilo only after their non-interactive event and approval contracts are pinned and tested.

Do not scrape terminal presentation output when a structured SDK, RPC, or JSON event mode exists. Never enable a permission-bypass flag as a substitute for the platform sandbox and approval policy.

Agent And Workflow Interoperation

Agent and workflow orchestration are bidirectional but retain separate domain ownership.

An agent starts a workflow through a typed gateway/API tool when a skill needs durable branching, retries, assertions, human tasks, or long waits. The agent stores the workflow instance reference and may stream or poll its public status; it does not reproduce the workflow steps in its own model loop.

The existing workflow call.agent behavior remains the backward-compatible native-workflow mode: light-workflow performs a bounded model call and validates schema-bound JSON without an interactive session or local tools. A future explicit agent-service mode submits a typed agent job to light-agent. Light-agent may satisfy that job in its service or through runner-agent placement according to the selected definition and policy.

light-workflow never directly launches an external agent binary and never mutates agent session history. light-agent never advances workflow tasks. Handoffs carry a correlation ID, caller and tenant binding, input/output schema, deadline, idempotency key, budget, cancellation policy, and bounded delegation depth. Cyclic delegation and unbounded agent/workflow recursion are rejected.

Shared Runner Contract

The workflow runner protocol should be origin-neutral before its first stable version. Do not require processId and taskId in every wire message.

Every scheduling request and lease carries:

  • execution ID;
  • origin service and authenticated origin instance;
  • subject kind, ID, and attempt;
  • tenant and host derived from trusted identity;
  • policy snapshot and digest;
  • execution profile and compatibility digest;
  • runner/backend selection;
  • lease ID and fencing token;
  • deadlines and cleanup policy;
  • idempotency key;
  • optional typed workflow or agent correlation.

Example standalone agent action:

{
  "executionId": "01970f5d-2222-7000-8000-000000000001",
  "origin": {
    "service": "light-agent",
    "instance": "account-agent-east"
  },
  "subject": {
    "kind": "agent-action",
    "id": "01970f5d-2222-7000-8000-000000000020",
    "attempt": 1
  },
  "agent": {
    "sessionId": "01970f5d-2222-7000-8000-000000000010",
    "turnId": "01970f5d-2222-7000-8000-000000000011",
    "agentDefId": "01970f5d-2222-7000-8000-000000000012"
  },
  "leaseId": "01970f5d-2222-7000-8000-000000000030",
  "fencingToken": 19,
  "policyDigest": "sha256:...",
  "executionProfile": "agent-microvm",
  "commandTemplateId": "agent-tool-cargo-test",
  "deadlineAt": "2026-07-10T20:30:00Z",
  "expiresAt": "2026-07-10T20:10:30Z"
}

The controller authenticates which services may submit each subject kind. light-agent cannot submit workflow-task work, and light-workflow cannot mutate an agent turn merely because both use the same runner.

Persistence Split

Use common execution tables for controller/runner state:

  • runner_scheduling_request_t;
  • execution_attempt_t;
  • runner session/backend capability records;
  • execution session, artifact, and runtime audit records where sharing is appropriate.

Use origin-specific tables for domain state:

  • task_info_t and workflow approval/transition records for workflow tasks;
  • agent_session_t, agent_turn_t, agent_action_attempt_t, and the ordered session event stream for agent work.

The common attempt stores subject identity, lease, fencing, backend, normalized result, and cleanup. The origin transaction conditionally accepts that result and advances only its own domain object.

This split is a design decision:

  • every runner-backed agent action references one shared execution_attempt_t row;
  • execution_attempt_t contains only controller/runner concerns such as origin, subject, attempt, reservation, lease, fencing, runner/backend, normalized result, and cleanup;
  • agent_action_attempt_t contains agent-domain concerns such as tool identity, model iteration, argument digest, effect class, approval, budgets, recovery policy, and acceptance into the turn;
  • gateway-only actions can have an agent_action_attempt_t without an execution_attempt_t and instead record the gateway request/idempotency identity;
  • controller-rs never writes agent turn, history, or approval state.

Result-Ready Wakeup

The common execution row remains the durable source of truth. In the same PostgreSQL transaction that conditionally stores a newly terminal execution_attempt_t, controller-rs emits a versioned execution_result_ready_v1 notification containing only the attempt ID, authenticated origin, subject kind, and correlation ID. It contains no result bytes, tenant content, or authorization.

light-agent listens for the notification, loads the authoritative row, verifies origin/subject/fencing bindings, and conditionally accepts the result in its own domain transaction. It must also scan indexed unaccepted terminal attempts at startup and periodically. The listener uses a dedicated connection and, on startup or reconnect, establishes LISTEN before its catch-up scan so a commit in that handoff window is either scanned or queued. LISTEN/NOTIFY is only a low-latency wakeup: delivery can be missed, duplicated, or reordered. A future typed controller callback may provide another wakeup, but it cannot replace the authoritative query or make controller-rs write agent tables.

Session And Turn Model

Session Admission

The front door authenticates the caller before accepting or resuming a session. The server derives:

  • tenant and host;
  • user or service principal;
  • allowed agent definition;
  • model/provider and data-boundary policy;
  • memory scope;
  • maximum session and idle lifetime.

On new session, light-agent creates a server-issued session ID and stores the ownership binding. On resume, all binding fields must match. A valid UUID alone never grants access.

The existing agent_memory_bank_t.user_id and agent_def_id fields should be populated. agent_session_history_t should either gain explicit ownership columns or reference a new agent_session_t that contains them.

Proposed Agent Tables

agent_session_t:

  • tenant, host, session, user/service principal, and agent definition;
  • definition, model, tool, memory, and execution policy digests;
  • memory bank ID;
  • optional execution session ID;
  • state, optimistic version, active turn, created/last/idle/max expiry;
  • cancellation, revocation, and retention state;
  • durable execution-session cleanup request, state, and evidence correlation.

agent_turn_t:

  • session and monotonically increasing turn sequence;
  • origin kind such as user, channel, workflow, scheduler, or connector plus an immutable origin reference and bounded delegation depth;
  • client message ID and idempotency key;
  • immutable prompt/input reference and policy snapshot;
  • model/provider reference and data boundary;
  • QUEUED, RECEIVED, RUNNING_MODEL, WAITING_ACTION, RUNNING_ACTION, WAITING_RECONCILIATION, WAITING_APPROVAL, COMPLETED, FAILED, CANCELLED, or UNKNOWN state;
  • enqueue sequence, queue deadline, activation time, and optional cancellation reason;
  • token, cost, model-call, action-call, and wall-clock budgets;
  • accepted result, error class, timestamps, and audit correlation.

agent_action_attempt_t:

  • turn and action/tool identity;
  • stable internal tool reference, model-facing alias, tool source, schema digest, effect classification, and selected execution placement;
  • optional runtime adapter ID/version, runtime action ID, and capability digest;
  • input schema and canonical argument digest;
  • approval requirement and binding;
  • numbered logical attempt and optional superseded/resumed-from attempt;
  • nullable execution_attempt_id referencing the common execution_attempt_t row where runner-backed;
  • gateway request/idempotency identity where remotely executed;
  • known-success, known-failure, cancelled, or unknown outcome;
  • recovery classification and remaining correction budget;
  • bounded result/artifact references and reconciliation state.

agent_approval_t:

  • approval ID, session, turn, canonical action intent and argument digest;
  • tool/operation, destination, data-boundary and policy digests, artifact or patch bindings where applicable, actor authority, state, and expiry;
  • source attempt when a running runtime discovered the approval boundary;
  • optional execution-session approval-hold ID and bounded hold expiry;
  • consuming post-approval agent-action and common execution-attempt IDs;
  • a unique active approval per exact subject and single-use consumption.

agent_session_event_t, or an equivalent append-only portal event stream:

  • session sequence, event ID, turn ID, optional action-attempt ID, and event type;
  • USER_MESSAGE, MODEL_RESPONSE, ACTION_DISPATCHED, ACTION_RESULT, APPROVAL_REQUESTED, APPROVAL_DECIDED, TURN_TERMINAL, or SYSTEM event;
  • immutable content reference/digest, source class, policy digest, timestamp, and actor;
  • unique action-result event per accepted agent action attempt.

agent_session_history_t is a rebuildable conversation-context projection over the ordered event stream. It is not the authoritative ledger proving that an effectful action occurred.

Personal-assistant deployments also require channel-domain records, either in the GenAI schema or a dedicated channel service:

  • agent_channel_binding_t binds tenant, principal, agent, platform, channel, remote identity, pairing/verification state, and revocation without storing raw channel secrets;
  • agent_channel_delivery_t deduplicates inbound platform events and outbound responses, records delivery state, and references the resulting turn;
  • scheduled trigger records bind agent, session/origin, schedule, quiet-hours, notification, idempotency, and maximum-delay policy.

Channel records do not replace agent turns. They prove ingress and delivery; light-agent remains authoritative for reasoning and action state.

Turn State Machine

QUEUED -> RECEIVED -> RUNNING_MODEL
RUNNING_MODEL -- no tool --> COMPLETED
RUNNING_MODEL -- tool --> WAITING_ACTION
WAITING_ACTION -- no approval --> RUNNING_ACTION
WAITING_ACTION -- approval required --> WAITING_APPROVAL
WAITING_APPROVAL -- approved; allocate new attempts --> RUNNING_ACTION
WAITING_APPROVAL -- rejected --> RUNNING_MODEL or FAILED by policy
RUNNING_ACTION -- known success/recoverable failure --> RUNNING_MODEL
RUNNING_ACTION -- uncertain outcome --> WAITING_RECONCILIATION
WAITING_RECONCILIATION -- known recoverable result --> RUNNING_MODEL
WAITING_RECONCILIATION -- terminal/unsafe --> FAILED
WAITING_RECONCILIATION -- cannot determine --> UNKNOWN

Any active state -- accepted cancellation --> CANCELLED
Policy/security violation or exhausted hard budget --> FAILED

A model call may be safely retried only when it has no external effect or the provider request is idempotent. An action with an unknown outcome is inspected or reconciled before another attempt.

An action failure does not automatically fail the turn. A known, policy-allowed recoverable failure such as a compiler error, failed test, linter result, or non-zero diagnostic command is persisted as untrusted tool output and returned to RUNNING_MODEL when correction budgets remain. The model may explain the failure or propose a new action.

The turn becomes FAILED or CANCELLED when:

  • policy, authentication, schema, or security enforcement rejects the action;
  • approval is rejected and policy treats rejection as terminal;
  • a hard turn deadline or token/cost/action budget is exhausted;
  • the client or control plane cancels the turn;
  • the action is classified non-recoverable;
  • an unknown side effect cannot be reconciled and policy requires termination.

A correction is a new action identity. Reusing an attempt is allowed only when the backend/external operation has a proven idempotency or inspection contract. maxCorrectionActions and per-tool retry limits prevent an agent from looping on the same failure. A turn may still finish COMPLETED with a user-facing explanation that one or more actions failed; completion means the response was durably delivered, not that every action succeeded.

WAITING_APPROVAL is durable agent state. It always ends the current action lease, model-broker capability, and action credential. A task-scoped sandbox is cleaned after its required evidence is exported.

A reusable agent-session workspace has a separate lifecycle. Under an explicit bounded non-secret retention policy, its execution_session_t may enter IDLE_APPROVAL_HOLD with no executable action, tool credential, or model channel. The hold expires at the earliest of approval expiry, session idle/max expiry, cost/retention policy, broker/credential boundary, and backend-native TTL. Pause or a verified checkpoint is preferred over consuming active compute. Absence of an action lease is not by itself a session-cleanup signal.

The origin may renew the session-retention record only from authenticated session activity and never beyond the fixed maximum; it must not fake action lease heartbeats while a person decides. If the backend cannot safely retain or checkpoint the non-secret workspace, the runner exports an approved immutable patch/checkpoint and cleans the sandbox. Preserving important uncommitted work must not depend only on a live sandbox.

The origin transition into WAITING_APPROVAL atomically persists exactly one session disposition: cleanup, or a policy-valid bounded hold. If common session state later lives in another database, use an idempotent transactional outbox. There must be no interval where a session reaper can interpret the ended action lease as abandonment before the hold is durable.

Approval never reactivates an execution attempt. If policy knows approval is required before dispatch, light-agent records the bound action intent and approval but creates no common execution attempt. If a running runtime discovers an approval boundary, it returns a known approval_required terminal result; controller-rs ends the action lease and the runner revokes grants and cleans or checkpoints according to policy. After approval, light-agent consumes the approval into a new numbered agent action attempt and a new common execution attempt with a fresh lease and monotonic fencing token. The previous attempt and its backend handle, grants, and fencing token remain immutable and cannot resume execution. A retained session workspace is reused only after principal/base/policy/runtime/expiry compatibility and cleanup state are revalidated; otherwise the new action starts in a fresh sandbox and restores only a verified policy-permitted checkpoint or patch.

Concurrency

Only one mutating turn should own a session version at a time by default. Multiple WebSockets or replicas must not overwrite the same history.

The default user experience is a bounded durable server-side FIFO per session, not immediate rejection:

  1. Authenticate and authorize the prompt, deduplicate its client message ID, assign the next session enqueue sequence, and persist a QUEUED turn.
  2. Return the turn ID, state, queue position, and estimated/retry timing to the client. The UI may disable or label the composer, but correctness does not depend on client-side serialization.
  3. When no active mutating turn exists, conditionally acquire the session version, revalidate revocation and current policy, snapshot the effective turn policy, and activate the oldest non-expired queued turn.
  4. Allow a user to cancel a queued turn. Interrupting an active turn requires an explicit cancel-and-enqueue operation; a second prompt never implicitly cancels in-flight work.

A queued prompt is durable but is not added to the active turn's model context or mutable history projection. It becomes eligible for conversation context only after it wins FIFO activation, so a later prompt cannot change the meaning of an in-flight action.

Queue depth and wait time are bounded per tenant, principal, agent, and session. A full queue returns a retryable admission response such as 429 with retryAfter. A 409 is reserved for a stale explicit session version or an operation that semantically requires exclusive ownership; it is not the normal second-prompt response.

Use a conditional active-turn or aggregate-version update across replicas. A read-only secondary view can stream state, but it cannot bypass the FIFO or append another active user turn without winning session activation.

Agent Definition And Policy Snapshot

Resolve and snapshot at turn admission:

  • agent definition/version;
  • system instructions and selected skills;
  • model/provider and regional/data-boundary policy;
  • memory scope and retention;
  • permitted catalog and tool policy;
  • action execution profile;
  • network and credential profile;
  • turn/model/tool/token/cost limits;
  • approval rules;
  • protected workspace policy;
  • artifact and audit policy.

Do not execute a long turn from mutable current rows. A catalog refresh may narrow executable tools immediately for emergency revocation, but it cannot widen the accepted snapshot without a new authorization decision.

For future multi-agent pooling, cache by host, agent definition ID, version, and policy digest. Never use one global catalog entry across definitions.

Centralized Skills Across Profiles

The centralized registry is the source of assigned skill identity, version, instructions, taxonomy, tool/workflow links, runtime compatibility, and governance metadata. It is not the process that executes a skill.

At turn admission, light-agent resolves an immutable effective skill set and records every selected version and digest. A profile-specific materializer then produces only the inputs required by the selected runtime:

Profile/runtimeMaterialized form
Enterprise agentBounded prompt instructions plus selected gateway tool schemas
Native workflow agentBounded instructions, structured input, and required output schema
Coding runtimeRead-only SKILL.md, references, and signed script/assets package inside the sandbox
Personal assistantInstructions, connector/tool mappings, schedule/notification constraints, and optional reviewed package
Workflow-backed skillInstructions plus a typed workflow reference and start contract

Skill content is layered in decreasing authority:

  1. server and execution policy;
  2. signed platform/tenant skill versions assigned to the agent;
  3. reviewed user-specific skill configuration;
  4. repository or workspace-local instructions;
  5. user prompts, retrieved content, and tool output.

Lower layers cannot override higher-layer policy. Repository instructions and downloaded or generated skills are untrusted content even when useful to the model. A self-generated skill is stored as an inactive proposal and requires validation, scanning, review, immutable packaging, and an explicit assignment before another turn can load it.

Do not execute source code copied directly from a mutable database row. Script or binary content belongs in an immutable artifact with digest, provenance, scanner results, entrypoint metadata, and a required sandbox profile. The trusted runner—not light-agent-worker or generated code—downloads the selected immutable packages before sandbox creation, verifies their digest/signature/size and archive safety, and stages them as read-only mounts with nodev, nosuid, and noexec unless a reviewed profile requires an executable entrypoint. The worker revalidates the mounted manifest before use. Neither the worker nor payload receives artifact-store credentials or package download egress.

See Centralized Skills for the catalog and package model and Skill Workflow Orchestration for workflow-backed skills.

Tool Authorization And Execution

Treat model tool calls as untrusted requests.

For each model iteration:

  1. Resolve the effective catalog for the authenticated agent and turn.
  2. Apply lifecycle, sensitivity, effect, approval, tenant, cost, and network policy.
  3. Partition candidates by the server-owned execution placement recorded in the catalog/policy snapshot: gateway, runner, workflow, or fixed service.
  4. For gateway candidates, intersect with live gateway tools/list and toolsListAccessControl under the downscoped turn identity.
  5. For runner candidates, intersect with the execution profile, lease allowedTools, server-approved runtime-tool manifest, and live worker or sandbox-local MCP enumeration where supported. Do not require these tools to exist in gateway tools/list.
  6. Expose workflow and fixed-service candidates only through their typed contracts; they are never converted to free-form local or gateway tools.
  7. Form a collision-free union. Bind each model-facing name to its internal tool reference, placement, schema digest, and policy snapshot. A duplicate alias across placements fails closed unless server policy assigned distinct deterministic aliases.
  8. Send only that accepted set to the model.
  9. On returned tool call, recheck that the exact bound tool remains in the accepted set.
  10. Parse arguments strictly. Malformed JSON fails; it does not become an empty object.
  11. Validate arguments against the accepted input schema and routing metadata.
  12. Re-evaluate effect, approval, quotas, cancellation, policy revocation, and destination immediately before dispatch.
  13. Compute the effective delegation as the intersection of caller authority, agent-definition policy, turn/action policy, tool policy, and current revocation state.
  14. For gateway execution, exchange the caller identity for a short-lived downscoped gateway token bound to the turn or exact action.
  15. Create a durable attempt and idempotency key when the action can have an effect.
  16. Dispatch only through the placement bound at disclosure; model output cannot change the route.
  17. Bound, redact, classify, and persist the result before giving it back to the model.

The gateway remains the final API authorization and routing boundary. Agent catalog policy is an additional restriction and must not be bypassed merely because the gateway would accept a broader caller token.

The runner lease and runtime-tool manifest are the corresponding final local availability boundaries. A local tool name is not authority by itself, and the model broker, credential broker, runner control socket, and backend lifecycle API are never included in the tool union.

Gateway Delegation

Production agent calls do not forward the caller's full bearer token directly to light-gateway. light-agent uses a trusted token-exchange or credential-broker service to mint a signed, short-lived delegated token whose authority can only narrow the caller.

The effective authority is:

caller grants
  intersect agent-definition policy
  intersect turn/action policy
  intersect tool and data-boundary policy
  intersect current revocation and quota state

A tools/list token is scoped to the turn and accepted tool set. A tools/call token should be scoped to one action and include or cryptographically bind:

  • gateway audience;
  • tenant, host, caller subject, and light-agent actor identity;
  • agent definition, session, turn, and action IDs;
  • exact tool or narrowly bounded tool set;
  • allowed scopes, destination/service, sensitivity ceiling, and data boundary;
  • policy snapshot/digest and argument or request digest where practical;
  • issued-at, short expiry, unique token ID, and replay/idempotency binding.

light-gateway validates the signature, audience, expiry, actor/delegation chain, policy binding, tool, destination, and current authorization. It intersects the delegated token with its own access-control and tool metadata; possession of a more powerful original user token cannot widen an agent turn.

If token exchange is unavailable or a requested binding cannot be enforced, the production call fails closed. Direct forwarding may exist only as an explicit local-development compatibility mode and must never be the default for effectful or sensitive tools.

Placement

Tool/actionDefault placement
Remote read-only HTTP/MCPlight-gateway
Remote effectful HTTP/MCPlight-gateway plus durable action attempt and approval/idempotency
Local command or language runtimerunner ExecutionBackend
Filesystem or repository mutationrunner task/session sandbox
Browser automationrunner sandbox with network policy
Local MCP serverrunner sandbox or dedicated tenant service
Branch/PR creationfixed action over accepted patch
Publish/sign/deployfixed external service or dedicated fixed runner action

Tool Results

Tool output is untrusted content even when the tool is authorized.

Result handling distinguishes action outcome from turn outcome:

  • known success is persisted and normally returns to RUNNING_MODEL;

  • known recoverable failure is persisted with bounded diagnostics and returns to RUNNING_MODEL when correction policy and budgets allow;

  • known terminal failure ends or cancels the turn according to policy;

  • unknown outcome enters WAITING_RECONCILIATION and cannot be represented to the model as if the action definitely failed;

  • a new corrective tool call receives a new action ID and idempotency decision.

  • enforce byte, item, nesting, and token limits;

  • preserve truncation markers and full artifact references when policy permits;

  • separate tool data from system instructions;

  • do not follow instructions found in tool output unless the agent policy explicitly treats that source as instructions;

  • redact secrets before persistence and again before model context;

  • store the action ID, tool/version, argument digest, authorization decision, destination, result digest, and model iteration.

Model Provider Boundary

Remote API Providers

Remote API providers can run from the long-lived service when:

  • the service data boundary permits the prompt;
  • provider credentials are service-owned or tenant-approved;
  • no local executable is spawned;
  • the turn has token, cost, timeout, and concurrency limits;
  • response and tool calls are treated as untrusted.

Do not send tenant-local repositories, private logs, or private-network data to a SaaS provider unless the effective policy authorizes that transfer.

CLI And External Agent Runtimes

Codex, Pi, Claude Code, Gemini CLI, Kilo CLI, and similar harnesses are agent runtimes, not ordinary model API adapters. Existing CLI implementations under model-provider are compatibility code and should migrate behind the agent-runtime adapter boundary.

They run under light-agent-worker with runner-agent placement and require:

  • fresh task or bounded session sandbox;
  • minimal allowlisted environment;
  • no inherited portal token, database URL, unrelated provider keys, or controller credential;
  • explicit workspace, network, tool, and resource policy;
  • local deadline and process-tree cancellation;
  • bounded stdout/stderr;
  • immutable binary/image identity and capability digest;
  • structured SDK, RPC, or JSON event integration where available;
  • normalized approval, cancellation, usage, patch, and terminal events;
  • cleanup journal and backend-native expiry.

Model access for these runtimes terminates at a runner-owned broker. Prefer a preconnected descriptor, peer-credential-checked Unix-domain socket, vsock, or an equivalent backend-local transport. A socket pathname alone is not an authorization boundary: the broker authenticates the attempt and peer, and the runner prevents descriptor inheritance, cross-process /proc inspection, and ptrace. The broker independently enforces the approved model, data boundary, policy digest, token/cost budget, rate, cancellation, and expiry. An adapter that can operate only with an extractable provider key is ineligible for an untrusted coding profile.

Permission-bypass flags are prohibited. If an adapter needs an unattended mode, the platform sandbox and approval policy—not a CLI bypass option—provide the effective boundary. The shared service never invokes these binaries directly, and light-workflow never invokes them at all.

Sandbox Scope And Backend

Backend and session scope are separate decisions.

ScopeUseDefault
noneRemote model plus gateway-only toolsLong-lived agent service
turn/taskCLI agent, untrusted tool, one repair/actionPreferred strong isolation
agent-sessionInteractive coding workspace reused across turnsExplicit TTL, same principal/policy/base
dedicatedPrivileged, regulated, or long-running tenant agentDedicated VM or service
WorkloadMinimum boundaryCandidate
Bounded remote reasoningservice containerlight-agent pod
Trusted internal commandshared-kernel-containerRootless OCI or ordinary Kubernetes Job
Autonomous code or untrusted packagemicrovmCube Sandbox or Docker Sandboxes
Strong tenant isolationdedicated-vmApproved dedicated VM
Trusted local developer helperhost-integratedToolbx, never represented as a sandbox
Publish/sign/deployexternal-serviceFixed typed action or service

The deployment advertises available backends. Server-owned policy chooses an eligible backend or defers/denies execution. It never silently downgrades.

Session Reuse

An agent sandbox session can be reused only when all of these match:

  • tenant, host, principal, agent definition, and policy digest;
  • workspace base revision and change policy;
  • backend, template/image, and compatibility digest;
  • network, credential, model-provider, and tool policy;
  • maximum lifetime, idle timeout, and cleanup state.

Credentials remain task-scoped even when the workspace is reused. A session that received a high-value credential is destroyed after the action unless an explicit policy proves safe cleanup.

The effective physical-session expiry is the earliest of the agent session's idle/max expiry, execution-session policy, broker/grant expiry, and backend-native TTL. An approval hold can preserve a compatible non-secret workspace only until that same effective expiry; it does not refresh or extend the fixed maximum and it carries no action credential or model channel.

When light-agent closes, revokes, or expires a logical session, the same durable transaction creates an idempotent common execution-session cleanup request. controller-rs immediately fences and cancels active attempts and dispatches cleanup; the runner destroys the backend session and records evidence. Cleanup is retried across restarts and the backend TTL remains only a final fail-safe, not the expected reclamation path. An EXPIRED agent session whose physical sandbox is merely waiting for its independent TTL is a reconciliation defect.

Memory Boundary

Conversation history and distilled memory are domain state, not sandbox state. The sandbox may receive a bounded prompt/context projection, but it does not own the memory database.

Required controls:

  • bind memory bank and session history to authenticated host, user/principal, and agent definition;
  • authorize every resume, recall, retain, and history update;
  • apply optimistic versioning to conversation projections;
  • keep accepted action attempts and append-only session events authoritative over the mutable history projection;
  • distinguish user-authored, tool-derived, model-derived, and operator instruction sources;
  • prevent tool output or retrieved memory from becoming privileged system instructions;
  • enforce retention, deletion, legal hold, export, and audit policy;
  • redact or tokenize sensitive values before embedding or cross-boundary model transfer.

History Conflict After An Effect

An optimistic history conflict must never cause an effectful action to be forgotten or repeated.

When an action reaches a known terminal result, light-agent performs an idempotent origin-acceptance transaction that:

  1. conditionally accepts the current agent_action_attempt_t;
  2. records or references the common execution_attempt_t/gateway result;
  3. appends one ACTION_RESULT event with the action/result digest;
  4. advances the agent turn to RUNNING_MODEL, WAITING_RECONCILIATION, or a terminal state.

Updating agent_session_history_t is a projection step after that transaction. If its expected version is stale, the projector rereads the ordered session events, deterministically rebuilds or merges the conversation context, and retries the projection. It does not redispatch the tool and does not overwrite another accepted user message.

Until the projection catches up, clients can reconstruct the authoritative timeline from turn/action/session events. The UI may show a temporary history-sync state, but the accepted action and audit record remain visible. Projection lag or conflict is an operational error, not an action retry signal.

Portal-command memory writes are the production default because they preserve event, authorization, and audit boundaries. Direct PostgreSQL mode remains an explicitly enabled local/development compatibility profile. Longer term, memory recall should also use a scoped service API so the general agent pod does not require a database password.

Authentication And Session Security

  • Authenticate before WebSocket upgrade or before accepting the first message.
  • Derive tenant, host, user, and allowed agent definition from trusted claims and server-side mappings.
  • Issue an opaque session handle or signed resume token with audience, expiry, principal, and agent binding.
  • Never use caller-provided tenant, host, user, agent, or memory-bank IDs as authority.
  • Rotate the session handle after privilege or policy changes.
  • Revoke active sessions on user, agent, provider, or policy revocation.
  • Serialize concurrent mutating prompts through the bounded durable per-session FIFO; only the active turn acquires the session version.
  • Remove committed/default bearer tokens and rotate any token that may have been usable.

Credentials

The long-lived service should contain only credentials required for its service profile. It should not hold credentials for possible future tools.

  • Prefer workload identity and brokered short-lived grants.
  • Keep end-user authorization separate from the service's portal identity.
  • Exchange user authorization for a signed, short-lived, audience-restricted, turn/action-scoped light-gateway delegation token. Do not forward the unrestricted user token in production.
  • Never forward SaaS model credentials into tenant runner sandboxes.
  • Do not replace provider keys with a reusable proxy bearer token visible to the worker or generated payload. Use the protected, peer-bound runner broker channel and enforce budget and expiry at the broker.
  • Never let a CLI child inherit the service environment.
  • Project action credentials after policy approval and revoke them at terminal state or lease loss.
  • Publish/sign/deploy credentials exist only in fixed actions.
  • Do not place raw secrets in prompts, session history, memory, tool arguments, logs, artifacts, environment snapshots, or execution journals.

Failure And Recovery

Service Restart

The session and turn are reconstructed from durable state. An incomplete turn is not automatically replayed:

  • RUNNING_MODEL with no action may be safely failed or retried by policy;
  • RUNNING_ACTION queries the gateway idempotency record or runner attempt;
  • an accepted ACTION_RESULT with stale history resumes projection/rebuild and never redispatches the action;
  • UNKNOWN action outcome requires inspection or operator decision;
  • COMPLETED result can be streamed again idempotently;
  • WAITING_APPROVAL has no action compute or credentials; an explicitly retained session workspace remains paused/checkpointed under its independent bounded hold and expiry.

Client Disconnect

Policy decides whether the turn:

  • cancels immediately;
  • continues to a durable result for later resume; or
  • continues only through the current non-effectful model call.

The decision is recorded at admission. A disconnect does not silently broaden the deadline.

Runner Or Backend Disconnect

Use the same lease, fencing, journal, inspection, watchdog, native TTL, and cleanup contract as workflow execution. light-agent accepts a result only for the current action attempt and fencing token. Result-ready notifications only wake reconciliation; startup and periodic scans of authoritative terminal attempts recover any notification lost while light-agent was disconnected.

Duplicate Client Message

The client supplies a message ID scoped to the session. A duplicate returns the existing turn or result. It does not create a second effectful action.

Limits And Admission

Every service profile and turn policy defines:

  • maximum concurrent sessions and turns;
  • maximum queued prompts per session/principal/agent and maximum queue wait;
  • per-principal and per-agent quotas;
  • maximum input/history/retrieval/tool-output tokens;
  • maximum model calls and tool calls per turn;
  • maximum correction actions and per-tool recovery attempts;
  • model and total wall-clock deadlines;
  • cost/token budget;
  • maximum pending approval time;
  • sandbox queue and runtime deadline;
  • session idle and maximum lifetime;
  • artifact and log limits.

Saturation is not a model or tool failure. Queue or reject before starting more work than the service, provider, gateway, or runner can support.

Audit And Observability

Record:

  • authenticated session admission and resume;
  • agent definition, model, catalog, memory, and execution-policy digests;
  • turn state and idempotency decision;
  • model provider/model, latency, token usage, and cost without hidden reasoning content;
  • selected, hidden, and attempted tools;
  • argument and result digests, effect class, approval, destination, and placement;
  • runner/backend/lease/fencing identity for sandboxed work;
  • cancellation, unknown outcome, retry, and reconciliation;
  • memory recall/retain source classes;
  • sandbox cleanup and artifact evidence.

Metrics include active sessions, turn latency/state, model/tool counts and budgets, per-session queue depth/wait, session activation conflicts, unauthorized resume attempts, rejected model tool names, malformed/schema-invalid arguments, downscoped-token issuance/rejection, recoverable action failures, unknown actions, history projection lag/conflict, approval wait, runner queue time, runtime-event lag/gaps, adapter failures, skill-package verification failures, channel duplicate/replay rejection, delivery latency/failure, scheduled-turn deferral, result-wakeup/catch-up latency, oldest unaccepted terminal attempt, model-broker denial/budget exhaustion, session-to-sandbox expiry skew, approval-held session count/age/cost, checkpoint/restore failures, and cleanup request latency/backlog.

Deployment Profiles

Shared Tenant Agent Service

One horizontally scalable deployment serves compatible enterprise and personal-assistant reasoning sessions for one tenant or strong tenant partition. It uses remote model APIs and gateway-only tools. It has no local workspace or external agent runtime.

Dedicated Agent Service

Use a separate long-lived deployment when an agent requires a distinct:

  • tenant or legal boundary;
  • model provider credential or regional endpoint;
  • private network zone;
  • service identity;
  • latency/scaling profile;
  • memory retention policy.

This is a deployment profile, not a requirement for every logical agent.

Coding Agent Worker Pool

light-agent submits agent-turn or agent-action execution subjects to controller-rs. The tenant runner selects an approved backend, creates a task- or session-scoped environment, and starts light-agent-worker with the pinned runtime adapter. Worker pools are grouped by real backend, network, workspace, model-proxy, and data-boundary compatibility, not by logical agent name.

Personal Assistant Channel Gateway

Deploy light-agent-channel separately from the reasoning service. Pool only channels and users whose webhook exposure, credential store, retention, regional, and delivery policies are compatible. Channel delivery can continue while a reasoning replica restarts because accepted messages and responses are durable.

Personal Edge Runner

Use a dedicated user- or tenant-owned runner when a personal assistant needs a local browser profile, desktop, filesystem, home network, or device access. The edge runner advertises explicit capabilities and receives short-lived leases; it is not a permanently authorized remote shell.

The implemented edge binding is principal-specific and names the exact runner, backend, execution profile, action allowlist, required capabilities, compatibility digest, and expiry. Light-Agent converts an allowed local effect to a fixed light-edge-action structured command and Controller refuses to reserve it on any other runner or backend. Revoked/expired bindings and action or capability mismatches fail before scheduling.

Release Agent

Use a dedicated runner pool and strong sandbox/VM profile. The agent can inspect and patch a copy-on-write workspace, but trusted fixed actions apply the accepted patch, create a branch/PR, publish, sign, or deploy. Approval is bound to immutable subjects and waits without an active lease.

Implementation Plan

The detailed repository and pull-request sequence is maintained in implementation/light-agent/2026-07-10-LightAgentRuntimeAndProfilesImplementationPlan.md.

Phase 0: Harden The Current Service

  • Remove and rotate embedded default bearer tokens.
  • Authenticate session admission and bind host/user/agent ownership.
  • Populate memory-bank ownership and reject unauthorized resume.
  • Revalidate model tool names against the accepted per-turn set.
  • Strictly parse and schema-validate arguments.
  • Exchange caller authorization for downscoped gateway delegation tokens.
  • Bound/redact tool output and retrieved memory.
  • Add overall turn timeout, cancellation, action/model-call limits, and a bounded per-session FIFO with one active mutating turn.
  • Disable CLI providers in shared-service profiles.

Phase 1: Durable Sessions And Turns

  • Add agent_session_t, agent_turn_t, agent_action_attempt_t, and the append-only agent session event stream/projection.
  • Add message idempotency, durable FIFO sequencing, and optimistic session activation/versioning.
  • Snapshot definition, catalog, model, memory, and execution policy.
  • Persist model/action state and normalized bounded results; accept an action and append its ACTION_RESULT event before updating conversation history.
  • Rebuild the history projection from ordered events after a version conflict.
  • Add approval wait without compute.
  • Distinguish action-lease release from a bounded execution-session IDLE_APPROVAL_HOLD; task sandboxes clean immediately while an eligible non-secret session workspace may pause/checkpoint until its independent effective expiry.
  • Bind approvals to immutable action intents and consume approval into a new action/common execution attempt with fresh fencing; never reopen an earlier attempt.
  • On session close, revocation, or expiry, atomically create an idempotent common execution-session cleanup request.
  • Move production memory writes to portal-command mode.

Phase 2: Origin-Neutral Runner Contract

  • Add execution subject and origin types to execution-runner-protocol.
  • Make controller scheduling and common execution attempts origin-neutral.
  • Reference shared execution_attempt_t from runner-backed agent_action_attempt_t rows while keeping agent-domain fields out of the common table.
  • Authenticate allowed subject kinds per origin service.
  • Keep workflow and agent domain transitions separate.
  • Emit identifiers-only execution_result_ready_v1 PostgreSQL wakeups in the common terminal-result transaction. Add light-agent LISTEN handling plus startup and periodic indexed catch-up; notification delivery is never the source of truth.
  • Add durable execution-session cleanup dispatch and evidence reconciliation.

Phase 3: Agent Runtime Protocol And Worker

  • Add agent-runtime-protocol, ordered runtime events, and capability documents.
  • Add stable tool-source/placement identities and an immutable local runtime-tool manifest. Gateway candidates intersect gateway tools/list; local candidates intersect server compatibility, execution policy, lease allowedTools, and live worker/local-MCP enumeration.
  • Add a deterministic mock adapter and the light-agent-worker executable.
  • Route command, filesystem, browser, local MCP, and external agent runtimes through ExecutionBackend.
  • Add a runner-owned model-broker transport contract with peer/attempt binding, no payload-visible reusable bearer, separate worker/payload identities, and broker-enforced model and budget policy.
  • Reconcile worker events into agent-domain state without giving the worker direct database ownership.

Phase 4: Skill Packages And Materialization

  • Add runtime compatibility and immutable skill-package records.
  • Verify digest, provenance, scan result, entrypoint, and sandbox policy before read-only materialization.
  • Make the trusted runner download, safely extract, verify, and stage selected packages before sandbox creation. The worker only revalidates mounted bytes and has no artifact-store credential or download path.
  • Implement enterprise, workflow, coding, and personal-assistant materializers.
  • Treat generated and repository-local skills as untrusted proposals/content.

Phase 5: Coding Agent Profile

  • Enable Cube Sandbox for untrusted task-scoped coding turns.
  • Add the first structured SDK/RPC coding adapter, preferably Pi.
  • Add optional bounded workspace-session reuse.
  • Add bounded approval-hold, pause/checkpoint, restore, expiry, cost, and cleanup semantics without renewing an action lease.
  • Add protected runner-brokered model access, canonical patch export, protected paths, artifacts, watchdog, origin-driven session cleanup, native TTL backstop, and cleanup evidence.
  • Migrate direct CLI model-provider implementations behind runtime adapters and disable their shared-service execution path.

Phase 6: Multi-Agent Service Pooling

  • Resolve an authorized agent definition per session/turn.
  • Cache immutable definitions/catalogs by host, ID, version, and digest.
  • Route provider/data-boundary profiles without sharing incompatible secrets.
  • Scale replicas with durable session admission rather than in-memory affinity.

Phase 7: Personal Assistant Profile

  • Add light-agent-channel, channel/principal binding, webhook verification, delivery idempotency, and proactive trigger policy.
  • Add typed connector tools and optional dedicated personal edge runners.
  • Add quiet hours, notification policy, scheduled-turn admission, and connector credential brokering.

Phase 8: Workflow Bridge And Fixed High-Value Actions

  • Keep existing call.agent behavior as native-workflow by default.

  • Add an explicit agent-service job mode with schema, deadline, idempotency, cancellation, correlation, and delegation-depth controls.

  • Expose workflow start/status/cancel as typed agent tools.

  • Add accepted-patch, branch/PR, publish, sign, and deploy contracts.

  • Require immutable input, approval, provenance, and fresh action-scoped credentials.

  • Consume every approval into a new numbered domain action and common execution attempt with a fresh lease and fencing token.

  • Reuse the trusted fresh-checkout and protected-path design.

  • Rebuild releases from reviewed immutable commits.

Acceptance And Failure-Injection Tests

  • A valid session cannot be resumed by another principal, agent, host, or tenant.
  • Concurrent prompts from one or more replicas receive durable FIFO sequence; one turn becomes active and the rest remain queued in order.
  • Queue-full admission is bounded and retryable; it does not drop or silently execute a prompt.
  • Duplicate client delivery resolves to the existing turn and cannot create a second action.
  • A model-returned hidden or unadvertised tool is rejected before gateway dispatch.
  • A gateway-only tool absent from gateway tools/list is hidden without removing an independently authorized runner tool; a runner tool absent from the lease/runtime/local manifest is hidden even if the gateway has the same name.
  • Model-facing alias collisions across placements fail closed, and a returned tool call cannot switch its snapshotted gateway/runner/workflow/fixed-service dispatch route.
  • Malformed or schema-invalid arguments never become an empty object.
  • Gateway authorization remains effective even when the catalog is stale.
  • Tool and memory output cannot exceed context limits or become system instructions.
  • A known recoverable command failure returns to the model within correction budgets; it does not automatically fail the turn.
  • An unknown action is reconciled and cannot be repeated or described as a definite failure.
  • A successful effect followed by a history-version conflict remains present in agent_action_attempt_t and the session event stream; projection recovery never redispatches it.
  • Gateway calls use a signed token narrowed to the caller, agent, turn/action, tool, data boundary, policy digest, audience, and expiry; the original broad user token is not forwarded.
  • Service restart during model-only work follows the configured retry policy.
  • Restart after effectful dispatch reconciles instead of blindly repeating.
  • Terminal result committed while light-agent is offline is found by indexed catch-up and accepted once; dropped, duplicate, and reordered notifications do not change correctness.
  • WAITING_APPROVAL holds no active action lease, model-broker channel, or action credential. A task sandbox is cleaned; an eligible non-secret session workspace is retained only through a separately persisted bounded hold or verified checkpoint.
  • Missing an action lease does not clean an approval-held session, while hold expiry, logical session close/revocation, or policy mismatch does. Approval cannot extend the session maximum lifetime.
  • Approval creates a fresh post-approval action/common execution attempt and fencing token; the pre-approval attempt, handle, and grant remain unusable.
  • CLI provider processes receive only an allowlisted environment inside a sandbox.
  • Generated code cannot read a provider/proxy bearer, inspect or inherit the worker's model-broker channel, impersonate another attempt, choose an unauthorized model, or exceed broker-enforced token/cost limits.
  • A CLI/provider timeout kills the process tree and triggers cleanup.
  • A shared light-agent process cannot directly start an external agent runtime.
  • Worker runtime events are ordered, resumable, bounded, and cannot mutate an agent turn without origin-side acceptance.
  • A runtime adapter cannot claim an unapproved capability or select a weaker sandbox than the turn policy requires.
  • A coding turn receives only the selected immutable skill packages and workspace-local instructions cannot grant additional authority.
  • Package download, digest/signature mismatch, unsafe archive entries, or staging failure occurs before sandbox start; sandbox code has no artifact-store credential or package-download egress.
  • A generated skill remains inactive until it is scanned, reviewed, packaged, and assigned.
  • A sandboxed turn survives controller and runner reconnect without accepting a stale fencing token.
  • A session-scoped workspace cannot be reused across principal, agent, base, policy, backend, or expiry changes.
  • Closing, revoking, or expiring an agent session durably fences active work and reclaims its physical sandbox without waiting for backend-native TTL; cleanup survives controller and runner restart.
  • Toolbx and ordinary containers cannot satisfy a microVM requirement.
  • A spoofed channel user, replayed webhook, duplicate delivery, or scheduled trigger cannot create an unauthorized or duplicate turn.
  • A personal channel gateway has no model-provider key, unrestricted shell, or tenant-wide connector credential.
  • A native-workflow agent call remains backward compatible and a service-mode call cannot cause an unbounded agent/workflow delegation cycle.
  • Publish/sign/deploy cannot be invoked as a free-form agent tool.
  • Secret scanning finds no credential in prompts, history, memory, logs, artifacts, journal, or child environment.

Open Decisions

  • Whether the first production deployment remains one agent definition per service or introduces request-time multi-agent pooling immediately.
  • Which session-scoped backends support safe checkpoint/restore.
  • Which structured coding adapter is enabled first after the mock and native adapters, and which versioned SDK/RPC contract is pinned.
  • Which runner-owned model broker and protected local transport are supported first on each backend: preconnected descriptor, peer-checked Unix-domain socket, vsock, or backend-native equivalent.
  • Which object store, signer, scanner, and review service own immutable skill packages.
  • Which channel bindings and personal connector grants remain in GenAI domain tables versus a dedicated channel/identity service.
  • Which exact workflow syntax selects agent-service while preserving the existing native-workflow default.
  • Which model providers support useful request idempotency.
  • Whether client disconnect defaults to cancel or durable continuation.
  • Which memory read service replaces direct PostgreSQL recall.
  • Which agent actions require workflow-owned approval versus standalone agent-owned approval.

Recommendation

Keep light-agent as the interactive session, durable turn, policy, memory, and model orchestration service for all profiles. Do not assign infrastructure per agent definition or fork separate enterprise, coding, and personal-assistant engines. Deploy service pools by real trust and data boundaries.

Use the shared controller/runner/ExecutionBackend path whenever an agent needs local execution or stronger isolation. Host workspace-aware loops in the small sandbox-side light-agent-worker; host messaging connections in the separate light-agent-channel; keep external agent products behind runtime adapters. Make the runner protocol origin-neutral so workflows and standalone agent turns share capacity, fencing, backend lifecycle, watchdog, credentials, artifacts, and cleanup without sharing domain ownership.

Database Design

The Light-Fabric utilizes a robust PostgreSQL schema to manage the entire lifecycle of agentic workflows, skills, agent execution, channels, and the biomimetic Hindsight memory system. The schema is organized into five logical layers:

1. Workflow Engine

These tables manage the definition and execution of long-running agentic workflows.

wf_definition_t

Stores the Agentic Workflow DSL (YAML) that defines the high-level orchestration logic.

process_info_t & task_info_t

Manage the runtime state of workflow instances (processes) and individual steps (tasks). They include input_data, context_data, and error_info to provide a resilient "scratchpad" for intermediate variables.

worklist_t & worklist_asst_t

Manage task assignments and visibility for human-in-the-loop interactions.


2. Agentic Core (The "Brain & Skills")

These tables define the identity, expertise, and capabilities of individual agents.

agent_definition_t

Defines the agent's persona, product profile, model policy, default execution profile, data boundary, and runtime limits. Enterprise, coding, and personal-assistant definitions share this table; a definition does not own a process or sandbox.

skill_t

Stores the "Expertise" of an agent in Markdown format. Skills are hierarchical and versioned.

tool_t & tool_param_t

The "Hands" of the agent. Defines governed REST/MCP capabilities and typed execution metadata. A tool row or script field is not authority to execute code; untrusted executable assets require an immutable reviewed package and an approved runner sandbox. The target model adds or derives a stable internal tool reference and records a server-owned gateway, runner, workflow, or fixed-service placement, model alias, schema digest, effect class, and dispatch-policy binding. Existing rows with proven current gateway-config linkage migrate as gateway. Ambiguous legacy script rows remain undisclosed until reviewed.

agent_skill_t & skill_tool_t

Maps agents to skills and skills to tools, implementing the Progressive Disclosure pattern where agents only see the tools required for their current skill context.

skill_package_t (proposed)

References immutable signed/scanned skill assets for coding, personal, or external runtime adapters. Package bytes live in object storage. The row binds digest, provenance, entrypoint, compatible profiles, required capabilities, review, revocation, and retention.


3. Hindsight Memory System

A biomimetic memory architecture that transitions from flat logs to structured "atoms of thought."

agent_memory_bank_t

Profiles for memory banks, defining the "Personality and Disposition" (e.g., skepticism, empathy) of the memory layer.

agent_memory_unit_t

The individual "Atoms" of memory. Each unit contains content and a vector embedding (384-dim) for semantic retrieval.

A Knowledge Graph layer that resolves entities and causal/semantic relationships between memory units.


4. Agent Session And Execution

agent_session_t, agent_turn_t, agent_action_attempt_t, and agent_approval_t (proposed)

Store authenticated session ownership, durable FIFO turns, effectful action attempts, policy snapshots, budgets, bound single-use approvals, reconciliation, and terminal results. Runner-backed actions reference the shared execution_attempt_t, but agent-domain fields remain owned by light-agent. Post-approval dispatch creates a new numbered agent action and a new common execution attempt; it never reopens the pre-approval attempt.

Shared runner execution records (proposed)

runner_scheduling_request_t, execution_attempt_t, execution_session_t, execution_session_cleanup_request_t, and execution_input_t store origin-neutral capacity, fencing, normalized results, bounded session cleanup, immutable staged inputs, and session state/version/fencing. Reused sessions can enter a bounded IDLE_APPROVAL_HOLD with hold ID/expiry, cost policy, and checkpoint/patch evidence while the action lease and credentials are gone. Zero active attempts does not imply cleanup for a valid held session; the hold cannot extend idle/max expiry or override close/revocation. Origin services retain their own workflow or agent state. A terminal-attempt transaction emits an identifiers-only wakeup; origins query the authoritative row and use startup/periodic catch-up rather than treating notification delivery as durable state.

agent_session_event_t (proposed)

An append-only authoritative ledger for accepted user messages, model results, actions, approvals, and terminal turn events. It is the source used to rebuild conversation context after a projection conflict.

agent_session_history_t

The materialized transcript used for active conversation context, linked to the session's Hindsight memory bank. It is not the authoritative proof of an effectful tool action. Agent action attempts and append-only session events survive history-version conflicts and can rebuild this projection. See Light-Agent Execution.

5. Personal Assistant Channels And Triggers

agent_channel_binding_t and agent_channel_delivery_t (proposed)

Bind a verified messaging identity to a principal and agent, and deduplicate inbound events/outbound responses. They store credential references rather than channel secrets and do not replace agent turns.

agent_trigger_t (proposed)

Stores scheduled or connector-triggered turn policy, including timezone, quiet hours, rate/cost limits, notification destination, idempotency, and maximum delay.

See Light-Agent Execution and the dedicated Light-Agent runtime implementation plan for constraints and phased rollout.


DDL Specification

-- Workflow Definitions: Stores the Agentic Workflow JSON
CREATE TABLE wf_definition_t (
    host_id             UUID NOT NULL,
    wf_def_id           UUID NOT NULL,
    namespace           VARCHAR(126) NOT NULL,
    name                VARCHAR(126) NOT NULL,
    version             VARCHAR(20) NOT NULL,
    definition          TEXT NOT NULL, -- The Agentic Workflow DSL in YAML
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT TRUE,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, wf_def_id),
    UNIQUE(host_id, namespace, name, version)
);

CREATE TABLE worklist_t (
  host_id              UUID NOT NULL,
  assignee_id          VARCHAR(126) NOT NULL,
  category_id          VARCHAR(126) DEFAULT '(all)' NOT NULL,
  status_code          VARCHAR(10) DEFAULT 'Active' NOT NULL,
  app_id               VARCHAR(512) DEFAULT 'global' NOT NULL,
  aggregate_version    BIGINT DEFAULT 1 NOT NULL,
  active               BOOLEAN NOT NULL DEFAULT TRUE,
  update_user          VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
  update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
  PRIMARY KEY(host_id, assignee_id, category_id)
);

CREATE TABLE worklist_column_t (
  host_id               UUID NOT NULL,
  assignee_id           VARCHAR(126) NOT NULL,
  category_id           VARCHAR(126) DEFAULT '(all)' NOT NULL,
  sequence_id           INTEGER NOT NULL,
  column_id             VARCHAR(126) NOT NULL,
  aggregate_version     BIGINT DEFAULT 1 NOT NULL,
  active                BOOLEAN DEFAULT TRUE,
  update_ts             TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
  update_user           VARCHAR(126) DEFAULT SESSION_USER,
  PRIMARY KEY(host_id, assignee_id, category_id, sequence_id),
  FOREIGN KEY(host_id, assignee_id, category_id) REFERENCES worklist_t(host_id, assignee_id, category_id) ON DELETE CASCADE
);

CREATE TABLE process_info_t (
  host_id                    UUID NOT NULL,
  process_id                 UUID NOT NULL, -- generated uuid
  wf_def_id                  UUID NOT NULL, -- workflow definition id
  wf_instance_id             VARCHAR(126)       NOT NULL, -- workflow intance id
  app_id                     VARCHAR(512)       NOT NULL, -- application id
  process_type               VARCHAR(126)      NOT NULL,
  status_code                CHAR(1)            NOT NULL, -- process status code 'A', 'C'
  started_ts                 TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
  ex_trigger_ts              TIMESTAMP WITH TIME ZONE          NOT NULL,
  custom_status_code         VARCHAR(126),
  completed_ts               TIMESTAMP WITH TIME ZONE,
  result_code                VARCHAR(126),
  source_id                  VARCHAR(126),
  branch_code                VARCHAR(126),
  rr_code                    VARCHAR(126),
  party_id                   VARCHAR(126),
  party_name                 VARCHAR(126),
  counter_party_id           VARCHAR(126),
  counter_party_name         VARCHAR(126),
  txn_id                     VARCHAR(126),
  txn_name                   VARCHAR(126),
  product_id                 VARCHAR(126),
  product_name               VARCHAR(126),
  product_type               VARCHAR(126),
  group_name                 VARCHAR(126),
  subgroup_name              VARCHAR(126),
  event_start_ts             TIMESTAMP WITH TIME ZONE,
  event_end_ts               TIMESTAMP WITH TIME ZONE,
  event_other_ts             TIMESTAMP WITH TIME ZONE,
  event_other                VARCHAR(126),
  risk                       NUMERIC,
  risk_scale                 INTEGER,
  price                      NUMERIC,
  price_scale                INTEGER, -- Scale (number of digits to the right of the decimal) of the risk column. NULL implies zero
  product_qy                 NUMERIC,
  currency_code              CHAR(3),
  ex_ref_id                  VARCHAR(126),
  ex_ref_code                VARCHAR(126),
  product_qy_scale           INTEGER,
  parent_process_id          VARCHAR(22),
  deadline_ts                TIMESTAMP WITH TIME ZONE,
  parent_group_id            NUMERIC,
  process_subtype_code       VARCHAR(126),
  owning_group_name          VARCHAR(126), -- Name of the group that owns the process
  input_data                 JSONB,        -- The initial data that triggered the workflow
  context_data               JSONB,        -- The runtime "scratchpad" for intermediate variables
  error_info                 TEXT,         -- Detailed error or stack trace if the process fails
  aggregate_version   BIGINT DEFAULT 1 NOT NULL,
  active              BOOLEAN DEFAULT TRUE,
  update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
  update_user         VARCHAR(126) DEFAULT SESSION_USER,
  PRIMARY KEY(host_id, process_id),
  FOREIGN KEY(host_id, wf_def_id) REFERENCES wf_definition_t(host_id, wf_def_id) ON DELETE CASCADE
);

CREATE TABLE task_info_t
(
    host_id             UUID NOT NULL,
    task_id             UUID NOT NULL,
    task_type           VARCHAR(126) NOT NULL,
    process_id          UUID NOT NULL,
    wf_instance_id      VARCHAR(126) NOT NULL,
    wf_task_id          VARCHAR(126) NOT NULL,
    status_code         CHAR(1)       NOT NULL, -- U, A, C
    started_ts          TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    locked              CHAR(1)       NOT NULL,
    priority            INTEGER        NOT NULL,
    completed_ts        TIMESTAMP WITH TIME ZONE      NULL,
    completed_user      VARCHAR(126)     NULL,
    result_code         VARCHAR(126)     NULL,
    locking_user        VARCHAR(126)     NULL,
    locking_role        VARCHAR(126)     NULL,
    deadline_ts         TIMESTAMP WITH TIME ZONE      NULL,
    lock_group          VARCHAR(126)     NULL,
    task_input          JSONB,           -- Specific data passed to the task
    task_output         JSONB,           -- Result returned by the task action
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT TRUE,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, task_id),
    FOREIGN KEY (host_id, process_id) REFERENCES process_info_t(host_id, process_id) ON DELETE CASCADE
);

CREATE TABLE task_asst_t
(
    host_id             UUID NOT NULL,
    task_asst_id         UUID NOT NULL,
    task_id              UUID NOT NULL,
    assigned_ts          TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    assignee_id          VARCHAR(126) NOT NULL,
    reason_code          VARCHAR(126) NOT NULL,
    unassigned_ts        TIMESTAMP WITH TIME ZONE      NULL,
    unassigned_reason    VARCHAR(126)     NULL,
    category_code        VARCHAR(126)     NULL,
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active               BOOLEAN DEFAULT TRUE,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user          VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, task_asst_id),
    FOREIGN KEY(host_id, task_id) REFERENCES task_info_t(host_id, task_id) ON DELETE CASCADE
);

CREATE TABLE audit_log_t
(
    host_id             UUID NOT NULL,
    audit_log_id        UUID NOT NULL,
    source_type_id      VARCHAR(126)      NULL,
    correlation_id      VARCHAR(126)      NULL,
    user_id             VARCHAR(126)     NULL,
    event_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    success             CHAR(1)           NULL,
    message0            VARCHAR(126)     NULL,
    message1            VARCHAR(126)     NULL,
    message2            VARCHAR(126)     NULL,
    message3            VARCHAR(126)     NULL,
    message             VARCHAR(500)     NULL,
    user_comment        VARCHAR(500)     NULL,
    PRIMARY KEY(host_id, audit_log_id)
);

CREATE INDEX audit_log_idx1 ON audit_log_t (source_type_id, correlation_id, event_ts, user_id);

-- Agent Definitions: Stores the "Brain" configuration
CREATE TABLE agent_definition_t (
    host_id             UUID NOT NULL,
    agent_def_id        UUID NOT NULL,
    agent_name          VARCHAR(126) NOT NULL,
    model_provider      VARCHAR(64) NOT NULL,  -- 'openai', 'anthropic', etc.
    model_name          VARCHAR(126) NOT NULL, -- 'gpt-4o', 'claude-3-5-sonnet'
    api_key_ref         VARCHAR(126),          -- Reference to Secret Manager key
    temperature         NUMERIC(3,2) DEFAULT 0.7,
    max_tokens          INTEGER,               -- max number of tokens can be used
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT TRUE,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, agent_def_id),
    UNIQUE(host_id, agent_name)
);


-- Skills: Stores Instructions and Domain Knowledge (The "Expertise")
-- Note: Use entity_tag_t and entity_category_t with entity_type = 'skill'
-- for flat tagging and hierarchical folder structure of skills.
CREATE TABLE skill_t (
    host_id             UUID NOT NULL,
    skill_id            UUID NOT NULL,
    parent_skill_id     UUID,                  -- Self-reference for Hierarchy
    name                VARCHAR(126) NOT NULL,
    description         VARCHAR(500),          -- High-level description for the initial LLM prompt
    content_markdown    TEXT NOT NULL,         -- The actual instructions/prompts

    description_embedding VECTOR(384),          -- For semantic lookup/discovery
    version             VARCHAR(20) DEFAULT '1.0.0',
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, skill_id),
    FOREIGN KEY(host_id, parent_skill_id) REFERENCES skill_t(host_id, skill_id)
);

CREATE INDEX idx_skill_active ON skill_t(active);
CREATE INDEX idx_skill_name ON skill_t(name);

-- Tools: Stores Executable Functions (The "Hands")
CREATE TABLE tool_t (
    host_id             UUID NOT NULL,
    tool_id             UUID NOT NULL,
    name                VARCHAR(126) NOT NULL,
    description         TEXT NOT NULL,         -- Instructions for LLM on when/how to use this tool

    -- Implementation specifics
    implementation_type VARCHAR(50),           -- 'java', 'mcp_server', 'rest', 'python', 'javascript'
    implementation_class VARCHAR(500),         -- FQCN if 'java'
    mcp_server_name      VARCHAR(126),         -- MCP server name if 'mcp_server'
    api_endpoint        VARCHAR(1024),         -- URL if 'rest'
    api_method          VARCHAR(10),           -- HTTP Method if 'rest'
    endpoint_id         UUID,                  -- Reference to fine-grained auth endpoint
    script_content      TEXT,                  -- Source code if 'python'/'javascript'
    response_schema     JSONB,                 -- Strict output schema for tool results

    description_embedding VECTOR(384),          -- For semantic lookup/discovery
    version             VARCHAR(20) DEFAULT '1.0.0',
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, tool_id),
    FOREIGN KEY(host_id, endpoint_id) REFERENCES api_endpoint_t(host_id, endpoint_id) ON DELETE CASCADE
);

CREATE INDEX idx_tool_host_endpoint ON tool_t(host_id, endpoint_id);
CREATE INDEX idx_tool_active ON tool_t(active);
CREATE INDEX idx_tool_name ON tool_t(name);

-- Tool Parameters: Defines the arguments for each tool
CREATE TABLE tool_param_t (
    host_id             UUID NOT NULL,
    param_id            UUID NOT NULL,
    tool_id             UUID NOT NULL,
    name                VARCHAR(255) NOT NULL,
    param_type          VARCHAR(50) NOT NULL,      -- 'string', 'number', 'boolean', 'object', 'array'
    required            BOOLEAN DEFAULT true,
    default_value       JSONB,
    description         TEXT,                      -- Helps LLM understand what value to extract
    validation_schema   JSONB,                     -- JSON Schema for complex validation
    order_index         INTEGER DEFAULT 0,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, param_id),
    FOREIGN KEY(host_id, tool_id) REFERENCES tool_t(host_id, tool_id) ON DELETE CASCADE
);

-- Skill Dependencies: Manages hierarchies where one skill requires another
CREATE TABLE skill_dependency_t (
    host_id             UUID NOT NULL,
    skill_id            UUID NOT NULL,
    depends_on_skill_id UUID NOT NULL,
    required            BOOLEAN DEFAULT true,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY (host_id, skill_id, depends_on_skill_id),
    FOREIGN KEY(host_id, skill_id) REFERENCES skill_t(host_id, skill_id),
    FOREIGN KEY(host_id, depends_on_skill_id) REFERENCES skill_t(host_id, skill_id)
);

-- Agent-Skill Mapping: Links Agents to their Skills
CREATE TABLE agent_skill_t (
    host_id             UUID NOT NULL,
    agent_def_id        UUID NOT NULL,
    skill_id            UUID NOT NULL,

    config              JSONB DEFAULT '{}',
    priority            INTEGER DEFAULT 0,
    sequence_id         INTEGER DEFAULT 0,     -- Order in which skills are concatenated

    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, agent_def_id, skill_id),
    FOREIGN KEY(host_id, agent_def_id) REFERENCES agent_definition_t(host_id, agent_def_id) ON DELETE CASCADE,
    FOREIGN KEY(host_id, skill_id) REFERENCES skill_t(host_id, skill_id) ON DELETE CASCADE
);
CREATE INDEX idx_agent_skill_agent ON agent_skill_t(agent_def_id);

-- Skill-Tool Mapping: Implements Progressive Disclosure
CREATE TABLE skill_tool_t (
    host_id             UUID NOT NULL,
    skill_id            UUID NOT NULL,
    tool_id             UUID NOT NULL,

    config              JSONB DEFAULT '{}',
    access_level        VARCHAR(20) DEFAULT 'read', -- e.g., 'read', 'write', 'execute'

    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, skill_id, tool_id),
    FOREIGN KEY(host_id, skill_id) REFERENCES skill_t(host_id, skill_id) ON DELETE CASCADE,
    FOREIGN KEY(host_id, tool_id) REFERENCES tool_t(host_id, tool_id) ON DELETE CASCADE
);
CREATE INDEX idx_skill_tool_skill ON skill_tool_t(skill_id);

-- -- Hindsight Advanced Memory System
-- Transitioned from flat logs to biomimetic memory banks (World, Experiences, Mental Models)

-- Memory bank profiles (Personality & Disposition)
CREATE TABLE agent_memory_bank_t (
    host_id             UUID NOT NULL,
    bank_id             UUID NOT NULL,
    agent_def_id        UUID,                  -- NULL if bank is shared across agents
    user_id             UUID,                  -- NULL if bank is global for the host/agent
    bank_name           VARCHAR(126) NOT NULL,
    disposition         JSONB NOT NULL DEFAULT '{"skepticism": 3, "literalism": 3, "empathy": 3}'::jsonb,
    background          TEXT,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, bank_id),
    FOREIGN KEY(host_id) REFERENCES host_t(host_id) ON DELETE CASCADE,
    FOREIGN KEY(host_id, agent_def_id) REFERENCES agent_definition_t(host_id, agent_def_id) ON DELETE CASCADE,
    FOREIGN KEY(user_id) REFERENCES user_t(user_id) ON DELETE CASCADE
);

-- Source documents for memory units
CREATE TABLE agent_memory_doc_t (
    host_id             UUID NOT NULL,
    doc_id              UUID NOT NULL,
    bank_id             UUID NOT NULL,
    original_text       TEXT,
    content_hash        TEXT,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY (host_id, bank_id, doc_id),
    FOREIGN KEY (host_id, bank_id) REFERENCES agent_memory_bank_t(host_id, bank_id) ON DELETE CASCADE
);

-- Individual sentence-level memories (The "Atoms" of thought)
CREATE TABLE agent_memory_unit_t (
    host_id             UUID NOT NULL,
    unit_id             UUID NOT NULL,
    bank_id             UUID NOT NULL,
    doc_id              UUID,
    content             TEXT NOT NULL,
    embedding           vector(384),
    context             TEXT,
    event_date          TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    occurred_start      TIMESTAMP WITH TIME ZONE,
    occurred_end        TIMESTAMP WITH TIME ZONE,
    mentioned_at        TIMESTAMP WITH TIME ZONE,
    fact_type           VARCHAR(32) NOT NULL DEFAULT 'world' CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model')),
    metadata            JSONB DEFAULT '{}'::jsonb,
    proof_count         INT DEFAULT 1,
    source_memory_ids   UUID[] DEFAULT ARRAY[]::UUID[],
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, bank_id, unit_id),
    FOREIGN KEY(host_id, bank_id) REFERENCES agent_memory_bank_t(host_id, bank_id) ON DELETE CASCADE,
    FOREIGN KEY(host_id, bank_id, doc_id) REFERENCES agent_memory_doc_t(host_id, bank_id, doc_id) ON DELETE CASCADE
);

CREATE INDEX idx_mem_unit_bank ON agent_memory_unit_t(bank_id);
CREATE INDEX idx_mem_unit_embedding ON agent_memory_unit_t USING hnsw (embedding vector_cosine_ops);

-- Resolved entities (Knowledge Graph Nodes)
CREATE TABLE agent_memory_entity_t (
    host_id             UUID NOT NULL,
    entity_id           UUID NOT NULL,
    bank_id             UUID NOT NULL,
    user_id             UUID,                  -- Link to user_t if this entity is a platform user
    canonical_name      TEXT NOT NULL,
    mention_count       INT DEFAULT 1,
    metadata            JSONB DEFAULT '{}'::jsonb,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY (host_id, bank_id, entity_id),
    FOREIGN KEY (host_id, bank_id) REFERENCES agent_memory_bank_t(host_id, bank_id) ON DELETE CASCADE,
    FOREIGN KEY (user_id) REFERENCES user_t(user_id) ON DELETE CASCADE
);

-- Association between memory units and entities
CREATE TABLE agent_memory_unit_entity_t (
    host_id             UUID NOT NULL,
    bank_id             UUID NOT NULL,
    unit_id             UUID NOT NULL,
    entity_id           UUID NOT NULL,
    PRIMARY KEY (host_id, bank_id, unit_id, entity_id),
    FOREIGN KEY (host_id, bank_id, unit_id) REFERENCES agent_memory_unit_t(host_id, bank_id, unit_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, bank_id, entity_id) REFERENCES agent_memory_entity_t(host_id, bank_id, entity_id) ON DELETE CASCADE
);

-- Cache of entity co-occurrences (Concept Relationship Graph)
CREATE TABLE agent_memory_entity_cooccur_t (
    host_id             UUID NOT NULL,
    bank_id             UUID NOT NULL,
    entity_id_1         UUID NOT NULL,
    entity_id_2         UUID NOT NULL,
    cooccur_count       INT DEFAULT 1,
    last_cooccurred     TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY (host_id, bank_id, entity_id_1, entity_id_2),
    CONSTRAINT entity_cooccur_order_check CHECK (entity_id_1 < entity_id_2),
    FOREIGN KEY (host_id, bank_id, entity_id_1) REFERENCES agent_memory_entity_t(host_id, bank_id, entity_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, bank_id, entity_id_2) REFERENCES agent_memory_entity_t(host_id, bank_id, entity_id) ON DELETE CASCADE
);

CREATE INDEX idx_mem_cooccur_e1 ON agent_memory_entity_cooccur_t(host_id, entity_id_1);
CREATE INDEX idx_mem_cooccur_e2 ON agent_memory_entity_cooccur_t(host_id, entity_id_2);

-- Links between memory units (Semantic & Causal relationships)
CREATE TABLE agent_memory_link_t (
    host_id             UUID NOT NULL,
    bank_id             UUID NOT NULL,
    from_unit_id        UUID NOT NULL,
    to_unit_id          UUID NOT NULL,
    link_type           VARCHAR(32) NOT NULL,
    weight              FLOAT NOT NULL DEFAULT 1.0,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY (host_id, bank_id, from_unit_id, to_unit_id, link_type),
    CONSTRAINT memory_links_type_check CHECK (link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')),
    FOREIGN KEY (host_id, bank_id, from_unit_id) REFERENCES agent_memory_unit_t(host_id, bank_id, unit_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, bank_id, to_unit_id) REFERENCES agent_memory_unit_t(host_id, bank_id, unit_id) ON DELETE CASCADE
);

-- Directives (Hard rules that override probabilistic learning)
CREATE TABLE agent_memory_directive_t (
    host_id             UUID NOT NULL,
    directive_id        UUID NOT NULL,
    bank_id             UUID NOT NULL,
    name                VARCHAR(256) NOT NULL,
    content             TEXT NOT NULL,
    priority            INT NOT NULL DEFAULT 0,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, bank_id, directive_id),
    FOREIGN KEY(host_id, bank_id) REFERENCES agent_memory_bank_t(host_id, bank_id) ON DELETE CASCADE
);

-- Reflections (Synthesized knowledge and high-level observations)
CREATE TABLE agent_memory_reflection_t (
    host_id             UUID NOT NULL,
    reflection_id       UUID NOT NULL,
    bank_id             UUID NOT NULL,
    content             TEXT NOT NULL,
    embedding           vector(384),
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, bank_id, reflection_id),
    FOREIGN KEY(host_id, bank_id) REFERENCES agent_memory_bank_t(host_id, bank_id) ON DELETE CASCADE
);

CREATE INDEX idx_mem_reflection_embedding ON agent_memory_reflection_t USING hnsw (embedding vector_cosine_ops);

-- Raw Session History (The source of Truth for active conversations)
CREATE TABLE agent_session_history_t (
    host_id             UUID NOT NULL,
    session_id          UUID NOT NULL,
    bank_id             UUID NOT NULL,         -- Links the session to a Hindsight bank
    messages            JSONB NOT NULL DEFAULT '[]'::jsonb,
    metadata            JSONB DEFAULT '{}'::jsonb,
    aggregate_version   BIGINT DEFAULT 1 NOT NULL,
    active              BOOLEAN DEFAULT true,
    update_ts           TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    update_user         VARCHAR(126) DEFAULT SESSION_USER,
    PRIMARY KEY(host_id, bank_id, session_id),
    FOREIGN KEY(host_id, bank_id) REFERENCES agent_memory_bank_t(host_id, bank_id) ON DELETE CASCADE
);

CREATE INDEX idx_session_bank ON agent_session_history_t(host_id, bank_id);


Light-Deployer Design

light-deployer is the cluster-local Kubernetes deployment executor in Light Fabric.

This document focuses only on the deployer service that lives in apps/light-deployer. The broader Light Portal deployment workflow, approval flow, deployment history model, controller routing, and portal UI are covered outside this repository.

Purpose

light-deployer receives a deployment command, fetches Kubernetes templates, renders them with deployment values, validates the resulting resources, applies or deletes resources in the target Kubernetes cluster, and returns safe status details.

It is intentionally narrow. It does not decide whether a user is allowed to deploy an instance, does not own portal deployment history, and does not create tenant business workflows. Those decisions belong to Light Portal, Light Controller, and the workflow engine.

Service Boundary

light-deployer owns:

  • local deployment policy enforcement
  • template repository fetch
  • YAML template rendering
  • manifest parsing and resource summary generation
  • Kubernetes dry-run, apply, delete, status, and pruning
  • safe event and error reporting
  • direct local/MicroK8s deployment endpoints

light-deployer does not own:

  • tenant authorization
  • instance metadata
  • deployment approval
  • deployment history persistence
  • config snapshot creation
  • long-running human workflow decisions

The deployer should reject commands outside its local policy even if an upstream service sends them.

Runtime Model

The service follows the same runtime pattern as light-agent.

main.rs builds the domain service and starts it through:

#![allow(unused)]
fn main() {
LightRuntimeBuilder::new(AxumTransport::new(app))
}

The HTTP listener is owned by light-runtime and light-axum, not by service-specific socket code. Bind address, HTTP/HTTPS ports, service identity, and registry settings live in runtime config files.

Default config files:

  • config/server.yml
  • config/deployer.yml
  • config/portal-registry.yml

Local cargo run resolves config from apps/light-deployer/config when run from the workspace root. The container image runs from /app and uses /app/config.

Public Endpoints

Phase 1 exposes a direct HTTP surface for local and MicroK8s testing:

GET  /health
GET  /ready
POST /mcp
GET  /mcp/tools
GET  /mcp/tools/list
GET  /mcp/tools/{tool}
POST /deployments
POST /mcp/tools/{tool}
GET  /events?request_id=...

POST /mcp is the MCP JSON-RPC 2.0 endpoint. It supports tools/list, tools/call, and a minimal initialize response. This is the endpoint that MCP clients, Light Portal, and AI agents should use.

/deployments accepts the canonical deployment request directly. /mcp/tools/{tool} maps tool names onto the same internal service functions as a REST-style local debugging convenience. The convenience tool-list endpoints return metadata with name, description, inputSchema, endpoint, and method, but they are not the MCP protocol endpoint.

Supported tool names:

  • deployment.render
  • deployment.dryRun
  • deployment.diff
  • deployment.apply
  • deployment.delete
  • deployment.status
  • deployment.rollback

The direct HTTP mode is useful for development and managed environments. The same internal command handling should later be reused by controller-mediated WebSocket/MCP routing.

Request Model

A deployment request is explicit and auditable.

{
  "requestId": "01964b05-0000-7000-8000-000000000001",
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "instanceId": "petstore-dev",
  "environment": "dev",
  "clusterId": "microk8s-local",
  "namespace": "petstore-dev",
  "action": "deploy",
  "values": {
    "name": "petstore",
    "image": {
      "repository": "networknt/openapi-petstore",
      "tag": "latest"
    }
  },
  "template": {
    "repoUrl": "https://github.com/networknt/openapi-petstore.git",
    "ref": "master",
    "path": "k8s"
  },
  "options": {
    "dryRun": false,
    "waitForRollout": true,
    "timeoutSeconds": 300,
    "pruneOverride": false
  }
}

The current implementation supports inline values. The request model also contains fields for future values references and immutable snapshot metadata so it can align with the full portal deployment workflow.

When invoking a specific /mcp/tools/{tool} endpoint, callers do not need to send action. The deployer derives the action from the tool name. The generic /deployments endpoint still expects an explicit action in the request body.

For the MCP endpoint, callers use JSON-RPC:

{
  "jsonrpc": "2.0",
  "id": "tools-list-1",
  "method": "tools/list",
  "params": {}
}

Tool invocation uses tools/call:

{
  "jsonrpc": "2.0",
  "id": "render-1",
  "method": "tools/call",
  "params": {
    "name": "deployment.render",
    "arguments": {
      "hostId": "local-host",
      "instanceId": "petstore-dev",
      "environment": "dev",
      "clusterId": "local",
      "namespace": "light-deployer",
      "values": {},
      "template": {
        "repoUrl": "local",
        "ref": "main",
        "path": "k8s"
      }
    }
  }
}

tools/call derives the deployment action from params.name; callers should not provide an action field in arguments.

Actions

render : Fetch templates, render manifests, add namespaces and management labels, and return resource summaries plus a manifest hash.

dryRun : Render manifests and validate them against Kubernetes using server-side dry-run.

diff : Render manifests, fetch current managed resources, calculate additions, modifications, and pruned resources, and return a redacted diff summary.

deploy : Accept the request, run the deployment in the background, apply manifests, prune removed managed resources, and stream events.

undeploy : Delete resources associated with the deployment.

status : Return current managed resource status.

rollback : Reserved for redeploying a previous immutable portal snapshot. Native Kubernetes rollout undo is not the target rollback model because it does not restore ConfigMaps, Secrets, or values snapshots.

Template Fetching

Templates are loaded through the TemplateSource trait.

The current source supports two modes:

  • local template root through LIGHT_DEPLOYER_TEMPLATE_BASE_DIR
  • remote HTTPS Git clone through gix

For remote repositories, the deployment request provides:

{
  "template": {
    "repoUrl": "https://github.com/networknt/openapi-petstore.git",
    "ref": "master",
    "path": "k8s"
  }
}

Private HTTPS Git access is controlled by environment variables:

  • LIGHT_DEPLOYER_GIT_TOKEN: token or app password
  • LIGHT_DEPLOYER_GIT_USERNAME: optional username override

Defaults:

  • GitHub uses x-access-token
  • Bitbucket Cloud uses x-token-auth

SSH authentication is intentionally deferred because it requires private key handling and strict known_hosts validation.

Template Format

The built-in renderer uses simple placeholders:

image: ${image.repository}:${image.tag:latest}

Supported behavior:

  • nested paths such as image.repository
  • default values after :
  • render failure when a required value is missing
  • placeholder replacement only inside YAML string scalar values

The renderer parses YAML into serde_yaml::Value, traverses the AST, replaces placeholders, and serializes or applies structured YAML values afterward. This avoids the most common raw string replacement bugs around quoting, indentation, certificates, and multi-line values.

Because placeholders currently produce strings, templates should avoid placeholders in numeric-only Kubernetes fields unless Kubernetes accepts a string value there. For example, containerPort should be fixed or rendered by a future typed placeholder extension.

Resource Metadata

After rendering, the deployer ensures every resource has the target namespace and adds management labels:

  • app.kubernetes.io/managed-by=light-deployer
  • lightapi.net/host-id
  • lightapi.net/instance-id
  • lightapi.net/request-id

These labels are used for status lookup and pruning.

Kubernetes Execution

Kubernetes execution is behind the KubeExecutor trait.

Current implementations:

  • KubeRsExecutor: real Kubernetes API execution through kube-rs
  • NoopKubeExecutor: local render/test mode

Execution mode:

  • LIGHT_DEPLOYER_KUBE_MODE=real: force real Kubernetes mode
  • LIGHT_DEPLOYER_KUBE_MODE=noop: force no-op mode
  • default: real mode when KUBERNETES_SERVICE_HOST is present, otherwise no-op

The production path uses kube-rs, not kubectl.

Kubernetes operations should use:

  • in-cluster ServiceAccount auth when running as a pod
  • server-side dry-run for validation
  • server-side apply with field manager light-deployer
  • structured status and error handling

Pruning

The deployer is declarative. If a previously managed resource is no longer rendered from the template, it should be considered for pruning.

Pruning is calculated by comparing:

  • current resources in the namespace with lightapi.net/instance-id
  • resources rendered from the new template

The policy layer enforces blast-radius protection:

  • maximum delete percentage
  • sensitive kinds requiring override
  • explicit pruneOverride in deployment options

This prevents stale resources while still protecting against accidental large-scale deletion.

Policy

The local deployer.yml policy constrains what a deployer is allowed to do.

Policy dimensions:

  • allowed namespaces
  • allowed repository hosts
  • allowed repository URL prefixes
  • allowed image registries
  • allowed actions
  • allowed Kubernetes kinds
  • blocked Kubernetes kinds
  • prune settings
  • development insecure mode

Version 1 allows application-level resource kinds by default:

  • Deployment
  • Service
  • Ingress
  • ConfigMap
  • Secret

Cluster-scoped and control-plane resources are blocked by default:

  • Namespace
  • ClusterRole
  • ClusterRoleBinding
  • CustomResourceDefinition
  • admission webhooks

Security

The deployer can mutate a Kubernetes cluster, so its default posture must be conservative.

Required practices:

  • run in Kubernetes with a dedicated ServiceAccount
  • prefer namespace-scoped Role and RoleBinding
  • restrict allowed namespaces and resource kinds
  • restrict template repository hosts or prefixes in production
  • restrict image registries in production
  • never log raw rendered Secret manifests
  • never log raw Kubernetes patch/apply payloads containing Secret data
  • return redacted summaries and diffs

Secret values in rendered manifests are redacted before being included in responses or diffs. Kubernetes Secret values are base64 encoded, not encrypted, so they must be treated as plaintext for logging purposes.

Response Model

Responses include enough detail for callers to understand what happened without exposing secrets.

Important fields:

  • requestId
  • action
  • status
  • deployerId
  • clusterId
  • namespace
  • manifestHash
  • templateCommitSha
  • resources
  • diff
  • events
  • error

Resource summaries contain kind, namespace, name, apiVersion, and action. Full rendered manifests should not be returned or persisted by default.

Event Model

Long-running operations return quickly and continue in the background.

Clients can subscribe to:

GET /events?request_id=...

Events contain:

  • request ID
  • timestamp
  • status
  • message
  • optional resource identity

The event stream is currently direct SSE. Controller-mediated mode can forward the same event shape later.

Installation

The app includes Kubernetes install manifests under apps/light-deployer/k8s:

  • namespace
  • RBAC
  • deployment
  • service

The deployment runs the container with LIGHT_DEPLOYER_KUBE_MODE=real. The image contains /app/config, and server.yml defaults the HTTP port to 7088.

For MicroK8s testing:

./apps/light-deployer/build.sh latest
docker save networknt/light-deployer:latest | microk8s ctr image import -
microk8s kubectl apply -f apps/light-deployer/k8s/namespace.yaml
microk8s kubectl apply -f apps/light-deployer/k8s/rbac.yaml
microk8s kubectl apply -f apps/light-deployer/k8s/deployment.yaml
microk8s kubectl apply -f apps/light-deployer/k8s/service.yaml

Current Limitations

  • Direct HTTP/MCP-style mode is implemented first; controller-mediated WebSocket routing is a later integration step.
  • Inline values are implemented; config-server valuesRef fetching is still a future integration point.
  • Rollback is represented in the model but needs portal snapshot integration.
  • Helm and Kustomize are not implemented yet.
  • Typed placeholders are not implemented yet.
  • Rollout watch depth is intentionally basic in the first phase.

Design Direction

Keep light-deployer small and cluster-local.

The deployer should execute precise deployment commands, enforce local safety policy, and report structured results. It should not grow into a portal, workflow engine, or deployment database. That separation keeps the service easy to install inside customer clusters and reduces the security blast radius.

Module Registry

Status: Phase 4 implemented for light-gateway/gateway; additional module reloaders remain planned.

Purpose

Light Fabric needs a runtime module registry equivalent to the ModuleRegistry feature in light-4j.

In light-4j, each active component registers its runtime configuration when the component loads. Older integrations exposed this through the /adm/server/info REST endpoint, but the current control-plane path uses MCP tools through portal-registry. The same registry is also used by the config-reload operation to decide which modules can reload configuration from the config server.

Light Fabric already has structured config files and a shared runtime startup flow, but it does not yet have a central registry that answers these operational questions:

  • which modules are active in this running instance
  • which config file each module loaded
  • what masked runtime config is currently active
  • which modules can be reloaded without restarting the process
  • what happened during the last reload attempt

This document proposes a registry in light-runtime so every Light Fabric application can expose the same control-plane behavior.

Goals

  • Register built-in runtime configs such as startup, server, client, and portal-registry.
  • Register application configs such as gateway, deployer, ollama, and mcp-client.
  • Store only masked config snapshots in the registry.
  • Expose a Java-compatible server-info payload through the get_service_info MCP tool.
  • Expose a module list through the get_modules MCP tool for config reload selection.
  • Support control-plane reload requests for one module, several modules, or all modules through the reload_modules MCP tool. Phase 3 reports non-reloadable modules as skipped. Phase 4 adds real hot reload for light-gateway/gateway.
  • Keep the feature transport-neutral by routing management requests through portal-registry, not through framework-specific REST routes.

Non-Goals

  • Do not make every config hot-reloadable in the first phase.
  • Do not rebind server ports or TLS listeners unless a transport explicitly supports it.
  • Do not expose decrypted secrets through diagnostics.
  • Do not make Rust type names part of the public control-plane contract.
  • Do not add /adm/... REST endpoints for Light Fabric.

Current Light Fabric Runtime Shape

The natural home for this feature is crates/light-runtime.

LightRuntimeBuilder already owns the startup sequence:

  1. load local bootstrap config
  2. optionally fetch remote config from config server
  3. build RuntimeConfig
  4. call registered runtime modules
  5. bind the transport
  6. register the running instance with the controller
  7. mark the runtime ready

RuntimeConfig already carries the merged resolved_values, config_dir, and external_config_dir. Application code can use those fields to load resolved application config without reparsing values.yml.

The config registry should build on that runtime boundary instead of creating a separate app-local registry per product.

Registry Model

Add a shared registry type in light-runtime.

#![allow(unused)]
fn main() {
pub struct ModuleRegistry {
    entries: RwLock<BTreeMap<String, ModuleEntry>>,
    reloaders: RwLock<BTreeMap<String, Arc<dyn ReloadableModule>>>,
}

pub struct ModuleEntry {
    pub module_id: String,
    pub config_name: String,
    pub kind: ModuleKind,
    pub active: bool,
    pub enabled: Option<bool>,
    pub reloadable: bool,
    pub config: serde_json::Value,
    pub masks: Vec<MaskSpec>,
    pub loaded_at: DateTime<Utc>,
    pub last_reload: Option<ReloadStatus>,
}

pub enum ModuleKind {
    Core,
    Framework,
    Application,
    Plugin,
}
}

Use stable module IDs instead of Rust type names. Java uses class names because they are stable operational identifiers in the JVM. Rust type names are not a good public API and can change during refactoring.

Example module IDs:

  • light-runtime/startup
  • light-runtime/server
  • light-client/client
  • light-runtime/portal-registry
  • light-gateway/gateway
  • light-deployer/deployer
  • light-agent/ollama
  • light-agent/mcp-client

The registry key should be module_id. Each entry also carries config_name so the server-info response can preserve the Java-style component map keyed by config name.

Registered Config Loading

Add a small registered-loader API around the existing ConfigLoader behavior.

#![allow(unused)]
fn main() {
let gateway_config: GatewayConfig = context
    .config()
    .load_registered(
        "gateway",
        "light-gateway/gateway",
        [MaskSpec::key("password")],
    )?;
}

The helper should:

  1. merge the base file from config_dir
  2. overlay the external file from external_config_dir
  3. resolve variables from RuntimeConfig.resolved_values
  4. deserialize the typed config
  5. serialize the resolved config to serde_json::Value
  6. apply masks to the serialized copy
  7. store only the masked copy in ModuleRegistry
  8. return the typed config to the caller

This keeps the app code simple and prevents accidental registry entries that contain raw secrets.

Phase 2 added this shared registered-loader path in ModuleRegistry and attached the registry to RuntimeConfig so apps that load after runtime bootstrap can register resolved config through the same runtime-owned registry. Apps that load before runtime startup can create the registry first, register their application configs, and pass that registry into LightRuntimeBuilder. For modules that must validate typed config before changing the registry snapshot, the same loader is also available as load_config(...) followed by register_loaded_config(...) after validation succeeds.

Masking

Masking must happen at registration time. The registry should not store raw config and then mask it later.

Support two mask forms:

#![allow(unused)]
fn main() {
pub enum MaskSpec {
    Key(String),
    Path(String),
}
}

MaskSpec::Key("password") masks every matching key recursively, matching the current light-4j behavior.

MaskSpec::Path("oauth.clientSecret") masks a precise path for configs where a generic key would be too broad.

Suggested default masks:

  • authorization
  • password
  • secret
  • clientSecret
  • apiKey
  • token
  • portalToken
  • controllerDiscoveryToken
  • privateKey
  • tlsKeyPath
  • bootstrapKeyPath

Add a runtime flag such as server.maskConfigProperties or admin.maskConfigProperties, defaulting to true, for parity with the Java server.maskConfigProperties behavior. Even if this flag is disabled, the control-plane documentation should treat unmasked output as a local debugging mode only.

Server Info MCP Response

The get_service_info MCP tool response should preserve the same logical shape that portal-view already understands from Java instances.

{
  "deployment": {
    "apiVersion": "0.1.0",
    "frameworkVersion": "0.1.0"
  },
  "environment": {
    "host": {
      "ip": "127.0.0.1",
      "hostname": "light-gateway-0"
    },
    "runtime": {},
    "system": {}
  },
  "security": {},
  "component": {
    "server": {},
    "gateway": {}
  },
  "plugin": {},
  "plugins": [],
  "modules": []
}

component should remain keyed by config_name for compatibility.

modules should provide richer Rust metadata:

[
  {
    "moduleId": "light-gateway/gateway",
    "configName": "gateway",
    "kind": "application",
    "active": true,
    "enabled": true,
    "reloadable": true,
    "loadedAt": "2026-05-07T14:30:00Z",
    "lastReload": {
      "status": "success",
      "message": "reloaded from config server",
      "completedAt": "2026-05-07T14:45:00Z"
    }
  }
]

MCP Access

Expose the registry only through MCP tools served by the runtime's portal-registry connection.

MCP tools:

get_service_info
get_modules
reload_modules

These are invoked through standard MCP JSON-RPC calls:

{
  "jsonrpc": "2.0",
  "id": "info-1",
  "method": "tools/call",
  "params": {
    "name": "get_service_info",
    "arguments": {}
  }
}

The controller remains the management channel. portal-registry receives the MCP request from the controller, dispatches it to the local runtime registry, and returns the result through the same websocket session. Light Fabric should not expose a parallel REST admin surface for this feature.

For compatibility with the existing Java and portal-view workflow, get_modules returns a string list of module IDs:

{
  "modules": [
    "light-runtime/server",
    "light-gateway/gateway"
  ]
}

The richer module metadata remains available in the modules field of get_service_info.

Reload Request

The reload_modules tool should accept omitted arguments, ALL, or explicit module IDs.

{
  "modules": [
    "light-gateway/gateway",
    "light-runtime/portal-registry"
  ]
}

An omitted modules value, an empty array, or ["ALL"] targets all registered modules. Registered modules without concrete reload implementations are reported as skipped instead of being marked as reloaded.

The response should be explicit about what happened:

{
  "modules": ["light-gateway/gateway"],
  "reloaded": ["light-gateway/gateway"],
  "skipped": [
    {
      "moduleId": "light-runtime/server",
      "reason": "requiresRestart"
    }
  ],
  "failed": [
    {
      "moduleId": "light-agent/ollama",
      "message": "missing ollama.yml"
    }
  ]
}

modules is a Java-compatible alias for the successfully reloaded module IDs and is the field portal-view reads today. reloaded, skipped, and failed carry the more explicit Rust result details.

Reload Implementation

Phase 4 adds a reload trait for modules that can safely swap runtime config.

#![allow(unused)]
fn main() {
#[async_trait]
pub trait ReloadableModule: Send + Sync {
    async fn reload(&self, ctx: ReloadContext) -> Result<ReloadOutcome, RuntimeError>;
}
}

ReloadContext includes:

  • a refreshed RuntimeConfig
  • updated resolved_values
  • the existing config_dir
  • the existing external_config_dir
  • the shared ModuleRegistry

Reload flow:

  1. Re-fetch values.yml, certs, and files from the config server into external_config_dir.
  2. Rebuild the merged resolved_values.
  3. Resolve requested module IDs.
  4. For each reloadable module, call its reload implementation.
  5. Each module validates the new typed config before swapping it into live state.
  6. Update the registry entry and last_reload status.
  7. Return a detailed reload result.

Use ConfigManager<T> or another ArcSwap-backed holder for modules that need hot reload. This avoids locking the request path while still allowing atomic config replacement.

Phase 4 implements this with ConfigManager<T> in light-runtime. It stores an Arc<T> behind a short-lived RwLock, so request handlers clone the current config quickly and reloaders replace the entire typed config only after the new config has loaded and validated.

Reloadability Rules

Classify configs by reload safety.

Reloadable candidates:

  • light-gateway/gateway
  • light-deployer/deployer
  • light-agent/ollama
  • light-agent/mcp-client
  • route, policy, provider, or rule configs that are already read through swappable state

Requires restart by default:

  • bind IP
  • HTTP/HTTPS port
  • protocol enablement
  • TLS certificate path used by the listener
  • runtime config directory
  • config-server bootstrap identity
  • controller registration identity

Some server.yml fields can still be reloadable later, such as shutdownGracefulPeriod, but listener-affecting fields should stay requiresRestart until each transport supports safe rebinding.

Framework Integration

The registry should not require each framework to expose admin routes.

light-runtime should attach an MCP-capable RegistryHandler to the portal-registry client. When the controller invokes tools/list or tools/call, the handler can advertise and execute the local management tools without involving light-axum or light-pingora request routing.

This keeps light-axum and light-pingora focused on application traffic. It also avoids adding service ports, Kubernetes routes, or Pingora request filters only for control-plane operations.

Application Integration

light-gateway is integrated first because it already loads gateway.yml from RuntimeConfig.resolved_values, config_dir, and external_config_dir. It loads the resolved typed config, validates upstreams, and then stores the masked registry snapshot. In Phase 4, light-gateway/gateway also registers a ReloadableModule that reloads and validates gateway.yml, updates the masked registry snapshot, and swaps the live GatewayConfig through ConfigManager.

light-deployer loads deployer.yml before the runtime is started, so it creates a ModuleRegistry before loading its config, registers the final env-overridden deployer config, and passes the same registry to LightRuntimeBuilder.

light-agent also loads application configs before runtime startup. It now registers ollama.yml and mcp-client.yml in the pre-runtime registry and passes that registry into LightRuntimeBuilder. The existing manual PortalRegistryClient setup is unchanged so the registry feature does not reintroduce duplicate controller registration.

Current Registered Modules

Phase 4 registers these modules:

Module IDConfig nameKindReloadable
light-runtime/startupstartupcoreno
light-runtime/serverservercoreno
light-client/clientclientcoreno
light-runtime/portal-registryportal-registrycoreno
light-gateway/gatewaygatewayapplicationyes
light-deployer/deployerdeployerapplicationno
light-agent/ollamaollamaapplicationno
light-agent/mcp-clientmcp-clientapplicationno

The application modules are visible in get_service_info once their owning application loads them. get_modules returns the corresponding module ID strings for portal-view selection. light-gateway/gateway can reload without a restart. Other application modules keep reloadable=false until their runtime state is moved behind swappable holders.

Rollout Plan

Phase 1: Registry and Masked Info

  • Implemented: ModuleRegistry, ModuleEntry, and mask utilities in light-runtime.
  • Implemented: built-in runtime config registration.
  • Implemented: tests proving raw secrets are not stored in registry entries.
  • Implemented: Java-compatible server-info response assembly.
  • Implemented: module-list response.
  • Implemented: a portal-registry MCP handler that exposes get_service_info and get_modules.

Phase 2: Application Registration

  • Implemented: convert light-gateway/gateway to registered config loading.
  • Implemented: convert light-deployer/deployer.
  • Implemented: convert light-agent/ollama and light-agent/mcp-client.
  • Implemented: add docs showing module IDs and reloadability.

Phase 3: Controller Operations

  • Implemented: add MCP tools/list and tools/call support for reload_modules.
  • Implemented: align portal-view calls so Java and Rust instances can be managed with the same control-plane workflow.
  • Implemented: return Java-compatible modules string lists while preserving detailed reloaded, skipped, and failed reload result fields.

Phase 4: Hot Reload

  • Implemented: add ReloadableModule, ReloadContext, and ReloadOutcome.
  • Implemented: add ConfigManager<T> for swappable typed configs.
  • Implemented: implement reload for light-gateway/gateway.
  • Implemented: add reload result tracking in the registry.
  • Implemented: add tests for registry reload results, gateway live config swapping, and config-server-backed reload context refresh.

Open Questions

  • Should module IDs be centrally reserved in light-runtime, or should each application own its ID namespace?
  • Should the Java-compatible component map include only active modules, while modules includes inactive-but-known modules?
  • Should MCP tool execution be enabled whenever portal-registry is enabled, or guarded by a separate admin-tools flag?
  • Should server.maskConfigProperties=false be allowed in production builds, or should Rust always mask known dangerous keys?

Implementation Sequence

Phase 1 implemented registry and masked server info first, without hot reload.

Phase 2 added application registration, so portal-view can display Rust application modules next to Java modules once it calls the MCP tools through portal-registry.

Phase 3 added the controller-facing reload_modules tool and Java-compatible module ID lists.

Phase 4 added the first real hot reload implementation for light-gateway/gateway. The next implementation step is to move additional application configs, such as light-deployer/deployer, light-agent/ollama, and light-agent/mcp-client, behind swappable runtime state before marking them reloadable.

Module Hot Reload

This document describes the design and implementation of the hot reload mechanism in Light Fabric, explaining how modules reload configuration at runtime without requiring a full process restart.


Overview

In Light Fabric, certain configurations can be updated dynamically at runtime to support continuous delivery and quick configuration tuning (e.g., routing changes, CORS policies, security settings, or service discovery URLs). The system provides a unified Module Registry and Reloadable Modules architecture that allows the control plane (via MCP tools) to trigger config reloads.


Reload Flow

When the reload_modules MCP tool is invoked, the control plane initiates the following sequence:

sequenceDiagram
    participant ControlPlane as Control Plane / Portal Registry
    participant Handler as Runtime MCP Handler
    participant Config as RuntimeConfig
    participant Registry as Module Registry
    participant Reloader as Module Reloaders
    
    ControlPlane->>Handler: Call reload_modules
    Handler->>Config: reload_context()
    Note over Config: Re-fetch remote files,<br/>re-read local config yml,<br/>and build ReloadContext
    Config-->>Handler: Return ReloadContext
    Handler->>Registry: reload_modules(context, target_modules)
    Note over Registry: Update direct-registry & client configs
    loop For each reloadable module
        Registry->>Reloader: reload(context)
        Reloader->>Config: Load new file config
        Reloader->>Registry: register_loaded_config(...)
        Note over Reloader: Store fresh config in ConfigManager
    end
    Registry-->>Handler: Return ReloadModulesResult
    Handler-->>ControlPlane: Return result JSON
  1. Build Reload Context: The runtime constructs a ReloadContext containing a fresh RuntimeConfig by parsing the updated config files (local or fetched from the config server) and merging dynamic values.yml parameters.
  2. Pre-update Built-in Configs: The core configurations stored inside ModuleRegistry (such as light-client/client and light-runtime/direct-registry) are updated in-memory using the reloaded config.
  3. Dispatch to Module Reloaders: The registry iterates over the target modules and invokes the corresponding ReloadableModule::reload implementation.
  4. Atomic State Swap: Inside each reloader, the new configuration is parsed, validated, registered in the registry, and swapped atomically using ConfigManager<T>.

ConfigManager and Thread Safety

To prevent request latency during reloads, Light Fabric uses a thread-safe ConfigManager<T> to manage dynamic configurations.

ConfigManager wraps an Arc<T> with a short-lived RwLock. Request handlers clone the Arc instantly (a simple reference count increment) without blocking, while the reloader replaces the entire Arc<T> atomically after the new configuration is successfully parsed and validated.


Core Hot Reload Implementations

Direct Registry Reload (light-runtime/direct-registry)

The direct registry maps service IDs to direct URLs for service discovery.

  • Reload Process: The direct URLs are updated in values.yml (either locally or on a remote config server). On reload, ReloadContext parses the new URLs, and reload_modules updates the registered config for "light-runtime/direct-registry" in the ModuleRegistry.
  • Propagation: Runtimes such as the McpRouterRuntime or the TokenRuntime re-read the updated direct_registry config from the fresh RuntimeConfig when they are reloaded.

Client Configuration Reload (light-client/client)

The client configuration contains TLS settings and OAuth token provider configurations.

  • Reloadable Flag: The client module is registered with reloadable: true at startup.
  • Reload Process: The ReloadContext re-loads client.yml from disk, applying new TLS properties or OAuth credentials. The reload_modules function updates the registered client configuration (applying proper masks to client secrets and certificates).
  • Reloader: A registered ClientReloader marks the transition success. Dependent modules (like light-pingora/mcp-router and light-pingora/token) query the new client configuration from the context upon reload.

Reloadable vs. Non-Reloadable Configs

Module IDConfig FileTypeReloadableDescription
light-runtime/startupstartup.ymlCoreNoCore server boot credentials
light-runtime/serverserver.ymlCoreNoServer host, IP, and listeners
light-runtime/portal-registryportal-registry.ymlCoreNoConnection to portal registry
light-runtime/direct-registryvalues.ymlCoreYesService discovery direct URL overrides
light-client/clientclient.ymlCoreYesOutbound TLS and OAuth client credentials
light-pingora/handlerhandler.ymlFrameworkYesActive handler chains and route mappings
light-pingora/correlationcorrelation.ymlFrameworkYesTraceability and MDC logging settings
light-pingora/corscors.ymlFrameworkYesCORS origin and header limits
light-pingora/mcp-routermcp-router.ymlFrameworkYesMCP server configurations and upstream rules
light-pingora/tokentoken.ymlFrameworkYesOAuth client credentials token handlers

[!NOTE] Modifying non-reloadable configurations requires a full restart of the gateway process to bind new server listeners or configure registry websocket connections securely.


Verification & Testing

Module hot-reloading can be verified using the following automated test suites:

  • Direct Registry Test: reload_modules_updates_direct_registry_config (defined in crates/light-runtime/src/module_registry.rs) asserts that updated direct discovery URLs are correctly reflected in the registry.
  • Client Config Test: gateway_client_config_reload (defined in apps/light-gateway/src/main.rs) asserts that updated TLS verification settings in client.yml are loaded and reflected in the registry.

Controller Registry Client

The Controller Registry Client (portal-registry) manages the connection between a gateway (or agent) instance and the Light Portal control plane. It enables runtime instance registration, service discovery queries, and dynamic configuration synchronization over a secure WebSocket connection.


Architecture Overview

The registry client operates as a background service inside the runtime. It establishes a persistent connection to the controller and handles bidirectional communication.

sequenceDiagram
    participant Instance as Gateway / Agent
    participant Client as Portal Registry Client
    participant Controller as Control Plane / Portal
    
    Instance->>Client: Initialize and run()
    loop Connection Loop
        Client->>Controller: WebSocket Handshake (WSS)
        Note over Client,Controller: Negotiate TLS & Custom Certificates
        
        alt Connection Succeeded
            Client->>Controller: service/register (JSON-RPC Request)
            Controller-->>Client: Registration Response (Instance ID)
            Note over Client: State = Registered
            
            loop Active Connection (tokio::select!)
                alt Heartbeat interval (30s)
                    Client->>Controller: Ping
                    Controller-->>Client: Pong
                end
                
                alt Server Request
                    Controller->>Client: JSON-RPC Request / Notification
                    Client->>Controller: Response
                end
            end
        else Connection Failed / Severed
            Note over Client: State = Disconnected
            Note over Client: Calculate backoff + jitter
            Client->>Client: Sleep before retry
        end
    end

Core Features

1. WebSocket Protocol & Handshake

The connection is established over standard WebSocket (secured via TLS: wss://). Once connected, the client performs an initial JSON-RPC handshake:

  • Method: service/register
  • Parameters: ServiceRegistrationParams (containing serviceId, version, host address, listening port, tags, envTag, and a verification jwt token).
  • Result: RegistrationResponse returning a unique runtimeInstanceId assigned by the control plane.

2. Heartbeat (Ping/Pong)

To prevent network firewalls from dropping inactive connections and to detect silent TCP half-open connection drops, the client sends a WebSocket Ping frame every 30 seconds.

  • If the control plane fails to reply, or the socket write fails, the connection loop is terminated immediately to initiate reconnection.
  • The client also responds immediately with a Pong to any inbound Ping frames received from the controller.

3. Exponential Backoff with Jitter

When a connection is lost, terminated, or fails to initialize, the client retries using an exponential backoff strategy:

  • Base delay starts at 1 second and doubles on subsequent retries up to a maximum of 60 seconds.
  • Random Jitter of 0-1000 milliseconds is added to each sleep duration.
  • Thundering Herd Prevention: Jitter prevents synchronized gateway instances (e.g., in a Kubernetes cluster) from flooding the control plane with connection requests at the exact same moment when it restarts.

4. TLS & Certificate Verification

The client supports establishing WSS connections with two cert verification modes:

  • Standard Verification (verifyHostname: true): Validates the server certificate chain against loaded CA certificates and verifies that the certificate hostname matches the controller domain.
  • No-Hostname Verification (verifyHostname: false): Useful in local development or custom routing networks. It validates the certificate chain against the trusted CA bundle but bypasses hostname verification.

Component Configuration

Registry settings are loaded from portal-registry.yml or mapped in startup configuration:

Configuration PropertyTypeDefaultDescription
portalUrlStringThe API endpoint of the Portal Registry controller
portalTokenStringJWT verification token used for handshakes
controllerDiscoveryTokenStringToken utilized for discovery lookups
bootstrapCaCertPathPathOptional path to CA certificate bundle

Verification & Testing

The registry client behaves predictably under connection drops and can be verified via:

  • Handshake Verification: registration_and_metadata_update_match_controller_protocol (defined in crates/portal-registry/src/client.rs) asserts correct JSON-RPC registration format and success handling.
  • WebSocket Gateway integration: websocket_gateway_proxies_text_binary_close_subprotocol_and_headers (defined in apps/light-gateway/src/main.rs) tests end-to-end WebSocket proxying alongside a mock controller registry.
  • Reconnect Loop Verification: test_registry_client_reconnects_and_reregisters_on_run_level (defined in crates/portal-registry/src/client.rs) verifies that client terminates connection on socket drop, calculates backoff delay, reconnects, and re-registers automatically.
  • Heartbeat Timeout Verification: test_heartbeat_timeout_detects_silent_controller_loss (defined in crates/portal-registry/src/client.rs) tests that the client detects silent connection drops by terminating and transitioning to Disconnected if the controller does not respond to Ping within the configured heartbeat timeout window.

Cache Control Plane

Status: Proposed

Purpose

Light Fabric should expose the same cache operations through the portal control plane that Java services expose through light-4j and portal-registry.

Today, portal-view can list caches and inspect cache entries for a running service instance. The next required operation is clearing a cache so cached data can be reloaded from its source of truth after operational data changes. A common case is clearing the reference-data cache in portal-service after reference tables are changed from light-portal.

The feature should be generic. It should not be a portal-service only endpoint. Any Java or Rust service that registers with the controller and has named local caches should be manageable through the same MCP tool contract.

Current Shape

The Java implementation already has most of the control-plane pieces:

  • light-4j/cache-manager defines the generic CacheManager API.
  • light-4j/caffeine-cache provides the Caffeine-backed implementation.
  • light-4j/portal-registry exposes MCP tools such as list_caches and get_cache_entries.
  • controller-rs and the Java controller forward instance-specific MCP tool calls by runtimeInstanceId.
  • portal-view calls the controller MCP websocket and passes runtimeInstanceId for cache exploration.

The main semantic gap is that CacheManager.removeCache(name) removes the cache from the manager in the Caffeine implementation. For a control-plane clear operation, the desired behavior is different: invalidate all entries while keeping the configured cache alive so the next application read repopulates it.

Goals

  • Add a generic whole-cache clear operation.
  • Keep the control-plane contract compatible between Java services and Light Fabric services.
  • Expose cache operations through portal-registry and controller MCP routing, not through service-specific REST endpoints.
  • Let portal-view clear a selected cache from the existing Cache Explorer page.
  • Use the same feature for portal-service reference data caching.
  • Preserve existing cache inspection behavior.

Non-Goals

  • Do not remove or unregister a configured cache when clearing entries.
  • Do not require every service to use the same cache backend.
  • Do not expose raw secrets or unsafe object internals through cache inspection.
  • Do not build event-driven cross-service cache invalidation in the first phase.
  • Do not confuse runtime data caches with the config-cache directory used for remote configuration files.

MCP Tool Contract

Add a new generic tool:

{
  "name": "clear_cache",
  "description": "Clear all entries from a named cache on a live runtime instance.",
  "inputSchema": {
    "type": "object",
    "required": ["runtimeInstanceId", "name"],
    "properties": {
      "runtimeInstanceId": { "type": "string", "format": "uuid" },
      "name": { "type": "string" }
    }
  }
}

The controller accepts runtimeInstanceId, removes it from the forwarded arguments, and sends this to the target runtime:

{
  "name": "clear_cache",
  "arguments": {
    "name": "reference-data"
  }
}

Recommended success response:

{
  "supported": true,
  "status": "success",
  "name": "reference-data",
  "beforeSize": 42,
  "afterSize": 0
}

Recommended unsupported response:

{
  "supported": false,
  "status": "unsupported",
  "name": "reference-data",
  "message": "Cache support is not available on this service."
}

Key-level invalidation can be added later as a separate invalidate_cache_entry tool with { "name": "...", "key": "..." }. Whole-cache clear should be implemented first because it solves the reference data reload case without introducing cache-key UX and serialization questions.

Java Compatibility Work

In light-4j, add an explicit clear operation to the generic cache API:

void clear(String cacheName);

The Caffeine implementation should call cache.invalidateAll() and keep the cache in the manager. It may call cache.cleanUp() before returning size data. removeCache(name) should keep its existing unregister/remove semantics.

portal-registry should advertise clear_cache in tools/list and handle it in tools/call by using CacheManager.getInstance(). The handler should return supported: false when cache classes or a cache manager are not available, matching the current list_caches and get_cache_entries behavior.

The controller catalogs need the same tool so portal-view can call it through the normal controller websocket:

  • controller-rs tool catalog and command serialization
  • Java light-controller tool catalog and routed-call handling, if it remains a supported control-plane runtime

Light Fabric Runtime Design

Light Fabric should provide a small cache abstraction at the runtime layer so applications do not each define a different operational surface.

A practical shape is:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait RuntimeCache: Send + Sync {
    async fn len(&self) -> usize;
    async fn entries_summary(&self) -> serde_json::Value;
    async fn clear(&self);
}

#[derive(Default)]
pub struct CacheRegistry {
    caches: RwLock<BTreeMap<String, Arc<dyn RuntimeCache>>>,
}
}

The registry should support:

  • register named cache
  • list cache names
  • get summarized entries
  • clear a named cache

moka is the preferred default backend for async Rust services because it maps well to the Caffeine use case. Applications should still be free to register custom cache wrappers as long as they implement the runtime trait.

RuntimeMcpHandler in light-runtime should expose the same tools as Java:

  • list_caches
  • get_cache_entries
  • clear_cache

If a runtime has no cache registry, these tools should return supported: false rather than failing the request.

Portal Service Reference Data Cache

portal-service can use the generic Light Fabric cache for /r/data.

Suggested cache names:

  • reference-data
  • reference-data-relation

Suggested keys:

  • host:{hostId|global}:lang:{lang}:table:{name}
  • host:{hostId|global}:lang:{lang}:table:{name}:rela:{rela}:from:{from}

The request flow becomes:

  1. /r/data receives a reference-data request.
  2. ReferenceService builds a stable cache key from host, language, table, relation, and source value.
  3. On cache hit, return cached reference data.
  4. On cache miss, query Postgres, cache the result, and return it.
  5. When reference data changes in light-portal, an operator clears reference-data or reference-data-relation for the target portal-service runtime instance from portal-view.
  6. The next /r/data call reloads from Postgres.

This keeps the first implementation manual and deterministic. A later phase can subscribe to reference-table change events and clear matching caches automatically.

Portal View UX

The existing Cache Explorer page should stay the main UI.

Add a clear action for the selected cache:

  • show the selected cache name
  • require confirmation before clearing
  • disable the button while the request is running
  • call clear_cache with { runtimeInstanceId, name }
  • show success or error status
  • refetch cache entries after a successful clear

The UI should not require users to know whether the target service is Java or Rust. Unsupported runtimes should show the returned unsupported message.

Implementation Phases

Phase 1: Java clear support

  • Add CacheManager.clear(cacheName).
  • Implement it in caffeine-cache.
  • Add clear_cache to portal-registry MCP tools.
  • Add targeted tests for clearing while preserving the configured cache.

Phase 2: Controller and portal-view

  • Add clear_cache to controller tool catalogs and command routing.
  • Add the Cache Explorer clear button and confirmation.
  • Verify the existing runtimeInstanceId forwarding path is reused.

Phase 3: Light Fabric generic cache

  • Add a runtime cache registry and trait.
  • Add moka backed cache support.
  • Expose list_caches, get_cache_entries, and clear_cache from RuntimeMcpHandler.
  • Add focused light-runtime tests for supported and unsupported cache cases.

Phase 4: Portal service reference data

  • Register reference-data and reference-data-relation caches.
  • Cache /r/data query results.
  • Clear the cache from portal-view and verify the next request reloads from Postgres.

Verification

Recommended targeted checks:

mvn -q -pl cache-manager,caffeine-cache,portal-registry test
cargo test -p light-runtime
cargo check --workspace
yarn build

Use the Maven command in light-4j, the Cargo commands in light-fabric and portal-service as appropriate, and the frontend build in portal-view.

Client Configuration And Modules

Status

Brainstorming proposal for standardizing client.yml across Light Fabric runtime, framework modules, and products.

The immediate trigger is that different Rust modules currently interpret client.yml differently. For example, light-runtime reads a small top-level verifyHostname field for controller and config-server clients, while light-pingora token and SPA modules read a Java-style nested tls section. That split makes a single client.verifyHostname: false value unreliable.

This document proposes a common contract so every Rust module uses the same client.yml file and the same typed configuration model.

Purpose

client.yml should describe outbound client behavior for a running service:

  • TLS trust, hostname verification, and optional client identity.
  • HTTP request timeout, retry, circuit breaker, connection pool, and HTTP/2 behavior.
  • OAuth 2.0 token, key, sign, dereference, and provider-selection behavior.
  • Path-prefix-to-service mapping used when different downstream services use different OAuth providers.

The file should be loaded once through the runtime configuration system, registered once in the module registry with secrets masked, then shared by all modules that make outbound calls.

Compatibility Contract

The Java light-4j client.yml remains the compatibility baseline. Rust can clean up the internal model, but it should not remove behavior that Java http-client and client-config expose.

Important Java sections:

tls:
  verifyHostname: ${client.verifyHostname:true}
  loadDefaultTrustStore: ${client.loadDefaultTrustStore:true}
  loadTrustStore: ${client.loadTrustStore:true}
  trustStore: ${client.trustStore:client.truststore}
  trustStorePass: ${client.trustStorePass:password}
  loadKeyStore: ${client.loadKeyStore:false}
  keyStore: ${client.keyStore:client.keystore}
  keyStorePass: ${client.keyStorePass:password}
  keyPass: ${client.keyPass:password}
  defaultCertPassword: ${client.defaultCertPassword:changeit}
  tlsVersion: ${client.tlsVersion:TLSv1.3}

oauth:
  multipleAuthServers: ${client.multipleAuthServers:false}
  token:
    cache:
      capacity: ${client.tokenCacheCapacity:200}
    tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
    expiredRefreshRetryDelay: ${client.expiredRefreshRetryDelay:2000}
    earlyRefreshRetryDelay: ${client.earlyRefreshRetryDelay:4000}
    server_url: ${client.tokenServerUrl:}
    serviceId: ${client.tokenServiceId:com.networknt.oauth2-token-1.0.0}
    proxyHost: ${client.tokenProxyHost:}
    proxyPort: ${client.tokenProxyPort:}
    enableHttp2: ${client.tokenEnableHttp2:true}
    authorization_code: {}
    client_credentials: {}
    refresh_token: {}
    token_exchange: {}
    key: {}
  sign: {}
  deref: {}

pathPrefixServices: ${client.pathPrefixServices:}

request:
  errorThreshold: ${client.errorThreshold:2}
  connectTimeout: ${client.connectTimeout:2000}
  timeout: ${client.timeout:3000}
  resetTimeout: ${client.resetTimeout:7000}
  injectOpenTracing: ${client.injectOpenTracing:false}
  injectCallerId: ${client.injectCallerId:false}
  enableHttp2: ${client.enableHttp2:true}
  connectionPoolSize: ${client.connectionPoolSize:1000}
  connectionExpireTime: ${client.connectionExpireTime:1800000}
  maxReqPerConn: ${client.maxReqPerConn:1000000}
  maxConnectionNumPerHost: ${client.maxConnectionNumPerHost:1000}
  minConnectionNumPerHost: ${client.minConnectionNumPerHost:250}
  maxRequestRetry: ${client.maxRequestRetry:3}
  requestRetryDelay: ${client.requestRetryDelay:1000}
  poolMetricsEnabled: ${client.poolMetricsEnabled:false}
  poolWarmUpEnabled: ${client.poolWarmUpEnabled:false}
  poolWarmUpSize: ${client.poolWarmUpSize:1}
  healthCheckEnabled: ${client.healthCheckEnabled:true}
  healthCheckIntervalMs: ${client.healthCheckIntervalMs:30000}

Rust should add fields such as tls.caCertPath, tls.clientCertPath, and tls.clientKeyPath because PEM files are the native Rust deployment shape. Rust does not need to support Java-specific JKS/JCEKS truststore or keystore formats. If those Java-only fields appear in a Rust client.yml, they can be ignored because config-server should control which fields it injects for Rust services.

Initial Rust Gaps

At the start of this migration, the Rust implementation had three separate interpretations of client configuration:

AreaCurrent behaviorProblem
light-runtime config-server and portal-registry clientsRead ClientConfig { verify_hostname } from top-level client.ymlDid not understand the Java nested tls.verifyHostname shape
light-pingora token, security JWKS, stateless auth, and MSAL exchangeRead ClientTokenConfig with tls, oauth, pathPrefixServices, and requestWas closer to Java, but framework-local and did not drive runtime clients
light-gateway upstream proxyRead the resolved flat value client.verifyHostname directly from values.ymlBypassed typed client.yml and could disagree with other modules

Before this design, Rust support was also partial compared with Java:

Java capabilityInitial Rust status
tls.verifyHostnameSupported by Pingora token/SPAs, not by runtime controller/config-server clients
CA trustSupported through Rust caCertPath; Java truststore fields are not modeled
Client certificate and key for mTLSNot yet modeled for outbound clients
TLS versionNot yet modeled
Request connect and total timeoutSupported for token/SPAs
Retries, circuit breaker, pool sizing, pool healthNot yet modeled as shared client behavior
OAuth authorization_codeSupported by SPA auth
OAuth client_credentialsSupported by token handler
OAuth refresh_tokenSupported by SPA auth
OAuth token_exchangeSupported by MSAL exchange and SPA auth
OAuth token key / JWKSPartially supported by security runtime
token.key.serviceIdAuthServers and audienceNot fully modeled in Rust
OAuth signNot yet modeled
OAuth sign.key / sign JWKSNot yet modeled
OAuth derefNot yet modeled
Multiple auth providers by service idSupported for client credentials, but should become a shared resolver
pathPrefixServicesSupported in token handler, but should become shared resolver logic

Goals

  • Keep client.yml as the only config file for outbound client behavior.
  • Make the Java nested shape canonical: tls.verifyHostname, not top-level verifyHostname.
  • Load and register the resolved client.yml once through light-runtime.
  • Share one typed ClientConfig across runtime, Pingora, gateway, agent, deployer, MCP clients, model-provider clients, and future products.
  • Preserve Java-compatible field names and config-server placeholder names.
  • Support direct URL, direct registry, and portal registry service discovery consistently for token, key, sign, deref, and generic outbound calls.
  • Keep secrets masked in module registry snapshots and logs.
  • Make invalid active client config fail startup or reject reload before it changes live runtime behavior.
  • Allow Rust-native PEM fields without forcing Java keystore names into every Rust deployment.

Non-Goals

  • Do not move handler activation into client.yml. Handler-specific files such as token.yml, statelessAuth.yml, and msal-exchange.yml still decide whether a handler runs.
  • Do not implement every Java-only low-level connection-pool behavior in the first phase. The shared schema should include the fields so config is not lost, but unsupported fields can be ignored deliberately until the transport supports them.
  • Do not expose decrypted client secrets, tokens, or legacy Java password fields through module registry, MCP tools, logs, metrics, or cache output.
  • Do not require every module to use OAuth. The shared config must support simple TLS-only clients too.

Resolved Decisions

  • Create a separate light-client crate now so the shared config, HTTP client factory, OAuth client, and provider resolver can be reused without coupling every consumer to light-runtime.
  • Standardize Rust outbound TLS material on PEM paths. Java truststore and keystore formats are not required for Rust services.
  • client.yml reload should not force an immediate portal-registry reconnect. Reload is primarily for newly onboarded JWKS/JWT access and future outbound requests. Existing long-lived controller connections can keep running until their normal reconnect or service restart.
  • Unsupported Java fields can be ignored by Rust. Config-server should avoid injecting unsupported fields into Rust service config.
  • Ignored Java-only fields should be ignored silently. Rust startup does not need to warn about fields that config-server may omit for Rust services.
  • oauth.multipleAuthServers remains accepted for Java compatibility, but Rust should infer multi-provider mode when serviceIdAuthServers is configured.
  • pathPrefixServices stays in client.yml. It is outbound-client provider selection and is different from inbound path routing to downstream services.
  • Circuit breaker behavior is only needed by Pingora. Shared request config can carry the Java-compatible fields, but non-Pingora clients do not need to own circuit breaker state.
  • SAML bearer is not required for Light Fabric and should remain out of scope unless a future product explicitly needs it.

Proposed Canonical Shape

The canonical Rust client.yml should stay close to Java:

tls:
  verifyHostname: ${client.verifyHostname:true}
  caCertPath: ${client.caCertPath:}
  clientCertPath: ${client.clientCertPath:}
  clientKeyPath: ${client.clientKeyPath:}
  tlsVersion: ${client.tlsVersion:TLSv1.3}

request:
  connectTimeout: ${client.connectTimeout:2000}
  timeout: ${client.timeout:3000}
  maxRequestRetry: ${client.maxRequestRetry:3}
  requestRetryDelay: ${client.requestRetryDelay:1000}
  errorThreshold: ${client.errorThreshold:2}
  resetTimeout: ${client.resetTimeout:7000}
  injectCallerId: ${client.injectCallerId:false}
  enableHttp2: ${client.enableHttp2:true}
  connectionPoolSize: ${client.connectionPoolSize:1000}
  connectionExpireTime: ${client.connectionExpireTime:1800000}
  maxReqPerConn: ${client.maxReqPerConn:1000000}
  maxConnectionNumPerHost: ${client.maxConnectionNumPerHost:1000}
  minConnectionNumPerHost: ${client.minConnectionNumPerHost:250}
  poolMetricsEnabled: ${client.poolMetricsEnabled:false}
  poolWarmUpEnabled: ${client.poolWarmUpEnabled:false}
  poolWarmUpSize: ${client.poolWarmUpSize:1}
  healthCheckEnabled: ${client.healthCheckEnabled:true}
  healthCheckIntervalMs: ${client.healthCheckIntervalMs:30000}

oauth:
  multipleAuthServers: ${client.multipleAuthServers:false}
  token:
    cache:
      capacity: ${client.tokenCacheCapacity:200}
    tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
    expiredRefreshRetryDelay: ${client.expiredRefreshRetryDelay:2000}
    earlyRefreshRetryDelay: ${client.earlyRefreshRetryDelay:4000}
    server_url: ${client.tokenServerUrl:}
    serviceId: ${client.tokenServiceId:com.networknt.oauth2-token-1.0.0}
    proxyHost: ${client.tokenProxyHost:}
    proxyPort: ${client.tokenProxyPort:}
    enableHttp2: ${client.tokenEnableHttp2:true}
    authorization_code:
      uri: ${client.tokenAcUri:/oauth2/token}
      client_id: ${client.tokenAcClientId:}
      client_secret: ${client.tokenAcClientSecret:}
      redirect_uri: ${client.tokenAcRedirectUri:}
      scope: ${client.tokenAcScope:}
    client_credentials:
      uri: ${client.tokenCcUri:/oauth2/token}
      client_id: ${client.tokenCcClientId:}
      client_secret: ${client.tokenCcClientSecret:}
      scope: ${client.tokenCcScope:}
      serviceIdAuthServers: ${client.tokenCcServiceIdAuthServers:}
    refresh_token:
      uri: ${client.tokenRtUri:/oauth2/token}
      client_id: ${client.tokenRtClientId:}
      client_secret: ${client.tokenRtClientSecret:}
      scope: ${client.tokenRtScope:}
    token_exchange:
      uri: ${client.tokenExUri:/oauth2/token}
      client_id: ${client.tokenExClientId:}
      client_secret: ${client.tokenExClientSecret:}
      scope: ${client.tokenExScope:}
      subjectToken: ${client.subjectToken:}
      subjectTokenType: ${client.subjectTokenType:urn:ietf:params:oauth:token-type:jwt}
      requestedTokenType: ${client.requestedTokenType:}
      audience: ${client.tokenExAudience:}
    key:
      server_url: ${client.tokenKeyServerUrl:}
      serviceId: ${client.tokenKeyServiceId:com.networknt.oauth2-key-1.0.0}
      uri: ${client.tokenKeyUri:/oauth2/key}
      client_id: ${client.tokenKeyClientId:}
      client_secret: ${client.tokenKeyClientSecret:}
      enableHttp2: ${client.tokenKeyEnableHttp2:true}
      serviceIdAuthServers: ${client.tokenKeyServiceIdAuthServers:}
      audience: ${client.tokenKeyAudience:}
  sign:
    server_url: ${client.signServerUrl:}
    serviceId: ${client.signServiceId:com.networknt.oauth2-token-1.0.0}
    uri: ${client.signUri:/oauth2/sign}
    timeout: ${client.signTimeout:2000}
    client_id: ${client.signClientId:}
    client_secret: ${client.signClientSecret:}
    proxyHost: ${client.signProxyHost:}
    proxyPort: ${client.signProxyPort:}
    enableHttp2: ${client.signEnableHttp2:true}
    key:
      server_url: ${client.signKeyServerUrl:}
      serviceId: ${client.signKeyServiceId:com.networknt.oauth2-key-1.0.0}
      uri: ${client.signKeyUri:/oauth2/key}
      client_id: ${client.signKeyClientId:}
      client_secret: ${client.signKeyClientSecret:}
      enableHttp2: ${client.signKeyEnableHttp2:true}
      audience: ${client.signKeyAudience:}
  deref:
    server_url: ${client.derefServerUrl:}
    serviceId: ${client.derefServiceId:com.networknt.oauth2-token-1.0.0}
    uri: ${client.derefUri:/oauth2/deref}
    client_id: ${client.derefClientId:}
    client_secret: ${client.derefClientSecret:}
    proxyHost: ${client.derefProxyHost:}
    proxyPort: ${client.derefProxyPort:}
    enableHttp2: ${client.derefEnableHttp2:true}

pathPrefixServices: ${client.pathPrefixServices:}

Compatibility aliases:

  • Accept serverUrl in addition to Java server_url for Rust callers.
  • Accept clientId and clientSecret in addition to Java client_id and client_secret only as aliases. The emitted template should keep Java names.
  • Temporarily accept top-level verifyHostname only as a migration fallback, but register a warning and normalize it into tls.verifyHostname.

Serde strategy for the top-level verifyHostname fallback:

  • The shared ClientConfig should deserialize into a struct that has a tls.verifyHostname field and a separate #[serde(default)] top-level verify_hostname field.
  • After deserialization, a post-parse normalization step should check whether the top-level field was explicitly set. If so, it logs a deprecation warning and copies the value into tls.verify_hostname only when the nested field was not also explicitly set.
  • When both the top-level and nested fields are present, the nested tls.verifyHostname value wins. The top-level value is ignored after the warning.
  • Do not rely on two competing #[serde(default)] fields resolving the conflict. Use a custom Deserialize impl or an explicit post-parse step.

Serde strategy for Java-compatible but unimplemented sections:

  • Do not use #[serde(deny_unknown_fields)] for the top-level ClientConfig or OAuth section during Phase 1.
  • Known but not-yet-implemented Java sections such as oauth.sign and oauth.deref should deserialize into typed structs or serde_json::Value placeholders so representative Java fixtures load successfully.
  • Demand-driven validation decides whether a section is required. If no active module consumes oauth.sign or oauth.deref, those sections can be present and ignored silently.

Proposed Rust Modules

Shared Config Model

Create one shared typed config model outside light-pingora and light-runtime:

crates/light-client/src/lib.rs
crates/light-client/src/config.rs
crates/light-client/src/http.rs
crates/light-client/src/oauth.rs
crates/light-client/src/provider.rs

light-runtime should use light-client for loading, validating, and building outbound clients, but the reusable client model should not live inside the runtime crate.

Core types:

#![allow(unused)]
fn main() {
pub struct ClientConfig {
    pub tls: ClientTlsConfig,
    pub request: ClientRequestConfig,
    pub oauth: ClientOauthConfig,
    pub path_prefix_services: BTreeMap<String, String>,
}

pub struct ClientTlsConfig {
    pub verify_hostname: bool,
    pub ca_cert_path: Option<PathBuf>,
    pub client_cert_path: Option<PathBuf>,
    pub client_key_path: Option<PathBuf>,
    pub tls_version: Option<TlsVersion>,
}

pub struct ClientRequestConfig {
    pub connect_timeout_ms: u64,
    pub timeout_ms: u64,
    pub max_request_retry: u32,
    pub request_retry_delay_ms: u64,
    pub error_threshold: u32,
    pub reset_timeout_ms: u64,
    pub inject_caller_id: bool,
    pub enable_http2: bool,
    pub pool: ClientPoolConfig,
}
}

TlsVersion should be an enum with serde names for Java-compatible strings such as TLSv1.2 and TLSv1.3, rather than a raw string in runtime code.

Secrets should use a type that serializes as masked data for registry output, or the registry masks should cover every secret field recursively.

Runtime Loader

light-runtime should own the startup lifecycle for client.yml loading, but delegate parsing and validation to light-client:

  1. Load local values.yml.
  2. Load local startup.yml.
  3. Load local client.yml with resolved values for config-server bootstrap.
  4. Fetch remote config if configured.
  5. Rebuild the final RuntimeConfig with the remote client.yml overlay.
  6. Register masked light-client/client in ModuleRegistry.

Every runtime client should use this shared config:

  • config-server fetch client
  • portal-registry WebSocket client
  • MCP client
  • future model-provider outbound clients
  • framework/application clients through RuntimeConfig.client

For the earlier hostname-verification bug, the controller client should read:

runtime_config.client.tls.verify_hostname

not a separate top-level ClientConfig.verify_hostname.

HTTP Client Factory

Add a small factory that converts ClientConfig plus optional per-endpoint overrides into concrete clients:

#![allow(unused)]
fn main() {
pub struct ClientFactory {
    config: Arc<ClientConfig>,
    direct_registry: DirectRegistryConfig,
    registry_client: Option<Arc<PortalRegistryClient>>,
}

pub struct EndpointOptions {
    pub server_url: Option<String>,
    pub service_id: Option<String>,
    pub proxy_host: Option<String>,
    pub proxy_port: Option<u16>,
    pub enable_http2: Option<bool>,
    pub timeout_ms: Option<u64>,
}
}

Responsibilities:

  • Build reqwest::Client with consistent TLS, timeout, proxy, HTTP/2, retry, and pool settings for non-Pingora consumers.
  • Build Pingora HttpPeer options from the same TLS config for gateway upstream proxying.
  • Resolve endpoint base URL by priority:
    1. direct server_url
    2. direct-registry.yml
    3. portal-registry discovery by serviceId
  • Apply per-service AuthServerConfig overrides without duplicating resolver logic in each handler.

The config-server bootstrap path still starts from BootstrapConfig because it needs enough client settings before remote client.yml has been fetched. To keep light-client independent from light-runtime, the factory should not take a BootstrapConfig type directly. Instead, light-runtime should adapt BootstrapConfig.connect_timeout, BootstrapConfig.timeout, authorization, and bootstrap CA path into EndpointOptions or a small bootstrap options type owned by light-client.

OAuth Client

Add a shared OAuth client module that implements Java http-client behavior:

oauth/client_credentials
oauth/authorization_code
oauth/refresh_token
oauth/token_exchange
oauth/key
oauth/sign
oauth/deref

The existing light-pingora SpaTokenClient, token handler client credentials code, and security JWKS fetcher should delegate to this shared module. Handler modules still own request-path decisions, cookies, headers, and rejection mapping.

OAuth provider selection should be one reusable resolver:

#![allow(unused)]
fn main() {
pub struct OAuthProviderResolver {
    client: Arc<ClientConfig>,
}

impl OAuthProviderResolver {
    pub fn service_for_path(&self, path: &str) -> Option<&str>;
    pub fn client_credentials_provider(&self, service_id: Option<&str>) -> Result<AuthServerConfig>;
    pub fn key_provider(&self, service_id: Option<&str>) -> Result<AuthServerConfig>;
}
}

Rules:

  • Single-provider mode uses global oauth.token.* defaults.
  • Multi-provider mode is enabled when oauth.multipleAuthServers: true or when relevant serviceIdAuthServers maps are non-empty.
  • Multi-provider mode selects the service id from an explicit request header first, then outbound pathPrefixServices.
  • client_credentials.serviceIdAuthServers[serviceId] selects the token provider.
  • key.serviceIdAuthServers[serviceId] selects the JWKS/key provider.
  • Per-service config inherits unset values from global oauth.token defaults.
  • Path-prefix matching should be boundary-aware in Rust. Java uses startsWith; the Rust implementation can be stricter as an intentional improvement. Exact rule: a prefix matches when the request path equals the prefix or starts with prefix + "/". Therefore /api matches /api and /api/orders, but does not match /api-v2.
  • pathPrefixServices is not an inbound routing table. It maps outbound request paths to service ids only for client-side OAuth provider selection.

Consumer Modules

All modules should consume the same shared config:

ModuleUses
light-runtime/config-serverlight-client tls, request
light-runtime/portal-registrylight-client tls, request
light-pingora/securityoauth.token.key, tls, request, provider resolver
light-pingora/tokenoauth.token.client_credentials, token cache settings, provider resolver
light-pingora/stateless-authauthorization_code, refresh_token, token client
light-pingora/msal-exchangetoken_exchange, token client
light-gateway/proxytls.verifyHostname, PEM mTLS, request timeout, retry, circuit breaker, and pool settings where Pingora supports them
light-agentcontroller/MCP outbound clients
light-deployercontroller/MCP/outbound clients as needed

Reload Behavior

client.yml should be reloadable as a module, but reload must be conservative:

  1. Load and validate the new config into a fresh ClientConfig.
  2. Build new shared client factories and OAuth clients.
  3. Swap the config atomically for future requests.
  4. Clear OAuth token caches because client credentials, scopes, providers, or trust settings may have changed.
  5. Keep old in-flight requests on their existing client instances.
  6. Reject the reload if active modules cannot build required clients from the new config.

Reload atomicity: all runtimes that consume client.yml must be swapped together in the same reload callback. Today, the gateway TokenReloader already rebuilds token_runtime, stateless_auth, and msal_exchange as a unit. This must remain a hard requirement. A reload that updates the client config without also rebuilding dependent runtimes would leave stale TLS or OAuth state in the old runtime instances.

Controller registration is long-lived. Reloading client.yml should not force an immediate portal-registry reconnect. New TLS and request settings should apply to future outbound clients and the next normal controller reconnect, but the active controller WebSocket can remain open.

Validation Rules

Base validation:

  • tls.verifyHostname: false requires explicit trust material unless the transport has a clear dev-only mode.
  • If Rust-native mTLS is configured, both client certificate and client key paths are required.
  • request.connectTimeout and request.timeout must be positive.
  • proxyPort must be 0 to 65535.
  • pathPrefixServices keys must start with /.
  • Secret fields may be empty only when the consuming active module does not need that grant.

OAuth validation should be demand-driven:

  • If token handler is active and enabled, validate client_credentials.
  • If stateless-auth is active, validate authorization_code and refresh_token.
  • If msal-exchange is active, validate token_exchange.
  • If security.yml enables JWKS bootstrap from key service, validate oauth.token.key.
  • If a future sign module is active, validate oauth.sign.
  • If a future deref module is active, validate oauth.deref.

This avoids forcing every service to configure every Java OAuth section.

Validation failure behavior:

  • At startup, validation failures are fatal. The process must exit with a clear error message identifying which active module requires which missing or invalid client config section.
  • On reload, validation failures are non-fatal. The reload is rejected, the old config stays live, and the rejection reason is logged and reported through the module registry reload outcome.

Masking

Mask these fields recursively in registry output:

  • client_secret
  • clientSecret
  • trustStorePass
  • keyStorePass
  • keyPass
  • defaultCertPassword
  • subjectToken
  • access_token
  • refresh_token
  • id_token
  • authorization
  • any field ending in Token whose value is a scalar string (not a nested object, list, or URN-typed field like subjectTokenType or requestedTokenType)
  • any field ending in Secret

Explicit exclusions from suffix matching:

  • subjectTokenType - a URN string, not a secret.
  • requestedTokenType - a URN string, not a secret.

The registry should store only the masked snapshot. It should not store raw config and mask later.

Migration Plan

Phase 0: Deprecation Logging

  • Add a tracing::warn! in light-gateway where it reads resolved_values["client.verifyHostname"] to alert operators that this path is deprecated and will be replaced by runtime_config.client.tls.verify_hostname.
  • This gives operators visibility into the migration before behavior changes.

Phase 1: Unify The Schema

  • Add the light-client crate with the full shared ClientConfig type.
  • Make light-runtime load nested tls.verifyHostname.
  • Keep top-level verifyHostname as a temporary compatibility fallback.
  • Update Rust config templates to include only the canonical nested shape.
  • Add tests proving client.verifyHostname: false reaches config-server, portal-registry, token, security JWKS, SPA auth, and gateway proxy clients.

Phase 2: Move Consumers To Shared Config

  • Replace light-pingora::token::ClientTokenConfig with the light-client shared type or a type alias.
  • Replace gateway direct resolved_values["client.verifyHostname"] lookup with runtime_config.client.tls.verify_hostname.
  • Move JWKS, token, and SPA token HTTP client construction behind the shared client factory.
  • Register one masked light-client/client module instead of separate partial client registry entries.

Phase 3: Shared OAuth Provider Resolver

  • Extract provider selection from the token handler.
  • Support token.key.serviceIdAuthServers and audience.
  • Use the same resolver for token injection and JWT key lookup.
  • Keep Java field names and config-server placeholders.

Phase 4: Java Feature Completion

  • Implemented sign client support in light-client.
  • Implemented deref client support in light-client.
  • Implemented Rust-native PEM mTLS for reqwest clients and Pingora upstreams.
  • Implemented retry, circuit breaker, and pool behavior where the Rust transport supports them.

Open Questions

None at this stage.

Test Plan

Unit tests:

  • Parse the Java client.yml template into the shared Rust config.
  • Parse the current Rust client.yml template into the shared Rust config.
  • Resolve client.verifyHostname into tls.verifyHostname.
  • Accept top-level verifyHostname only as a fallback and prefer nested TLS when both are set.
  • Mask every secret field in the module registry snapshot.
  • Validate provider selection by service id and path prefix.
  • Validate per-service override inheritance for token and key providers.

Runtime tests:

  • Config-server bootstrap uses tls.verifyHostname.
  • Portal-registry controller WebSocket uses tls.verifyHostname.
  • Gateway upstream proxy uses tls.verifyHostname.
  • Token handler, stateless auth, MSAL exchange, and security JWKS all receive the same ClientConfig instance or snapshot.
  • Client reload clears token caches and rejects invalid active grant config.
  • Reload round-trip: verify that reloading from config A to config B swaps the ClientConfig, creates fresh token caches, and that in-flight requests on the old config are not affected. Verify that a reload from valid config to invalid config is rejected and the old config stays live.

Compatibility tests:

  • Reuse representative Java client.yml fixtures for single provider, multiple providers, proxy, token key, sign, and deref sections.
  • Confirm Java-compatible form bodies for authorization_code, client_credentials, refresh_token, and token_exchange.
  • Confirm config-server injected YAML strings and structured YAML maps both deserialize for serviceIdAuthServers and pathPrefixServices.

Embedded Configuration Templates

Status

Initial implementation completed. Rust applications in light-fabric and related portal-service applications keep template configuration files under each app's config directory. Container images may copy those files into /app/config-defaults, then runtime overlays local config, downloaded config-cache, remote values.yml, and environment variables.

That works well for container deployments. It is awkward for native binary deployments on a VM because the operator must copy a full template directory beside the binary even when they only want to provide values.yml, certs, or a small local override.

This design embeds the template files into the Rust binary while keeping the app config directories in source control as the readable template source.

Purpose

Embedded configuration templates should make the Rust deployment model match the Java module model more closely:

  1. The application binary carries its default template files.
  2. Operators provide only overrides, usually values.yml, startup.yml, certs, keys, or environment variables.
  3. Config-server can still return values.yml after bootstrap, plus external files for explicit migration or operational exceptions.
  4. Developers and operators can still inspect the app's config directory in source control to learn supported properties.

The embedded files are defaults. They are not runtime state and should not be written out automatically unless an explicit diagnostic/export command is added later.

Current Model

The current runtime model has these filesystem layers:

LayerExamplePurpose
Default templatesconfig-defaults/server.ymlApp-provided templates copied into the container image
Local configconfig/values.yml, config/startup.ymlOperator overrides and bootstrap inputs
External/cache configconfig-cache/values.ymlFiles downloaded from config-server
Remote valuesconfig-server response bodyRuntime values fetched during bootstrap
Environment variablesCLIENT_VERIFYHOSTNAME=falseLast-mile process overrides during placeholder expansion

For light-fabric runtime applications, LightRuntimeBuilder passes default_config_dir, config_dir, and external_config_dir into light-runtime. load_bootstrap_config() reads bootstrap-time values.yml, startup.yml, and client.yml before remote config-server bootstrap. After remote bootstrap, runtime config loads server.yml, client.yml, portal-registry.yml, and framework/application module files through the same merged configuration path.

Some portal-service apps share the light-runtime path, while standalone apps such as config-server and light-oauth have local helper functions that merge config-defaults and config.

Goals

  • Allow a native binary deployment to start with embedded templates and a small external config/values.yml.
  • Keep apps/<app>/config/*.yml as the source of truth for template content.
  • Keep container deployment behavior compatible with the current /app/config-defaults copy.
  • Preserve the existing overlay order and placeholder expansion behavior.
  • Support bootstrap-time files such as startup.yml and client.yml.
  • Support runtime module files such as handler.yml, proxy.yml, model-provider.yml, provider configs, and product-specific files.
  • Provide one reusable loading abstraction for light-fabric and portal-service instead of app-specific parsing logic.
  • Avoid writing embedded templates to disk during normal startup.

Non-Goals

  • Do not embed secrets, certificates, private keys, trust bundles, static web assets, or downloaded config-server files.
  • Do not remove the source config directories. They remain the reviewable, documented template source.
  • Do not make values.yml mandatory. Apps should keep current defaults where they are already valid.
  • Do not make config-server responsible for delivering template files that are already part of the binary.
  • Do not change the meaning of values.yml placeholders or environment variable expansion.

Proposed Layer Order

The new effective source order should be:

  1. Embedded template file from the binary.
  2. Filesystem default template from config-defaults, if present.
  3. Local operator file from config.
  4. External/cache file from config-cache, when runtime loading supports it.
  5. Remote values.yml payload from config-server.
  6. Environment variables during placeholder resolution.

This keeps existing container images compatible. If config-defaults exists, it can override the embedded template. That gives operators and image builders a transition path and a deliberate escape hatch for patched images.

For native binary deployment, config-defaults is simply absent and the binary falls back to embedded templates.

Structured config files and values.yml should use different overlay semantics:

File typeSemanticsReason
Structured config files such as server.yml, handler.yml, proxy.yml, and model-provider.ymlSource-level override. The highest-priority source that contains the file supplies the whole template.Avoids surprising hybrid files assembled from embedded, image, local, and cache layers. Operators should use values.yml for partial property overrides.
values.ymlKey-level overlay in source order, followed by remote values and environment variables.values.yml is explicitly the property override surface. Partial overlays are expected and useful.

After the structured file source is selected, placeholders in that file are resolved from the merged values map and environment variables.

Embedded Template Representation

include_dir is a possible embedding mechanism. It embeds the entire app config directory at compile time and avoids custom directory-scanning build scripts in every application crate:

#![allow(unused)]
fn main() {
use include_dir::{include_dir, Dir};

pub static EMBEDDED_CONFIG: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/config");
}

The runtime should hide the concrete embedding mechanism behind a small config source abstraction. A typed file representation is still useful as the stable runtime boundary:

#![allow(unused)]
fn main() {
pub struct EmbeddedConfigFile {
    pub name: &'static str,
    pub content: &'static str,
}
}

Application code should pass a flattened static file list into the runtime:

#![allow(unused)]
fn main() {
LightRuntimeBuilder::new(transport)
    .with_embedded_config(embedded_config::FILES)
    .build();
}

include_str! is still acceptable for one or two files, but application main.rs files should not accumulate hand-maintained include_str! lists. include_bytes! is not preferred for YAML templates because configuration templates should be valid UTF-8 before they are parsed.

The initial implementation uses a shared build-time generator instead of adding an external embedding dependency. Each app has a small build.rs that calls config-embed-build, which scans the committed config directory and produces a manifest like this under OUT_DIR:

#![allow(unused)]
fn main() {
pub const FILES: &[config_loader::EmbeddedConfigFile] = &[
    config_loader::EmbeddedConfigFile {
        name: "server.yml",
        content: include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config/server.yml")),
    },
    config_loader::EmbeddedConfigFile {
        name: "startup.yml",
        content: include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config/startup.yml")),
    },
];
}

Build-Time Generation Fallback

The project currently uses the build-time manifest path. Each app uses a shared build.rs helper to scan its config directory and generate the embedded manifest. The generator lives in one reusable crate so apps do not carry duplicated build logic.

The generated manifest should:

  • Include only known text config extensions, initially .yml, .yaml, .json, and .toml.
  • Preserve the file name relative to the app config directory.
  • Emit cargo:rerun-if-changed=config.
  • Fail the build if a template file cannot be read as UTF-8.

Nested config paths are not needed for current app templates, but the manifest should allow names such as oauth/server.yml if a future product needs them.

Runtime API

Add embedded defaults to LightRuntimeBuilder:

#![allow(unused)]
fn main() {
LightRuntimeBuilder::new(transport)
    .with_embedded_config(embedded_config::FILES)
    .with_default_config_dir(DEFAULT_CONFIG_DIR)
    .with_config_dir(CONFIG_DIR)
    .with_external_config_dir(EXTERNAL_CONFIG_DIR)
    .build();
}

RuntimeConfig should carry the embedded source as skipped runtime state, the same way it carries default_config_dir and registries today:

#![allow(unused)]
fn main() {
pub struct RuntimeConfig {
    // existing fields
    #[serde(skip, default)]
    pub embedded_config: &'static [EmbeddedConfigFile],
}
}

The stable contract is lookup by relative file name and iteration for diagnostics or dumping. The concrete representation can remain a static file slice or later move behind a provider abstraction if needed.

The low-level loader should accept named in-memory content as another config source:

#![allow(unused)]
fn main() {
pub enum ConfigSource {
    Embedded { name: &'static str, content: &'static str },
    File(PathBuf),
}
}

ConfigLoader can then parse embedded and filesystem sources with the same YAML/JSON/TOML parser. Structured config loading should select the highest priority source for the requested file. values.yml loading should continue to merge maps in source order.

Bootstrap Behavior

Bootstrap must support embedded templates because this is the path that native deployments need most.

load_bootstrap_values() should merge:

  1. Embedded values.yml, if present.
  2. config-defaults/values.yml, if present.
  3. config/values.yml, if present.

load_bootstrap_config() should load startup.yml and client.yml from:

  1. Embedded templates.
  2. config-defaults.
  3. config.

For startup.yml and client.yml, the highest-priority source that contains the file should be used as the full template. Placeholder resolution still uses the merged bootstrap values.

After bootstrap fetches remote values, load_values_map() should merge embedded values.yml before the existing file and remote layers. This allows remote values to override embedded placeholders exactly as they override copied template files today.

Application Integration

Light-Gateway

light-gateway should be the first light-fabric application to adopt the runtime API because it has the richest template set:

  • bootstrap and server files
  • client and portal registry files
  • handler chain files
  • proxy, resource, MCP, websocket, auth, token, metrics, and rule-related files

After integration, a native gateway deployment can run with the binary plus a small config/values.yml and any required cert/key files.

Light-Agent

light-agent should use the same runtime API for all provider templates. The embedded set should include model-provider.yml, mcp-client.yml, and every provider-specific template such as openai.yml, bedrock.yml, codex.yml, anthropic.yml, and ollama.yml.

Runtime provider selection should still happen after bootstrap. Embedded templates do not mean provider clients are created before config-server values are loaded.

Light-Deployer

light-deployer currently has a separate app-level config load for deployer.yml. It should either move to the shared embedded-source helper or set embedded defaults on LightRuntimeBuilder and use the same merged source logic for its application config.

Portal-Service App

portal-service/apps/portal-service already uses LightRuntimeBuilder, but it loads portal-service.yml before runtime startup to create the database pool. That pre-runtime load should use the same shared embedded-source helper.

The portal-service.yml config remains non-reloadable because dbUrl and hostId feed process-owned state.

Portal-Service Config-Server And Light-OAuth

portal-service/apps/config-server and apps/light-oauth do not bootstrap from config-server. They should still embed their server.yml templates so native deployment does not require a copied config-defaults directory.

Because these apps have local merge helpers today, they should consume a shared config-loader helper that can merge:

  1. Embedded defaults.
  2. Filesystem defaults.
  3. Local config.

This keeps their behavior aligned with light-runtime without requiring them to become runtime-bootstrap applications.

Operator Model

For a native deployment, the recommended layout becomes:

/opt/light-gateway/
  light-gateway
  config/
    values.yml
    startup.yml        # optional, only when values/env defaults are not enough
    cert.pem           # optional external asset
    key.pem            # optional external asset

The operator no longer needs to copy every template file beside the binary. They only provide files that are deployment-specific.

For a container deployment, the current layout continues to work:

/app/light-gateway
/app/config-defaults/*.yml
/config/values.yml
/app/config-cache/values.yml

In the long term, the /app/config-defaults copy can become optional. Keeping it during migration is useful because it lets operators inspect templates inside the image and provides a familiar override layer.

After embedded templates are stable across production deployments, Docker images should deprecate and then remove the unconditional /app/config-defaults copy. Template inspectability should move to explicit dump/print commands rather than extra image layers.

Diagnostics

The runtime should expose enough information to make source precedence clear:

  • Log whether embedded templates were registered for the application.
  • When a required config file is missing, include the searched source names: embedded, config-defaults, config, and config-cache.
  • Module registry snapshots should show the resolved config, not the raw embedded template.
  • Module registry metadata should include config source provenance when available, for example embedded, file:/app/config-defaults/server.yml, or file:/config/server.yml.
  • Normal startup should not write embedded templates to disk.

Native operators should have explicit inspection commands:

light-gateway --print-default-config server.yml
light-gateway --dump-default-configs ./config-defaults

The print command writes one embedded template to stdout. The dump command writes all embedded templates to a target directory so operators can inspect, copy, and customize them.

Controller Server Info Compatibility

Rust services register with the controller, and the controller can call the runtime MCP service-info path to inspect runtime configuration. This behavior must continue to work with embedded templates.

The service-info response should expose resolved runtime configuration, not raw templates. The implementation contract is:

  1. Select the effective structured config source, such as embedded server.yml, filesystem config/server.yml, or cached config-cache server.yml.
  2. Build the merged values map from embedded, filesystem, cached, remote values.yml, and environment variables.
  3. Resolve placeholders in the selected config source.
  4. Deserialize the resolved config into the typed runtime or module config.
  5. Register that typed config in ModuleRegistry.
  6. Return ModuleRegistry component configs from the controller service-info MCP call.

With that flow, the controller still sees every registered config file with defaults and overrides applied. Embedded templates only replace the missing filesystem default-template layer. They should not bypass typed config loading, masking, module registration, reload validation, or service-info reporting.

Source provenance can be added as metadata beside each registered config, but it must not replace the resolved config payload that operators and the controller depend on.

Testing Strategy

Add unit tests at the shared loader boundary:

  • Embedded-only server.yml loads successfully.
  • Local config/server.yml replaces embedded server.yml rather than deep merging with it.
  • config-defaults/server.yml replaces embedded server.yml.
  • config-cache/server.yml replaces local config during runtime loads.
  • Embedded values.yml is overridden by local values.yml.
  • Remote values.yml overrides embedded and filesystem values.
  • Missing required config reports all searched layers.
  • Source provenance is recorded for resolved module configs.
  • --print-default-config and --dump-default-configs expose embedded templates without changing normal startup behavior.
  • Controller service-info output includes resolved values from embedded defaults plus local, cached, remote, and environment overrides.

Add application-level smoke tests for:

  • light-gateway startup with no filesystem server.yml, using embedded templates plus local values.yml.
  • light-agent provider config loading from embedded templates after bootstrap.
  • portal-service/apps/portal-service pre-runtime portal-service.yml load from embedded templates.
  • portal-service/apps/config-server standalone server.yml load from embedded templates.

Migration Plan

  1. Add embedded source support to config-loader and light-runtime.
  2. Add shared build-time template embedding for light-gateway.
  3. Wire light-gateway to pass embedded templates to LightRuntimeBuilder.
  4. Keep Docker config-defaults copies unchanged and verify container parity.
  5. Add native startup tests that run without a copied template directory.
  6. Roll the same pattern to light-agent and light-deployer.
  7. Add the shared embedded-source helper to portal-service and migrate portal-service, config-server, and light-oauth.
  8. Add print and dump commands for embedded templates.
  9. After several releases, deprecate Docker config-defaults copies and rely on embedded defaults plus explicit dump commands for inspectability.

Risks And Mitigations

RiskMitigation
Embedded templates drift from source templatesEmbed the committed config/ directory directly with include_dir, or generate a manifest from that directory at build time
Operators cannot inspect templates in native deploymentKeep source templates in repo and add print/dump commands for embedded templates
Docker behavior changes unexpectedlyKeep config-defaults above embedded defaults during migration
Config-server remote values stop overriding defaultsPreserve remote values as the highest non-env value layer
Apps duplicate merge logicMove embedded-source merging into shared loader/runtime helpers
Secrets accidentally embeddedEmbed only committed template files and keep secrets in values, env, or external files
Structured config becomes hard to reason aboutUse source-level override for config files and reserve key-level merging for values.yml

Resolved Decisions

  • Native operators should get --print-default-config <name> and --dump-default-configs <directory> commands.
  • Module registry should expose resolved config first, with source provenance as metadata when available.
  • Docker images should keep /app/config-defaults during migration, then deprecate it once embedded templates and dump commands are stable.
  • Rust deployments should standardize on embedded templates plus remote values.yml. Config-server should not normally deliver full template files for Rust services.

Decision Summary

Embed app config/*.yml templates into the binary as the lowest-priority default configuration source. The initial implementation uses a shared build-time manifest generator, with include_dir remaining a possible future implementation detail. Keep the existing source config directories for documentation and build input. Use source-level override for structured config files and key-level overlay for values.yml. Preserve current filesystem and remote value layers so container deployments keep working, while native deployments can run with only the binary and a small deployment-specific config directory.

Handler Chain

Status: Phases 1, 2, 3, 4, 5, 6, 7, and 8 implemented; further transport phases proposed

Purpose

Light Fabric needs a light-pingora handler chain for the Rust light-gateway product.

The first implementation should focus on light-pingora, not a generic cross-framework abstraction. A Pingora-first design is simpler and matches the gateway family of use cases: gateway, sidecar, proxy server, proxy client, load balancer, and BFF.

The deployment model should use one light-gateway binary. Different runtime behaviors should come from product-specific configuration managed in light-portal and delivered by config-server. A BFF deployment, a sidecar deployment, and a load-balancer deployment can therefore run the same binary with different handler.yml, traffic/resource config, and handler-specific config files.

The design should preserve the useful part of light-4j handler.yml: ordered configuration of cross-cutting request and response concerns. It should not copy the Java reflection model, mutable next handler pattern, or class-name-based configuration.

Goals

  • Add middleware handler-chain support to frameworks/light-pingora.
  • Use one apps/light-gateway binary for the Pingora gateway family.
  • Keep handler.yml as the chain and ordering configuration.
  • Let light-portal manage product-specific configuration and config-server deliver it at startup.
  • Support virtual hosts selected from the HTTP Host header.
  • Serve static SPA content directly from Pingora.
  • Proxy API, BFF, sidecar, and balancer routes to upstream services.
  • Use stable handler IDs instead of Rust type names.
  • Use explicit handler registration. Do not require inventory.
  • Integrate loaded handler and traffic/resource config with ModuleRegistry.
  • Keep the design compatible with runtime config reload.

Non-Goals

  • Do not build a transport-neutral light-handler crate in the first phase.
  • Do not add an Axum/Tower adapter in the first phase.
  • Do not create separate binaries for gateway, sidecar, proxy server, proxy client, load balancer, and BFF in the first phase.
  • Do not dynamically load handler crates from handler.yml.
  • Do not use Java-style reflection or string-to-type construction.
  • Do not make Rust type names part of the public config contract.
  • Do not support multi-certificate TLS SNI selection in the first phase.
  • Do not implement streaming static-file delivery in the first phase unless it is needed for a concrete SPA asset size problem.

Current Shape

light-pingora already adapts a Pingora proxy into the shared runtime:

#![allow(unused)]
fn main() {
pub trait PingoraApp: Send + Sync + 'static {
    type Proxy: ProxyHttp + Send + Sync + 'static;

    fn proxy(&self, config: &RuntimeConfig) -> Result<Self::Proxy, RuntimeError>;
}
}

PingoraTransport calls app.proxy(config) and passes the result to pingora::proxy::http_proxy_service(...).

Pingora's ProxyHttp lifecycle already has the hooks needed for the gateway family:

  • request_filter: validate, authenticate, rate limit, or directly write a local response such as a static file
  • upstream_peer: select the upstream for proxy routes
  • upstream_request_filter: mutate the request sent to upstream
  • upstream_response_filter: mutate the upstream response before caching
  • response_filter: mutate the response sent to the browser

The current light-gateway already writes /health directly from request_filter. Static SPA serving can use the same pattern.

Product Model

The Rust light-gateway binary should link all built-in Pingora gateway capabilities:

  • virtual host routing
  • static SPA serving
  • reverse proxy routing
  • outbound proxy behavior
  • upstream load balancing
  • sidecar token/header behavior
  • shared middleware handlers

The active behavior is selected by configuration, not by compiling a different binary. The six product personas are configuration profiles:

  • gateway
  • sidecar
  • proxy-server
  • proxy-client
  • balancer
  • bff

These profiles can be represented in light-portal as product-specific config sets. At runtime, light-gateway only sees the resolved files returned by config-server. The binary should not need to know whether the files came from a portal product template, an environment override, or a local fallback.

This keeps deployment simple:

  • one binary
  • one container image
  • one light-pingora framework
  • different behavior by remote config

The tradeoff is that config validation must be strong. A product config should not silently start in a different mode if a static root, virtual host, upstream, or chain is wrong.

High-Level Flow

The Pingora gateway request flow should be:

request
  -> match handler.yml paths by path and method
  -> fall back to handler.yml defaultHandlers when no path matches
  -> run request handlers
  -> proxy fixed upstream, route by service_id/service_url, serve static file,
     or return error
  -> run response handlers
  -> response

For static handlers such as virtual-host or path-resource, request_filter writes the response and returns Ok(true) so Pingora does not proxy the request.

For proxy or router handlers, request_filter stores the selected upstream decision in the per-request context and returns Ok(false). upstream_peer and upstream_request_filter then use that context to connect to the right upstream and set headers.

Crate Layout

Keep the first implementation inside frameworks/light-pingora.

Suggested modules:

frameworks/light-pingora/src/
  lib.rs
  handler.rs
  correlation.rs
  cors.rs
  metrics.rs
  proxy.rs
  resource.rs
  router.rs
  service.rs
  token.rs

Responsibilities:

  • parse and validate handler.yml
  • parse handler.yaml as a compatibility fallback
  • parse and validate proxy.yml, router.yml, path-resource.yml, and virtual-host.yml
  • build explicit handler registry
  • resolve handler chains
  • match handler paths and fallback handlers
  • capture Java-style {name} path-template variables
  • load active handler-specific config files
  • serve static SPA content
  • select fixed proxy upstreams from proxy.yml
  • select dynamic sidecar/router upstreams from router.yml
  • resolve sidecar service_id values from pathPrefixService.yml
  • retrieve and cache OAuth client-credentials tokens from client.yml
  • expose module-registry entries for active handler and traffic/resource config

This keeps the first implementation close to the Pingora lifecycle and avoids premature abstractions for Axum.

If Axum later needs the same handler semantics, extract the framework-neutral parts after the Pingora implementation has stabilized.

Configuration Split

Use handler.yml for the Java-compatible handler middleware contract: handler declarations, reusable chains, path-to-chain mappings, and fallback handlers.

Use Java-compatible product-specific config files for traffic and static resource behavior:

  • proxy.yml: fixed inbound reverse proxy targets for gateway, proxy server, balancer, and simple BFF API forwarding.
  • router.yml: dynamic outbound routing by service_id or service_url, mainly for sidecar-style deployments.
  • path-resource.yml or path-resource.yaml: a single static resource mount.
  • virtual-host.yml or virtual-host.yaml: host-based static resource mounts for BFF/SPA deployments.

The product profile selected in light-portal decides which of these files are included and which handlers are active in handler.yml. The Rust binary should not require a separate gateway.yml to duplicate these existing contracts.

Handler-specific files such as correlation.yml, cors.yml, metrics.yml, header.yml, security.yml, apikey.yml, basic-auth.yml, unified-security.yml, and limit.yml stay separate. They are loaded only when the corresponding handler is active in the resolved path/default execution model. Phase 3 implements this active loading for correlation.yml, cors.yml, and metrics.yml. Phase 4 extends the same active-loading and reload model to header.yml, security.yml, apikey.yml, basic-auth.yml, unified-security.yml, and limit.yml.

Remote Config Source

light-gateway starts with enough local bootstrap configuration to contact config-server. The existing Light Fabric runtime then resolves local and remote configuration before light-pingora builds the runtime handler/resource/proxy model.

Startup flow:

  1. load local bootstrap files from the configured config directory
  2. contact config-server using the configured service identity, environment, and authorization
  3. download remote product configuration managed by light-portal
  4. merge remote config with local fallback config
  5. load handler.yml, applicable traffic/resource config files, and active handler-specific config files
  6. validate the complete route and handler model
  7. bind Pingora listeners
  8. register the runtime instance with the controller

The remote product config should include:

  • handler.yml
  • proxy.yml for fixed inbound proxy profiles
  • router.yml for sidecar/router profiles
  • path-resource.yml or virtual-host.yml for static/BFF profiles
  • active handler config files
  • TLS, trust, or client files required by the runtime
  • optional product-specific static file references or mount paths

handler.yml decides which linked handlers are active. A handler that is registered in the binary but not referenced by any configured paths entry or defaultHandlers chain should not be instantiated, should not load its config file, and should never run.

Handler Config

Example handler.yml:

enabled: ${handler.enabled:true}
reportHandlerDuration: ${handler.reportHandlerDuration:false}
handlerMetricsLogLevel: ${handler.handlerMetricsLogLevel:DEBUG}
basePath: ${handler.basePath:/}
handlers: ${handler.handlers:[]}
chains: ${handler.chains:{}}
paths: ${handler.paths:[]}
defaultHandlers: ${handler.defaultHandlers:[]}

The config-server values managed by light-portal provide the concrete arrays and maps:

handler.handlers:
  - correlation
  - headers
  - metrics
  - cors
  - jwt
  - rate-limit

handler.chains:
  spa:
    exec:
      - correlation
      - headers
      - metrics
      - cors
  api:
    exec:
      - correlation
      - headers
      - metrics
      - cors
      - jwt
      - rate-limit
  public:
    exec:
      - correlation
      - headers
      - metrics

handler.paths:
  - path: /api/
    method: GET
    exec:
      - api

handler.defaultHandlers:
  - public

This keeps the same top-level handler.yml contract as the Java framework: enabled, reportHandlerDuration, handlerMetricsLogLevel, basePath, handlers, chains, paths, and defaultHandlers.

The Rust implementation also accepts the Java extension fields additionalHandlers, additionalChains, and additionalPaths. They are merged into the effective handler model before validation.

Unlike Java, the Rust handlers list uses stable short handler IDs. It does not use fully qualified class names, and it does not need @alias because the IDs are already short and stable.

handler.yml is the preferred Rust file name. handler.yaml is accepted as a compatibility fallback because some Java modules and templates use that suffix.

Fixed Proxy Config

proxy.yml should keep the Java inbound reverse-proxy contract. It is used when the deployment has a known set of target upstream URIs.

enabled: ${proxy.enabled:true}
http2Enabled: ${proxy.http2Enabled:false}
hosts: ${proxy.hosts:http://localhost:8080}
connectionsPerThread: ${proxy.connectionsPerThread:20}
maxRequestTime: ${proxy.maxRequestTime:1000}
rewriteHostHeader: ${proxy.rewriteHostHeader:true}
reuseXForwarded: ${proxy.reuseXForwarded:false}
maxConnectionRetries: ${proxy.maxConnectionRetries:3}
maxQueueSize: ${proxy.maxQueueSize:0}
forwardJwtClaims: ${proxy.forwardJwtClaims:false}
metricsInjection: ${proxy.metricsInjection:false}
metricsName: ${proxy.metricsName:proxy-response}

The Rust implementation should parse proxy.hosts as one or more comma separated http:// or https:// targets and select a target with round-robin load balancing. It should preserve rewriteHostHeader, reuseXForwarded, request timeout, retry, and queue settings where Pingora exposes equivalent behavior.

Router Config

router.yml should keep the Java outbound router contract. This is primarily for the sidecar pattern, where earlier handlers resolve service_id, service_url, tokens, and discovery context before the router connects to the downstream service.

http2Enabled: ${router.http2Enabled:true}
httpsEnabled: ${router.httpsEnabled:true}
maxRequestTime: ${router.maxRequestTime:1000}
pathPrefixMaxRequestTime: ${router.pathPrefixMaxRequestTime:{}}
connectionsPerThread: ${router.connectionsPerThread:10}
softMaxConnectionsPerThread: ${router.softMaxConnectionsPerThread:5}
maxQueueSize: ${router.maxQueueSize:0}
rewriteHostHeader: ${router.rewriteHostHeader:true}
reuseXForwarded: ${router.reuseXForwarded:false}
maxConnectionRetries: ${router.maxConnectionRetries:3}
preResolveFQDN2IP: ${router.preResolveFQDN2IP:false}
hostWhitelist: ${router.hostWhitelist:[]}
serviceIdQueryParameter: ${router.serviceIdQueryParameter:false}
urlRewriteRules: ${router.urlRewriteRules:[]}
methodRewriteRules: ${router.methodRewriteRules:[]}
queryParamRewriteRules: ${router.queryParamRewriteRules:{}}
headerRewriteRules: ${router.headerRewriteRules:{}}
metricsInjection: ${router.metricsInjection:false}
metricsName: ${router.metricsName:router-response}

The Java router chooses the target from service_url first, guarded by hostWhitelist, or from service_id plus optional env_tag through service discovery.

Phase 5 implements the Pingora router execution path and keeps the Java configuration shape. The active router handler loads and registers router.yml, selects direct service_url targets after hostWhitelist validation, supports serviceIdQueryParameter, and removes router selection headers before forwarding upstream. It also applies Java-style URL, method, query-parameter, and header rewrite rules.

Phase 6 adds the sidecar path-prefix and token flow. Phase 7 adds controller-backed service_id discovery while keeping the same request contract. For local/static deployments, use direct-registry.yml as the fallback service map.

Sidecar Path Prefix And Token Config

pathPrefixService.yml maps request path prefixes to downstream service IDs. The handler writes service_id only when the request does not already provide one.

enabled: ${pathPrefixService.enabled:true}
mapping: ${pathPrefixService.mapping:{}}

Rust intentionally selects the longest path-boundary prefix. This avoids map iteration ambiguity when prefixes overlap and prevents /v1/address from matching /v1/address2.

token.yml gates when the token handler should run:

enabled: ${token.enabled:false}
appliedPathPrefixes: ${token.appliedPathPrefixes:}

The token handler reads the Java-compatible client credentials section from client.yml:

tls:
  verifyHostname: ${client.verifyHostname:true}
oauth:
  multipleAuthServers: ${client.multipleAuthServers:false}
  token:
    cache:
      capacity: ${client.tokenCacheCapacity:200}
    tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
    server_url: ${client.tokenServerUrl:}
    serviceId: ${client.tokenServiceId:com.networknt.oauth2-token-1.0.0}
    proxyHost: ${client.tokenProxyHost:}
    proxyPort: ${client.tokenProxyPort:}
    enableHttp2: ${client.tokenEnableHttp2:true}
    client_credentials:
      uri: ${client.tokenCcUri:/oauth2/token}
      client_id: ${client.tokenCcClientId:}
      client_secret: ${client.tokenCcClientSecret:}
      scope: ${client.tokenCcScope:}
      serviceIdAuthServers: ${client.tokenCcServiceIdAuthServers:}
pathPrefixServices: ${client.pathPrefixServices:}
request:
  connectTimeout: ${client.connectTimeout:2000}
  timeout: ${client.timeout:3000}
  enableHttp2: ${client.enableHttp2:true}

In single-auth-server mode, the handler uses the configured token server and client credentials for all matched paths. In multipleAuthServers mode, it uses service_id or pathPrefixServices to select client_credentials.serviceIdAuthServers[service_id].

The token request follows the Java request shape:

  • POST to server_url + uri
  • Content-Type: application/x-www-form-urlencoded
  • Accept: application/json
  • HTTP Basic authentication with client_id:client_secret
  • form fields grant_type=client_credentials and optional space-joined scope

The injected header follows the Java gateway rule:

  • if the inbound request has no Authorization, inject Authorization: Bearer <token>
  • if the inbound request already has Authorization, inject X-Scope-Token: Bearer <token>

The Rust cache is local to the gateway process and is registered as light-pingora/token-cache when a runtime cache registry is available. Cache summaries expose key and expiry metadata but never expose bearer token values. Tokens are refreshed synchronously inside the configured renew-before-expiry window. Async background renewal can be added later if blocking refresh latency becomes visible.

When server_url is not configured, phase 7 discovers the token service from serviceId through the runtime portal-registry client. This requires server.enableRegistry and a live controller registration. A disconnected registry client returns a clear configuration/runtime error instead of silently falling back to an unknown token endpoint.

Static Resource Config

For a single static site, keep path-resource.yml:

path: ${path-resource.path:/public}
base: ${path-resource.base:/opt/light-4j/public}
prefix: ${path-resource.prefix:true}
transferMinSize: ${path-resource.transferMinSize:1024}
directoryListingEnabled: ${path-resource.directoryListingEnabled:false}

For host-based BFF/static sites, keep virtual-host.yml:

hosts: ${virtual-host.hosts:[]}

Example config-server values:

virtual-host.hosts:
  - domain: local.localhost
    path: /
    base: /lightapi/dist
    transferMinSize: 10245760
    directoryListingEnabled: false
  - domain: signin.localhost
    path: /
    base: /signin/dist
    transferMinSize: 10245760
    directoryListingEnabled: false

Rust should preserve the Java domain, path, base, transferMinSize, and directoryListingEnabled fields. It should also add the Rust improvement for SPA fallback: when a static virtual host cannot find a requested browser route and the path does not look like an asset, it should serve index.html from the matched static root.

BFF Wiring Example

The Java BFF config in portal-config-loc/all-in-lt/light-gateway uses handler.paths to send API routes through the default chain, which includes path-prefix service resolution, token handling, and the router. It then uses:

handler.defaultHandlers:
  - cors
  - virtual

That means unmatched browser routes fall through to CORS plus virtual-host static serving. Rust should keep this pattern: handler.yml decides whether a request goes to proxy/router/static handling, based on paths and fallback handlers.

Other product personas use different config file combinations. A BFF commonly uses handler.yml, router.yml, path-prefix/token configs, and virtual-host.yml. A simple proxy or balancer can use handler.yml and proxy.yml. A sidecar uses handler.yml, router.yml, token/cache config, registry/discovery config, and usually no static resource config.

Phase 3 Handler Config

Phase 3 implements the first three Java-compatible cross-cutting handlers.

correlation.yml:

enabled: ${correlation.enabled:true}
autogenCorrelationID: ${correlation.autogenCorrelationID:true}
correlationMdcField: ${correlation.correlationMdcField:cId}
traceabilityMdcField: ${correlation.traceabilityMdcField:tId}

The Rust handler reads X-Correlation-Id and X-Traceability-Id, generates a Java-compatible URL-safe UUID value when correlation is missing, passes the correlation ID to the upstream request, and echoes X-Traceability-Id on the response. It stores the values in the Pingora request context instead of MDC.

cors.yml:

enabled: ${cors.enabled:true}
allowedOrigins: ${cors.allowedOrigins:}
allowedMethods: ${cors.allowedMethods:}
pathPrefixAllowed: ${cors.pathPrefixAllowed:}

The Rust handler accepts the same list/string forms as Java, supports pathPrefixAllowed, short-circuits preflight OPTIONS, rejects disallowed origins with 403, and adds the CORS response headers before static or proxied responses are sent. Rust intentionally uses longest-prefix selection for pathPrefixAllowed so overlapping prefixes are deterministic.

metrics.yml:

enabled: ${metrics.enabled:true}
enableJVMMonitor: ${metrics.enableJVMMonitor:false}
serverProtocol: ${metrics.serverProtocol:http}
serverHost: ${metrics.serverHost:localhost}
serverPath: ${metrics.serverPath:/apm/metricFeed}
serverPort: ${metrics.serverPort:8086}
serverName: ${metrics.serverName:metrics}
serverUser: ${metrics.serverUser:admin}
serverPass: ${metrics.serverPass:admin}
reportInMinutes: ${metrics.reportInMinutes:1}
productName: ${metrics.productName:http-sidecar}
sendScopeClientId: ${metrics.sendScopeClientId:false}
sendCallerId: ${metrics.sendCallerId:false}
sendIssuer: ${metrics.sendIssuer:false}
issuerRegex: ${metrics.issuerRegex:}

Phase 3 parses and registers this config with serverPass masked, records request counts and status classes in memory, and logs request metrics with the matched endpoint and correlation ID. enableJVMMonitor is parsed for config compatibility but is not applicable to Rust. External Influx/APM reporters are deferred until the metrics sink decision is made.

Phase 4 Handler Config

Phase 4 implements the security-oriented Java-compatible handlers that fit the Pingora request metadata model.

header.yml:

enabled: ${header.enabled:false}
request:
  remove: ${header.request.remove:}
  update: ${header.request.update:}
response:
  remove: ${header.response.remove:}
  update: ${header.response.update:}
pathPrefixHeader: ${header.pathPrefixHeader:}

The Rust handler applies request header remove/update rules before proxying and response header remove/update rules before static or proxied responses are sent. Rust intentionally uses longest-prefix selection for pathPrefixHeader so overlapping prefixes are deterministic.

apikey.yml:

enabled: ${apikey.enabled:true}
hashEnabled: ${apikey.hashEnabled:false}
pathPrefixAuths: ${apikey.pathPrefixAuths:[]}

The Rust handler follows the Java rule that no matching path prefix means the handler passes the request. A matching rule validates the configured header against either a plain API key or the Java iterations:saltHex:hashHex PBKDF2-HMAC-SHA1 hash format.

basic-auth.yml:

enabled: ${basic.enabled:false}
enableAD: ${basic.enableAD:true}
allowAnonymous: ${basic.allowAnonymous:false}
allowBearerToken: ${basic.allowBearerToken:false}
users: ${basic.users:[]}

The Rust handler supports configured local users, anonymous path users, and the Java-compatible bearer pass-through mode. LDAP/AD authentication is parsed for configuration compatibility but is not implemented in phase 4.

security.yml:

enableVerifyJwt: ${security.enableVerifyJwt:true}
ignoreJwtExpiry: ${security.ignoreJwtExpiry:false}
enableH2c: ${security.enableH2c:false}
enableMockJwt: ${security.enableMockJwt:false}
jwt:
  clockSkewInSeconds: ${security.jwt.clockSkewInSeconds:60}
skipPathPrefixes: ${security.skipPathPrefixes:[]}
passThroughClaims: ${security.passThroughClaims:{}}

The Rust handler verifies Bearer JWTs with configured JWKs, honors kid when present, supports RSA and EC algorithms handled by the Rust JWT library, applies clock skew and optional expiry bypass, caches decoded claims, and forwards configured pass-through claims as request headers. Dynamic JWK key service bootstrap and SWT/SJWT verification are deferred until the runtime has the discovery and key-service client surface needed by those flows.

unified-security.yml:

enabled: ${unified-security.enabled:true}
anonymousPrefixes: ${unified-security.anonymousPrefixes:[]}
pathPrefixAuths: ${unified-security.pathPrefixAuths:[]}

The Rust handler supports Java-style path-prefix selection across Basic, JWT, and API-key authentication. Anonymous prefixes bypass authentication. SWT/SJWT rules return a clear not-implemented response until the discovery-backed key flow is added.

limit.yml:

enabled: ${limit.enabled:false}
concurrentRequest: ${limit.concurrentRequest:0}
queueSize: ${limit.queueSize:0}
errorCode: ${limit.errorCode:429}
rateLimit: ${limit.rateLimit:}
headersAlwaysSet: ${limit.headersAlwaysSet:false}
key: ${limit.key:server}
server: ${limit.server:{}}
address: ${limit.address:{}}
client: ${limit.client:{}}
user: ${limit.user:{}}

The Rust handler implements in-memory request rate limiting by server, client address, JWT client ID, or JWT user ID. It emits X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After when a request is rejected, and it can always emit the rate-limit headers when headersAlwaysSet is enabled. Cluster-wide distributed counters are deferred until there is a concrete gateway clustering requirement.

Handler Registry

Use explicit registration.

#![allow(unused)]
fn main() {
let handlers = PingoraHandlerRegistry::new()
    .register(correlation::descriptor())
    .register(headers::descriptor())
    .register(metrics::descriptor())
    .register(cors::descriptor())
    .register(jwt::descriptor())
    .register(rate_limit::descriptor());
}

No inventory is needed for the first version. Explicit registration is deterministic, testable, and makes the compiled-in handler set clear from the service binary.

The light-gateway binary can register every built-in handler it supports. Registration only makes a handler available. Activation is controlled by handler.yml.

Build the active handler set lazily:

  1. parse handler.yml
  2. resolve paths and defaultHandlers
  3. expand any referenced chains
  4. compute the set of referenced handler IDs
  5. instantiate only referenced handlers
  6. load config only for referenced handlers

This allows one binary to support gateway, sidecar, proxy, balancer, and BFF profiles without requiring unused handler config files.

The registry maps stable config IDs to factories:

#![allow(unused)]
fn main() {
pub struct PingoraHandlerDescriptor {
    pub id: &'static str,
    pub kind: PingoraHandlerKind,
    pub factory: PingoraHandlerFactory,
}
}

Suggested first handler IDs:

  • correlation
  • headers
  • metrics
  • cors
  • jwt
  • api-key
  • basic-auth
  • rate-limit
  • request-size-limit

Trace headers should be handled by correlation; there should not be a separate traceability handler.

Handler API

Use Pingora phases directly. Avoid a generic exchange abstraction until another framework needs it.

The current implementation keeps PingoraHandler as a descriptor/factory surface and executes the built-in phase 3 handlers from light-gateway's Pingora lifecycle. This keeps the first implementation straightforward:

  • request_filter resolves the configured chain and runs request-stage handlers in order.
  • A request-stage handler can continue, short-circuit with a local response, or select a terminal action such as proxy/static/health.
  • upstream_request_filter applies upstream request mutations such as generated correlation IDs.
  • response_filter applies response-stage headers and records proxied response metrics.
  • Static responses call the same response decoration and metrics code before writing the local response.

Once security/rate-limit handlers are added, this can be lifted into a richer trait with request/upstream/response hooks if the duplication becomes real. It is intentionally not generalized before the Pingora behavior stabilizes.

Response handlers should run before both static and proxied responses are sent. For proxied responses, this maps to Pingora response_filter. For static responses, the static-file renderer calls the same response handler chain before writing the local response.

Request Context

The per-request context should carry route decisions across Pingora phases.

#![allow(unused)]
fn main() {
pub struct GatewayRequestContext {
    pub upstream: Option<ProxyTarget>,
    pub endpoint: String,
    pub method: String,
    pub path_params: BTreeMap<String, String>,
    pub correlation: CorrelationState,
    pub cors: Option<CorsResponseHeaders>,
    pub metrics_enabled: bool,
}
}

The context is created by ProxyHttp::new_ctx() and populated in request_filter.

upstream_peer should only select an upstream after a proxy or router handler has selected one. If no upstream is selected for a proxied request, the implementation should return a clear configuration error rather than silently falling back.

Virtual Hosts

Virtual-host static serving should use the HTTP Host header.

Host normalization rules:

  • lowercase the host
  • strip the port when present
  • reject empty or invalid hosts unless a default virtual host is configured
  • exact host match first
  • wildcard match such as *.example.com after exact hosts, with the longest matching suffix winning

HTTP host routing is enough for the first implementation.

TLS certificate selection by SNI is separate. The current light-pingora transport uses one Rustls TLS setting for the listener, so the first production options are:

  • terminate TLS at ingress or a load balancer
  • use a wildcard certificate
  • use one certificate with all required SANs

Phase 8 evaluated dynamic multi-cert SNI selection. The current light-pingora build uses Pingora's Rustls listener, and Pingora 0.8 Rustls TLS settings do not support certificate callbacks. For now the production options remain terminating TLS before light-gateway, using a wildcard certificate, or using one certificate with all required SANs. Native multi-cert SNI can be added only after moving to a Pingora TLS backend/version that supports server certificate callbacks or certificate resolution through Rustls.

Static SPA Rendering

Static SPA rendering should be part of the Pingora resource engine, not a generic middleware handler. It is enabled by path-resource.yml or virtual-host.yml, typically for BFF profiles.

Rules for the first implementation:

  • support GET and HEAD
  • return 405 for unsupported methods on static routes
  • canonicalize requested paths under the configured static root
  • reject path traversal
  • do not serve files outside the static root
  • deny dotfiles by default
  • do not list directories
  • serve index.html for the root path
  • support SPA fallback to index.html for non-asset routes
  • infer Content-Type from file extension
  • set Cache-Control: no-cache for index.html
  • set long immutable cache headers for hashed assets
  • allow static route prefixes to be bypassed by API routes such as /api/, /oauth/, /mcp/, or /ws/

Recommended cache behavior:

index.html                 Cache-Control: no-cache
*.js, *.css with hash       Cache-Control: public, max-age=31536000, immutable
images/fonts with hash      Cache-Control: public, max-age=31536000, immutable
other assets                Cache-Control: public, max-age=3600

Phase 8 keeps small static files on the simple read-then-write path and streams files whose size is greater than or equal to the configured transferMinSize. Static responses include ETag and Last-Modified, honor If-None-Match and If-Modified-Since, and return 304 without a response body when the browser cache is current.

Proxy And Router Behavior

proxy.yml selects from configured upstream URIs. This is the simpler inbound reverse-proxy case and should be implemented before dynamic sidecar routing.

Fixed proxy target behavior:

  • parse comma-separated proxy.hosts
  • support http:// and https://
  • duplicate a single host internally if retry/load-balancer behavior needs at least two entries
  • select upstream with round-robin
  • apply timeout, retry, queue, and host-forwarding settings where Pingora supports them

router.yml selects from request metadata. Phase 5 implements direct service_url targets, host whitelist enforcement, and rewrite behavior. Phase 7 adds controller-backed service_id lookup through the runtime portal-registry client with direct-registry.yml as the static fallback.

Router target behavior:

  • prefer service_url when present and allowed by router.hostWhitelist
  • otherwise use service_id plus optional env_tag
  • optionally allow service_id from the query string when serviceIdQueryParameter is true
  • resolve service_id from controller discovery when the portal-registry client is connected
  • fall back to direct-registry.directUrls for local/static deployments or controller lookup failures
  • support URL, method, query-parameter, and header rewrite rules
  • remove service_url and service_id headers before forwarding

upstream_peer creates the HttpPeer from the selected upstream:

  • address
  • TLS enabled
  • SNI
  • optional host header

upstream_request_filter should set or override upstream headers such as:

  • Host
  • X-Forwarded-For
  • X-Forwarded-Proto
  • X-Forwarded-Host

It must also remove client-supplied internal trust markers such as X-Light-Gateway. The generic gateway does not synthesize a Portal-specific identity marker; deployments that require authenticated upstream identity must use a mutually authenticated transport or equivalent infrastructure control.

Handler-specific upstream mutations should also run from this phase.

Chain Resolution

Startup should validate handler and selected traffic/resource configuration before binding listeners.

Validation rules:

  • every handler ID in handler.yml must exist in the explicit registry
  • every chain item must resolve to a registered handler or another chain
  • recursive chain references are invalid
  • every handler.paths entry must reference existing chains or handlers
  • every handler.defaultHandlers entry must reference existing chains or handlers
  • proxy.yml hosts must be valid http:// or https:// URIs when the proxy handler is active
  • router.yml rewrite rules must be parseable when the router handler is active
  • every static virtual host must have a static root
  • static roots must be absolute or resolved relative to a configured base
  • duplicate exact virtual hosts are invalid
  • duplicate handler IDs in the registry are invalid

The resolved model should be immutable and cheap to read:

#![allow(unused)]
fn main() {
pub struct GatewayRuntimeModel {
    pub virtual_hosts: BTreeMap<String, Arc<VirtualHost>>,
    pub default_host: Option<Arc<VirtualHost>>,
    pub chains: BTreeMap<String, Arc<ResolvedHandlerChain>>,
    pub proxy_targets: Vec<Arc<ProxyTarget>>,
}
}

Config reload should continue to swap loaded models atomically. In-flight requests should keep using the handler/resource/proxy/router model they already selected.

Runtime Integration

light-runtime remains responsible for bootstrap, config loading, lifecycle, controller registration, and module registry. light-pingora should load its Pingora-specific handler, traffic, and resource config through the existing runtime config loader.

Module IDs:

  • light-pingora/handler
  • light-pingora/proxy
  • light-pingora/router
  • light-pingora/path-prefix-service
  • light-pingora/token
  • light-client/client
  • light-pingora/path-resource
  • light-pingora/virtual-host
  • light-pingora/correlation
  • light-pingora/cors
  • light-pingora/metrics
  • light-pingora/header
  • light-pingora/security
  • light-pingora/apikey
  • light-pingora/basic-auth
  • light-pingora/unified-security
  • light-pingora/limit

The module registry should expose:

  • handler config snapshot, masked
  • proxy, router, path-resource, and virtual-host config snapshots, masked
  • active handler IDs
  • active chains
  • active virtual hosts
  • active proxy/router/static capabilities
  • reloadable status

The implemented phases use the existing ReloadableModule pattern for active handler, proxy, router, resource, virtual-host, path-prefix service, token, and handler-specific config files. Phase 7 exposes a capabilities summary from get_service_info, including active modules, traffic capabilities, active handlers, chain names, path mappings, default handlers, virtual hosts, and path-resource config.

Suitable First Handlers

Start with handlers that map cleanly to Pingora request and response metadata:

  • correlation ID and trace headers
  • response headers
  • metrics
  • CORS
  • JWT verification
  • API key verification
  • basic auth
  • request size limit from headers
  • simple rate limiting by principal, IP, host, or route

Defer handlers that require deeper body handling:

  • request decompression
  • response compression policy beyond Pingora modules
  • request body sanitizer
  • generic body parser
  • WebSocket message handlers

Error Model

Handlers and proxy/resource selection should return structured errors that render consistently.

#![allow(unused)]
fn main() {
pub struct HandlerError {
    pub status: u16,
    pub code: Cow<'static, str>,
    pub message: Cow<'static, str>,
    pub metadata: serde_json::Value,
}
}

Security handlers should avoid returning sensitive validation details to the browser. Detailed diagnostics should go to logs with correlation IDs.

Common gateway errors:

  • unknown host: 404
  • no matching handler path or static resource: 404
  • unsupported method for static route: 405
  • static file outside root: 403
  • missing upstream: startup validation error
  • auth failure: 401 or 403
  • rate limit: 429

Testing Strategy

Unit tests in light-pingora:

  • build active handler set from referenced paths and defaultHandlers
  • ignore registered but unreferenced handlers
  • do not require config files for unreferenced handlers
  • parse valid handler.yml
  • reject unknown handler IDs
  • reject recursive chains
  • resolve path/default handler chains in order
  • parse handler.yaml fallback
  • merge additionalHandlers, additionalChains, and additionalPaths
  • capture path-template variables
  • parse CORS list/string and path-prefix config
  • classify metrics status codes
  • normalize host names and strip ports
  • reject duplicate virtual hosts
  • match exact virtual hosts
  • parse and validate proxy.yml hosts
  • parse and validate router.yml rewrite-rule config
  • select router targets from direct service_url
  • reject direct router targets that do not match hostWhitelist
  • select router targets from controller discovery and direct-registry.yml
  • apply router URL, method, query-parameter, and header rewrites
  • parse pathPrefixService.yml and avoid partial-segment path matches
  • parse token.yml and the client credentials subset of client.yml
  • support single and multiple auth-server token configuration
  • discover token service endpoints from client.yml token serviceId
  • mask token cache summaries and never expose bearer token values
  • expose gateway capabilities in get_service_info
  • prevent static path traversal
  • deny dotfiles by default
  • serve index.html for /
  • serve SPA fallback for non-asset paths
  • avoid SPA fallback for /api/ proxy routes
  • select cache headers for index.html and hashed assets
  • stop handler execution on early response
  • run response handlers before static response write

Integration tests:

  • same binary starts with BFF profile config
  • same binary starts with proxy or balancer profile config
  • BFF profile can route API paths through configured handlers and serve SPA fallback through defaultHandlers
  • static SPA route returns index.html
  • static asset route returns correct content type and cache header
  • virtual host A and virtual host B serve different roots
  • API route is proxied to the configured proxy.yml upstream
  • auth handler blocks protected API routes
  • public static route does not require auth unless configured

Rollout Plan

Phase 1: Product config and active handler model (implemented)

  • keep a single apps/light-gateway binary
  • register all built-in handler descriptors explicitly
  • resolve active handler IDs from handler.yml
  • instantiate only active handlers
  • load config only for active handlers
  • document product profiles managed by light-portal

Phase 2: BFF and fixed proxy engine (implemented)

  • load and register proxy.yml, path-resource.yml, and virtual-host.yml

  • match handler.yml paths and fallback handlers in Java-compatible order

  • select fixed proxy upstreams from proxy.yml

  • match virtual hosts by Host

  • serve single-site and virtual-host static content

  • implement safe static path resolution

  • serve static files from request_filter

  • add Rust SPA fallback improvement

  • add content type and cache headers

  • add traversal, dotfile, fallback, proxy-host, and virtual-host tests

Phase 3: Handler chain execution (implemented)

  • run request and response handlers around static and proxied responses
  • implement correlation, CORS, and basic metrics
  • parse correlation.yml, cors.yml, and metrics.yml
  • pass generated correlation IDs upstream
  • apply response headers to both static and proxied responses
  • log handler duration when reportHandlerDuration is enabled
  • defer generic response headers to a handler-specific follow-up

Phase 4: Security and request/response policy handlers (implemented)

  • implement JWT, API key, basic auth, and rate-limit handlers
  • implement the generic header handler for request and response mutation
  • implement unified-security path-prefix selection for Basic, JWT, and API key
  • parse Java-compatible security.yml, apikey.yml, basic-auth.yml, unified-security.yml, header.yml, and limit.yml
  • add JWT pass-through claim request header mutation
  • add path-level chain selection for public SPA and protected API routes

Phase 5: Sidecar router (implemented)

  • load and register router.yml
  • implement dynamic target selection by service_url or service_id
  • enforce hostWhitelist
  • support router URL, method, query-parameter, and header rewrites
  • apply router request mutation in upstream_request_filter
  • remove router selection headers before forwarding
  • include router config in the active reload model
  • add sidecar-focused tests

Phase 6: Sidecar path-prefix and token flow (implemented)

  • load and register pathPrefixService.yml
  • resolve service_id by longest path-boundary prefix
  • load and register token.yml
  • load and register the token-related view of client.yml
  • support single-auth-server and multipleAuthServers client credentials
  • cache tokens locally and expose masked cache summaries through the runtime cache registry
  • inject Authorization or X-Scope-Token according to inbound request state
  • extend reload coverage to pathPrefixService.yml, token.yml, and token-related client.yml
  • add sidecar token/path-prefix tests

Phase 7: Discovery and control plane (implemented)

  • expose the runtime portal-registry client to framework transports
  • add discovery/lookup support to the portal-registry client
  • resolve router service_id targets through controller discovery
  • fall back to direct-registry.yml for local/static profiles
  • discover token service endpoints from client.yml token serviceId
  • expose active capabilities, hosts, paths, handlers, and chains through get_service_info
  • atomically replace resolved handler/resource/proxy models on reload

Phase 8: Advanced transport features (implemented)

  • add streaming static-file delivery for files at or above transferMinSize
  • add conditional static requests with ETag and Last-Modified
  • add wildcard virtual hosts with exact-host precedence
  • evaluate multi-cert TLS SNI support and document the Rustls limitation

Phase 2 Decisions

  • Static roots can be absolute, matching the Java deployment model, or relative to the runtime config directory for local Rust development.
  • SPA fallback applies only to browser routes. Paths that look like assets, such as /app.js or /favicon.ico, return 404 when the file is missing.
  • Handler path matching supports exact paths and Java/OpenAPI-style {name} path-template segments.

Open Questions

  • Should static content support ETag in the first implementation if portal deployments depend on browser cache validation?

MCP Router

Status

Phases 1, 2, 3, and 4 are implemented in light-pingora and light-gateway. The configurable tokenization client remains deferred until light-tokenization is migrated to portal-service/apps/portal-service and the protocol is selected. Stateful backend MCP session mapping is implemented for the single-process gateway session store and documented below.

This page describes the implemented legacy stateful profile. The design for serving that profile concurrently with the sessionless and stateless 2026-07-28 profile is documented in MCP 2026-07-28 Dual-Profile Gateway Design.

Purpose

The Java mcp-router module exposes a configured Model Context Protocol endpoint, /mcp by default, and turns configured gateway targets into MCP tools. AI agents can call initialize, tools/list, and tools/call; the router then forwards the tool call to an HTTP service or another MCP server.

In light-fabric this should be a light-pingora handler that is activated by light-gateway through handler.yml. The same gateway binary can contain the MCP router implementation, but each product decides whether it runs by including the mcp handler and the mcp-router.yml configuration from the config server.

This feature is separate from the existing runtime MCP control plane in light-runtime. Runtime MCP is an internal management surface exposed through the portal registry connection. The MCP router is an HTTP-facing gateway feature and is subject to the normal inbound handler chain.

The implemented legacy transport baseline is MCP Streamable HTTP as defined by the 2025-06-18 transport specification: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports. It must not be interpreted as the current MCP protocol revision; later legacy and stateless revision support is governed by the dual-profile design linked above.

Goals

  • Keep the Java configuration model recognizable: enabled, path, and tools.
  • Allow mcp-router.tools to be injected by the config server the same way handler.handlers, handler.chains, handler.paths, and handler.defaultHandlers are injected.
  • Activate the router with the existing mcp handler id in handler.yml.
  • Expose one MCP endpoint with Streamable HTTP semantics, so /mcp is the only public MCP path for both POST messages and optional GET streams.
  • Support MCP JSON-RPC methods needed by the Java module: initialize, notifications/initialized, tools/list, and tools/call.
  • Route tools to direct targetHost endpoints, discovered serviceId targets, and backend MCP servers.
  • Reuse existing cross-cutting handlers such as correlation, security, CORS, rate limit, header, metrics, and proxy routing where the chain order allows.
  • Register the router configuration with the module registry so it can be inspected and reloaded consistently with other light-fabric modules.

Non-Goals

  • Do not use Rust dynamic plugins or inventory for runtime tool registration. The active tools are product configuration, not compile-time discovery.
  • Do not merge the public MCP router and the internal runtime MCP control plane into one handler.
  • Do not implement a full MCP server framework in the first pass. The gateway only needs the methods used by agents to discover and call configured tools.
  • Do not copy Java's legacy HTTP+SSE endpoint split as the target transport. Streamable HTTP is the Rust target; legacy SSE can be considered only as a compatibility mode if an older client requires it.
  • Do not hardcode tokenization or masking service URLs. Java currently has a hardcoded tokenization endpoint in this path; the Rust port should make that configurable when masking/tokenization is added.

Java Behavior To Map

The Java module has three main pieces:

  • McpConfig loads mcp-router.yml with enabled, path, and tools.
  • McpHandler owns the HTTP MCP endpoint and JSON-RPC protocol handling.
  • McpToolRegistry stores configured tool implementations by name.

Java configuration:

enabled: ${mcp-router.enabled:true}
path: ${mcp-router.path:/mcp}
maxSessions: ${mcp-router.maxSessions:10000}
maxSessionsPerClient: ${mcp-router.maxSessionsPerClient:100}
tools: ${mcp-router.tools:}

Each tool supports these fields:

- name: weather
  description: Get weather information
  protocol: http
  serviceId: com.networknt.weather-1.0.0
  envTag: dev
  targetHost: http://localhost:7081
  path: /weather
  method: GET
  endpoint: /weather@get
  apiType: http
  inputSchema:
    type: object
    properties:
      city:
        type: string
  toolMetadata: {}

The Java handler currently supports:

  • GET /mcp as an SSE compatibility endpoint. It creates a session id and emits an endpoint event pointing to /mcp?sessionId=....
  • POST /mcp for JSON-RPC messages.
  • initialize, returning protocol version, tool capabilities, and server info.
  • notifications/initialized, returning no response.
  • tools/list, optionally filtered by params.query or params.intent.
  • tools/call, forwarding arguments to the configured tool.

The Java tool execution supports two target types:

  • HTTP tools call a configured HTTP endpoint. GET maps arguments to query parameters. Other methods send the arguments as a JSON body.
  • MCP proxy tools call a backend MCP server by sending a JSON-RPC tools/call request to the configured backend path.

Java also includes rule-based access checks, response filtering, masking, and tokenization around tool calls. The Rust version now implements access checks, response filtering, and schema-driven request masking without hardcoded service endpoints. Tokenization is intentionally deferred.

The Rust implementation should map this behavior to MCP Streamable HTTP rather than keeping Java's legacy HTTP+SSE transport as the default. Streamable HTTP uses one MCP endpoint path. Clients send JSON-RPC messages with POST /mcp; the server can return either a single application/json response or text/event-stream from that same POST when streaming is needed. Clients may also issue GET /mcp to open an optional server-to-client SSE stream on the same endpoint.

Resolved Decisions

  • Use Streamable HTTP so only one public MCP endpoint, normally /mcp, is exposed.
  • Defer the tokenization client design until light-tokenization is migrated into portal-service/apps/portal-service and its protocol is selected.
  • Reuse the light-4j access-control.yml compatibility contract for MCP, REST, and JSON-RPC authorization.
  • Do not add configured per-tool outbound headers. Backend tool calls should pass through the headers received from the agent, subject only to headers that the HTTP client must regenerate for a new outbound request and MCP session headers that the gateway must map or regenerate.

Rust Architecture

Add the MCP router to light-pingora because it is a request/response gateway handler. light-gateway should wire it into the existing handler descriptor table and runtime state.

Proposed modules:

frameworks/light-pingora/src/access_control.rs
frameworks/light-pingora/src/mcp.rs

Primary types:

#![allow(unused)]
fn main() {
pub struct McpRouterConfig {
    pub enabled: bool,
    pub path: String,
    pub tools: Vec<McpToolConfig>,
}

pub struct McpToolConfig {
    pub name: String,
    pub description: String,
    pub protocol: Option<String>,
    pub service_id: Option<String>,
    pub env_tag: Option<String>,
    pub target_host: Option<String>,
    pub path: String,
    pub method: HttpMethod,
    pub endpoint: Option<String>,
    pub api_type: McpToolType,
    pub input_schema: serde_json::Value,
    pub tool_metadata: serde_json::Value,
}

pub struct McpRouterRuntime {
    pub config: ArcSwap<McpRouterConfig>,
    pub client: reqwest::Client,
    pub registry_client: Option<Arc<PortalRegistryClient>>,
}
}

The exact field names should follow the existing light-fabric serde naming style while accepting the Java config names through aliases:

  • serviceId
  • envTag
  • targetHost
  • apiType
  • inputSchema
  • toolMetadata

mcp-router.yml should be the primary Rust file name, but the loader should also accept mcp-router.yaml for Java compatibility.

Tool Registration

The router does not need global static registration. Build an immutable tool map when mcp-router.yml is loaded:

McpRouterConfig -> BTreeMap<String, McpToolConfig> -> Arc<McpRouterState>

On reload, build a new state and atomically swap the Arc. In-flight requests continue with the old state.

This is simpler than Java's static McpToolRegistry and avoids Rust plugin complexity. It also matches the light-fabric product model: all handlers can be linked into one binary, while the config server decides which handlers and tools are active for a product.

Request Flow

The mcp handler should participate in the normal handler chain:

request
  -> correlation
  -> metrics
  -> cors
  -> security or unified security
  -> limit
  -> mcp
  -> proxy or route handler, only if mcp did not consume the request
response
  -> header
  -> metrics
  -> access log

When the request path matches mcp-router.path:

  • POST parses a JSON-RPC message. Requests return either application/json for a single response or text/event-stream for a streamed response on the same endpoint. Notifications and JSON-RPC responses sent by the client return 202 Accepted with no body when accepted.
  • GET with Accept: text/event-stream may open a server-to-client SSE stream on the same endpoint. If the gateway has no server-initiated messages to stream, it should return 405 Method Not Allowed.
  • DELETE should terminate the gateway session and any mapped backend MCP sessions. Until session termination is implemented, it can return 405 Method Not Allowed.
  • Other methods return 405 Method Not Allowed.

When the path does not match, the handler continues to the next handler in the configured chain.

The handler must be safe to include in shared chains. If mcp-router.enabled is false, or the mcp handler is not in handler.yml, no MCP route is exposed.

JSON-RPC Handling

Supported methods:

initialize
notifications/initialized
tools/list
tools/call

initialize response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "light-gateway-mcp",
      "version": "1.0.0"
    }
  }
}

tools/list returns configured tools with name, description, and inputSchema. It should preserve Java's simple filtering:

  • params.query matches tool name or description.
  • params.intent matches tool name or description.

tools/call validates params.name, finds the tool, validates or forwards params.arguments, and returns either:

{
  "content": [
    {
      "type": "text",
      "text": "..."
    }
  ]
}

or the structured result returned by the backend MCP server.

JSON-RPC errors should use the same codes as Java where practical:

-32700 parse error
-32601 method or tool not found
-32602 invalid params
-32000 tool execution failed
-32001 access denied

Rust improvement: malformed transport payloads should return a clear HTTP 400 with a JSON-RPC error body instead of a generic HTTP 500.

For Streamable HTTP:

  • Clients must send each JSON-RPC message as a separate POST to the MCP endpoint.
  • Clients should send Accept: application/json, text/event-stream.
  • The router should negotiate and honor MCP-Protocol-Version.
  • The router terminates the client-facing MCP session. initialize responses should include a gateway-owned Mcp-Session-Id, and later client requests should be validated against that gateway session.

MCP Session Management

The MCP router should use a facade model. To the agent, light-gateway is the MCP server. To upstream MCP targets, light-gateway is an MCP client. This keeps gateway security, access-control policy, masking, response filtering, and tool aggregation in one place while still respecting upstream MCP session state.

There are two distinct session scopes:

  • Frontend session: the session between the MCP client and light-gateway.
  • Backend session: one upstream MCP server session owned by the gateway for a specific frontend session and backend target.

The frontend session is created during client initialize:

  1. The client sends initialize to mcp-router.path.
  2. The gateway returns the MCP capabilities it exposes and a gateway-generated Mcp-Session-Id.
  3. The gateway stores session state keyed by that id. The state should include the negotiated protocol version, client info, security principal or relevant auth context, and any backend MCP sessions created for this client session.
  4. Later client requests must include the gateway session id. Unknown or expired session ids should fail before tool execution.
  5. A client DELETE request, explicit expiry, or gateway shutdown should close all backend sessions associated with the frontend session.

Session Duration And Limits

Frontend MCP sessions last until one of these events happens:

  • The session is idle for 30 minutes.
  • The client sends DELETE to the MCP endpoint with the gateway Mcp-Session-Id.
  • The gateway process exits.

The 30-minute value is an idle timeout, not a fixed session lifetime. Each valid session-bound request refreshes last_accessed, so an active client can keep using the same frontend MCP session for longer than 30 minutes. Once the session has been idle for 30 minutes, the next validation or lazy purge removes it and later requests with that session id fail as an unknown session.

The idle timeout is currently compiled into light-pingora as MCP_SESSION_IDLE_TIMEOUT and is not configurable from mcp-router.yml. The lazy purge throttle is also compiled in as MCP_SESSION_PURGE_INTERVAL with a 60-second interval. Expired sessions may therefore remain in memory briefly until another MCP request triggers validation or purge, but they are rejected when used after the idle timeout.

The configurable session settings are capacity limits:

enabled: ${mcp-router.enabled:true}
path: ${mcp-router.path:/mcp}
maxSessions: ${mcp-router.maxSessions:10000}
maxSessionsPerClient: ${mcp-router.maxSessionsPerClient:100}
tools: ${mcp-router.tools:[]}
  • maxSessions limits the total number of frontend MCP sessions held by one gateway process. The default is 10000.
  • maxSessionsPerClient limits sessions for one client key. The default is 100.

Both capacity values must be greater than zero. When a new initialize request would exceed either limit, the router first forces an expired-session purge. If the limit is still reached, the request fails without issuing another Mcp-Session-Id: total store exhaustion returns 503, and per-client exhaustion returns 429.

Expired sessions are purged lazily during later MCP requests, and any mapped backend MCP sessions are closed during that purge. If a frontend session is deleted or expires, the gateway also terminates every backend MCP session mapped to that frontend session.

The per-client key is derived from the authenticated principal when available, preferring client_id, then user_id, email, and host. If no security principal is available, the key falls back to MCP clientInfo.name and clientInfo.version from the initialize request.

For a single gateway process, the session store can start in memory. In a multi-pod deployment, the store should be external, such as Redis, or ingress must provide sticky routing for all requests that carry the same Mcp-Session-Id.

Backend handling depends on the tool type.

For apiType: http, the backend is a normal stateless API:

  1. No backend MCP session is created.
  2. The gateway translates tools/call arguments into a normal HTTP request.
  3. GET tools serialize arguments into the query string; body-capable methods send JSON.
  4. The gateway wraps the HTTP response into an MCP tools/call result.
  5. User-specific auth, tenant, correlation, and trace headers come from the frontend session or inbound request and are applied to the outbound HTTP call as normal gateway headers.

For apiType: mcp, the backend is a stateful MCP server:

  1. The gateway lazily initializes the backend session the first time a frontend session calls a tool for that backend target. If future dynamic tool discovery depends on the backend, this initialization can happen before tools/list instead.
  2. The gateway sends initialize to the backend MCP endpoint as an MCP client. It should use the client-requested protocol version when supported and pass only the capabilities it needs upstream.
  3. If the backend returns Mcp-Session-Id, the gateway stores it in a mapping keyed by the gateway session id and backend target identity.
  4. The gateway sends notifications/initialized to the backend when the backend session is established.
  5. For later backend calls, the gateway sends the backend session id to that backend. It must not forward the frontend gateway session id as if it were a backend session id.
  6. The gateway still performs access checks before calling the backend and response filtering after the backend response.
  7. When the frontend session ends, the gateway should terminate each mapped backend MCP session to avoid leaking backend resources.

The backend target identity used in the session map should be stable across requests. It should include the resolved route information that distinguishes one backend MCP endpoint from another, such as targetHost or serviceId, envTag, protocol, and tool path.

When the router aggregates tools from both MCP servers and normal APIs, the client still sees one gateway MCP session and one tools/list response. The gateway registry decides how each tools/call is executed:

FeatureMCP server backendNormal API backend
Config typeapiType: mcpapiType: http or omitted
Backend sessionYes, mapped from gateway session to backend targetNo
InitializationGateway initializes backend as an MCP clientNo upstream initialization
Message handlingJSON-RPC tools/call through backend MCP sessionTranslate JSON-RPC arguments to HTTP
Backend session headerSend backend Mcp-Session-Id only to that backendDo not send MCP session state
Tear-downClose backend session on client session endNothing backend-specific

The configured tools/list remains the gateway's public contract. A future dynamic-discovery mode may call backend MCP tools/list and merge those tools with configured HTTP tools, but that must still preserve the gateway's policy surface and avoid exposing backend tools that are not authorized for the product.

HTTP Tool Execution

For apiType: http or missing apiType:

  1. Resolve the target base URL.
  2. Build the target URL from base URL plus tool path.
  3. For GET, serialize arguments with url::form_urlencoded.
  4. For POST, PUT, and PATCH, send arguments as JSON.
  5. Pass through the inbound agent headers to the backend tool call so caller identity, authorization, correlation, tenant, locale, and tracing context are preserved.
  6. Let the HTTP client regenerate transport-specific headers for the new outbound request, such as Host, Content-Length, Transfer-Encoding, and connection management headers.
  7. Treat 2xx as success.
  8. Parse JSON responses as structured MCP results.
  9. Wrap non-JSON responses as MCP text content.
  10. Return an empty 2xx response as { "result": "success" }.

Target resolution:

  • Prefer targetHost for direct calls.
  • Otherwise use serviceId, protocol, and envTag through the existing portal registry discovery client.
  • If neither is available, return a tool execution error.

MCP Proxy Tool Execution

For apiType: mcp:

  1. Resolve the target base URL the same way as HTTP tools.
  2. Ensure a backend MCP session exists for the current gateway session and backend target. If none exists, initialize the backend MCP endpoint and store the returned backend Mcp-Session-Id.
  3. POST to the configured backend path.
  4. Pass through the inbound agent headers to the backend MCP server, with transport-specific headers regenerated for the new outbound request. Replace any frontend gateway Mcp-Session-Id with the mapped backend session id for this backend target.
  5. Send a backend JSON-RPC request:
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "tool-name",
    "arguments": {}
  }
}
  1. If the backend returns error, map it to -32000.
  2. If the backend returns result, return it to the caller.
  3. On frontend session termination or expiry, close the backend MCP session.

This preserves the Java McpProxyTool behavior while using Rust's typed JSON-RPC models where possible and adds the MCP session mapping required by stateful backend MCP servers.

Configuration Loading

The router should be loaded as a normal light-fabric module:

config-server product values
  -> mcp-router.yml placeholders
  -> light-gateway startup
  -> light-pingora mcp router state

Example product values:

mcp-router.enabled: true
mcp-router.path: /mcp
mcp-router.tools:
  - name: get_pet
    description: Get a pet by id.
    targetHost: http://petstore:8080
    path: /v1/pets
    method: GET
    inputSchema:
      type: object
      properties:
        id:
          type: string

Example handler.yml path wiring:

handlers:
  - correlation
  - metrics
  - cors
  - jwt
  - mcp
  - proxy

chains:
  default:
    - correlation
    - metrics
    - cors
    - jwt
    - proxy
  mcp:
    - correlation
    - metrics
    - cors
    - jwt
    - mcp

paths:
  - path: /mcp
    method: POST
    exec:
      - mcp
  - path: /mcp
    method: GET
    exec:
      - mcp

defaultHandlers:
  - proxy

The exact chain names are product choices. The important point is that /mcp can have a narrow chain while normal API proxy traffic keeps the normal proxy chain.

Module Registry

The MCP router should register its configuration with the module registry:

  • module name: mcp-router
  • config files: mcp-router.yml, with mcp-router.yaml as compatibility fallback
  • enabled status
  • configured path
  • tool count
  • tool names

The module registry should mask any future secret fields in toolMetadata, headers, or credential configuration.

Reload behavior:

  1. Reload mcp-router.yml.
  2. Validate duplicate tool names, missing paths, unsupported methods, and target resolution fields.
  3. Build a new immutable router state.
  4. Swap the runtime state atomically.
  5. Report the updated module registry status.

Security And Policy

The first layer of protection should be the handler chain. Products can place JWT, API key, basic auth, unified security, CORS, rate limit, and header handlers before or after mcp as needed.

Because MCP Streamable HTTP is browser-reachable, the mcp handler must also validate the Origin header according to the configured CORS or security policy. Invalid origins should fail before tool execution.

Fine-grained tool authorization should be added after the base router:

  • Reuse the existing light-4j access-control.yml model as the compatibility contract. access-control.yml controls enabled, accessRuleLogic, defaultDeny, defaultInclude, and skipPathPrefixes; rule.yml provides ruleBodies and endpointRules.
  • Make the access policy endpoint stable. Java uses the tool endpoint field, such as /weather@get; when omitted, Rust derives {path}@{method}.
  • Include correlation id, caller claims, request headers, tool name, endpoint, and arguments in the policy input.
  • Support default deny when access control is enabled and no req-acc rule matches.
  • Support fail-closed row filtering when response filtering is enabled and no caller claim matches a configured row-filter entry.
  • Provide built-in Rust actions compatible with the Java class names used by current config: RoleBasedAccessControlAction, ResponseColumnFilterAction, and ResponseRowFilterAction.

Response filtering should be implemented as a second policy stage:

  • Apply policy after backend execution and before JSON-RPC response emission.
  • Support both structuredContent and single text content responses, matching Java's behavior.
  • Match endpoint rules exactly first, then Java-style path templates and parent path entries such as /v1/accounts@get for /v1/accounts/123@get.

req-acc And res-fil Rule Design

The MCP router should treat endpoint rules as two separate policy stages:

  • req-acc: request access rules. These run before backend tool execution. They decide whether the caller can invoke the tool at all.
  • res-fil: response filter rules. These run after backend tool execution and before the JSON-RPC result is sent back to the caller. They can remove rows, remove columns, or otherwise reduce the returned payload.

Both stages use Light-Rule with CEL rule conditions. Light-Fabric only supports CEL conditions for new rule execution. The old native condition-row format is a legacy Java yaml-rule format and is not the Light-Fabric runtime contract. CEL keeps the rule predicate explicit while still letting the Java-compatible action classes perform the stable role, row, and column filter behavior. In this model, CEL decides whether a rule is eligible to run, and permission carries the endpoint-specific role, row, and column policy values.

The endpoint key must be stable and should not include the query string. For example, the demo request:

curl -s "http://127.0.0.1:8086/offers?segment=premium&state=ON&category=travel"

maps to the endpoint rule key /offers@get. The query parameters are part of the tool arguments and can be inspected by CEL through toolArguments.

For the offer demo, the backend should return enough rows to prove filtering, for example:

[
  {
    "offerId": "OFFER-TRAVEL-01",
    "title": "Premium travel credit",
    "segment": "premium",
    "state": "ON",
    "category": "travel",
    "priority": 1,
    "active": true
  },
  {
    "offerId": "OFFER-TRAVEL-50",
    "title": "Premium lounge bundle",
    "segment": "premium",
    "state": "ON",
    "category": "travel",
    "priority": 50,
    "active": true
  },
  {
    "offerId": "OFFER-TRAVEL-OLD",
    "title": "Retired companion fare",
    "segment": "premium",
    "state": "ON",
    "category": "travel",
    "priority": 10,
    "active": false
  }
]

Two roles are enough to demonstrate the behavior:

  • offer-viewer: can invoke the offer tool, but can only see rows where priority < 50 and active == true. The active column must not be returned.
  • offer-admin: can invoke the offer tool and can see every row and every column, including inactive offers and all priorities.

Example rule mapping:

ruleBodies:
  allowOfferSearch:
    common: Y
    ruleId: allowOfferSearch
    ruleName: Allow offer search
    ruleType: req-acc
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      auditInfo.subject_claims.ClaimsMap.role != null
    actions:
      - actionClassName: com.networknt.rule.RoleBasedAccessControlAction

  filterOfferRows:
    common: Y
    ruleId: filterOfferRows
    ruleName: Filter offer rows
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200
      && responseBody != ""
      && auditInfo.subject_claims.ClaimsMap.role != null
    actions:
      - actionClassName: com.networknt.rule.ResponseRowFilterAction

  filterOfferColumns:
    common: Y
    ruleId: filterOfferColumns
    ruleName: Filter offer columns
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200
      && responseBody != ""
      && auditInfo.subject_claims.ClaimsMap.role != null
    actions:
      - actionClassName: com.networknt.rule.ResponseColumnFilterAction

endpointRules:
  /offers@get:
    req-acc:
      - allowOfferSearch
    res-fil:
      - filterOfferRows
      - filterOfferColumns
    permission:
      roles: offer-viewer offer-admin
      row:
        role:
          offer-viewer:
            - colName: priority
              operator: "<"
              colValue: 50
            - colName: active
              operator: "="
              colValue: true
      col:
        role:
          offer-viewer: offerId,title,segment,state,category,priority

res-fil order matters. Row filtering must run before column filtering when a row predicate depends on a column that may be hidden from the final response. In the example above, active is needed to select rows but is then removed for offer-viewer.

res-fil always executes as a sequential pipeline. accessRuleLogic only controls how multiple req-acc rules are combined; it does not apply to response filters. The pipeline should parse the MCP result JSON once, pass the same mutable JSON value through each res-fil action, and serialize it back into the MCP result once after all filters complete.

The current compatibility actions support the existing permission model:

  • roles is used by RoleBasedAccessControlAction.
  • row.role, row.group, row.position, row.attribute, and row.user provide row filters for the matching caller claim. Additional dimensions are supported when their claim names are declared in claimMappings.
  • If permission.row exists but no row-filter entry matches the caller's claims, access-control.defaultInclude decides the miss behavior. defaultInclude: false returns no rows and is the secure default. defaultInclude: true keeps the legacy include-all behavior.
  • Row filters support =, !=, <, >, <=, >=, in, and not in.
  • col.role, col.group, col.position, col.attribute, and col.user provide the returned field list for the matching caller claim. Mapped custom dimensions are also supported. A field list prefixed with ! is a remove list; otherwise it is a keep list.
  • Column filtering must apply to top-level JSON objects as well as top-level arrays and object payloads containing items. Row filtering treats a top-level JSON object as a single candidate row and returns an MCP tool error with isError: true when it is denied.

This should remain the default design for Java and Rust parity. The Java row action must apply the same single-object behavior instead of returning maps unchanged. If a policy needs arbitrary per-row CEL predicates, use the explicit ResponseCelRowFilterAction rather than changing the declarative permission format of ResponseRowFilterAction.

CEL must not directly manipulate the MCP result JSON. The rule-level CEL expression decides whether a res-fil rule applies; the action performs the mutation. This keeps result extraction, structuredContent handling, text-content handling, single-pass JSON parsing, failure behavior, and audit logging inside tested Rust pipeline and action code.

For MCP, defaultInclude is evaluated inside the same response-filter pipeline as HTTP access-control. The router must apply it before the final JSON-RPC result is emitted:

  1. Execute the backend tool call.
  2. Extract the response payload from structuredContent or supported text content.
  3. Run res-fil actions in order.
  4. If ResponseRowFilterAction sees configured row filters but no matching caller claim, retain no rows when defaultInclude: false.
  5. Serialize the filtered result back into the MCP response.

This makes direct HTTP endpoints and MCP-routed tools share the same row-filter security behavior.

A CEL-aware row action can be added when the permission row-filter format is not expressive enough:

ruleBodies:
  filterOfferRowsWithCel:
    ruleId: filterOfferRowsWithCel
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      statusCode == 200 && responseBody != ""
    actions:
      - actionClassName: com.networknt.rule.ResponseCelRowFilterAction
        actionValues:
          rowExpression: >
            auditInfo.subject_claims.ClaimsMap.role == "offer-admin"
            || (row.priority < 50 && row.active == true)

Even in that case, CEL only returns a boolean for each row. The action owns row iteration and mutation of the parsed response value; the response-filter pipeline owns final serialization and updating the MCP response.

The row action should not deep-clone the full rule context for every response row. It should use a child CEL context that shadows row, or reuse one mutable context and update only the row binding for each evaluation. If a row-level CEL evaluation fails because the row is missing a referenced field, the action should drop that row and continue. Invalid action configuration, such as an expression that fails to compile, should fail the whole filter action closed.

Masking and tokenization handling:

  • Preserve Java schema extensions: x-mask, x-mask-pattern, and x-tokenize.
  • Parse these extensions from inputSchema as serde_json::Value.
  • Apply schema-driven x-mask request masking before backend tool execution.
  • Keep x-tokenize as a future extension point. Do not call a tokenization service until the portal-service tokenization protocol is finalized.
  • Do not hardcode a tokenization service URL. The tokenization client should be designed after light-tokenization is migrated into portal-service/apps/portal-service, whether the final protocol is JSON-RPC, MCP, or gRPC.

Per-tool outbound headers would mean headers that the MCP router adds from tool configuration when it calls a specific backend target, for example a configured Authorization, X-API-Key, tenant routing header, or vendor-specific version header. We do not need that feature. The required behavior is header pass-through: backend tool calls receive the headers that came from the agent, while the HTTP client regenerates only the transport-specific headers required for a valid outbound request. MCP session headers are not normal pass-through headers. The gateway owns the frontend Mcp-Session-Id and maps it to backend session ids when an upstream MCP server is involved.

Relationship To Existing Runtime MCP

light-runtime already has RuntimeMcpHandler for runtime management tools. That should remain internal and registry-facing.

The gateway MCP router should not automatically expose runtime management tools. If a product needs that bridge later, add an explicit configured tool type, for example:

apiType: runtime

That keeps public agent-facing tools separate from management tools and avoids accidentally exposing cache, module, or service operations through a public gateway route.

Phased Implementation

Phase 1: Core Router

  • Add mcp-router.yml config parsing in light-pingora.
  • Accept tools as either a YAML array or a JSON string to match Java config server injection behavior.
  • Add immutable tool map validation.
  • Implement the base Streamable HTTP single endpoint: unary POST /mcp, Accept validation for application/json and text/event-stream, 202 Accepted for accepted notifications, and 405 for unsupported methods.
  • Implement JSON-RPC initialize, notifications/initialized, tools/list, and tools/call.
  • Implement direct targetHost HTTP tools.
  • Pass through agent request headers to direct HTTP and backend MCP tool calls, except MCP session headers that the gateway must map separately.
  • Wire the existing mcp handler id in light-gateway.
  • Register module status and config with the module registry.
  • Add parser and handler tests.

Status: implemented.

Phase 2: Discovery And MCP Proxy

  • Resolve serviceId, protocol, and envTag through the existing portal registry discovery client.
  • Implement apiType: mcp backend proxy tools.
  • Add reload support with atomic state swap.
  • Add tests with fake discovery and backend MCP responses.

Status: implemented.

Phase 3: Streamable HTTP Streaming

  • Add streamed text/event-stream responses from POST /mcp for long-running tool calls or server-to-client messages related to the originating request.
  • Add optional GET /mcp server-to-client streams on the same endpoint.
  • Track frontend sessions when Mcp-Session-Id is issued. Return 405 for standalone GET streams until server-initiated messages are implemented.
  • Add tests for content negotiation, 202 Accepted notifications, streamed POST responses, and optional GET behavior.

Status: implemented.

Phase 4: Policy, Filtering, Masking

  • Add tool-level authorization using the access-control.yml compatibility contract.
  • Add response filtering for structured and text MCP results.
  • Add schema-driven request masking.
  • Add MCP tool-call log fields for tool name, endpoint, duration, status, and policy outcome.

Status: implemented for access control, response filtering, and request masking. Tokenization is deferred until the portal-service tokenization client is designed.

Phase 5: Stateful MCP Backend Sessions

  • Add a gateway session store keyed by frontend Mcp-Session-Id.
  • Validate later client requests against the gateway session.
  • For apiType: mcp, maintain backend session mappings keyed by gateway session id and backend target identity.
  • Lazily initialize backend MCP sessions by sending backend initialize, capturing backend Mcp-Session-Id, and sending notifications/initialized.
  • Replace the frontend session id with the mapped backend session id on upstream MCP calls.
  • Terminate mapped backend MCP sessions when the frontend session is deleted, expires, or the gateway shuts down.
  • Add tests for frontend session validation, backend session creation, backend session reuse, and backend session termination.

Status: implemented for the in-memory frontend session store, configurable global and per-client session caps, 30-minute lazy idle expiry, lazy backend initialization, backend Mcp-Session-Id mapping, backend session reuse, and explicit DELETE teardown. Shutdown cleanup, external session storage, and multi-backend isolation tests remain future hardening for multi-pod deployments.

Testing Strategy

  • Config tests:
    • empty config
    • disabled config
    • duplicate tool names
    • tools as YAML array
    • tools as JSON string
    • inputSchema as object and string
  • JSON-RPC tests:
    • initialize
    • notifications/initialized
    • notification returns 202 Accepted
    • tools/list
    • tools/list with query and intent
    • missing method
    • invalid params
    • malformed JSON
  • Streamable HTTP tests:
    • single /mcp endpoint handles POST
    • POST validates Accept
    • unsupported methods return 405
    • optional GET stream returns 405 until enabled
  • Tool execution tests:
    • direct GET with encoded arguments
    • direct POST with JSON arguments
    • non-JSON backend response
    • empty 2xx backend response
    • non-2xx backend response
    • agent headers are forwarded to backend tool calls
    • discovered service target
    • backend MCP proxy success and error
  • Handler chain tests:
    • /mcp consumed by mcp
    • non-MCP path continues to the next handler
    • disabled router does not expose /mcp
  • Reload tests:
    • tool added
    • tool removed
    • invalid reload keeps the prior good state

Remaining Decisions

  • Confirm whether Phase 1 includes only unary Streamable HTTP POST or also streamed POST responses.
  • Decide the tokenization client protocol after light-tokenization is migrated into portal-service/apps/portal-service.
  • Map the Java access-control.yml schema to Rust policy execution and define how it will be shared by REST, JSON-RPC, and MCP handlers.

MCP 2026-07-28 Dual-Profile Gateway Design

Status

Implemented through the release-candidate profile and disabled by default. Phase 7 promotion readiness is implemented; final-spec reconciliation and the production canary remain blocked until the final revision is published and operational evidence is recorded.

This design was written against the 2026-07-28 release candidate available on July 16, 2026. The two principal SEPs are final, but the consolidated protocol revision is not final until July 28, 2026. Before enabling the new profile, the implementation must be checked against the final schema, conformance suite, and error-code registry.

The executable readiness, provenance, coverage, canary, and rollback contracts live under implementation/light-gateway/mcp-2026-07-28/phase7. Its promotion gate fails closed while the final provenance or operational evidence has pending status.

The current stable MCP protocol revision is 2025-11-25. The existing mcp-router design remains the source of truth for the legacy stateful profile. This document defines how light-gateway adds the new stateless profile without removing or weakening that behavior.

Primary specification sources:

Specification Interpretation

The consolidated specification defines the wire contract. Individual SEPs explain why a change was made, its security implications, rejected alternatives, and migration guidance, but their proposal text can predate later integration edits.

When sources differ, use this order:

  1. The final revision's TypeScript schema, which the specification identifies as the protocol message source of truth.
  2. The final normative specification pages and generated JSON Schema.
  3. The final revision changelog and error-code registry.
  4. Final SEP text for rationale and requirements not changed during integration.
  5. Release-candidate blog examples and non-normative guidance.

Until July 28, the locked release-candidate schema occupies the first position, but every difference from the final schema must be reviewed before enablement. For example, SEP-2575 proposal text and the consolidated draft differ in the normative strength of clientInfo and in error-code allocations. The gateway must implement the consolidated schema rather than preserve superseded SEP examples.

The release coverage matrix later in this document records every consolidated changelog area that affects light-gateway. A row marked deferred still needs an explicit capability or compatibility boundary; deferred does not mean that arbitrary messages may pass through unchecked.

Executive Decision

light-gateway will support the legacy stateful and 2026-07-28 stateless MCP profiles at the same configured endpoint, normally /mcp.

The gateway selects a profile from the request's protocol contract. It must not select the stateless profile merely because Mcp-Session-Id is absent. An absent session id can also mean that a legacy request is malformed, and treating it as stateless would bypass the legacy session boundary.

Both profiles terminate at one transport-neutral application core for tool visibility, authorization, request masking, execution, response filtering, auditing, and metrics. Protocol adapters own only the lifecycle, wire fields, response envelope, and transport behavior specific to their revision.

The first production milestone will support server/discover, tools/list, and tools/call for stateless clients. Long-lived subscriptions/listen, multi-round-trip requests, and an optional stateless-to-legacy backend bridge are separate gates and must not be advertised before they are implemented.

Context

The existing Rust MCP router implements a stateful Streamable HTTP facade:

  1. A client calls initialize.
  2. light-gateway creates a gateway-owned frontend session and returns an Mcp-Session-Id.
  3. Every later request is validated against that session.
  4. For an apiType: mcp tool, the gateway lazily initializes a backend MCP session and maps it to the frontend session and backend target.
  5. Deleting or expiring the frontend session terminates its backend sessions.

The current implementation keeps frontend and backend sessions in the McpRouterRuntime. It preserves them across configuration reloads within the same process. It also maintains an authorization-aware tools-list cache.

The code currently negotiates 2025-06-18, 2025-03-26, and 2024-11-05; it does not yet negotiate the stable 2025-11-25 revision. Phase 0 must add a real 2025-11-25 tool-only compatibility contract and fixtures before adding the stateless adapter. Merely accepting the version string is insufficient. The existing 2024-11-05 value remains a frozen gateway compatibility behavior and does not mean this design adds or claims compliance with the deprecated two-endpoint HTTP+SSE transport.

The 2026-07-28 profile changes that lifecycle:

  • initialize and notifications/initialized are removed.
  • Mcp-Session-Id is removed.
  • Protocol version and client capabilities travel with every request.
  • server/discover reports supported versions, server capabilities, and server identity.
  • Streamable HTTP requests expose routing fields through Mcp-Method and, when applicable, Mcp-Name.
  • List results include freshness and cache-scope information.
  • Ordinary results carry resultType: "complete".
  • Long-lived server notifications use a POST response stream created with subscriptions/listen rather than the HTTP GET endpoint.

Supporting both generations therefore requires two versioned protocol adapters, not an optional-session branch inside one wire contract.

Goals

  • Serve legacy and 2026-07-28 clients concurrently on one MCP path.
  • Preserve existing legacy initialization, session validation, backend session mapping, teardown, authorization, and filtering behavior.
  • Make every stateless request independently understandable, authenticated, authorized, bounded, and routable to any gateway replica.
  • Reuse one application core for tool listing and tool execution so the two profiles do not drift in policy behavior.
  • Support HTTP tools, legacy MCP backends, and stateless MCP backends through explicit compatibility rules.
  • Return truthful capabilities and explicit incompatibility errors rather than silently downgrading or emulating unsupported semantics.
  • Keep new behavior disabled by default until the final specification and conformance gates pass.

Non-Goals

  • Do not add an application setting that changes the meaning of one protocol version between stateful and stateless operation.
  • Do not infer protocol profile from user agent, HTTP version, connection reuse, missing headers, or backend topology.
  • Do not remove the legacy profile while supported clients still depend on it.
  • Do not pool hidden legacy backend sessions by user identity for stateless clients.
  • Do not interpret or authorize arbitrary application state handles in the gateway. Handles are normal tool arguments and results.
  • Do not implement prompts, resources, sampling, roots, logging, MCP Apps, Tasks, or multi-round-trip requests merely because the protocol schema can represent them.
  • Do not advertise subscriptions/listen until the Pingora response path can keep an SSE response open and cancel it safely.

Terminology

TermMeaning
Legacy profileMCP 2025-11-25 and supported earlier revisions using initialization and protocol-level sessions
Stateless profileMCP 2026-07-28, with per-request version/capabilities and no protocol-level session
FrontendThe MCP client-to-light-gateway side
BackendAn HTTP API or MCP server invoked by a configured gateway tool
Frontend sessionA gateway-owned legacy client session identified by Mcp-Session-Id
Backend sessionA legacy upstream MCP session owned by the gateway
Explicit state handleAn opaque application identifier returned by a tool and supplied to later tool calls; it is not an MCP protocol primitive
Principal fingerprintA stable, non-secret digest of the authenticated subject, issuer, tenant, and other identity fields required to bind state or caches

Protocol Profiles

ConcernLegacy stateful profile2026-07-28 stateless profile
Lifecycleinitialize, then notifications/initializedNo initialization handshake
VersionNegotiated and retained in the sessionSent in the HTTP header and request params._meta
Client capabilitiesRetained from initializationSupplied for every request
Client identitySupplied during initializationclientInfo SHOULD be supplied in request params._meta
Gateway sessionMcp-Session-IdProhibited
DiscoveryInitialization resultserver/discover
Tool listsMay be session-sensitiveMust not vary by connection; may vary by authenticated principal or deployment state
Application stateMay exist behind a legacy sessionExplicit tool arguments and server-minted handles
Server notificationsLegacy Streamable HTTP behaviorsubscriptions/listen POST response stream
Horizontal routingRequires affinity or shared session routingAny replica can handle any ordinary request
Stream resumptionLegacy-version behaviorNo SSE event replay or Last-Event-ID resumption

The version adapter must apply the complete contract for its selected profile. It must not mix a legacy lifecycle with stateless result shapes or accept a stateless lifecycle under a legacy version.

HTTP Method Matrix

HTTP methodLegacy stateful profile2026-07-28 stateless profile
POSTInitialize, notifications, requests, and responses permitted by the negotiated legacy revisionSingle self-contained JSON-RPC message; the only request entry point
DELETETerminates the identified frontend session and its backend sessionsNot a protocol operation; reject without touching state
GETKeep the currently implemented behavior; this gateway returns 405 unless a separately supported legacy transport requires itNo server-notification endpoint; use subscriptions/listen through POST
Other methods405 Method Not Allowed405 Method Not Allowed

The deprecated HTTP+SSE transport is not added as part of dual-profile support. Compatibility with that older two-endpoint transport requires a separate, explicit design and route so it cannot be confused with Streamable HTTP.

Request Classification

Classification Rules

The gateway classifies a POST only after enforcing request-body limits and parsing a single JSON-RPC message. Batch requests remain unsupported unless a future design explicitly adds them.

Use this ordered decision table:

ConditionResult
Method is initialize, no session id, and requested version is an enabled legacy versionLegacy initialization path
Mcp-Session-Id is present and the request does not claim 2026-07-28Legacy session path
Version header and request params._meta both select enabled 2026-07-28, agree exactly, and required routing headers are validStateless path
Version header and body select enabled 2026-07-28, with a stale Mcp-Session-Id also presentSelect stateless, ignore the legacy header, and never read, mint, echo, or delete session state
Legacy non-initialize request has no session idReject as missing legacy session id
Stateless version is present only in the header or only in params._metaReject as a header mismatch
Version is missing or unsupported and no valid legacy initialization can negotiate itReject; do not guess

The classifier must be a pure, unit-tested component. Tool execution must not start and no frontend or backend state may be mutated until classification, version validation, authentication, header/body validation, and authorization have succeeded.

Stateless HTTP Header Validation

For a 2026-07-28 POST:

  • Content-Type must identify JSON and the body must be one UTF-8 JSON-RPC request or notification permitted by the protocol. Client-sent JSON-RPC responses are prohibited because this revision has no server-initiated requests.
  • Accept must list both application/json and text/event-stream.
  • MCP-Protocol-Version is required and must equal params._meta["io.modelcontextprotocol/protocolVersion"].
  • Mcp-Method is required for every JSON-RPC request and must equal the JSON-RPC method. This revision does not define routing-header requirements for notification POSTs; the gateway must not invent them.
  • Mcp-Name is required where SEP-2243 defines a named operation, including tools/call, and must equal the corresponding params.name or params.uri value.
  • Header names are compared case-insensitively; method and name values are case-sensitive after decoding. Ambiguous duplicate semantic headers are rejected.
  • Mcp-Name and Mcp-Param-* use the specified visible-ASCII representation. A non-ASCII, control-containing, leading/trailing-whitespace, or literal sentinel-shaped value uses the exact =?base64?{base64-utf8}?= encoding. Decode before comparing with the body; compare integer parameters numerically rather than requiring one decimal spelling.
  • This sentinel is MCP-specific and is not an RFC 2047 MIME encoded-word. Implementations must not substitute =?utf-8?B?...?= or apply MIME header decoding. The lowercase =?base64? prefix and ?= suffix are literal, case-sensitive protocol markers.
  • A missing, malformed, or mismatched required header returns HTTP 400 with HeaderMismatch code -32020.
  • An unsupported version returns HTTP 400 with UnsupportedProtocolVersion code -32022 and includes the requested and supported versions.
  • A required capability that the client did not declare returns MissingRequiredClientCapability code -32021.

The core 2026-07-28 protocol defines no client-to-server notification over Streamable HTTP. Because the first milestone also advertises no extension that defines one, it rejects notification POSTs as unsupported. If a supported extension notification is added later, acceptance returns HTTP 202 with no body; rejection uses an HTTP error and may include an id-less JSON-RPC error. A JSON-RPC request returns either one application/json object or an SSE response stream. The adapter never returns a JSON-RPC response to a notification and always rejects a client-sent JSON-RPC response.

Origin validation happens before JSON parsing or state mutation. When Origin is present, compare the complete normalized origin against an exact allowlist; suffix, substring, wildcard-host, and reflected-origin matching are prohibited. An invalid origin returns HTTP 403. An empty or absent allowlist rejects requests that carry Origin while still allowing non-browser clients that do not send the header. This is an MCP transport security boundary, not merely a response CORS-header concern. The same rule applies to both enabled Streamable HTTP profiles; legacy compatibility does not weaken it.

Trusted reverse proxies, WAFs, ingresses, and load balancers must preserve the browser's Origin header exactly. Stripping or rewriting it is deployment nonconformance because the gateway cannot reliably distinguish that browser from a real non-browser client. User-Agent and other spoofable headers are not acceptable recovery signals. Deployment conformance tests must exercise Origin preservation through the complete external path.

If a tool schema uses x-mcp-header, the gateway terminates and parses the request, so it must validate each applicable Mcp-Param-* header against the tool argument before policy evaluation or execution. A gateway must never authorize or route on a header value and then execute a different body value.

The final July 28 schema and error registry override release-candidate details if they change before publication.

Legacy Validation Hardening

Legacy behavior remains version-specific, but simultaneous support must not leave the legacy session id as an authorization bearer token.

When creating a frontend session on a protected route, store a principal fingerprint derived from the independently verified request identity. Every session-bound POST or DELETE must recompute and compare the fingerprint before touching the session or a backend. Missing identity, a different identity, or a changed tenant binding fails closed.

An anonymous MCP route must be an explicit product decision rather than the result of missing or failed authentication. An anonymous legacy session uses a separate gateway-derived anonymous client binding for capacity and abuse controls; its cryptographically random session id necessarily remains a bearer capability. A protected and anonymous session must never share the same binding namespace.

The stored fingerprint must contain no bearer token, cookie, CSRF value, or other reusable credential. The gateway may retain the existing client key for capacity accounting, but capacity identity and security identity must be separate concepts.

Common Application Core

Both adapters normalize accepted messages into an effective request context:

#![allow(unused)]
fn main() {
enum FrontendProtocol {
    Legacy {
        session_id: String,
        negotiated_version: String,
    },
    Stateless,
}

struct EffectiveMcpRequestContext {
    protocol: FrontendProtocol,
    protocol_version: String,
    client_info: Option<ClientInfo>,
    client_capabilities: ClientCapabilities,
    requested_log_level: Option<LoggingLevel>,
    auth: Option<AuthPrincipal>,
    correlation_id: Option<String>,
    delegation: Option<DelegationClaims>,
}
}

The common core owns:

  1. Method allowlisting.
  2. Delegated-authority validation.
  3. Tool-list visibility and deterministic ordering.
  4. Request access control.
  5. Input-schema validation and request masking.
  6. Backend target resolution and SSRF protection.
  7. Tool execution and bounded retry policy.
  8. Response filtering and output-schema handling.
  9. JSON-RPC application error mapping.
  10. Audit events, metrics, and safe diagnostics.

The common core returns a protocol-neutral result. The selected adapter adds the correct result envelope, _meta, cache fields, protocol headers, or legacy session headers.

Stateless Methods

server/discover

The stateless adapter must implement server/discover and return:

  • enabled protocol versions in deterministic preference order;
  • only capabilities implemented and enabled by this gateway instance;
  • optional instructions that describe the configured tool facade;
  • resultType: "complete";
  • gateway server identity in _meta["io.modelcontextprotocol/serverInfo"] with the normative strength from the final schema;
  • ttlMs and cacheScope because discovery is cacheable.

Discovery is independently authenticated and authorization-aware. Its cache key uses the same principal, protocol, policy, and configuration revisions as the capability result. The first milestone reports gateway capabilities derived from enabled handlers and configured tools; it does not depend on call-time portal-registry discovery. cacheScope defaults to private; public is valid only when the complete discovery result is identical for every caller. The advertised TTL must not exceed the internal entry lifetime.

A configuration or policy swap advances its revision and makes old entries unreachable before a response is served from the new runtime. TTL is an expiry backstop, not the primary reload-coherence mechanism. If a future dynamically discovered catalog makes discovery/list results depend on portal-registry state, that work must first add a monotonic registry generation or canonical snapshot revision plus a push/watch invalidation signal. The current request/response DiscoverySnapshot has neither. Until that contract exists, the gateway must not claim immediate backend-discovery invalidation or advertise listChanged; a documented short TTL is the only available staleness bound.

The initial stateless release advertises tools only. It must not advertise prompts, resources, notifications, multi-round-trip requests, Tasks, Apps, or extensions that are not wired through the application core.

Legacy clients continue to use initialize. A dual-version client may probe server/discover; failure may cause legacy fallback only under the downgrade rules defined later in this document.

tools/list

Stateless tools/list uses the same authorization-aware visibility logic as legacy tools/list, with these additional rules:

  • The returned order is deterministic.
  • The list must not vary because of a connection or prior tool call.
  • It may vary by current authenticated principal, scopes, tenant, active policy, or gateway configuration. The first milestone does not hide or add configured tools based on call-time backend discovery; backend availability is checked by tools/call.
  • The result contains resultType: "complete", ttlMs, and cacheScope.
  • cacheScope defaults to private because the visible catalog can vary by authenticated principal and access-control policy.

The first milestone returns the complete bounded visible catalog, omits nextCursor, and rejects a non-empty cursor that it did not issue. It does not pretend to paginate. If the computed visible catalog exceeds maxToolsListItems, or its encoded response would exceed maxResponseBodyBytes, the gateway fails the whole request with the bounded implementation-defined -32000 resource-limit error locked in Phase 0. The message states that the visible catalog exceeds a gateway limit and that pagination is not supported. It must not truncate the catalog, emit a cursor, or cache a partial result. Cursor generation, integrity, principal binding, expiry, and reload invalidation require a separate pagination design before larger visible catalogs are accepted.

The internal cache key must include at least:

  • protocol profile and version;
  • normalized query or intent parameters;
  • authenticated-principal fingerprint;
  • relevant forwarded-header fingerprint;
  • MCP router configuration revision;
  • access-control policy revision;

The initial configured catalog has no backend-discovery component in its cache key. A future discovery-dependent catalog must add the registry generation or snapshot revision described above; a TTL alone is insufficient to claim immediate invalidation.

Cache entries must expire no later than the advertised ttlMs. A policy or router reload invalidates affected entries before new responses are served.

tools/call

Stateless tools/call is authorized and executed independently. It must not read, create, touch, or delete a frontend session. A successful ordinary result includes resultType: "complete" and the server identity fields required or recommended by the final protocol schema.

Mutating calls are not automatically replayed after ambiguous transport failure. Existing retry metadata remains authoritative, but retries must be limited to operations explicitly declared safe or idempotent.

Stateless calls use a typed outbound-header allowlist. The gateway regenerates profile routing, correlation, trace, tenant, locale, and backend credential headers from trusted request context and target configuration. It does not copy raw frontend X-Forwarded-*, cookies, authorization, backend-specific credentials, unknown Mcp-*, or arbitrary extension headers to a backend. Legacy header forwarding remains a separately versioned compatibility contract and must not be reused as the modern default.

Unsupported Methods

Methods not implemented by the gateway return a normal JSON-RPC method-not-found response. For the stateless Streamable HTTP profile this is HTTP 404 Not Found with JSON-RPC code -32601; the body distinguishes a modern unknown method from a missing legacy HTTP+SSE endpoint. Capabilities must not imply that those methods are available. This rule is especially important for deprecated roots, sampling, and logging features and for extensions that are not part of the first milestone.

Tool and JSON Schema Contract

The current router stores and advertises configured inputSchema values and uses schema annotations for request masking. That is not equivalent to full JSON Schema validation. Supporting the 2026-07-28 tools contract requires a dedicated, bounded schema compilation and validation path.

Schema Loading

At configuration load, before a runtime swap:

  • inputSchema must be a valid JSON Schema object and must describe an object at the root. A no-argument tool should use { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": {}, "additionalProperties": false }.
  • outputSchema, when present, may describe any JSON value, including arrays, primitives, or null.
  • A schema without $schema uses JSON Schema 2020-12.
  • JSON Schema 2020-12 is mandatory. Any additional dialect is an explicit, documented configuration choice; an unsupported dialect is rejected rather than interpreted as 2020-12 or treated permissively.
  • Local $ref and $defs resolution is supported within the schema document.
  • Network dereferencing of external $ref URIs is disabled. An unresolved external reference rejects the tool. A future opt-in resolver needs a separate SSRF review, exact host allowlist, byte/depth/time limits, and cache policy.
  • Schema compilation is bounded by document bytes, nesting depth, subschema count, reference expansions, regular-expression complexity where supported, and a time budget.

Compilation produces immutable validators stored with the tool configuration. Invalid schemas fail the candidate configuration before it becomes active. One invalid configured tool must not silently turn into an unconstrained tool.

Composition does not replace the MCP object-root declaration. allOf, anyOf, oneOf, conditionals, and local references are supported as siblings of root type: object. If an older Portal-generated tool contains only allOf or another composition keyword, add type: object at the same level as an immediate repair, then regenerate the tool. Remove or reset any stale selected-tool schema override so it cannot replace the regenerated schema. An empty string is not an empty schema; no-argument tools use the explicit closed object above.

Tool Names and Aggregation

Names exposed through the stateless profile are case-sensitive, unique within the gateway catalog, 1 to 128 characters, and limited to ASCII letters, digits, underscore, hyphen, and dot. Enabling the stateless profile fails validation if an exposed configured name violates this contract. Legacy-only deployments may retain their existing names until migrated.

If future dynamic backend discovery introduces collisions, configuration must provide a stable explicit alias or prefix. Backend serverInfo.name is not a unique identifier and must not be used as the automatic disambiguation key.

x-mcp-header Validation

At schema load, every x-mcp-header annotation must:

  • be non-empty and match the HTTP field-name token syntax;
  • contain no control, carriage-return, or line-feed characters;
  • be case-insensitively unique within the tool schema;
  • annotate only a statically reachable primitive string, integer, or boolean;
  • keep integers within the protocol's safe IEEE-754 integer range;
  • not name a pseudo-header, hop-by-hop or proxy-authentication header, HTTP framing header, credential header, or gateway-owned MCP routing/session header;
  • not annotate the same property as sensitive or schema-masked data.

Invalid annotations fail a configured tool. If a future backend-discovery client receives an invalid backend tool definition, it excludes that tool and emits a bounded warning without exposing schema values. Sensitive parameters, tokens, secrets, and PII must never be mirrored into Mcp-Param-* headers.

At call time, extract the annotated value according to the final transport encoding rules and require the regenerated or received header to match the validated argument exactly. Missing argument and JSON null mean that the header is absent.

Input and Output Validation

For tools/call, the common core applies this order:

  1. Apply transport byte/depth limits and authenticate or accept the explicitly anonymous route.
  2. Parse, classify, validate routing headers, resolve the configured tool, and apply principal/tool-level visibility and coarse authorization.
  3. Validate the original arguments against inputSchema within the validation budget.
  4. Evaluate delegation and argument-dependent request access policy against the validated, unmasked arguments.
  5. Apply request masking and invoke the backend.
  6. Parse the backend result, apply response policy and filtering, and then validate the final structuredContent against outputSchema when present.
  7. Construct the version-specific result envelope.

The error boundary is intentionally split:

  • an unknown tool or malformed tools/call envelope that does not satisfy the protocol's CallToolRequest schema returns a JSON-RPC protocol error;
  • arguments that fail the selected tool's inputSchema return a completed tool result with resultType: "complete", isError: true, and bounded, model-actionable content, without backend traffic.

The validation result must identify the failing field and constraint when safe, but must not echo the complete input, secrets, schema-masked values, or unbounded validator diagnostics. Coarse authorization runs first so validation details cannot be used to probe a tool the principal cannot invoke. This SEP-1303 distinction lets a model correct tool arguments while preserving protocol errors for malformed envelopes and unknown tool names.

A configured output schema makes final output conformance a gateway responsibility because the gateway terminates and may filter the backend result. A backend or response filter that produces non-conforming structured content results in a tool error; the gateway must not emit data that contradicts its advertised schema.

structuredContent may be any JSON value. Masking and response filtering must therefore handle objects, arrays, strings, numbers, booleans, and null without assuming an object root. When structured content is returned, the gateway should also provide its serialized JSON in a text content block for backward compatibility unless the backend result already supplies an equivalent block.

Result Types, MRTR, and Extensions

Result Types

Every successful 2026-07-28 result contains a recognized resultType. Ordinary results use "complete". A stateless backend response that omits the field is invalid for that backend version. When consuming a response from an earlier negotiated backend version, the gateway treats an absent field as "complete" for backward compatibility.

An unknown result type is invalid unless it belongs to an extension explicitly supported and negotiated by both sides. The gateway must not relabel an unknown or input_required result as complete merely to fit a legacy frontend.

Multi Round-Trip Requests

The first stateless milestone does not implement MRTR and must not advertise the associated client or server capabilities. If a backend returns resultType: "input_required" before the gateway implements MRTR, the gateway replaces that backend result with a terminal gateway-generated tool error. For a modern frontend it has resultType: "complete", isError: true, and the bounded message light-gateway does not support MCP multi round-trip bridging for this backend. This is not a schema-validation failure and does not relabel the backend's input request as a successful complete result. The gateway does not expose backend requestState, inputRequests, or other opaque retry state to the frontend.

A later MRTR design must specify:

  • the exact supported input-request methods;
  • required client-capability validation;
  • authorization and integrity protection for opaque requestState;
  • bounds on state bytes, input-request count, nesting, and retry count;
  • a new JSON-RPC id for every retry while preserving correlation and audit lineage;
  • translation behavior for every frontend/backend profile combination;
  • how policy is reevaluated on every retry.

Request-scoped notifications or server input requests belong to the response stream of the initiating request, not to subscriptions/listen.

The per-request io.modelcontextprotocol/logLevel value is never retained as gateway session state. The gateway must not emit or forward notifications/message for a stateless request that omitted it. If request- scoped logging is later implemented, the requested threshold applies only to that response stream and is independently bounded and filtered; the deprecated Logging capability and logging/setLevel remain absent.

Extensions

The core ClientCapabilities and ServerCapabilities contain an extensions map. Extension support is optional, independently versioned, and disabled by default.

For the first milestone:

  • server/discover omits or returns an empty extension map;
  • unknown client extensions do not change core behavior;
  • an operation that requires an unsupported extension fails explicitly;
  • extension metadata and result types are not forwarded through the gateway unless a versioned gateway adapter validates and translates that extension;
  • MCP Apps and the Tasks extension remain separate designs and are not advertised;
  • tool task support is omitted or normalized to forbidden unless the Tasks extension is implemented and negotiated.

The gateway is not a transparent byte proxy, so backend extension support does not automatically make the same extension available to frontend clients. Each supported extension needs an owner, version allowlist, capability intersection, resource limits, authorization review, and conformance tests.

Deprecated and Removed Core Features

The stateless profile does not implement removed initialize, notifications/initialized, ping, or logging/setLevel methods. It does not add the removed HTTP GET notification endpoint or SSE resumption.

Roots, sampling, and logging are deprecated in this release. Because the gateway does not currently implement them, it leaves their capabilities absent rather than introducing new deprecated functionality. The deprecated HTTP+SSE transport, deprecated includeContext values, and deprecated Dynamic Client Registration are likewise not added by the MCP router.

Frontend and Backend Compatibility

The gateway is both an MCP server to the frontend and, for apiType: mcp, an MCP client to the backend. These protocol profiles are independent.

Frontend profileHTTP backendLegacy MCP backendStateless MCP backend
Legacy statefulExisting direct translationExisting mapped backend sessionTranslate stored legacy client metadata into each stateless backend request
StatelessDirect translationReject by default; optional per-request bridgeDirect stateless proxy

Controller WebSocket Control Plane Is Separate

The browser control-plane route /ctrl/mcp is not the Streamable HTTP /mcp endpoint described here. It is routed by websocket-router, remains payload-opaque at light-gateway, and continues to use the separately frozen controller JSON/WebSocket contract. This design does not route it through mcp-router, translate it to the stateless profile, or require statelessToLegacyBridge.

Only a future tool explicitly configured with apiType: mcp and a controller Streamable HTTP target would enter the compatibility matrix above. Such a tool must not be marked sessionIndependent: true merely because controller operations appear request/response-shaped. The preferred choices are to keep that frontend/backend path legacy or upgrade the target to stateless; a bridge still requires the proof and opt-in defined below.

Backend Profile Configuration

Each MCP tool target has an explicit backend profile:

backendMcpProtocol: legacy # legacy, stateless, or auto
sessionIndependent: false

legacy is the compatibility default for existing apiType: mcp tools. The fields are ignored for apiType: http.

All tools resolving to the same normalized MCP backend target must declare a compatible backend profile. Configuration loading fails if one target is simultaneously declared legacy and stateless or has conflicting bridge properties.

Legacy Frontend to Stateless Backend

This direction is supported. The gateway retains the legacy frontend's negotiated client information and capabilities, converts them into stateless per-request metadata, generates the required backend headers, and does not create a backend session.

The gateway returns a legacy-shaped result to the frontend. Fields introduced only in 2026-07-28 are consumed or translated deliberately; they must not be copied blindly into an older result schema.

Stateless Frontend to Stateless Backend

This is the preferred proxy path. The gateway:

  1. Re-authorizes the configured gateway tool.
  2. Resolves and validates the backend target.
  3. constructs a new backend request using the backend's supported stateless version;
  4. regenerates MCP-Protocol-Version, Mcp-Method, Mcp-Name, and applicable Mcp-Param-* headers;
  5. propagates the effective client capabilities and safe trace context;
  6. filters the backend response before returning it.

Ingress routing headers are never forwarded without regeneration and body/header consistency validation.

Stateless Frontend to Legacy Backend

This direction is rejected by default. A stateless frontend has no lifecycle scope that can safely own, route, or terminate a legacy backend session. Pooling backend sessions by authenticated principal would mix independent agents, conversations, browser tabs, or subagents and recreate hidden application state.

An optional perRequest bridge may be implemented later only when all of these conditions hold:

  • the administrator explicitly enables the bridge;
  • the tool is declared sessionIndependent: true;
  • one request can be completed without state from a prior backend call;
  • initialize, call, and delete are bounded by independent deadlines;
  • backend session creation has separate global and per-principal limits;
  • every success and failure path attempts backend teardown;
  • metrics expose incomplete teardown without logging session ids.

The bridge performs initialize, one operation, and delete. It must never be selected silently by auto discovery.

Backend auto Discovery

auto is optional and must be conservative:

  1. Probe server/discover using the preferred enabled stateless version.
  2. Cache the result by normalized target, principal fingerprint, configuration revision, and a bounded TTL.
  3. Select stateless only after a valid discovery response.
  4. Fall back to legacy only for an explicit unsupported-version or method-not-found response indicating an older server.

Do not fall back after authentication or authorization rejection, TLS failure, timeout, malformed response, header mismatch, DNS/SSRF rejection, or HTTP 5xx. Those failures are terminal for that attempt because fallback could become a downgrade path.

Explicit Application State Handles

The stateless protocol does not prohibit stateful applications. A backend may return an opaque handle such as basket_id or browser_id, and later tools may accept that handle as an ordinary argument.

The gateway does not introduce a generic handle type or handle registry. It continues to authorize the tool call, validate the input schema, apply masking, and filter the result. The backend that owns the handle must:

  • validate (handle, auth_context) on every call;
  • avoid treating possession as authorization when authentication exists;
  • document lifetime and recovery behavior in the tool description;
  • return a useful expired-handle error;
  • provide bounded expiry and optional cleanup tools;
  • use at least 128 bits of cryptographic entropy and a bounded lifetime when an unauthenticated handle necessarily acts as a bearer capability.

Handles, session ids, request ids, and bearer credentials must not be metric labels or appear in normal logs.

Authentication and Authorization

The MCP handler remains inside the normal light-gateway handler chain. The security or unified-security handler must establish McpRequestContext.auth before the MCP handler runs on a protected route.

For both profiles:

  • complete authentication, or an explicit anonymous-route decision, before protocol state mutation or backend traffic;
  • apply delegation binding before the requested operation;
  • evaluate tools-list visibility against the current request identity;
  • evaluate request access control before tool execution;
  • apply response filtering after backend execution;
  • fail closed when policy is unavailable under a default-deny deployment;
  • forward only explicitly allowed identity/delegation material to a backend;
  • strip and regenerate hop-by-hop, MCP routing, protocol, and session headers.

For the stateless profile, every protected request carries fresh authorization input and must be independently authenticated and authorized. An explicitly anonymous request is still independently rate-limited and evaluated against the route's anonymous policy. No previous request, connection, discovery response, or subscription grants authority to a later request.

For the legacy profile on a protected route, the current request must both authenticate successfully and match the session's stored principal fingerprint. Session validation does not replace current-token expiry, revocation, audience, issuer, or scope validation.

Frontend Resource-Server Boundary

For a protected MCP route, light-gateway is the OAuth resource server even though the MCP router delegates token parsing to security or unified-security. The product deployment must provide:

  • OAuth Protected Resource Metadata for the canonical MCP resource URI;
  • exact audience/resource validation for every bearer token;
  • Authorization: Bearer on every protected HTTP request, never a query-string access token;
  • HTTP 401 with an appropriate WWW-Authenticate challenge for a missing, invalid, or expired token;
  • HTTP 403 and an insufficient_scope challenge with the required scope set when the authenticated principal lacks permission;
  • a resource_metadata link and scope guidance consistent with the canonical MCP resource;
  • bounded step-up behavior on clients, without repeatedly replaying an ambiguous mutation.

JSON-RPC authorization errors may accompany the HTTP response where permitted, but they do not replace the required HTTP status and challenge headers.

Backend Client and Token Audience Boundary

For an apiType: mcp target, light-gateway is also an MCP client. A bearer token accepted for the frontend gateway resource must not be copied to a different backend MCP resource merely because it arrived in an agent header. That would violate audience binding and create a confused-deputy path.

Each backend target must select one credential strategy:

  • forward a caller token only when independent validation proves that the backend is an intended audience/resource for that exact token;
  • exchange or mint a bounded delegated token for the backend resource;
  • use a configured service credential when the call is intentionally performed as the gateway rather than the end user;
  • use no credential only for an explicitly anonymous backend.

The strategy is part of the normalized backend identity and cache key. Tokens, refresh tokens, client secrets, PKCE verifiers, and registered client metadata are owned by the security/client runtime, not stored in McpGatewaySession, backend discovery caches, or tool configuration.

For caller forwarding, the gateway security boundary verifies aud against the normalized configured backendResource before opening backend traffic; backend validation is defense in depth, not the gateway's authorization decision. String and array claims use exact audience membership. Missing or mismatched audience evidence fails closed. An opaque token cannot use caller mode unless trusted introspection returns the required audience evidence.

Release Authorization Dependencies

The MCP router consumes an authenticated principal, but the complete release also changes OAuth behavior. Before claiming 2026-07-28 compliance, the relevant light-fabric security and client modules must verify or explicitly defer:

  • authorization-response iss validation against previously validated issuer metadata;
  • binding persisted client credentials to the issuer that created them;
  • correct OpenID Connect application_type when deprecated Dynamic Client Registration is used for compatibility;
  • Client ID Metadata Documents as the preferred dynamic registration model;
  • .well-known protected-resource and authorization-server discovery rules;
  • confidential refresh-token storage, rotation requirements for public clients, and correct optional offline_access behavior;
  • bounded scope accumulation and step-up retries.

These are cross-cutting security dependencies rather than duplicate MCP-router implementations. Their release-gate evidence must nevertheless be linked from the coverage matrix.

Response Model and Streaming

The existing response model stores a complete Vec<u8> and a streamed boolean. The Pingora writer sends that body once with end = true. That is sufficient for a single JSON response or one buffered SSE frame, but it cannot implement subscriptions/listen.

Before adding subscriptions, replace it with an explicit response body:

#![allow(unused)]
fn main() {
enum McpResponseBody {
    Empty,
    Buffered(Bytes),
    Stream(McpResponseStream),
}

struct McpResponseStream {
    receiver: BoundedReceiver<Bytes>,
    cancellation: CancellationToken,
}
}

The gateway writer must keep a streaming response open, apply backpressure, detect disconnect, cancel producers, and finish exactly once. Buffered SSE and long-lived SSE must not share a misleading boolean flag.

For a stateless SSE response, closing the HTTP stream cancels that request; notifications/cancelled is not expected on Streamable HTTP. The writer stops work as soon as practical and emits nothing after cancellation. It also sends X-Accel-Buffering: no. A long-lived subscription may emit bounded SSE comment keep-alives to survive intermediary idle timeouts; comments carry no JSON-RPC meaning and consume the subscription byte/rate budget.

subscriptions/listen

When implemented, a client sends subscriptions/listen through POST and explicitly requests supported notification types. The response is a long-lived SSE stream.

The gateway must:

  1. Authorize the listen request independently.
  2. Enforce global and per-principal subscription limits.
  3. Open a bounded event channel.
  4. Send notifications/subscriptions/acknowledged as the first JSON-RPC message.
  5. Identify the subscription with the original request id.
  6. Tag each emitted notification with io.modelcontextprotocol/subscriptionId.
  7. Emit only notification types requested by the client and supported by the gateway.
  8. Cancel the producer when the HTTP stream closes, expires, reloads incompatibly, or encounters a slow consumer.

On deliberate server teardown, the gateway sends the empty subscriptions/listen result (with the original request id and the modern complete discriminator) before closing the SSE response. A saturated slow consumer may instead observe a remote close when the bounded channel cannot admit that terminal result; the gateway never grows or replays the queue to make graceful close succeed.

The first supported notification should be toolsListChanged, produced after a successful MCP router or relevant policy reload. Prompts and resources remain unadvertised until the gateway owns equivalent event sources.

A disconnected stream is not resumable. The client must create a new request with a new JSON-RPC id, re-fetch authoritative state when necessary, and re-subscribe. The gateway ignores Last-Event-ID and keeps no replay buffer for this profile.

An access token does not gain an indefinite lifetime because its response stream remains open. A protected subscription closes at token expiry, policy revocation convergence deadline, configured maximum duration, or gateway shutdown, whichever occurs first. Reconnection performs fresh authentication, authorization, discovery/list rehydration when needed, and subscription creation.

Configuration

The following is the locked configuration contract. All new fields have Serde defaults so existing configuration continues to load unchanged.

enabled: ${mcp-router.enabled:true}
path: ${mcp-router.path:/mcp}
maxSessions: ${mcp-router.maxSessions:10000}
maxSessionsPerClient: ${mcp-router.maxSessionsPerClient:100}
maxRequestBodyBytes: ${mcp-router.maxRequestBodyBytes:1048576}
maxResponseBodyBytes: ${mcp-router.maxResponseBodyBytes:4194304}
maxJsonDepth: ${mcp-router.maxJsonDepth:128}
originAllowlist: ${mcp-router.originAllowlist:[]}

schema:
  defaultDialect: ${mcp-router.schema.defaultDialect:https://json-schema.org/draft/2020-12/schema}
  allowExternalRefs: ${mcp-router.schema.allowExternalRefs:false}
  maxSchemaBytes: ${mcp-router.schema.maxSchemaBytes:1048576}
  maxDepth: ${mcp-router.schema.maxDepth:64}
  maxSubschemas: ${mcp-router.schema.maxSubschemas:4096}
  maxConcurrentValidations: ${mcp-router.schema.maxConcurrentValidations:32}
  validationWatchdogMs: ${mcp-router.schema.validationWatchdogMs:50}

protocols:
  legacy:
    enabled: ${mcp-router.protocols.legacy.enabled:true}
    versions: ${mcp-router.protocols.legacy.versions:["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]}
  stateless:
    enabled: ${mcp-router.protocols.stateless.enabled:false}
    versions: ${mcp-router.protocols.stateless.versions:["2026-07-28"]}
    discoverTtlMs: ${mcp-router.protocols.stateless.discoverTtlMs:30000}
    discoverCacheScope: ${mcp-router.protocols.stateless.discoverCacheScope:private}
    maxDiscoverCacheEntries: ${mcp-router.protocols.stateless.maxDiscoverCacheEntries:1024}
    toolsListTtlMs: ${mcp-router.protocols.stateless.toolsListTtlMs:30000}
    toolsListCacheScope: ${mcp-router.protocols.stateless.toolsListCacheScope:private}
    maxToolsListCacheEntries: ${mcp-router.protocols.stateless.maxToolsListCacheEntries:4096}
    maxToolsListItems: ${mcp-router.protocols.stateless.maxToolsListItems:1024}
    maxConcurrentRequests: ${mcp-router.protocols.stateless.maxConcurrentRequests:1024}
    maxConcurrentRequestsPerPrincipal: ${mcp-router.protocols.stateless.maxConcurrentRequestsPerPrincipal:32}
    maxConcurrentBackendCallsPerTarget: ${mcp-router.protocols.stateless.maxConcurrentBackendCallsPerTarget:32}
    maxSubscriptions: ${mcp-router.protocols.stateless.maxSubscriptions:10000}
    maxSubscriptionsPerPrincipal: ${mcp-router.protocols.stateless.maxSubscriptionsPerPrincipal:4}
    maxSubscriptionDurationMs: ${mcp-router.protocols.stateless.maxSubscriptionDurationMs:900000}
    statelessToLegacyBridge: ${mcp-router.protocols.stateless.statelessToLegacyBridge:reject}

tools: ${mcp-router.tools:[]}

The legacy version list above is the active compatibility baseline for this release.

Example backend declaration:

tools:
  - name: weather
    description: Get weather information
    apiType: mcp
    targetHost: https://weather.internal
    path: /mcp
    method: call
    backendMcpProtocol: stateless
    sessionIndependent: true
    backendCredentialMode: service
    backendResource: https://weather.internal/mcp
    inputSchema:
      type: object
      properties:
        city:
          type: string

Configuration validation must reject:

  • no enabled protocol versions;
  • 2026-07-28 listed under the legacy adapter;
  • a legacy version listed under the stateless adapter;
  • an invalid or non-normalized origin or a wildcard/suffix origin rule;
  • unsupported cacheScope or bridge values;
  • allowExternalRefs: true without a separately approved resolver policy;
  • invalid, unsupported-dialect, or over-limit tool schemas;
  • invalid tool names when the stateless profile exposes those tools;
  • invalid, duplicate, sensitive, or unreachable x-mcp-header annotations;
  • zero or internally inconsistent resource limits;
  • sessionIndependent on a normal HTTP tool when treated as an MCP bridge control;
  • a stateless MCP target without explicit backendCredentialMode, or a caller/exchange target without backendResource;
  • the deprecated caller-compat credential mode on a new stateless target;
  • conflicting backend profiles for one normalized backend target;
  • auto when backend discovery is disabled by product policy.

discoverCacheScope: public and toolsListCacheScope: public are invalid whenever authentication, delegation, or access-control policy can change the corresponding result. An empty origin allowlist means browser-originated MCP requests are rejected; it does not reject non-browser requests without an Origin header.

These field names are the locked configuration contract. The secure defaults are normative: legacy enabled, stateless disabled, and stateless-to-legacy bridging rejected. The runtime models the fixed-value cache-scope and bridge fields explicitly. Only private and reject, respectively, are accepted in this release; unknown stateless protocol fields fail configuration loading instead of being silently ignored.

For an explicit targetHost that does not set toolMetadata.runtime.allowPrivateTargetHost: true, SSRF protection is split across two checks. Literal IP addresses are rejected during static URL validation when they are loopback, private, link-local, or metadata addresses. Hostnames are also checked by the HTTP client's connection-time DNS resolver; the resolver rejects the entire lookup when any returned address is non-public, closing the validation-to-connect DNS rebinding window.

Targets resolved from the privileged service registry control plane are already approved internal targets. This includes both direct-registry.directUrls and portal-discovered nodes. The resolved target carries that trust decision to the separate private-target client without requiring duplicate per-tool metadata. The public client is never silently downgraded for an explicit target. The public-target resolver cannot be combined with an HTTP proxy because the proxy would resolve the origin outside the gateway's connection-time policy; such a client configuration fails closed.

Resource Limits

Legacy session capacity and stateless request capacity are separate budgets. One must not consume or release permits from the other. maxResponseBodyBytes must be at least 2048 so the gateway can always return a bounded protocol error; error messages are truncated safely and backend error bodies are never copied into client-visible errors.

The stateless profile requires bounded:

  • request body bytes and JSON nesting depth;
  • tools returned per list result, in addition to the buffered response-byte limit;
  • schema document bytes, schema depth, subschema count, reference expansion, and validation time;
  • concurrent requests globally and per principal;
  • concurrent backend calls per target;
  • response bytes for buffered responses;
  • tools-list cache entries and TTL;
  • discovery cache entries and TTL;
  • extension metadata and MRTR state, even when the initial limit is zero because those features are unsupported;
  • open subscriptions globally and per principal;
  • events and bytes queued per subscription;
  • subscription lifetime, additionally capped by current credential expiry on a protected route;
  • initialization and teardown work for an enabled compatibility bridge.

Schema validation runs on a dedicated fixed-size worker pool, not Tokio's core workers or shared blocking pool. The pool is bounded by available parallelism and maxConcurrentValidations; admission and queue capacity are bounded by the same configured ceiling, and overload fails before enqueue. The duration watchdog is observational because a running validator cannot be cancelled. Structural bounds, linear-time regex, the adversarial corpus, and isolation bound the gateway-wide impact without claiming a hard per-validation timeout.

Overload responses must be explicit and observable. A slow subscription is closed rather than allowed to grow an unbounded queue. Limits should use RAII permits so completion, timeout, cancellation, panic unwinding, and disconnect all release capacity.

Reload, Scaling, and Failure Semantics

Configuration Reload

Legacy frontend sessions and their mapped backend sessions continue to survive a compatible in-process reload. If a reload disables a version still used by an established legacy session, the rollout policy must decide whether to retain that version until session expiry or terminate affected sessions explicitly; it must not reinterpret the session under another version.

Stateless ordinary requests retain no protocol state across calls. New requests immediately use the new router and policy revisions.

An active subscription owns a live response stream, not a protocol session. The subscription hub should be shared across compatible runtime swaps so a successful catalog reload can publish toolsListChanged. An incompatible reload closes affected streams; clients reconnect and re-subscribe.

Multiple Replicas

Ordinary stateless requests must work through round-robin routing without affinity or a shared protocol session store. Caches may remain per replica if their keys and invalidation rules are safe.

Legacy requests still require affinity to the gateway process that owns the frontend session, or a separately designed shared session/routing layer. Adding the stateless profile does not make legacy sessions horizontally portable.

Subscriptions remain attached to the replica holding their HTTP stream. They do not require subsequent ordinary requests to return to that replica.

Retry and Downgrade

The gateway and clients must distinguish compatibility failures from security and availability failures.

Permitted compatibility fallback:

  • server/discover returns method not found from a server believed to predate the stateless profile;
  • the server returns a well-formed unsupported-version error listing a mutually supported legacy version.

Terminal failures with no automatic downgrade:

  • authentication or authorization rejection;
  • missing or mismatched headers;
  • TLS or certificate failure;
  • DNS or SSRF rejection;
  • malformed JSON-RPC or discovery response;
  • timeout, connection failure, or HTTP 5xx;
  • missing required client capability;
  • policy or internal gateway failure.

Mutations with ambiguous outcomes are not replayed unless their tool metadata explicitly allows a safe retry. Read-only or declared-idempotent calls may use the existing bounded retry policy.

Observability

Record bounded labels for:

  • frontend profile and protocol version;
  • JSON-RPC method;
  • configured tool name;
  • backend type and backend profile;
  • status and normalized error class;
  • compatibility fallback decision;
  • cache hit or miss;
  • active request and subscription counts;
  • subscription termination reason;
  • legacy session and bridge capacity utilization.

Structured diagnostics may include correlation id, method, configured tool name, status, elapsed time, and byte counts. They must omit request arguments, result bodies, cookies, authorization headers, CSRF values, session ids, explicit state handles, and raw principal identifiers.

Valid W3C trace context received in the protocol-defined _meta fields may be propagated through the gateway's tracing model. Conflicting or malformed trace context must not replace independently generated correlation identifiers.

2026-07-28 Release Coverage Matrix

This matrix is the traceability contract between the consolidated release, this design, the responsible light-fabric boundary, and the delivery gate. Required means the feature is needed before the stateless profile is enabled. Deferred means the gateway must omit the capability and reject or translate the feature explicitly. Dependency means another light-fabric module owns the behavior but must provide release evidence.

Release areaPrimary sourcesGateway decision and ownerStatus and gate
Remove protocol sessions and Mcp-Session-IdSEP-2567Stateless frontend adapter never touches the session store; legacy adapter remains isolatedRequired, Phases 1-2
Remove initialize handshake; per-request version and capabilitiesSEP-2575Classifier and versioned frontend adapters normalize into EffectiveMcpRequestContextRequired, Phases 1-2
server/discoverSEP-2575, discovery specImplement auth-aware cacheable discovery with truthful capabilities, server identity, TTL, and cache scopeRequired, Phase 2
Standard HTTP routing and parameter headersSEP-2243Validate and regenerate Mcp-Method, applicable Mcp-Name, and valid Mcp-Param-* headersRequired, Phases 2-3
Streamable HTTP request rules and Origin protectionTransport specEnforce POST body/content negotiation, exact Origin allowlist, profile-specific GET/DELETE rules, initial notification rejection, and 202 only for a future supported notificationRequired, Phase 2
Session-independent list resultsSEP-2567Catalog may vary by principal/config/policy, never by connection or prior callRequired, Phase 2
Cache TTL and scopeSEP-2549server/discover and tools/list return bounded ttlMs and normally private cacheScopeRequired, Phase 2
Deterministic tool orderingTools specPreserve BTreeMap ordering after authorization filtering and test byte-stable resultsRequired, Phase 2
Tool name format and collision handlingSEP-986, tools specValidate stateless-exposed names; require explicit aliases for aggregate collisionsRequired, Phases 1-2
JSON Schema 2020-12 and schema dialectsSEP-1613, SEP-2106, base specAdd bounded compile/validation; 2020-12 mandatory; external network references disabledRequired, Phases 1-2
Tool input validation error semanticsSEP-1303, tools specReturn schema failures as bounded isError: true tool results; reserve protocol errors for malformed envelopes and unknown toolsRequired, Phases 1-2
Arbitrary structuredContent and output schemasSEP-2106, tools specSupport all JSON roots and validate final filtered output against configured outputSchemaRequired, Phase 2
Standalone request/result payload definitionsSEP-1319No wire-format change; pin generated schemas and keep protocol-neutral payload models separate from JSON-RPC adaptersRequired architecture boundary, Phases 0-1
Required resultTypeSEP-2322, base specEmit complete; accept absent only from earlier negotiated backends; reject unknown valuesRequired, Phases 2-3
Multi Round-Trip RequestsSEP-2322, SEP-2260Do not advertise initially; reject input_required from a backend without down-conversionDeferred, separate design
Elicitation and MRTR migration changesSEP-1034, SEP-1036, SEP-1330, changelogNot reachable while MRTR and elicitation are unadvertised; a later design must cover defaults, URL/enum schemas, and removal of notifications/elicitation/complete and elicitationIdDeferred with MRTR
subscriptions/listenSEP-2575Replace buffered streaming abstraction, then add bounded tools-list-change streamsDeferred to Phase 5
Remove SSE replay and Last-Event-IDSEP-2575No replay buffer; clients retry with a new request id and rehydrate stateRequired, Phase 2/5
Remove ping, logging/setLevel, roots-list-changed, old subscribe methodsSEP-2575Return method not found and keep capabilities absentRequired boundary, Phase 2
Per-request log level and message notification ruleSEP-2575, changelogDo not emit or forward notifications/message unless that request supplied io.modelcontextprotocol/logLevel; retain no logging stateRequired boundary, Phase 2
Trace context in _metaSEP-414Validate and bridge W3C trace context without overriding trusted gateway correlation stateRequired, Phase 2
Core extensions map and independent extension versionsSEP-2133Empty/absent initially; only validated adapters may advertise or forward an extensionRequired boundary, Phase 2
Tasks extensionSEP-2663Do not advertise; task support is forbidden/absent until a separate extension designDeferred
MCP Apps extensionSEP-1865Do not advertise or forward UI metadata without a separate sandbox/consent designDeferred
Deprecate roots, sampling, logging, and sampling includeContext valuesSEP-2577, SEP-2596Do not add new deprecated features or the deprecated thisServer/allServers values to this tool-only gateway profileNo new implementation
Feature lifecycle and HTTP+SSE deprecationSEP-2596Keep legacy Streamable HTTP only; no implicit two-endpoint HTTP+SSE fallbackRequired boundary, Phase 0
MCP error-code allocationBase spec/changelogPreserve legacy implementation codes by version; reserve -32020 through -32022 for their assigned stateless meanings; lock implementation-defined -32000 to the catalog resource-limit errorRequired, Phases 0-2
Resource-not-found error becomes -32602SEP-2164No direct tool-only behavior; any future resource adapter must be version-awareDeferred resource design
Authorization issuer validation and mix-up defenseSEP-2468Security/client runtime validates recorded issuer and returned issDependency, Phase 0 gate
Client registration type and issuer-bound credentialsSEP-837, SEP-2352Client runtime owns registration metadata and keys credentials by issuerDependency, Phase 0 gate
Protected-resource discovery, refresh tokens, and scope step-upAuthorization spec, SEP-2207, SEP-2350, SEP-2351Security/client runtime owns metadata, token, challenge, and bounded step-up behaviorDependency, Phase 0 gate
Dynamic Client Registration deprecationChangelogDo not add DCR to the MCP router; prefer Client ID Metadata Documents in owning client codeDependency/boundary
Authorization extensionsSEP-2133, authorization extensionsDisabled and unadvertised unless separately configured, negotiated, and testedDeferred
Conformance scenarios required for standardsSEP-2484Pin official schema/scenarios and map every supported feature to CI evidenceRequired, Phase 6
Schema generator numeric correctionChangelogPin final generated schema; do not maintain hand-copied numeric field typesRequired, Phase 0
Governance and SEP process changesSEP-1850 and governance entriesNo runtime behavior; retain source links and final-spec refresh procedureNo runtime impact

The matrix must be updated in the same change whenever the implementation advertises another core capability or extension. A capability without an owner, resource bounds, authorization behavior, and conformance evidence is invalid.

Delivery Sequence

Phase 0: Contract and Legacy Baseline

  • Add the current stable 2025-11-25 revision to the legacy compatibility suite before introducing 2026-07-28.
  • Freeze legacy initialize, notification, list, call, SSE, DELETE, access-control, reload, and backend-session fixtures.
  • Vendor or pin the RC TypeScript and generated JSON schemas plus conformance scenario revision, then replace them with the final July 28 artifacts.
  • Record schema/checksum provenance so generated field types are not copied by hand.
  • Lock the -32000 catalog resource-limit error and over-limit no-truncation fixtures before implementing stateless tools/list.
  • Add legacy principal-to-session binding.
  • Close or assign every authorization dependency in the release coverage matrix, including backend token-audience strategy.

Phase 1: Protocol-Neutral Core

  • Extract the classifier and EffectiveMcpRequestContext.
  • Separate protocol validation/envelopes from common authorization and tool execution.
  • Replace version constants with configured profile registries.
  • Add configuration fields with backward-compatible defaults.
  • Add bounded JSON Schema 2020-12 compilation and input/output validators.
  • Validate stateless tool names and x-mcp-header annotations at load time.

Phase 2: Stateless Frontend Vertical Slice

  • Implement server/discover, tools/list, and tools/call.
  • Enforce Origin, content negotiation, per-request metadata, method rules, and SEP-2243 headers.
  • Add stateless result, cache, and error envelopes.
  • Add the empty extension boundary and reject unsupported result types/MRTR.
  • Keep subscriptions and backend bridging disabled and unadvertised.

Phase 3: Stateless Backend Adapter

  • Add explicit backend profiles.
  • Implement legacy-to-stateless and stateless-to-stateless translation.
  • Validate backend schemas, result types, extension capabilities, and output conformance according to the negotiated backend version.
  • Add an audience-correct credential strategy per backend target.
  • Add conservative backend discovery and downgrade tests.

Phase 4: Optional Compatibility Bridge

  • Keep rejection as the default.
  • If a real compatibility requirement exists, implement the bounded per-request bridge only for explicitly session-independent tools.

Phase 5: Streaming and Subscriptions

  • Replace the buffered response abstraction.
  • Implement cancellation-safe long-lived POST response streams.
  • Add toolsListChanged subscriptions and truthful discovery capability.

Phase 6: Conformance and Canary

  • Run the final official conformance suite where available.
  • Verify every Required and Dependency coverage-matrix row has linked test or operational evidence.
  • Exercise the full frontend/backend compatibility matrix.
  • Test reload, multi-replica routing, downgrade resistance, limits, disconnect, and credential leakage.
  • Enable stateless support for a canary client and target before changing the product default.

Verification Matrix

At minimum, automated tests must cover:

AreaRequired cases
ClassificationLegacy initialize, legacy session request, stateless request, missing session, stale session header ignored by a fully identified stateless request, header/meta mismatch, unsupported version
HTTP transportJSON content type, Accept requires JSON and SSE, exact Origin allowlist, empty allowlist with browser/non-browser clients, POST/GET/DELETE matrix, client-response rejection, unsupported notification rejection, future accepted-extension notification 202, unknown-method HTTP 404 plus JSON-RPC -32601
Legacy regressionExisting JSON and SSE responses, DELETE, expiry, reload preservation, backend session reuse and teardown
Stateless discoveryDeterministic versions/capabilities, no false capabilities, server identity in _meta, TTL/scope, principal-aware cache, unsupported version details
Stateless listPer-principal visibility, deterministic order, bounded complete catalog without nextCursor, rejection of unissued cursors, whole-request -32000 failure without truncation/caching when item or response limits are exceeded, private cache, TTL, policy/config invalidation
Tool schemasDefault and explicit dialects, invalid schema, local and external $ref, composition/depth/time bounds, arbitrary output roots, post-filter output validation
Tool error semanticsMalformed envelope and unknown tool produce protocol errors; input-schema failures produce bounded resultType: complete, isError: true results before backend traffic
Tool headers and namesValid/invalid names, collision handling, all x-mcp-header constraints, encoding, missing/null values, mismatch, sensitive/header conflict
Stateless callHTTP backend, stateless MCP backend, authorization denial, masking, filtering, output conformance, safe retries, no session-store mutation
Results and MRTRRequired complete, missing field by backend version, unknown extension result, unsupported input_required mapped to the exact gateway-generated tool error without opaque state, new request id on a future retry
Request-scoped notificationsNo notifications/message without per-request log level; no retained log-level state; initiating response stream used instead of subscription stream
ExtensionsEmpty capability map, unknown optional extension, required unsupported extension, no backend extension smuggling, Tasks and Apps absent
AuthorizationProtected-resource metadata, token on every request, 401/403 challenges, issuer/audience binding, frontend token not forwarded to wrong backend, bounded scope step-up
Backend compatibilityAll six frontend/backend matrix cells with explicit success or incompatibility outcome
Downgrade resistanceNo fallback after 401, 403, TLS, timeout, malformed response, mismatch, SSRF rejection, or 5xx
Resource safetyBody/depth/concurrency/cache/subscription bounds, cancellation, permit release, slow consumer
StreamingFirst acknowledgment, subscription id tagging, disconnect cleanup, reload, no replay or resumption
ScalingStateless calls across alternating replicas; legacy behavior requires documented affinity
SecretsNo token, cookie, CSRF, session id, handle, key, or private payload in logs and gate output

The release gate must prove that enabling the stateless adapter does not alter legacy fixtures when the same legacy configuration is loaded.

Risks and Mitigations

RiskMitigation
Missing session id is misclassified as statelessRequire the complete 2026-07-28 version and metadata contract before selecting stateless
Legacy session id crosses principalsBind the session to a verified principal fingerprint and revalidate current authentication on every request
Wire behavior drifts between profilesShare application logic and isolate only versioned adapters and envelopes
Gateway silently downgrades after a security failurePermit fallback only for explicit method/version compatibility responses
Stateless client is proxied through shared hidden legacy stateReject by default; allow only bounded per-request bridging for declared session-independent tools
Tool catalog leaks across usersUse private cache scope and auth/policy/config-aware cache keys
Capability advertisement exceeds implementationBuild capabilities from enabled handlers and tested features, not schema availability
Buffered SSE is mistaken for subscription supportReplace the response type and add disconnect/cancellation tests before advertising subscriptions
Config reload leaves stale list resultsInclude revisions in cache keys, invalidate on reload, and publish list change only after a successful swap
A future discovery-dependent catalog is stale until TTLDo not enable it until the registry provides a monotonic revision plus push/watch invalidation; treat TTL only as a documented backstop
Complex schemas exhaust CPU or trigger SSRFCompile with depth/subschema/time limits and disable external network $ref resolution
Filtering produces output that violates the advertised schemaValidate final structuredContent after filtering and return a tool error on mismatch
Unknown extensions or result types pass through the facadeAdvertise and forward only explicitly adapted, versioned extensions; reject unknown result types
Frontend bearer token is replayed to another resourceRequire an audience-correct backend credential strategy and never transit arbitrary tokens
Raw frontend or unrecognized headers reach a stateless backendUse a typed outbound allowlist and regenerate admitted context from trusted gateway state
Browser Origin is treated as ordinary CORS metadataEnforce exact request Origin validation before JSON parsing; empty allowlist rejects browser origins
Release-candidate schema changesKeep stateless disabled until the final schema and error registry are pinned and conformance tests pass

Acceptance Criteria

This design is implemented when:

  1. One /mcp endpoint concurrently accepts enabled legacy and 2026-07-28 clients without heuristic profile selection.
  2. Existing legacy contract tests remain unchanged and pass.
  3. A stateless list or call touches no frontend session state and can be routed to alternating gateway replicas.
  4. Both profiles use the same access-control, masking, execution, filtering, audit, and metrics core.
  5. Backend protocol compatibility follows the explicit matrix and never silently pools hidden state.
  6. Discovery and capabilities describe only implemented behavior.
  7. Required stateless headers, metadata, result fields, cache fields, and error codes conform to the final 2026-07-28 specification.
  8. Tool names, inputSchema, optional outputSchema, arbitrary structured content, and x-mcp-header behavior conform to the final tools and schema contracts under bounded validation.
  9. Origin validation, frontend OAuth challenges, issuer/audience binding, and backend credential selection pass their release gates.
  10. Unknown or deferred core features and extensions remain unadvertised and cannot pass transparently through the gateway.
  11. Every Required and Dependency release-coverage row links to test or operational evidence.
  12. All request, response, schema, concurrency, cache, and optional streaming resources are bounded and cancellation-safe.
  13. Stateless support remains opt-in until the final conformance and canary gates pass.

Resolved Conditional Profile Decision

The Phase 9 dependency assessment found no configured backend that requires auto discovery and no named legacy backend with an approved session-independence proof. The release therefore keeps backend profiles explicit, keeps stateless-to-legacy behavior at reject, and adds no dormant discovery cache or hidden backend-session machinery. This decision may be reopened only for a named dependency with the compatibility fixtures, security review, administrator opt-in, lifecycle bounds, and teardown evidence required by the applicable design section.

The browser controller route /ctrl/mcp does not qualify: it remains a separate JSON/WebSocket control plane routed by websocket-router, not an mcp-router Streamable HTTP backend.

Open Decisions Before Implementation Lock

  • Confirm the final July 28 schema's exact clientInfo and serverInfo requirements; the SEP text and consolidated RC use different normative strength.
  • Decide whether MCP Origin policy is stored directly in mcp-router.yml or supplied by a shared exact-origin security module. One component must be the authoritative validator; duplicated allowlists are not acceptable.
  • Select additional JSON Schema dialects, if any. Supporting only mandatory 2020-12 is the safest initial profile.
  • Select each backend target's audience-correct credential strategy and define which component performs token exchange or service-token acquisition.
  • Select final default request, cache, and subscription limits from baseline measurements rather than treating the illustrative values as tuned limits.
  • Decide whether subscription state should survive an in-process MCP router configuration swap or close and force re-subscription. The implementation must make either outcome deterministic and tested.

WebSocket Router

Status

Phases 1, 2, and 3 are implemented. Phase 1 added configuration parsing, Java-compatible pathPrefixService normalization, route resolution, and upstream URI cleanup in light-pingora. Phase 2 wired the websocket handler into light-gateway with WebSocket upgrade detection, discovery-based upstream selection, request context storage, and upstream header/query cleanup. Phase 3 added a real gateway-to-backend WebSocket integration test for text, binary, close, subprotocol, and header behavior.

Purpose

The Java light-websocket-4j websocket-router module routes WebSocket traffic through a gateway or sidecar. A client connects to the gateway, the router resolves the downstream service from headers, query parameters, or path prefix configuration, and the gateway connects to the target WebSocket service.

In light-fabric this should be a light-pingora traffic handler activated by light-gateway through handler.yml. The same light-gateway binary can link the WebSocket router implementation, while each product decides whether it runs by including the websocket handler and websocket-router.yml configuration from config-server.

The Rust implementation should preserve the Java routing semantics and most of the Java configuration shape, but it should not copy Java's enabled flag or frame-bridging architecture. Pingora already supports HTTP/1 upgrade proxying, so the first implementation should resolve the target and let Pingora tunnel the upgraded connection.

Goals

  • Add a Java-compatible WebSocket router to frameworks/light-pingora.
  • Activate the router with the existing websocket handler id in apps/light-gateway.
  • Keep the Java websocket-router routing configuration recognizable: defaultProtocol, defaultEnvTag, and pathPrefixService.
  • Allow websocket-router.pathPrefixService to be injected by config-server at startup the same way other handler-specific config is injected.
  • Resolve downstream services from header, query parameter, or longest path prefix.
  • Reuse the existing light-gateway discovery and upstream selection model.
  • Preserve WebSocket handshake headers and pass normal agent/browser headers through to the downstream service.
  • Register the router configuration with the module registry and support the same reload model as other light-pingora handler configs.
  • Keep the design suitable for gateway, sidecar, and BFF deployments.

Non-Goals

  • Do not implement a separate WebSocket server framework in light-fabric.
  • Do not terminate and re-create WebSocket frames in the first phase.
  • Do not multiplex multiple client WebSocket sessions over one downstream connection.
  • Do not support HTTP/2 extended CONNECT for WebSocket in the first phase.
  • Do not use Rust dynamic plugins or inventory for WebSocket route registration.
  • Do not create a separate gateway binary for WebSocket routing.
  • Do not use enabled in websocket-router.yml. The handler is active when handler.yml includes websocket in the matched execution chain.

Resolved Decisions

  • Activation is controlled only by handler.yml. If a matched chain includes websocket, the router is enabled for that request.
  • websocket-router.yml should not contain enabled.
  • WebSocket-specific controls should cover both request/upgrade rate and active upgraded connection count.
  • The first implementation should use Pingora HTTP/1 upgrade passthrough, not a frame-aware WebSocket bridge.
  • Invalid websocket-router.yml configuration should fail startup. Invalid reloads should be rejected while the last valid runtime state keeps serving existing traffic.

Java Behavior To Map

Java configuration includes enabled, but the Rust target config removes it:

# Light websocket router configuration
defaultProtocol: ${websocket-router.defaultProtocol:http}
defaultEnvTag: ${websocket-router.defaultEnvTag:}
pathPrefixService: ${websocket-router.pathPrefixService:}
preserveRoutingHeaders: ${websocket-router.preserveRoutingHeaders:false}
idleTimeoutMs: ${websocket-router.idleTimeoutMs:3600000}
maxConnectionDurationMs: ${websocket-router.maxConnectionDurationMs:}
maxActiveConnections: ${websocket-router.maxActiveConnections:}
maxUpgradeRequestsPerSecond: ${websocket-router.maxUpgradeRequestsPerSecond:}

The Java enabled field is intentionally not carried forward. In Rust, the handler chain is the activation contract. Removing websocket from a path or default chain disables WebSocket routing for that path.

Production controls are optional. idleTimeoutMs defaults to one hour; blank or zero values disable the matching control. preserveRoutingHeaders defaults to false, so routing-only Service-Id, service_id, and serviceId headers are stripped before the upstream handshake unless a backend explicitly needs them.

pathPrefixService accepts three forms:

pathPrefixService:
  /chat:
    serviceId: com.networknt.llmchat-1.0.0
    protocol: http
    envTag: dev
pathPrefixService:
  /chat: com.networknt.llmchat-1.0.0
pathPrefixService: {"/chat":{"serviceId":"com.networknt.llmchat-1.0.0","protocol":"http","envTag":"dev"}}

The Java handler resolves the downstream service in this order:

  1. Header: first non-blank value from Service-Id, service_id, or serviceId.
  2. Query parameter: first non-blank value from service_id or serviceId.
  3. Path prefix: pathPrefixService match against the request path.

If a target is found, query parameters can override the target protocol and environment tag:

  • protocol
  • env_tag
  • envTag

The Java handler removes router-only query parameters before connecting to the downstream service:

  • protocol
  • service_id
  • serviceId
  • env_tag
  • envTag

The Java implementation accepts client WebSocket subprotocols, opens a new JDK WebSocket client connection to the downstream service, forwards Authorization, forwards the selected subprotocols, and then bridges text and binary frames in both directions.

Rust Architecture

Add the WebSocket router to light-pingora because it is a Pingora gateway traffic handler.

Proposed module:

frameworks/light-pingora/src/websocket.rs

Primary types:

#![allow(unused)]
fn main() {
pub struct WebSocketRouterConfig {
    pub default_protocol: String,
    pub default_env_tag: Option<String>,
    pub path_prefix_service: BTreeMap<String, WebSocketServiceTarget>,
}

pub struct WebSocketServiceTarget {
    pub service_id: String,
    pub protocol: String,
    pub env_tag: Option<String>,
}

pub struct WebSocketRouteDecision {
    pub service_id: String,
    pub protocol: String,
    pub env_tag: Option<String>,
    pub upstream_path_and_query: String,
}
}

The serde layer should accept Java field names through aliases:

  • defaultProtocol
  • defaultEnvTag
  • pathPrefixService
  • serviceId
  • envTag

Use websocket-router.yml as the preferred Rust file name. Accept websocket-router.yaml as a compatibility fallback.

Config Normalization

Normalize pathPrefixService at load time:

raw config
  -> validate defaultProtocol/defaultEnvTag
  -> parse pathPrefixService YAML map, JSON string map, or legacy key/value string
  -> apply defaults to entries missing protocol or envTag
  -> sort prefixes by length for longest-prefix matching
  -> build Arc<WebSocketRouterState>

An invalid entry should fail config loading instead of being ignored silently. This is stricter than Java and is safer for remote config delivered by config-server.

Handler Registration

apps/light-gateway already reserves the websocket handler id as a traffic handler. The implementation should attach that id to the WebSocket router runtime:

handlers:
  - correlation
  - metrics
  - jwt
  - limit
  - websocket

paths:
  - path: /chat
    method: GET
    exec:
      - correlation
      - metrics
      - jwt
      - limit
      - websocket

The router should only run for chains that include websocket. This lets a BFF serve static SPA assets, REST APIs, MCP, JSON-RPC, and WebSocket endpoints from the same gateway binary with path-specific handler chains.

Request Flow

The target flow should be:

client request
  -> handler.yml path/chain match
  -> cross-cutting request handlers
  -> websocket handler
       -> verify WebSocket upgrade
       -> resolve service target
       -> strip router-only query parameters
       -> store WebSocketRouteDecision in request context
  -> Pingora upstream_peer selects discovered target
  -> Pingora upstream_request_filter preserves WebSocket handshake headers
  -> Pingora proxies the HTTP/1 upgraded stream
  -> response/metrics handlers observe completion

The router should not read the request body and should not buffer WebSocket messages. Once the request is upgraded, Pingora owns the tunnel.

Upgrade Detection

The handler should require the normal WebSocket handshake:

  • method GET
  • Connection contains upgrade
  • Upgrade equals websocket
  • Sec-WebSocket-Key exists
  • HTTP version is compatible with HTTP/1 upgrade

If the websocket handler is selected by handler.yml but the request is not a WebSocket upgrade, return 426 Upgrade Required.

HTTP/2 extended CONNECT can be considered later, but should not block the first implementation.

Target Resolution

Target resolution should match Java precedence:

1. service id header
2. service id query parameter
3. pathPrefixService longest-prefix match

Header names:

Service-Id
service_id
serviceId

Query names:

service_id
serviceId
protocol
env_tag
envTag

For path-prefix matches, use the request path without the query string. When multiple prefixes match, choose the longest prefix.

The resolved protocol should be http or https. Conceptually this maps to ws or wss, but Pingora should still connect to the upstream as HTTP or HTTPS and then perform the WebSocket upgrade.

Header And Query Policy

Because the Rust implementation should use Pingora upgrade passthrough, it should preserve the original handshake headers:

  • Upgrade
  • Connection
  • Sec-WebSocket-Key
  • Sec-WebSocket-Version
  • Sec-WebSocket-Protocol
  • Sec-WebSocket-Extensions
  • Authorization
  • cookies
  • normal agent/browser headers

The router should strip only router-control query parameters from the upstream URI:

  • protocol
  • service_id
  • serviceId
  • env_tag
  • envTag

The service-id routing headers should be removed before the upstream request by default:

  • Service-Id
  • service_id
  • serviceId

This keeps gateway routing controls separate from backend application headers. If a backend later needs these headers, add an explicit config option rather than leaking them by default.

Discovery And Upstream Selection

The WebSocket router should reuse the same discovery/runtime model as router.yml and the existing Pingora proxy flow.

Resolved target:

protocol + serviceId + envTag

Discovery returns an upstream HTTP or HTTPS endpoint. upstream_peer creates the Pingora peer:

  • http: non-TLS upstream
  • https: TLS upstream with normal SNI/hostname handling

For the first implementation, require HTTP/1.1 to the backend for WebSocket upgrade. HTTP/2 WebSocket tunneling can be a later feature.

Error Handling

Errors should be returned before the connection is upgraded:

ConditionResponse
Handler selected but request is not WebSocket upgrade426 Upgrade Required
No service id and no path-prefix match403 Forbidden
Invalid protocol override400 Bad Request
Discovery has no usable endpoint502 Bad Gateway
Upstream connect/upgrade failure502 Bad Gateway

Returning HTTP errors before upgrade is clearer than Java's close-frame behavior because the Rust implementation does not accept the WebSocket until the target is known.

Module Registry And Reload

Register the loaded configuration with the module registry:

module id: light-pingora/websocket-router
config name: websocket-router
config file: websocket-router.yml or websocket-router.yaml

On reload:

  1. Load and validate the new config.
  2. Build a new immutable route state.
  3. Atomically swap the state.
  4. Let in-flight upgraded connections continue with the old decision.

Existing WebSocket tunnels should not be interrupted by a config reload unless the gateway process is restarted.

Observability

The handler should integrate with existing correlation and metrics handlers:

  • include correlation id in pre-upgrade logs
  • record target resolution result
  • record route source: header, query, or pathPrefixService
  • count upgrade attempts, successful upgrades, rejected upgrades, and upstream connection failures
  • optionally record tunnel duration once Pingora exposes completion

Do not log full query strings by default because they may contain application data.

Test Plan

Parser and resolver tests:

  • YAML object pathPrefixService
  • string service id entries
  • JSON string map entries
  • legacy key/value string entries
  • default protocol and env tag application
  • invalid entries fail load
  • header beats query and path prefix
  • query beats path prefix
  • longest prefix wins
  • query protocol/envTag override
  • router query params are stripped

Gateway tests:

  • non-upgrade request to a WebSocket chain returns 426
  • missing target returns 403
  • unknown discovery target returns 502
  • upgrade request preserves Sec-WebSocket-Protocol
  • Authorization and normal browser/agent headers pass through
  • service-id routing headers are stripped before upstream

Integration tests:

  • connect through light-gateway to a local WebSocket echo backend
  • text message round trip
  • binary message round trip
  • close frame behavior
  • subprotocol negotiation
  • TLS upstream smoke test when a local test certificate is available

Implementation Phases

Phase 1: Config And Resolver

Status: implemented.

  • Add frameworks/light-pingora/src/websocket.rs.
  • Parse websocket-router.yml and websocket-router.yaml.
  • Normalize all Java-compatible pathPrefixService forms.
  • Implement target resolution and upstream URI cleanup.
  • Add unit tests.

Phase 2: Gateway Handler Wiring

Status: implemented.

  • Connect the existing websocket handler id to the router runtime.
  • Detect WebSocket upgrade requests in the Pingora request flow.
  • Store WebSocketRouteDecision in the request context.
  • Select the discovered upstream in upstream_peer.
  • Strip router query params and service-id headers in upstream_request_filter.

Phase 3: WebSocket Integration Tests

Status: implemented.

  • Add a local test WebSocket echo service.
  • Verify text, binary, close, subprotocol, and header behavior through light-gateway.
  • Verify HTTP and HTTPS upstream paths if practical in CI.

Phase 4: Production Controls

Status: implemented.

  • Add optional idle timeout and max connection duration.
  • Add WebSocket-specific limit controls for both upgrade/request rate and active upgraded connection count.
  • Add explicit config for preserving routing headers if a backend requires them.
  • Add access-control integration once the same access-control model is shared across REST, JSON-RPC, MCP, and WebSocket routes.

Implementation notes:

  • maxUpgradeRequestsPerSecond gates accepted upgrade attempts before discovery lookup.
  • maxActiveConnections tracks proxied upgraded sessions with a permit that is released when Pingora finishes the request context. The active counter is preserved across router and policy reloads.
  • idleTimeoutMs is applied to downstream and upstream tunnel IO. Pingora's body-filter hooks also check idle age when either side sends tunneled data.
  • maxConnectionDurationMs is checked by the tunnel body filters and is also used as an IO timeout when it is the only timeout configured. A connection that continuously exchanges frames is closed on the next tunneled body chunk after the duration is exceeded.
  • WebSocket access-control uses the shared access-control.yml and rule.yml model. The rule context uses tool name websocket, endpoint from handler.yml, and tool arguments containing serviceId, protocol, envTag, upstreamPathAndQuery, and route source.

Open Questions

None.

Stateless Auth Handler

Status

Initial Rust implementation is complete in light-pingora and light-gateway. It includes the shared SPA session runtime, authorization-code entrypoint, logout, cookie handling, CSRF validation, refresh-token renewal, Google/Facebook/GitHub callback entrypoints, handler wiring, config stubs, and runtime-load tests.

Purpose

The Java light-spa-4j stateless-auth module is the BFF login bridge for SPA deployments that use OAuth 2.0 authorization code flow in the cloud. The browser completes the provider redirect, calls the gateway callback path with the authorization code, and the gateway exchanges that code for light-oauth tokens. The gateway then stores the internal access token, refresh token, user metadata, and CSRF value in browser cookies.

In light-fabric this should be a light-pingora security handler used by light-gateway. The handler should be activated by handler.yml, loaded from config-server with the same product-level configuration model as the rest of the gateway, and implemented with the same shared SPA session runtime used by the MSAL exchange handler.

Goals

  • Preserve the Java BFF behavior for authorization code login, logout, CSRF validation, refresh-token renewal, and downstream Authorization injection.
  • Keep the Java statelessAuth.yml field names recognizable so light-portal can inject statelessAuth.* values into config-server output.
  • Use handler.yml as the primary activation and ordering contract.
  • Keep the existing stateless handler id as the public handler-chain name.
  • Share cookie, CSRF, JWT parsing, refresh-token single-flight, and Authorization injection code with the MSAL exchange handler.
  • Use the existing client.yml OAuth token configuration for authorization code and refresh-token calls.
  • Register the loaded config in ModuleRegistry and reject invalid config at startup.
  • Support BFF chains that also use static SPA serving, proxy/router, WebSocket routing, and MCP routing.
  • Support Google, Facebook, and GitHub login entrypoints in addition to the generic authorization-code callback.

Non-Goals

  • Do not use Rust dynamic plugins or inventory.
  • Do not create a separate BFF binary.
  • Do not store server-side browser sessions in the first implementation.
  • Do not require the Rust social-login implementation to copy Java's provider-specific classes. Rust should preserve the external behavior and config contract, but it can use established OAuth/OIDC crates for provider protocol handling.
  • Do not redirect the browser from the gateway by default. Java returns a JSON body containing redirectUri, denyUri, and scopes; Rust should preserve that behavior.

Resolved Decisions

  • Google, Facebook, and GitHub login handlers are in scope. The existing google, facebook, and github handler ids should remain as public handler-chain names.
  • Rust should prefer provider-appropriate crates instead of hand-rolling every provider flow. openidconnect is a good fit for OpenID Connect providers such as Google, and oauth2 is a good fit for plain OAuth 2.0 providers or provider-specific extensions.
  • cookieTimeoutUri should be used by Rust to return a structured session-expired response when a browser session cannot be renewed.

Java Behavior To Map

Java config file:

enabled: ${statelessAuth.enabled:true}
redirectUri: ${statelessAuth.redirectUri:https://localhost:3000/#/app/dashboard}
denyUri: ${statelessAuth.denyUri:https://localhost:3000/#/app/dashboard}
enableHttp2: ${statelessAuth.enableHttp2:false}
authPath: ${statelessAuth.authPath:/authorization}
logoutPath: ${statelessAuth.logoutPath:/logout}
logoutCsrfEnforced: ${statelessAuth.logoutCsrfEnforced:false}
cookieDomain: ${statelessAuth.cookieDomain:localhost}
cookiePath: ${statelessAuth.cookiePath:/}
cookieTimeoutUri: ${statelessAuth.cookieTimeoutUri:/}
cookieSecure: ${statelessAuth.cookieSecure:true}
sessionTimeout: ${statelessAuth.sessionTimeout:3600}
rememberMeTimeout: ${statelessAuth.rememberMeTimeout:604800}
bootstrapToken: ${statelessAuth.bootstrapToken:token}
googlePath: ${statelessAuth.googlePath:/google}
googleClientId: ${statelessAuth.googleClientId:google_client_id}
googleClientSecret: ${statelessAuth.googleClientSecret:secret}
googleRedirectUri: ${statelessAuth.googleRedirectUri:https://localhost:3000}
facebookPath: ${statelessAuth.facebookPath:/facebook}
facebookClientId: ${statelessAuth.facebookClientId:facebook_client_id}
facebookClientSecret: ${statelessAuth.facebookClientSecret:secret}
githubPath: ${statelessAuth.githubPath:/github}
githubClientId: ${statelessAuth.githubClientId:github_client_id}
githubClientSecret: ${statelessAuth.githubClientSecret:secret}

Java request behavior:

  • GET authPath, normally /authorization, expects query parameter code and optional state.
  • Missing code returns ERR10035.
  • The handler generates a CSRF value and sends an authorization-code token request through http-client using client.yml oauth.token.authorization_code.
  • On success, it sets browser cookies and returns JSON containing scopes, redirectUri, and denyUri.
  • POST logoutPath, normally /logout, validates the readable CSRF cookie/header pair when enforcement is enabled, clears BFF cookies, and returns 204 No Content.
  • Other requests are treated as downstream BFF requests. The handler reads the accessToken cookie, verifies/parses it, validates CSRF, refreshes the token if it expires within 90 seconds, and injects Authorization: Bearer <access-token> before the proxy/router handler runs.
  • If no access token exists but a refresh token exists, the handler attempts refresh and then injects the new access token.
  • If neither cookie exists, Java allows the request to continue. The downstream service can still decide whether the endpoint is anonymous or protected.

Java error codes to preserve:

CodeMeaning
ERR10035Authorization code is missing
ERR10000Access token is invalid
ERR10036CSRF token is missing from request
ERR10038CSRF claim is missing from JWT
ERR10039Request CSRF and JWT CSRF do not match
ERR10037Refresh-token response is empty
ERR10008Method is not allowed for a mutation or callback endpoint
ERR11649Logout CSRF cookie/header validation failed without exposing either value

Rust Architecture

Add a shared SPA auth runtime in light-pingora and expose it through light-gateway.

Proposed modules:

frameworks/light-pingora/src/spa_auth.rs
frameworks/light-pingora/src/stateless_auth.rs

spa_auth.rs owns the reusable mechanics:

#![allow(unused)]
fn main() {
pub struct SpaCookieConfig {
    pub cookie_domain: String,
    pub cookie_path: String,
    pub cookie_secure: bool,
    pub session_timeout: u64,
    pub remember_me_timeout: u64,
    pub same_site: CookieSameSite,
    pub renew_before_seconds: u64,
}

pub struct SpaSessionRuntime {
    pub cookies: SpaCookieConfig,
    pub token_client: Arc<SpaTokenClient>,
    pub jwt_verifier: Arc<SecurityRuntime>,
    pub refresh_single_flight: RefreshSingleFlight,
}

pub struct SpaSessionResult {
    pub access_token: Option<String>,
    pub principal: Option<AuthPrincipal>,
    pub response_cookies: Vec<SetCookie>,
}
}

stateless_auth.rs owns the authorization-code entrypoint:

#![allow(unused)]
fn main() {
pub struct StatelessAuthConfig {
    pub enabled: bool,
    pub redirect_uri: String,
    pub deny_uri: Option<String>,
    pub enable_http2: bool,
    pub auth_path: String,
    pub logout_path: String,
    pub cookie_domain: String,
    pub cookie_path: String,
    pub cookie_timeout_uri: String,
    pub cookie_secure: bool,
    pub session_timeout: u64,
    pub remember_me_timeout: u64,
    pub bootstrap_token: Option<String>,
    pub renew_before_seconds: u64,
    pub google: Option<SocialProviderConfig>,
    pub facebook: Option<SocialProviderConfig>,
    pub github: Option<SocialProviderConfig>,
}

pub struct SocialProviderConfig {
    pub path: String,
    pub client_id: String,
    pub client_secret: String,
    pub redirect_uri: Option<String>,
    pub scopes: Vec<String>,
}

pub struct StatelessAuthRuntime {
    pub config: StatelessAuthConfig,
    pub session: SpaSessionRuntime,
}
}

Use Java-compatible serde aliases for camel-case config fields. The primary file should be statelessAuth.yml; accept statelessAuth.yaml as a compatibility fallback.

The serde layer can keep the Java-compatible flat fields, such as googlePath, googleClientId, and googleClientSecret, and normalize them into SocialProviderConfig entries after load. This keeps config-server compatibility while giving Rust a cleaner internal model.

Handler Registration

apps/light-gateway already reserves the stateless handler id. The runtime loader should follow the same pattern as MCP:

#![allow(unused)]
fn main() {
let stateless_auth = load_stateless_auth_runtime(
    config,
    active_handlers.is_handler_active("stateless"),
)?;
}

If stateless is not active in any chain, the config does not need to be loaded. If the config is active but enabled: false, register the disabled module and return None.

No @alias syntax is needed. The handler id in handler.yml is the stable Rust contract.

Example BFF chain:

handlers:
  - exception
  - cors
  - stateless
  - header
  - prefix
  - token
  - router

chains:
  default:
    - exception
    - cors
    - stateless
    - header
    - prefix
    - token
    - router
  websocket:
    - exception
    - stateless
    - security
    - websocket

paths:
  - path: /authorization
    method: GET
    exec:
      - default
  - path: /google
    method: GET
    exec:
      - google
  - path: /facebook
    method: GET
    exec:
      - facebook
  - path: /github
    method: GET
    exec:
      - github
  - path: /logout
    method: POST
    exec:
      - default
  - path: /logout
    method: OPTIONS
    exec:
      - default

The handler should normally run after CORS and before proxy/router/WebSocket. POST /logout is the only allowed logout method. The authorization and enabled /google, /facebook, and /github authorization-code callbacks remain permanently GET-only. Keep the explicit OPTIONS /logout route permanently.

Login Flow

For authPath:

GET /authorization?code=...&state=...
  -> validate code
  -> generate csrf
  -> call token endpoint with authorization_code grant
  -> verify/parse returned internal access token
  -> set BFF cookies
  -> return { "scopes": [...], "redirectUri": "...?state=...", "denyUri": "..." }

Token request mapping should reuse client.yml:

  • oauth.token.server_url or oauth.token.serviceId
  • oauth.token.enableHttp2
  • oauth.token.authorization_code.uri
  • oauth.token.authorization_code.client_id
  • oauth.token.authorization_code.client_secret
  • oauth.token.authorization_code.redirect_uri
  • oauth.token.authorization_code.scope

The form body should match Java:

grant_type=authorization_code
code=<code>
redirect_uri=<optional redirect_uri>
csrf=<generated csrf>
scope=<space separated scopes, if configured>

Logout Flow

POST /logout
Cookie: accessToken=...; csrf=...
X-CSRF-TOKEN: <csrf>

  -> optionally enforce logout double-submit CSRF
  -> emit deletion cookies for every cookie the runtime can set
  -> return 204 No Content with no body or response content type

The logout request has no required body. A zero-length body is valid even when a shared client declares Content-Type: application/json. A legacy GET or any other unsupported logout method returns 405, ERR10008, and Allow: POST; a wrong callback method returns 405, ERR10008, and Allow: GET. OPTIONS continues to reach CORS.

Session Validation Flow

For requests that are not login/logout:

request
  -> read accessToken cookie
  -> verify/parse internal JWT with security.yml rules
  -> extract csrf claim
  -> find request CSRF from X-CSRF-TOKEN, WebSocket subprotocol, or query
  -> compare csrf values
  -> refresh token if exp is inside renew window
  -> inject Authorization: Bearer <access-token>
  -> continue handler chain

CSRF source order should match Java:

  1. X-CSRF-TOKEN header.
  2. Sec-WebSocket-Protocol value starting with csrf. when the request has Sec-WebSocket-Key and Sec-WebSocket-Version.
  3. Query parameter csrf.

The WebSocket subprotocol behavior is important for browser WebSocket clients that cannot set arbitrary headers. The auth handler should run before the websocket router so the downstream handshake receives the internal Authorization header.

Session-Expired Response

The Java handler usually allows requests with no cookies to continue so the downstream service can decide whether the endpoint is anonymous. Rust should preserve that pass-through behavior for requests with no session evidence.

When the request does have session evidence but the session cannot be renewed, for example an expired or rejected refresh token, Rust should clear BFF cookies and return a structured response using cookieTimeoutUri:

{
  "code": "ERR10040",
  "message": "SPA session expired",
  "timeoutUri": "/",
  "authenticated": false
}

The status should be 401 unless a later product config explicitly asks for a different behavior. This gives the SPA a deterministic signal to navigate to the configured timeout or login page without scraping an Undertow-style status string.

Internal JWT Verification

The shared SPA runtime should not call the existing verify_jwt_request function directly. That function is designed for API requests with an Authorization header, path skips, pass-through claims, and normal security handler behavior.

The SPA auth runtime needs a lower-level token verifier that can:

  • verify the access-token signature using the same certificates and algorithms as security.yml;
  • parse claims from a token stored in a cookie;
  • optionally ignore expiration while deciding whether the token can be refreshed;
  • fail hard on invalid signature, invalid algorithm, malformed JWT, and missing key;
  • return an AuthPrincipal and raw claims for CSRF, cookie metadata, and optional request-context propagation.

This can be implemented by extracting a reusable helper from security.rs, for example:

#![allow(unused)]
fn main() {
verify_jwt_token(
    runtime: &SecurityRuntime,
    token: &str,
    expiry_mode: JwtExpiryMode,
) -> Result<AuthPrincipal, HandlerRejection>
}

The normal security handler can keep its current request-level wrapper, while SPA auth uses the token-level helper for cookie tokens.

Social Provider Login

Google, Facebook, and GitHub login are implemented as thin handler entrypoints that reuse the same cookie/session runtime as the authorization-code callback. The existing handler ids are kept:

chains:
  google:
    - exception
    - correlation
    - cors
    - google
    - stateless
    - header
    - prefix
    - router
  facebook:
    - exception
    - correlation
    - cors
    - facebook
    - stateless
    - header
    - prefix
    - router
  github:
    - exception
    - correlation
    - cors
    - github
    - stateless
    - header
    - prefix
    - router

The implemented provider flow is:

  1. Match its configured provider path, for example googlePath, facebookPath, or githubPath.
  2. For Google, exchange the authorization code with the Google token endpoint and use the returned id_token as the subject token. If the provider does not return an ID token, fall back to access_token.
  3. For Facebook, accept the Java-compatible accessToken query parameter, or exchange an authorization code with the Facebook token endpoint.
  4. For GitHub, exchange the authorization code with the GitHub token endpoint.
  5. Use client.yml oauth.token.token_exchange to exchange the provider subject token for an internal light-oauth token set with a CSRF claim.
  6. Set the same BFF cookies as the generic stateless handler and return the same JSON shape.

Provider token endpoints default to the public provider URLs, but can be overridden for tests or regional deployments:

googleTokenEndpoint: ${statelessAuth.googleTokenEndpoint:https://oauth2.googleapis.com/token}
facebookTokenEndpoint: ${statelessAuth.facebookTokenEndpoint:https://graph.facebook.com/v19.0/oauth/access_token}
githubTokenEndpoint: ${statelessAuth.githubTokenEndpoint:https://github.com/login/oauth/access_token}

External identity mapping is intentionally delegated to the internal token-exchange implementation. Once portal-service tokenization has a final RPC contract, the subject-token exchange can map provider identities there without changing the gateway cookie/session runtime.

Refresh Flow

The Java handler refreshes 90 seconds before expiry and deduplicates concurrent refreshes with RefreshTokenSingleFlight. Rust should keep that behavior.

Default Rust settings:

renewBeforeSeconds: ${statelessAuth.renewBeforeSeconds:90}
refreshSingleFlightWaitMs: ${statelessAuth.refreshSingleFlightWaitMs:5000}
refreshSingleFlightCacheMs: ${statelessAuth.refreshSingleFlightCacheMs:3000}
refreshSingleFlightMaxEntries: ${statelessAuth.refreshSingleFlightMaxEntries:10000}

These fields are Rust improvements. They can be omitted from config-server templates until a product needs to tune them.

Refresh-token request mapping should reuse client.yml oauth.token.refresh_token and send:

grant_type=refresh_token
refresh_token=<cookie refresh token>
csrf=<new csrf>
scope=<space separated scopes, if configured>

Cookies

Cookie names should remain Java-compatible:

CookieHttpOnlySource
accessTokentrueOAuth access token
refreshTokentrueOAuth refresh token
csrffalseGenerated CSRF value
userIdfalseJWT uid claim
userTypefalseJWT userType claim
rolesfalseBase64-encoded JWT role claim, default user
hostfalseJWT host claim
emailfalseJWT eml claim
eidfalseJWT eid claim

Access-token, user-info, and CSRF cookies should use the access token expires_in value as Max-Age. Refresh-token cookie Max-Age should use sessionTimeout unless the token response includes a remember value other than N, in which case it should use rememberMeTimeout.

Java only clears cookies that were present on the request. Rust should improve logout by always emitting deletion cookies for the known cookie names, using the configured domain/path/secure attributes. This avoids stale browser cookies when a cookie is omitted from a particular request.

Default SameSite should remain None for Java parity. Add a Rust-only optional cookieSameSite field with default None so deployments can choose Lax or Strict when the SPA and BFF are same-site.

Config Server Model

The config-server should continue to resolve placeholders before startup:

statelessAuth.redirectUri: https://localhost:3000/#/app/dashboard
statelessAuth.cookieDomain: localhost
statelessAuth.cookieSecure: true
client.tokenAcClientId: ...
client.tokenAcClientSecret: ...
client.tokenRtClientId: ...
client.tokenRtClientSecret: ...

The Rust gateway should only consume the resolved statelessAuth.yml, client.yml, security.yml, and handler.yml files. It should not need to know whether the values came from product defaults, environment variables, or light-portal overrides.

Implemented Surface

  • Shared SPA cookie/session runtime, including cookie parser/writer, CSRF extraction, JWT claim extraction, and Java-compatible cookie names.
  • OAuth token client support for authorization-code, refresh-token, and token-exchange grant requests using client.yml.
  • Refresh-token renewal with a bounded completed-result cache.
  • statelessAuth.yml loader, module registry registration, active-handler gating, and runtime reload.
  • stateless, google, facebook, and github request handling in light-gateway.
  • Structured session-expired response using cookieTimeoutUri.
  • Unit/runtime-load coverage for config parsing, cookie attributes, provider subject-token selection, active-handler loading, and gateway wiring.

MSAL Exchange Handler

Status

Initial Rust implementation is complete in light-pingora and light-gateway. It includes config loading, named security-msal.yml validation support, token-exchange handling, shared SPA session/cookie/CSRF logic, logout, refresh-token renewal, handler wiring, config stubs, and runtime-load tests.

Purpose

The Java light-spa-4j msal-exchange module is the on-prem BFF login bridge for SPA deployments that use Microsoft Authentication Library SSO. The browser uses MSAL.js to obtain a Microsoft token, sends that token to the gateway, and the gateway exchanges it for an internal light-oauth token set. After exchange, the browser session behaves the same as the stateless authorization-code handler: internal tokens are stored in cookies, CSRF is validated on subsequent requests, refresh tokens keep the session alive, and the gateway injects Authorization: Bearer <internal-token> before routing downstream.

In light-fabric this should be a light-pingora security handler in light-gateway. It should share most of its implementation with stateless-auth.md; only the initial login exchange differs.

Goals

  • Preserve the Java MSAL token-exchange flow.
  • Keep msal-exchange.yml field names recognizable for light-portal and config-server product configuration.
  • Validate the incoming Microsoft token with a separate security-msal.yml runtime before token exchange.
  • Exchange the Microsoft token with light-oauth using client.yml oauth.token.token_exchange.
  • Store the returned internal token set in the same Java-compatible cookies as the stateless handler.
  • Share CSRF validation, cookie writing, logout, refresh-token renewal, and downstream Authorization injection with the stateless handler.
  • Add a stable msal-exchange handler id to light-gateway.
  • Register loaded config in ModuleRegistry and fail startup on invalid active configuration.

Non-Goals

  • Do not forward the Microsoft token to downstream services after exchange.
  • Do not implement a server-side browser session store.
  • Do not merge MSAL token validation into the normal downstream security handler. MSAL validation applies only to the exchange endpoint.
  • Do not invent a REST-specific tokenization or portal-service client in this handler. The only outbound call is the OAuth token-exchange request.
  • Do not require a separate BFF binary.

Resolved Decisions

  • Support subjectTokenType in both client.yml and msal-exchange.yml. The handler-specific value takes precedence when set, and client.yml remains the shared OAuth token-exchange default.
  • Support strict Microsoft token validation in security-msal.yml when a deployment needs issuer and audience checks.

Java Behavior To Map

Java config file:

enabled: ${msal-exchange.enabled:true}
exchangePath: ${msal-exchange.exchangePath:/auth/ms/exchange}
logoutPath: ${msal-exchange.logoutPath:/auth/ms/logout}
logoutCsrfEnforced: ${msal-exchange.logoutCsrfEnforced:false}
cookieDomain: ${msal-exchange.cookieDomain:localhost}
cookiePath: ${msal-exchange.cookiePath:/}
cookieSecure: ${msal-exchange.cookieSecure:false}
sessionTimeout: ${msal-exchange.sessionTimeout:3600}
rememberMeTimeout: ${msal-exchange.rememberMeTimeout:604800}

Java also loads a separate security config named security-msal:

SecurityConfig.load("security-msal")

This config verifies the incoming Microsoft token. The normal security.yml runtime verifies/parses internal light-oauth access tokens used in cookies.

Java request behavior:

  • POST exchangePath, normally /auth/ms/exchange, requires Authorization: Bearer <microsoft-token>.
  • Missing bearer token returns ERR11647.
  • The handler verifies the Microsoft token with security-msal.yml.
  • Verification failure returns ERR10000.
  • The handler generates a CSRF value and sends an OAuth token-exchange request with the Microsoft token as subject_token.
  • Token-exchange failure returns ERR11648.
  • On success, the handler sets the same BFF cookies as the stateless handler and returns JSON containing scopes.
  • POST logoutPath, normally /auth/ms/logout, validates the readable CSRF cookie/header pair when enforcement is enabled, clears BFF cookies, and returns 204 No Content.
  • Subsequent requests use the same cookie, CSRF, refresh, and downstream Authorization injection flow as the stateless handler.

Error codes aligned:

CodeMeaning
ERR11647Microsoft bearer token is missing
ERR11648Internal token exchange failed
ERR10000Incoming Microsoft token or returned internal token is invalid
ERR10036CSRF token is missing from request
ERR10038CSRF claim is missing from JWT
ERR10039Request CSRF and JWT CSRF do not match
ERR10008Method is not allowed for a mutation endpoint
ERR11649Logout CSRF cookie/header validation failed without exposing either value

Rust Architecture

Use the shared SPA auth runtime described in stateless-auth.md.

Proposed modules:

frameworks/light-pingora/src/spa_auth.rs
frameworks/light-pingora/src/msal_exchange.rs

msal_exchange.rs owns only the Microsoft-token exchange entrypoint:

#![allow(unused)]
fn main() {
pub struct MsalExchangeConfig {
    pub enabled: bool,
    pub exchange_path: String,
    pub logout_path: String,
    pub cookie_domain: String,
    pub cookie_path: String,
    pub cookie_secure: bool,
    pub session_timeout: u64,
    pub remember_me_timeout: u64,
    pub renew_before_seconds: u64,
    pub subject_token_type: String,
}

pub struct MsalExchangeRuntime {
    pub config: MsalExchangeConfig,
    pub session: SpaSessionRuntime,
    pub msal_security: SecurityRuntime,
}
}

Use msal-exchange.yml as the primary file name and accept msal-exchange.yaml as a compatibility fallback.

The SecurityRuntime loader should be generalized so the MSAL handler can load a named security config:

#![allow(unused)]
fn main() {
load_security_runtime_from_file(
    runtime_config,
    "security-msal.yml",
    "light-pingora/security-msal",
    "security-msal",
    active,
)
}

That keeps normal downstream JWT behavior on security.yml while the exchange endpoint validates Microsoft tokens against security-msal.yml.

Handler Registration

Add msal-exchange to apps/light-gateway handler descriptors as a security handler:

#![allow(unused)]
fn main() {
("msal-exchange", PingoraHandlerKind::Security)
}

The primary handler id should be msal-exchange. No @alias syntax is needed. An additional short alias such as msal can be added later only if a real product config needs it.

Runtime loading should follow the existing active-handler model:

#![allow(unused)]
fn main() {
let msal_exchange = load_msal_exchange_runtime(
    config,
    active_handlers.is_handler_active("msal-exchange"),
)?;
}

If the handler is not active in handler.yml, no MSAL config is required. If the handler is active and its config is invalid, startup should fail. If enabled: false, register the disabled module and return None.

Example chain:

handlers:
  - exception
  - cors
  - msal-exchange
  - header
  - prefix
  - token
  - router

chains:
  bff:
    - exception
    - cors
    - msal-exchange
    - header
    - prefix
    - token
    - router
  websocket:
    - exception
    - msal-exchange
    - security
    - websocket

paths:
  - path: /auth/ms/exchange
    method: POST
    exec:
      - bff
  - path: /auth/ms/exchange
    method: OPTIONS
    exec:
      - bff
  - path: /auth/ms/logout
    method: POST
    exec:
      - bff
  - path: /auth/ms/logout
    method: OPTIONS
    exec:
      - bff

Exchange and logout are POST-only. Keep OPTIONS permanently and keep cors before msal-exchange in the selected chain.

Exchange Flow

For exchangePath:

POST /auth/ms/exchange
Authorization: Bearer <microsoft-token>

  -> extract bearer token
  -> verify Microsoft token with security-msal.yml
  -> generate csrf
  -> call light-oauth token endpoint with token-exchange grant
  -> verify/parse returned internal access token
  -> set BFF cookies
  -> return { "scopes": [...] }

The exchange request body is optional. A zero-length body is valid even when a shared client declares Content-Type: application/json.

Logout Flow

POST /auth/ms/logout
Cookie: accessToken=...; csrf=...
X-CSRF-TOKEN: <csrf>

  -> optionally enforce logout double-submit CSRF
  -> emit deletion cookies for every cookie the runtime can set
  -> return 204 No Content with no body or response content type

A legacy GET or any other unsupported exchange/logout method returns 405, ERR10008, and Allow: POST before token-server, cookie, or proxy side effects. Explicit OPTIONS routing continues to reach CORS.

The token-exchange request should use client.yml oauth.token.token_exchange:

  • oauth.token.server_url or oauth.token.serviceId
  • oauth.token.enableHttp2
  • oauth.token.token_exchange.uri
  • oauth.token.token_exchange.client_id
  • oauth.token.token_exchange.client_secret
  • oauth.token.token_exchange.scope
  • oauth.token.token_exchange.subjectTokenType as the default subject token type when the handler config does not override it

The form body should match Java and the http-client composer:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<microsoft-token>
subject_token_type=urn:ietf:params:oauth:token-type:jwt
csrf=<generated csrf>
requested_token_type=<optional requested token type>
audience=<optional audience>
scope=<space separated scopes, if configured>

The handler should set Authorization: Basic <client_id:client_secret> on the outbound token-exchange request.

Session Validation Flow

After exchange, MSAL and stateless auth must use the same downstream request flow:

request
  -> read accessToken cookie
  -> verify/parse internal JWT with security.yml
  -> validate CSRF from request against JWT csrf claim
  -> refresh internal token when it is inside the renew window
  -> inject Authorization: Bearer <internal-access-token>
  -> continue handler chain

CSRF source order should be identical to the stateless handler:

  1. X-CSRF-TOKEN header.
  2. Sec-WebSocket-Protocol value starting with csrf. when the request has Sec-WebSocket-Key and Sec-WebSocket-Version.
  3. Query parameter csrf.

The MSAL handler must never inject the Microsoft token downstream. The only downstream bearer token after login is the internal light-oauth token.

Internal JWT Verification

MSAL exchange should use the same lower-level token verifier as stateless auth for internal cookie tokens. It should not use the request-oriented verify_jwt_request wrapper because the token source is a cookie, not an Authorization header.

The shared verifier should validate signature and key material from security.yml, parse claims for CSRF and user cookies, and support an expiry-mode option so the refresh path can inspect tokens close to expiry without treating that as a downstream API authentication success.

Cookies

MSAL exchange should use the same cookie contract as stateless auth:

CookieHttpOnlySource
accessTokentrueInternal OAuth access token
refreshTokentrueInternal OAuth refresh token
csrffalseGenerated CSRF value
userIdfalseJWT uid claim
userTypefalseJWT userType claim
rolesfalseBase64-encoded JWT role claim, default user
hostfalseJWT host claim
emailfalseJWT eml claim
eidfalseJWT eid claim

For Java parity, keep cookieSecure defaulting to false in msal-exchange.yml, but production config should set it to true when the BFF is served over HTTPS.

Rust should share the logout improvement from stateless auth: always emit deletion cookies for known cookie names rather than only clearing cookies that were present on the request.

Security Config

security-msal.yml should be treated as an active handler dependency when msal-exchange is active. Missing or invalid config should fail startup because the gateway would otherwise accept an exchange endpoint without a working Microsoft-token verifier.

Recommended distinction:

  • security-msal.yml: verifies the incoming Microsoft token on exchangePath.
  • security.yml: verifies/parses internal light-oauth tokens in BFF cookies and is also used by normal API security handlers.

The Java code skips audience verification for MSAL in the current call path. Rust should preserve compatibility unless security-msal.yml explicitly configures audience validation support. That keeps on-prem deployments working when the Microsoft token audience is the SPA client id rather than the BFF.

When a product requires stricter validation, security-msal.yml should be able to require issuer and audience checks for the incoming Microsoft token. The initial implementation can add these checks to the named SecurityRuntime loader as optional fields:

issuer: ${security-msal.issuer:}
audience: ${security-msal.audience:}

Blank values preserve the Java-compatible relaxed behavior. Non-blank values must be enforced during exchange-path token verification, and invalid issuer/audience should return the same invalid-token error path as other Microsoft token verification failures.

Config Server Model

Light-portal should manage the product config values and config-server should deliver resolved files:

msal-exchange.exchangePath: /auth/ms/exchange
msal-exchange.logoutPath: /auth/ms/logout
msal-exchange.cookieDomain: localhost
msal-exchange.cookieSecure: true
msal-exchange.subjectTokenType: urn:ietf:params:oauth:token-type:jwt
client.tokenExClientId: ...
client.tokenExClientSecret: ...
client.subjectTokenType: urn:ietf:params:oauth:token-type:jwt
security-msal.issuer: https://login.microsoftonline.com/{tenant-id}/v2.0
security-msal.audience: <spa-client-id>

The gateway consumes only the resolved files:

  • handler.yml
  • msal-exchange.yml
  • security-msal.yml
  • security.yml
  • client.yml

Implemented Surface

  • Shared SPA auth runtime from stateless-auth.md.
  • Named SecurityRuntime loading for security-msal.yml.
  • Token-exchange support in the shared OAuth token client.
  • msal-exchange.yml parsing, module registry registration, active-handler gating, and runtime reload.
  • msal-exchange request handling in light-gateway.
  • Required bearer-token extraction, Microsoft token validation, token-exchange request, Java-compatible cookie writing, logout, refresh renewal, and downstream internal Authorization injection.
  • Optional issuer/audience validation through security-msal.yml.
  • Unit/runtime-load coverage for subject-token-type precedence and gateway wiring.

MSAL Auth Handler

Status

Initial Rust implementation is complete in light-pingora and light-gateway. It includes config loading (msal-auth.yml), standalone Microsoft Entra ID token validation through security-msal.yml, double-submit cookie CSRF handling, gateway auth-principal propagation, and downstream Authorization injection.

Purpose

The msal-auth module is an alternative to msal-exchange for Microsoft Entra ID single-page application (SPA) architectures where the frontend acts as the primary OAuth client.

In this flow:

  1. The SPA handles Microsoft authentication, token acquisition, and token refresh directly.
  2. The SPA submits the Entra ID access token to the gateway's /auth/ms/login endpoint.
  3. The gateway validates the Entra ID token with security-msal.yml and sets the accessToken and csrf cookies using the double-submit cookie pattern.
  4. On subsequent API calls, the gateway validates the Microsoft JWT with expiry enforcement, compares the CSRF request value to the CSRF cookie, sets the gateway auth principal for later handlers, and forwards the token in the Authorization: Bearer header.

This eliminates the need for an internal light-oauth token exchange, reducing infrastructure dependencies while maintaining backend API security.

Configuration

handler.yml

Register msal-auth in the handler chain before handlers that need ctx.auth or the downstream Authorization header, such as access-control, router, or proxy handling.

handlers:
  - cors
  - msal-auth
  - router

chains:
  bff:
    - cors
    - msal-auth
    - router

paths:
  - path: /auth/ms/login
    method: POST
    exec:
      - bff
  - path: /auth/ms/login
    method: OPTIONS
    exec:
      - bff
  - path: /auth/ms/logout
    method: POST
    exec:
      - bff
  - path: /auth/ms/logout
    method: OPTIONS
    exec:
      - bff

defaultHandlers:
  - cors
  - msal-auth
  - router

msal-auth.yml

enabled: ${msal-auth.enabled:true}
loginPath: ${msal-auth.loginPath:/auth/ms/login}
logoutPath: ${msal-auth.logoutPath:/auth/ms/logout}
logoutCsrfEnforced: ${msal-auth.logoutCsrfEnforced:false}
cookieDomain: ${msal-auth.cookieDomain:localhost}
cookiePath: ${msal-auth.cookiePath:/}
cookieSecure: ${msal-auth.cookieSecure:false}
sessionTimeout: ${msal-auth.sessionTimeout:3600}
cookieSameSite: ${msal-auth.cookieSameSite:None}

security-msal.yml

msal-auth requires security-msal.yml when the handler is active and msal-auth.enabled is true. The config is loaded independently from the normal security.yml runtime.

enableVerifyJwt: ${security-msal.enableVerifyJwt:true}
ignoreJwtExpiry: ${security-msal.ignoreJwtExpiry:false}
enableRelaxedKeyValidation: ${security-msal.enableRelaxedKeyValidation:false}
issuer: ${security-msal.issuer:}
audience: ${security-msal.audience:}
jwt:
  clockSkewInSeconds: ${security-msal.jwt.clockSkewInSeconds:60}

Handlers

  • Login (/auth/ms/login): Expects an Entra ID token in the Authorization: Bearer header. Validates it using the security-msal runtime with expiry enforcement. Generates a secure CSRF token and returns both accessToken and csrf as Set-Cookie headers.
  • Logout (POST /auth/ms/logout): Validates logout CSRF when configured, clears every cookie the runtime sets (accessToken and csrf), and returns 204 No Content without a response body or content type.
  • Session Validation (any path with cookies): Reads the accessToken cookie. Validates the JWT with expiry enforcement. Checks that the CSRF request value matches the CSRF cookie. If valid, it sets the gateway auth principal and forwards the accessToken downstream in the Authorization: Bearer header.

Frontend Integration

The Single Page Application (SPA) must coordinate with the gateway for session creation and destruction.

Login Request

When the SPA acquires an access token from Microsoft Entra ID (e.g., using MSAL.js), it must send that token to the gateway's login endpoint to establish the secure HTTP-only cookies. Both login and logout use POST; neither needs a request body. A zero-length body is also accepted when a shared client sets Content-Type: application/json.

For cross-origin deployments, both examples require preflight: login sends the non-safelisted Authorization header and CSRF-protected logout sends the non-safelisted X-CSRF-TOKEN header. Keep the explicit OPTIONS routes and qualify the exact origin, credentials, method, and requested headers.

async function gatewayLogin(entraIdToken) {
  const response = await fetch('/auth/ms/login', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Authorization': `Bearer ${entraIdToken}`
    }
  });

  if (!response.ok) {
    throw new Error('Failed to create gateway session');
  }
  
  console.log('Gateway session established');
}

Logout Request

When the user logs out, the SPA must call the gateway's logout endpoint to clear the HTTP-only session cookies. Send credentials and the X-CSRF-TOKEN header read from the readable csrf cookie; no body is needed.

// Helper to read the csrf cookie
function getCookie(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
}

async function gatewayLogout() {
  const csrfToken = getCookie('csrf');
  
  const response = await fetch('/auth/ms/logout', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'X-CSRF-TOKEN': csrfToken
    }
  });

  if (response.status !== 204) {
    throw new Error(`Unexpected logout status ${response.status}`);
  }

  console.log('Gateway session cleared');
}

Login and logout are POST-only. A legacy GET or any other unsupported method returns 405, ERR10008, and Allow: POST before authentication or cookie side effects. Keep explicit OPTIONS routes permanently with cors before msal-auth in the selected chain.

Error Handling

CodeMeaning
ERR10008Method is not allowed; MSAL login/logout responses advertise Allow: POST.
ERR10036Logout CSRF header is missing when enforcement is enabled.
ERR11649Logout CSRF cookie/header validation failed without exposing either value.

API Request

For standard API calls to backend services, the browser will automatically include the HTTP-only accessToken cookie. However, any request that modifies state or requires CSRF protection must include the CSRF token. The SPA must read the csrf cookie and append it as the X-CSRF-TOKEN header.

async function callBackendApi(endpoint, data) {
  const csrfToken = getCookie('csrf');
  
  const response = await fetch(endpoint, {
    method: 'POST', // or PUT, DELETE, etc.
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-TOKEN': csrfToken
    },
    body: JSON.stringify(data)
  });

  if (!response.ok) {
    throw new Error('API call failed');
  }

  return response.json();
}

WebSocket Connection

The browser's native WebSocket API does not allow setting custom HTTP headers. To pass the CSRF token during the WebSocket handshake upgrade, the SPA must pass it as a subprotocol string prefixed with csrf.. The gateway will extract and validate it.

function connectWebSocket(path) {
  const csrfToken = getCookie('csrf');
  
  // Create a subprotocol string that the gateway recognizes
  const csrfProtocol = `csrf.${csrfToken}`;
  
  // Note: Depending on your WebSocket server, you may also need to pass 
  // the actual subprotocol you intend to use (e.g., 'wamp', 'graphql-ws')
  // alongside the csrf protocol.
  const ws = new WebSocket(`wss://api.example.com${path}`, [csrfProtocol]);

  ws.onopen = () => {
    console.log('WebSocket connected securely');
  };

  ws.onerror = (error) => {
    console.error('WebSocket connection failed (possible CSRF or Auth issue)', error);
  };

  return ws;
}

Because an Entra ID token cannot be minted with a custom CSRF claim by this gateway, msal-auth enforces CSRF protections using the double-submit cookie pattern. The SPA reads the generated csrf cookie and submits it back.

The CSRF value is accepted from the following sources, in order of precedence:

  1. X-CSRF-TOKEN header.
  2. Sec-WebSocket-Protocol value starting with csrf. (when the request has Sec-WebSocket-Key and Sec-WebSocket-Version). This provides specialized CSRF support for Websocket upgrades since browser WebSockets cannot send custom HTTP headers.
  3. Query parameter csrf.

The gateway compares the value from one of these sources against the csrf cookie. If they match, the session is validated.

Refresh Flow

Unlike msal-exchange or stateless-auth, msal-auth does not issue or manage refresh tokens. The SPA is responsible for using MSAL.js to silently refresh the Entra ID token and calling /auth/ms/login again to update the session cookies before they expire.

Reload Behavior

The gateway reloads msal-auth when handler.yml, msal-auth.yml, or security-msal.yml changes. Reloading security-msal.yml refreshes both msal-auth and msal-exchange because both handlers validate Microsoft tokens with that security runtime.

Unified Security Handler

Status: Phase 4 partially implemented; jwkServiceIds and sjwkServiceIds per-prefix JWK routing are wired, SJWT routing is implemented, and SWT introspection remains outstanding.

Purpose

Light Fabric's light-gateway serves as a shared API gateway for multiple upstream services that may belong to different organizations or security domains. In this shared model, different request path prefixes need different authentication strategies:

  • An internal /admin route may require HTTP Basic authentication.
  • A customer-facing /api/orders route may require a JWT from the company's own identity provider.
  • A partner /salesforce route may require a JWT issued by Salesforce with its own JWK endpoint.
  • A webhook /webhook route may require an API key.

The UnifiedSecurityHandler (Java) / unified-security handler (Rust) solves this by providing a single, path-prefix-aware security dispatch point. It replaces the need to wire separate security handlers into independent handler chains for each path family.

Java Reference

The canonical implementation lives in:

  • Handler: light-4j/unified-security/src/main/java/com/networknt/security/UnifiedSecurityHandler.java
  • Config: light-4j/unified-config/src/main/resources/config/unified-security.yml

The Java handler:

  1. Loads UnifiedSecurityConfig on every request (double-checked locking, hot-reload safe).
  2. Checks anonymousPrefixes first — if the path matches, all security checks are skipped.
  3. Iterates pathPrefixAuths; the first matching prefix wins.
  4. For the matched rule, checks which auth methods are enabled (basic, jwt, sjwt, swt, apikey) and dispatches to the corresponding sub-handler.
  5. Passes jwkServiceIds / sjwkServiceIds / swtServiceIds to the sub-handler so it can fetch JWKs from the correct per-prefix OAuth/JWK server.
  6. Returns ERR10078 MISSING_PATH_PREFIX_AUTH if no rule matches any prefix.

Rust Implementation Location

frameworks/light-pingora/src/unified_security.rs

The Rust implementation is loaded in apps/light-gateway/src/main.rs when the unified-security or unified handler IDs appear in the active handler chain:

#![allow(unused)]
fn main() {
let unified_security_config = load_unified_security_config(
    &runtime_config,
    handler_active(&active_handlers, &["unified-security", "unified"]),
)?;
}

Configuration

unified-security.yml

# Enable or disable this handler.
enabled: ${unified-security.enabled:true}

# Paths that bypass all security checks.
# Accepts comma-separated string, JSON array string, or YAML list.
anonymousPrefixes: ${unified-security.anonymousPrefixes:[]}

# Per-prefix authentication rules.
# Accepts comma-separated string, JSON array string, or YAML list of objects.
pathPrefixAuths: ${unified-security.pathPrefixAuths:[]}

Per-Prefix Rule Fields

FieldTypePurpose
prefixStringPath prefix to match. Longest matching prefix wins (Rust) / first wins (Java).
basicboolAllow HTTP Basic authentication for this prefix.
jwtboolRequire Bearer JWT verification for this prefix.
sjwtboolAllow Simple-JWT (no scopes) for this prefix.
swtboolAllow SWT (opaque token introspection) for this prefix.
apikeyboolAllow API key authentication for this prefix.
jwkServiceIdsVec<String>JWK service IDs (from client.yml) used to verify JWT tokens for this prefix.
sjwkServiceIdsVec<String>JWK service IDs used to verify SJWT tokens for this prefix.
swtServiceIdsVec<String>Introspection service IDs used to verify SWT tokens for this prefix.

Example values.yml Entry

handler.handlers:
  - correlation
  - headers
  - unified-security
  - proxy

handler.defaultHandlers:
  - default

unified-security.anonymousPrefixes:
  - /health
  - /server/info

unified-security.pathPrefixAuths:
  - prefix: /salesforce
    jwt: true
    jwkServiceIds:
      - com.networknt.oauth2-salesforce-1.0.0
  - prefix: /blackrock
    jwt: true
    jwkServiceIds:
      - com.networknt.oauth2-blackrock-1.0.0
  - prefix: /admin
    basic: true
  - prefix: /webhook
    apikey: true
  - prefix: /internal
    jwt: true

Why unified-security.yml Is Not in the light-gateway Config Folder

The config/ directory in apps/light-gateway contains only active handler configurations that the current local development profile uses. The local profile (defined by config/values.yml) activates only correlation, headers, and proxy. Because unified-security is not in that handler chain, load_unified_security_config returns None and the file is never needed.

A production or staging deployment that enables unified security would receive unified-security.yml from config-server, populated by the light-portal product configuration for that deployment. To use it locally, add unified-security to handler.handlers and handler.defaultHandlers (or a path-specific chain) in values.yml, then add a unified-security.yml to the config/ directory.

Prefix Matching: Java vs. Rust

BehaviorJavaRust
Match algorithmFirst matching prefix in list orderLongest matching prefix (most specific wins)
Tie-breakingOrder in config listLongest prefix.len()

The Rust best_auth_rule function uses max_by_key(|rule| rule.prefix.len()), which is intentionally more deterministic than Java's iteration order. This means /api/v2 will match before /api regardless of declaration order.

Authentication Dispatch Logic

Request arrives at unified-security handler
│
├── anonymousPrefixes match? → Pass through (no auth)
│
├── No matching pathPrefixAuth rule? → 403 ERR10078
│
└── Matched rule:
    ├── basic=true OR jwt=true OR sjwt=true OR swt=true?
    │   ├── No Authorization header → 401
    │   ├── Scheme=Basic AND basic=true → BasicAuth verify
    │   ├── Scheme=Bearer:
    │   │   ├── jwt=true → JWT verify (using jwkServiceIds)
    │   │   ├── sjwt=true → SJWT verify (using sjwkServiceIds)
    │   │   └── swt=true  → SWT introspect (using swtServiceIds) [⚠ Gap: not implemented]
    │   └── Unknown scheme → 401
    └── apikey=true (only) → API Key verify

Current Implementation Status

Implemented ✅

CapabilityLocation
UnifiedSecurityConfig and UnifiedPathAuth deserializationunified_security.rs:15–55
anonymousPrefixes bypassunified_security.rs:153–158
pathPrefixAuths parsing (YAML, JSON-string, comma-string)via deserialize_typed_list
Longest-prefix rule selection (best_auth_rule)unified_security.rs:160–169
Basic auth dispatchunified_security.rs:113–123
JWT/SJWT dispatch (Bearer)unified_security.rs:126–131
jwkServiceIds / sjwkServiceIds JWK routingsecurity.rs
API key dispatchunified_security.rs:145–149
Hot-reload via ConfigManager and UnifiedSecurityReloadermain.rs:1374–1410
Handler IDs: unified-security, unifiedmain.rs:114, 121, 128, 131, 133

Gaps ⚠️

Gap 1 — SWT (opaque token) introspection not implemented (Low)

When swt=true, the Rust handler returns HTTP 501. SWT introspection requires calling an OAuth2 introspection endpoint, which needs service discovery and client credentials.

Fix: Implement SWT introspection using the existing client.yml OAuth provider infrastructure once service discovery is stable.

Recently Closed Gaps

jwkServiceIds and sjwkServiceIds per-prefix JWK routing

verify_unified_security now passes the matched rule's jwkServiceIds or sjwkServiceIds list into JWT verification. The Rust verifier tries the configured service IDs in order for JWK lookup and accepts any matching configured audience.

SJWT routing

Java supports two SJWT modes:

  • sjwt=true, jwt=false — always treated as SJWT.
  • sjwt=true, jwt=true — pre-parses the JWT to check for scope/scp claim to distinguish SJWT (no scope) from a full JWT (with scope).

Rust now implements the same routing split. Non-JWT Bearer tokens are routed to SWT when swt=true; otherwise they are rejected as unsupported Bearer tokens.

unified-security.yml added to light-gateway config folder

The sample config/ directory now includes an example unified-security.yml, making the expected configuration clearer when activating the handler.

Java uses first-match; Rust uses longest-match (Design difference)

This is an intentional Rust improvement, not a bug, but it should be documented clearly so operators migrating from Java understand that configuration ordering matters less in Rust. The design difference is already captured in this document.

Interaction with security.yml

unified-security and security.yml (standalone JWT handler) are mutually exclusive in a given handler chain. Do not include both unified-security and jwt handler IDs in the same chain; the security check would be applied twice.

When unified-security is active:

  • security.yml is still loaded to provide the SecurityRuntime (JWK cache, config).
  • basic-auth.yml is loaded if any rule has basic: true.
  • apikey.yml is loaded if any rule has apikey: true.

Interaction with client.yml

JWK source resolution uses the client.yml OAuth/JWK configuration:

oauth:
  token:
    key:
      serviceId: com.networknt.oauth2-token-1.0.0
      serviceIdAuthServers:
        com.networknt.oauth2-salesforce-1.0.0:
          server_url: https://login.salesforce.com
          uri: /id/keys
        com.networknt.oauth2-blackrock-1.0.0:
          server_url: https://idp.blackrock.com
          uri: /.well-known/jwks.json

jwkServiceIds: [com.networknt.oauth2-salesforce-1.0.0] in a pathPrefixAuth rule will cause the JWT verifier to fetch and cache JWKs from https://login.salesforce.com/id/keys for that path prefix only.

Verification Plan

Existing Tests

  • tests::unified_security_accepts_java_style_lists in unified_security.rs — verifies YAML/JSON deserialization for anonymousPrefixes and pathPrefixAuths.

Tests to Add

  1. jwkServiceIds override — mock two JWK servers; configure two prefixes pointing to different service IDs; verify that JWT verification for each prefix fetches from the correct server.

  2. SJWT scope detection — provide a JWT with and without a scope claim; verify that sjwt=true, jwt=true routes to the correct verifier.

  3. SJWT-only rulesjwt=true, jwt=false; verify the handler always uses the SJWT verifier regardless of scope presence.

  4. SWT rule — configure swt=true with a mock introspection endpoint; verify the handler calls introspection with the correct service ID.

  5. No-match returns 403 — request a path not covered by any prefix; verify 403 with ERR10078.

  6. Anonymous prefix bypass — request a path in anonymousPrefixes; verify no auth header is required.

PII Tokenization

Status

Proposed design for migrating the light-tokenization capability into light-fabric as light-pingora handlers used by light-gateway.

Purpose

PII tokenization protects sensitive employee/customer data when a request is sent from inside the organization to an external cloud service through the gateway. The outbound request replaces configured PII fields with generated tokens. When the cloud response returns, the gateway replaces those tokens with the original cleartext values so internal employees can complete their work.

This is a request/response hot-path concern. The first Rust implementation should therefore run inside light-gateway and access PostgreSQL directly instead of making a network call to a tokenization service for every field.

Current Java Behavior

The current light-tokenization service exposes REST endpoints:

  • POST /v1/token: body { "schemeId": <int>, "value": "<cleartext>" }; returns a token string. If the value already exists, it returns the existing token.
  • GET /v1/token/{token}: returns the cleartext value.
  • DELETE /v1/token/{token}: deletes the token mapping.
  • GET /v1/scheme and GET /v1/scheme/{id}: return token format schemes.

Startup loads multiple JDBC pools from datasource.yml. One database is named tokenization; the others are vault databases such as vault000. The tokenization database maps client_id to a vault database through client_database. Each vault database has a token_vault table.

Java tokenization flow:

  1. Read client_id from the JWT audit info.
  2. Resolve client_id -> db_name.
  3. Select a vault datasource by db_name.
  4. For tokenization, look up by cleartext value; return existing id if found.
  5. If not found, generate a token with the configured schemeId, insert (id, value), cache token -> value, and return the token.
  6. For detokenization, check the cache first, then query by token id.

The current Java MCP router also uses tokenization through token-client. Tool input schemas can mark fields with x-tokenize; the router extracts JsonPath rules from the schema and calls the tokenization service.

Design Direction

Use direct PostgreSQL access for the initial light-fabric implementation.

Reasons:

  • It removes one HTTP hop per tokenized field in the gateway hot path.
  • It avoids running and scaling another service only to perform local database lookups.
  • PostgreSQL connection pooling is already used in nearby light-fabric apps with sqlx.
  • The same database will also support other gateway handlers that need local data access, such as vector search for MCP routing.
  • Multi-tenancy is cleaner with host_id in the schema than with one vault database per tenant.

If this capability is later exposed as a standalone service, prefer gRPC over MCP for the hot-path service API. gRPC gives a strongly typed protobuf contract, HTTP/2 multiplexing, compact binary payloads, deadlines, and well-understood client pooling. MCP is useful when tokenization is exposed as an agent tool or administrative capability, but it adds JSON-RPC/tooling semantics that are not needed for a low-latency service-to-service data-plane call.

Goals

  • Implement TokenizeHandler and DetokenizeHandler in light-pingora.
  • Activate handlers only through handler.yml.
  • Use one PostgreSQL database with host_id tenant isolation.
  • Integrate schema into portal-db/postgres/ddl.sql and future patch files.
  • Preserve the Java token schemes and stable tokenization behavior.
  • Avoid storing/indexing cleartext PII directly in PostgreSQL.
  • Support request-body tokenization before proxy/router sends to the external service.
  • Support response-body detokenization before the gateway returns to the internal caller.
  • Reuse the same runtime for MCP tool argument tokenization.

Non-Goals

  • Do not preserve multiple vault databases.
  • Do not preserve MySQL or SQLite runtime support in light-fabric.
  • Do not make tokenization an MCP-only service.
  • Do not require a separate tokenization service for the first implementation.
  • Do not try to tokenize arbitrary binary payloads in the first pass.

Handler Model

Use two public handler ids:

  • tokenize: request-phase handler that replaces cleartext fields with tokens.
  • detokenize: response-phase handler that replaces configured token fields with cleartext.

Both handlers share one runtime:

frameworks/light-pingora/src/pii_tokenization.rs

Primary types:

#![allow(unused)]
fn main() {
pub struct PiiTokenizationConfig {
    pub database: PiiDatabaseConfig,
    pub host_id_claim: String,
    pub max_body_size: usize,
    pub cache: PiiTokenCacheConfig,
    pub crypto: PiiTokenCryptoConfig,
    pub rules: Vec<PiiTokenizationRule>,
}

pub struct PiiTokenizationRuntime {
    pub config: Arc<PiiTokenizationConfig>,
    pub pool: PgPool,
    pub tokenizers: TokenizerRegistry,
    pub value_cache: TokenCache,
    pub token_cache: TokenCache,
    pub keyring: PiiKeyring,
}

pub struct PiiTokenizationRule {
    pub path_prefix: String,
    pub methods: Vec<String>,
    pub request: Vec<PiiFieldRule>,
    pub response: Vec<PiiFieldRule>,
}

pub struct PiiFieldRule {
    pub path: String,
    pub scheme: String,
    pub required: bool,
}
}

The handler should fail startup if an active config references an unknown scheme, has invalid field paths, cannot initialize the keyring, or cannot connect to PostgreSQL within the configured startup timeout.

Resolved Decisions

  • Handler ids are tokenize and detokenize to align with other light-fabric handler names.
  • Encrypt stored cleartext with AES-256-GCM. Resolve key material from environment variables first, with direct config values allowed only as a local-development fallback.
  • Detokenization fails closed by default when a configured token field cannot be resolved.
  • Field selection uses a constrained compiled JsonPath subset rather than full dynamic JsonPath evaluation.
  • Cleartext reverse caching is configurable through cache.cacheCleartext.
  • Request/response mutation buffers are bounded by configurable maxBodySize.

Handler Chain

For a BFF or gateway that calls an external cloud service:

handlers:
  - correlation
  - security
  - tokenize
  - router
  - detokenize

chains:
  external-cloud:
    - correlation
    - security
    - tokenize
    - router
    - detokenize

paths:
  - path: /claims
    method: POST
    exec:
      - external-cloud

tokenize must run after authentication so it can resolve host_id from the verified JWT principal. It must run before router or proxy so the external service never receives cleartext PII. detokenize must run after the upstream response body is available and before response delivery.

This likely requires extending the existing gateway handler model with a response-body filter phase:

#![allow(unused)]
fn main() {
pub trait PingoraBodyHandler {
    async fn request_body_filter(&self, ctx: &mut GatewayRequestContext, body: Bytes)
        -> Result<Bytes, HandlerRejection>;

    async fn response_body_filter(&self, ctx: &mut GatewayRequestContext, body: Bytes)
        -> Result<Bytes, HandlerRejection>;
}
}

The first implementation can wire this directly in light-gateway; later it can be generalized for other body-mutating handlers.

Configuration

Primary file: pii-tokenization.yml.

enabled is not needed. If neither tokenize nor detokenize appears in handler.yml, this config is not loaded. If either handler is active, the config is required and invalid config fails startup.

Example:

database:
  url: ${pii-tokenization.database.url:${database.url:}}
  maxConnections: ${pii-tokenization.database.maxConnections:8}
  minConnections: ${pii-tokenization.database.minConnections:1}
  connectTimeoutMs: ${pii-tokenization.database.connectTimeoutMs:2000}

hostIdClaim: ${pii-tokenization.hostIdClaim:host_id}
maxBodySize: ${pii-tokenization.maxBodySize:1048576}

crypto:
  algorithm: ${pii-tokenization.crypto.algorithm:AES-256-GCM}
  keyId: ${pii-tokenization.crypto.keyId:default}
  valueEncryptionKeyEnv: ${pii-tokenization.crypto.valueEncryptionKeyEnv:PII_TOKENIZATION_VALUE_ENCRYPTION_KEY}
  valueHashKeyEnv: ${pii-tokenization.crypto.valueHashKeyEnv:PII_TOKENIZATION_VALUE_HASH_KEY}
  valueEncryptionKey: ${pii-tokenization.crypto.valueEncryptionKey:}
  valueHashKey: ${pii-tokenization.crypto.valueHashKey:}

cache:
  enabled: ${pii-tokenization.cache.enabled:true}
  maxEntries: ${pii-tokenization.cache.maxEntries:10000}
  ttlSeconds: ${pii-tokenization.cache.ttlSeconds:86400}
  cacheCleartext: ${pii-tokenization.cache.cacheCleartext:true}

rules:
  - pathPrefix: /claims
    methods: [POST]
    request:
      - path: $.claimant.ssn
        scheme: LN
        required: false
      - path: $.payment.cardNumber
        scheme: CC4
        required: false
    response:
      - path: $.claimant.ssn
        scheme: LN
        required: false
      - path: $.payment.cardNumber
        scheme: CC4
        required: false

Field paths should support the Java-compatible JsonPath subset used by mcp-router tokenization rules: object fields and [*] arrays. For performance and predictable mutation, the Rust implementation should compile rules at startup and avoid dynamic path parsing on every request.

For MCP tools, keep supporting x-tokenize in input schemas. The MCP router can convert schema annotations into the same compiled field rules and call the shared PiiTokenizationRuntime directly.

PostgreSQL Schema

Replace the old split between tokenization and vault databases with tenant-scoped tables in portal-db.

Recommended DDL:

CREATE TABLE pii_token_scheme_t (
    scheme_id        SMALLINT PRIMARY KEY,
    scheme_code      VARCHAR(16) NOT NULL UNIQUE,
    description      TEXT NOT NULL,
    active           BOOLEAN DEFAULT TRUE NOT NULL,
    update_ts        TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    update_user      VARCHAR(126) DEFAULT SESSION_USER NOT NULL
);

CREATE TABLE pii_token_vault_t (
    host_id           UUID NOT NULL,
    token             TEXT NOT NULL,
    scheme_id         SMALLINT NOT NULL,
    value_hash        BYTEA NOT NULL,
    value_ciphertext  BYTEA NOT NULL,
    value_nonce       BYTEA NOT NULL,
    key_id            VARCHAR(128) NOT NULL,
    created_ts        TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    expires_ts        TIMESTAMP WITH TIME ZONE,
    active            BOOLEAN DEFAULT TRUE NOT NULL,
    update_ts         TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    update_user       VARCHAR(126) DEFAULT SESSION_USER NOT NULL,
    PRIMARY KEY(host_id, token),
    FOREIGN KEY(scheme_id) REFERENCES pii_token_scheme_t(scheme_id)
);

CREATE UNIQUE INDEX pii_token_vault_value_uk
ON pii_token_vault_t(host_id, scheme_id, value_hash)
WHERE active = TRUE;

CREATE INDEX pii_token_vault_expiry_idx
ON pii_token_vault_t(expires_ts)
WHERE expires_ts IS NOT NULL;

Seed schemes:

IdCodeMeaning
0UUIDUUID v4 token
1GUIDURL-safe base64 UUID token
2LNLuhn compliant numeric token
3NRandom numeric token, length preserving
4LN4Luhn numeric token retaining last four digits
5ANRandom alpha-numeric token, length preserving
6AN4Alpha-numeric token retaining last four characters
7CCCredit-card-shaped Luhn token retaining first digit
8CC4Credit-card-shaped Luhn token retaining first and last four digits

The old database_owner and client_database tables are not needed. Tenant isolation is by host_id, resolved from the authenticated request. If a legacy client only has client_id, handle that with normal portal auth/client metadata rather than recreating tokenization-specific vault routing.

Cleartext Storage

The Java schema stores cleartext PII in token_vault.value and indexes it. The Rust schema should not.

Use:

  • value_hash: deterministic HMAC-SHA-256 of (host_id, scheme_id, canonical_value) with valueHashKey; used for idempotent token lookup.
  • value_ciphertext and value_nonce: encrypted cleartext value, for example AES-GCM or ChaCha20-Poly1305 with valueEncryptionKey.
  • key_id: identifies which key encrypted the row so key rotation is possible.

This keeps tokenization idempotent without indexing cleartext PII.

Tokenization Algorithm

Shared runtime operation:

tokenize(host_id, scheme_id, value)
  -> canonicalize value
  -> compute value_hash
  -> cache lookup by (host_id, scheme_id, value_hash)
  -> SELECT token WHERE host_id, scheme_id, value_hash, active
  -> if found, cache and return
  -> generate scheme-specific token
  -> encrypt cleartext
  -> INSERT row
  -> on token collision, retry generation
  -> on value_hash conflict, SELECT existing token and return it

Use PostgreSQL uniqueness instead of application locks:

INSERT INTO pii_token_vault_t (...)
VALUES (...)
ON CONFLICT DO NOTHING;

If no row is inserted, determine whether the conflict was on (host_id, token) or (host_id, scheme_id, value_hash). Token collision means retry with a new token. Value conflict means another request already inserted the mapping; select and return the existing token.

Detokenization:

detokenize(host_id, token)
  -> cache lookup by (host_id, token)
  -> SELECT encrypted value WHERE host_id, token, active
  -> decrypt cleartext
  -> cache and return

If token is not found, the handler fails the response with a handler error. For gateway response-body detokenization, fail closed so employees do not see partial or incorrect data without a signal.

Runtime Caching

Use bounded in-process caches:

  • (host_id, scheme_id, value_hash) -> token
  • (host_id, token) -> cleartext

The cache must be tenant-scoped and bounded by count and TTL. Because the reverse cache contains cleartext PII, make it configurable and register it with the runtime cache registry only with masked summaries. A clear-cache operation should be available through the runtime control plane.

The cache is an optimization only. PostgreSQL remains the source of truth.

Request And Response Mutation

Only mutate supported structured content:

  • application/json in phase 1.
  • JSON arrays and nested objects through compiled path rules.
  • Missing optional fields are ignored.
  • Missing required fields reject the request or response with a handler error.

For outbound request tokenization:

  1. Buffer the JSON request body within a configured max body size.
  2. Parse to serde_json::Value.
  3. Apply matching request rules.
  4. Replace every string value with a token.
  5. Serialize JSON, update Content-Length, and forward upstream.

For inbound response detokenization:

  1. Buffer the JSON response body within a configured max body size.
  2. Parse to serde_json::Value.
  3. Apply matching response rules.
  4. Replace every string token with cleartext.
  5. Serialize JSON, update Content-Length, and return downstream.

For very large or streaming payloads, skip mutation and fail closed by default. Streaming tokenization can be considered later only if a real product requires it.

Security

  • Require a verified JWT principal before tokenization.
  • Resolve host_id from a configured claim, default host_id.
  • Reject active tokenization if host_id is missing.
  • Do not log cleartext values, generated tokens, value hashes, ciphertext, or keys.
  • Mask crypto keys in module registry summaries.
  • Use least-privilege PostgreSQL credentials: only select/insert/update on the tokenization tables.
  • Prefer encrypted cleartext storage, not plaintext value.
  • Keep tokens scoped by host_id; the same token string in another tenant does not detokenize.

Future Service API

The direct database implementation should be the first production path. However, keep the core API independent from Pingora:

#![allow(unused)]
fn main() {
#[async_trait]
pub trait PiiTokenVault: Send + Sync {
    async fn tokenize(&self, host_id: Uuid, scheme_id: i16, value: &str)
        -> Result<String, PiiTokenError>;

    async fn detokenize(&self, host_id: Uuid, token: &str)
        -> Result<String, PiiTokenError>;
}
}

Then a future service can wrap the same trait.

Protocol recommendation:

  • gRPC for request-path service-to-service tokenization if a standalone service becomes necessary.
  • MCP only as an optional tool surface for agents or administrative workflows.
  • REST/JSON-RPC only for compatibility or operational simplicity, not the preferred low-latency path.

The gRPC API can be very small:

service PiiTokenization {
  rpc Tokenize(TokenizeRequest) returns (TokenizeResponse);
  rpc Detokenize(DetokenizeRequest) returns (DetokenizeResponse);
  rpc BatchTokenize(BatchTokenizeRequest) returns (BatchTokenizeResponse);
  rpc BatchDetokenize(BatchDetokenizeRequest) returns (BatchDetokenizeResponse);
}

Batch operations are important if a future remote service is used; otherwise per-field network calls will dominate latency.

Implementation Phases

  1. Add portal-db DDL and seed data for pii_token_scheme_t and pii_token_vault_t.
  2. Add a light-pingora shared tokenization runtime with sqlx::PgPool, scheme registry, value hashing, encryption, token generation, and tests.
  3. Add pii-tokenization.yml loader, module registry registration, and runtime reload.
  4. Add gateway request-body and response-body filter support.
  5. Implement tokenize and detokenize handler wiring in light-gateway.
  6. Integrate MCP x-tokenize with the same runtime so MCP tools do not call a hardcoded tokenization service.
  7. Add optional gRPC service wrapper only if deployment needs a separate tokenization service.

Remaining Considerations

  • KMS or light-portal managed keys can be added later, but the first implementation should read the configured environment variables before any resolved config fallback.
  • Products that disable cache.cacheCleartext will still use PostgreSQL as the source of truth, with higher detokenization latency.

Token Handler

Status

Proposed design for migrating the Java egress-router TokenHandler into light-fabric as the token handler used by light-pingora and light-gateway.

A baseline Rust token runtime already exists in light-pingora. This document captures the Java behavior, the compatibility contract, and the design direction for hardening it for gateway and sidecar deployments.

Purpose

The token handler obtains an OAuth 2.0 client credentials access token on behalf of the backend service in the sidecar or gateway egress path. The token is then attached to the outbound request before router or proxy sends the request to the downstream API.

This is different from the PII tokenize and detokenize handlers. The token handler deals only with service-to-service OAuth tokens.

Java Behavior To Map

The Java implementation is centered on:

  • egress-router/.../TokenHandler.java
  • sidecar/.../SidecarTokenHandler.java
  • router-config/.../TokenConfig.java
  • client-config/.../client.yaml
  • sidecar-config/.../sidecar.yml

token.yml controls whether the handler is active and which request paths need token injection:

enabled: ${token.enabled:false}
appliedPathPrefixes: ${token.appliedPathPrefixes:}

The OAuth provider, client credentials, cache, timeout, proxy, HTTP/2, and single-vs-multiple-auth-server settings live in client.yml:

oauth:
  multipleAuthServers: ${client.multipleAuthServers:false}
  token:
    cache:
      capacity: ${client.tokenCacheCapacity:200}
    tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
    expiredRefreshRetryDelay: ${client.expiredRefreshRetryDelay:2000}
    earlyRefreshRetryDelay: ${client.earlyRefreshRetryDelay:30000}
    server_url: ${client.tokenServerUrl:}
    serviceId: ${client.tokenServiceId:}
    proxyHost: ${client.tokenProxyHost:}
    proxyPort: ${client.tokenProxyPort:}
    enableHttp2: ${client.tokenEnableHttp2:true}
    client_credentials:
      uri: ${client.tokenCcUri:/oauth2/token}
      client_id: ${client.tokenCcClientId:}
      client_secret: ${client.tokenCcClientSecret:}
      scope: ${client.tokenCcScope:}
      serviceIdAuthServers: ${client.tokenCcServiceIdAuthServers:}
pathPrefixServices: ${client.pathPrefixServices:}
request:
  connectTimeout: ${client.connectTimeout:2000}
  timeout: ${client.timeout:4000}

The Java request flow is:

  1. Reload token.yml for the request.
  2. Check appliedPathPrefixes with a string prefix match.
  3. Read service_id from the request. This header is expected to be set by PathPrefixServiceHandler or ServiceDictHandler.
  4. Resolve the auth server configuration from client.yml.
  5. Get or refresh a cached client credentials JWT for the service.
  6. If the request has no Authorization header, set Authorization: Bearer <token>.
  7. If the request already has Authorization, preserve it and set X-Scope-Token: Bearer <token>.
  8. Continue to the next handler, usually router.

For multiple auth servers, Java reads oauth.token.client_credentials.serviceIdAuthServers[service_id] and enriches that entry with the global token defaults. For a single auth server, it uses the global oauth.token.client_credentials section.

The Java cache is a static map keyed by service_id. The cached Jwt stores the access token and its exp claim in milliseconds. OauthHelper refreshes synchronously after expiry and attempts async refresh while the token is in the renewal window.

SidecarTokenHandler adds an egress gate before calling TokenHandler:

  • sidecar.egressIngressIndicator: header runs the token handler only when the request has service_id or service_url.
  • sidecar.egressIngressIndicator: protocol runs the token handler for HTTP requests, which is the usual in-pod sidecar egress protocol.
  • Any other value skips token injection.

The base Java TokenHandler still needs service_id to choose the service token. A request with only service_url can identify egress traffic, but it does not by itself select a service-specific token.

Goals

  • Preserve the Java configuration files: token.yml and client.yml.
  • Activate the handler with the existing token id in handler.yml.
  • Support config-server injection for token.enabled, token.appliedPathPrefixes, client.multipleAuthServers, client.tokenCcServiceIdAuthServers, sidecar.egressIngressIndicator, and the rest of the client.yml token fields.
  • Support single auth server and per-service auth server configurations.
  • Support token endpoint discovery through oauth.token.serviceId when a direct server_url is not configured.
  • Preserve the Java header behavior for Authorization and X-Scope-Token.
  • Keep token retrieval fast and safe for request-path execution.
  • Register configuration and token cache state with the module registry and runtime cache registry without exposing token or secret values.
  • Keep the design usable by light-gateway, future sidecar products, and BFF deployments that need to call downstream APIs.

Non-Goals

  • Do not use inventory or dynamic plugins. Handler availability is compiled into the binary; handler activation is controlled by handler.yml.
  • Do not implement authorization code, refresh token, or token exchange in this handler. This handler only performs client_credentials.
  • Do not migrate Java SAMLTokenHandler as part of this design.
  • Do not use the PII tokenization table or handlers. token, tokenize, and detokenize are separate concerns.
  • Do not send the generated access token to logs, metrics labels, module registry output, or cache summaries.

Resolved Decisions

  • Use sidecar.yml to differentiate inbound proxy traffic from outbound router traffic before applying token injection.
  • Implement refresh with the same concurrency model as Java http-client: synchronize refresh per cached token, refresh expired tokens synchronously, refresh valid tokens in the renewal window asynchronously, and use retry windows to prevent repeated failed refresh attempts.

Handler Chain

The token handler must run after service resolution and before egress routing:

handlers:
  - correlation
  - security
  - path-prefix-service
  - token
  - router

chains:
  sidecar-egress:
    - correlation
    - security
    - path-prefix-service
    - token
    - router

paths:
  - path: /v1/pets
    method: GET
    exec:
      - sidecar-egress

path-prefix-service sets service_id from path configuration. token uses that service id to resolve and cache the client credentials token. router uses the same service id to select the downstream API target and should remove routing-only headers before forwarding.

For products where only some outbound APIs need a scope token, keep one chain with token and another without it, or use token.appliedPathPrefixes to limit token injection inside a shared chain.

Rust Architecture

Keep the implementation in light-pingora because token injection is a request-path gateway handler. light-gateway wires the handler into the existing chain execution model.

Primary Rust module:

frameworks/light-pingora/src/token.rs

Primary types:

#![allow(unused)]
fn main() {
pub struct TokenHandlerConfig {
    pub enabled: bool,
    pub applied_path_prefixes: Vec<String>,
}

pub struct ClientTokenConfig {
    pub tls: ClientTlsConfig,
    pub oauth: ClientOauthConfig,
    pub path_prefix_services: BTreeMap<String, String>,
    pub request: ClientRequestConfig,
}

pub struct TokenRuntime {
    handler: TokenHandlerConfig,
    sidecar: SidecarTrafficConfig,
    client: ClientTokenConfig,
    cache: Arc<TokenCache>,
    registry_client: Option<Arc<PortalRegistryClient>>,
}
}

apps/light-gateway should load TokenRuntime only when the matched handler configuration contains token. For Java compatibility, token.yml still has enabled; therefore the handler is effective only when both conditions are true:

handler.yml contains token
token.yml enabled is true

If token.yml enables the handler, client.yml is required and invalid configuration should fail startup. sidecar.yml is also loaded into the token runtime so the same handler chain can distinguish inbound proxy requests from outbound router requests. Invalid reloads should be rejected while the last valid runtime keeps serving traffic.

Request Flow

The Rust request flow should be:

  1. Resolve the active handler chain for the path and method.
  2. When token is encountered, check TokenHandlerConfig.enabled.
  3. Evaluate sidecar.yml and skip token injection for inbound proxy traffic.
  4. Check appliedPathPrefixes with boundary-aware matching. /v1/address should match /v1/address/123, but not /v1/address2.
  5. Resolve the token service id:
    • first from the service_id request header,
    • then from client.yml pathPrefixServices,
    • then from oauth.token.serviceId for single-auth-server token endpoint discovery when applicable.
  6. Resolve the token endpoint:
    • use direct server_url first,
    • otherwise discover oauth.token.serviceId through portal registry.
  7. Select client credentials:
    • for single auth server, use oauth.token.client_credentials,
    • for multiple auth servers, require client_credentials.serviceIdAuthServers[service_id] and merge it with global token defaults.
  8. Look up the token cache.
  9. Fetch a new token when the cache is missing, expired, or inside the refresh window.
  10. Add Authorization or X-Scope-Token using the Java-compatible rule.

The outbound token request should be Java-compatible:

POST {server_url}{uri}
Content-Type: application/x-www-form-urlencoded
Accept: application/json
Authorization: Basic base64(client_id:client_secret)

grant_type=client_credentials&scope=...

The response must contain access_token. Expiry should be derived from the JWT exp claim when available, with expires_in as a fallback for non-JWT token servers.

Cache And Refresh

Use a bounded async cache owned by TokenRuntime.

The cache key should include both service id and scope:

#![allow(unused)]
fn main() {
pub struct TokenCacheKey {
    pub service_id: Option<String>,
    pub scope: Option<String>,
}
}

This is stricter than the Java Map<String, Jwt> keyed only by service_id and avoids collisions when the same service uses multiple scope sets.

Refresh policy:

  • If the token is valid and outside the renewal window, use the cached token.
  • If the token is expired, synchronize on that cache entry and refresh synchronously. Concurrent requests for the same service and scope should wait on the same per-entry lock, then re-check the refreshed token instead of making duplicate token endpoint calls.
  • If the token is expired but another failed refresh attempt is still inside expiredRefreshRetryDelay, fail closed with a token-not-available rejection.
  • If the token is in the renewal window but not expired, return the current token and start one background refresh for that cache entry when no refresh is already running and earlyRefreshRetryDelay has elapsed.
  • Keep refresh state per cached token: token string, expiry, scope, renewing, expired_retry_timeout, and early_retry_timeout.

This intentionally mirrors Java OauthHelper.populateCCToken. The Rust implementation should use tokio locks/tasks instead of Java synchronized and ScheduledExecutorService, but the observable behavior should stay the same: expired tokens block the current request, early refresh does not block the current request, and multiple concurrent requests for the same token are coordinated through one cache entry.

On token.yml or client.yml reload, build a new TokenRuntime and discard the old cache. This prevents tokens issued with old client credentials or old scope configuration from being reused after a config change.

Sidecar Egress Gate

The token handler must use sidecar.yml to decide whether the current request is outbound router traffic or inbound proxy traffic. This allows one gateway or sidecar process to host both directions while applying token injection only to egress calls.

Use the Java sidecar.yml contract:

egressIngressIndicator: ${sidecar.egressIngressIndicator:header}

Rust behavior:

  • header: run token only when service_id or service_url is present.
  • protocol: run token for HTTP requests entering the sidecar listener.
  • any other value: skip token injection.

Even with this gate, token selection should still require either a resolved service id or a single-auth-server configuration that can use a direct server_url.

The sidecar config should be registered in the module registry as a framework config. Invalid values should fail startup or reject reload.

Configuration Examples

Single auth server:

# sidecar.yml
egressIngressIndicator: ${sidecar.egressIngressIndicator:header}
# token.yml
enabled: ${token.enabled:true}
appliedPathPrefixes: ${token.appliedPathPrefixes:/v1}
# client.yml
oauth:
  multipleAuthServers: false
  token:
    server_url: ${client.tokenServerUrl:https://oauth.example.com}
    tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
    client_credentials:
      uri: ${client.tokenCcUri:/oauth2/token}
      client_id: ${client.tokenCcClientId:gateway-client}
      client_secret: ${client.tokenCcClientSecret:}
      scope: ${client.tokenCcScope:petstore.r petstore.w}

Multiple auth servers:

# client.yml
oauth:
  multipleAuthServers: true
  token:
    tokenRenewBeforeExpired: ${client.tokenRenewBeforeExpired:60000}
    client_credentials:
      uri: /oauth2/token
      serviceIdAuthServers: ${client.tokenCcServiceIdAuthServers:}
pathPrefixServices: ${client.pathPrefixServices:}

The config server can inject client.tokenCcServiceIdAuthServers as YAML or a JSON string:

com.networknt.petstore-1.0.0:
  server_url: https://oauth-petstore.example.com
  client_id: petstore-client
  client_secret: ${PETSTORE_CLIENT_SECRET}
  scope:
    - petstore.r
    - petstore.w

Rust Improvements Over Java

  • Use boundary-aware path prefix matching instead of raw startsWith.
  • Include scope in the cache key.
  • Mask client_secret and token values in module registry and cache output.
  • Fail startup for enabled but invalid token configuration.
  • Use Rust async primitives to implement the same per-token synchronized refresh behavior as Java without spawning a dedicated executor per refresh attempt.
  • Support direct server_url and portal-registry discovery with the same runtime path.
  • Keep all config-server injected values in the normal module registry and reload model.

Observability

Record metrics and logs around the token operation, but never include the token or client secret:

  • handler duration for token,
  • cache hit, miss, refresh, and failure counts,
  • token endpoint latency and HTTP status,
  • service id and provider selection,
  • refresh retry suppression counts,
  • module registry entry for loaded token.yml and masked client.yml,
  • runtime cache entry count and expiry summaries without access token strings.

Failure Behavior

Fail closed when token injection is required but cannot be completed:

  • missing service_id for multiple auth servers,
  • missing serviceIdAuthServers[service_id],
  • missing client_id or client_secret,
  • no direct server_url and failed token service discovery,
  • token endpoint returns non-2xx,
  • token response has no access_token,
  • token response has neither JWT exp nor expires_in,
  • invalid proxy, URL, or TLS configuration.

Requests outside appliedPathPrefixes should bypass the handler without error.

Test Plan

Unit tests in light-pingora:

  • parse Java-compatible token.yml and client.yml,
  • parse and validate Java-compatible sidecar.yml,
  • parse appliedPathPrefixes as YAML list, JSON string list, and comma list,
  • parse serviceIdAuthServers as YAML map and JSON string map,
  • verify boundary-aware prefix matching,
  • verify sidecar.yml header mode applies token only to outbound requests with service_id or service_url,
  • verify sidecar.yml protocol mode applies token to HTTP egress traffic,
  • verify single auth server option resolution,
  • verify multiple auth server option merging,
  • verify Authorization versus X-Scope-Token header selection,
  • verify cache key includes service id and scope,
  • verify token cache summaries never include token strings,
  • verify expired token refresh is synchronized across concurrent requests,
  • verify early-window refresh returns the current token and starts only one background refresh.

Gateway tests in light-gateway:

  • chain with path-prefix-service -> token -> router,
  • inbound proxy request skips token injection according to sidecar.yml,
  • outbound router request applies token injection according to sidecar.yml,
  • missing service id for multiple auth servers returns a handler rejection,
  • existing caller Authorization is preserved and scope token is added to X-Scope-Token,
  • token runtime reload swaps config and clears old cache,
  • inactive token handler does not require token.yml or client.yml.

Integration tests:

  • mock OAuth token endpoint with client credentials Basic auth,
  • mock discovered token service through portal registry,
  • mock downstream service and assert the final outbound headers,
  • refresh behavior with expired and near-expiry tokens.

Service Discovery

Status

Implemented baseline.

light-runtime, portal-registry, light-pingora, and light-gateway already have the main pieces needed for controller-backed service discovery. This document captures the supported invocation path, the configuration contract, and the intended hardening direction for gateway, sidecar, BFF, MCP, WebSocket, and token-handler deployments.

Purpose

light-gateway should be able to discover downstream service instances from the Light Controller through portal-registry instead of relying only on static host lists in router.yml, proxy.yml, mcp-router.yml, or handler-specific configuration.

The same mechanism should work with both controller implementations:

  • Rust controller-rs
  • Java light-controller

The gateway should use one portal-registry connection for registration, runtime control-plane callbacks, and service discovery lookup. A separate discovery client connection is not required for a registered runtime.

Goals

  • Reuse the existing portal-registry JSON-RPC WebSocket client.
  • Keep service discovery available to all light-pingora handlers through RuntimeConfig.registry_client.
  • Support controller-backed lookup for:
    • REST/router outbound calls
    • WebSocket routing
    • MCP tool routing
    • OAuth token-server resolution
    • SPA auth token-server resolution
  • Keep direct URL configuration as an explicit override when a handler supports it.
  • Keep static target configuration as a fallback where it already exists.
  • Preserve Java-compatible discovery data names such as serviceId, envTag, protocol, address, and port.
  • Let light-portal and config-server manage product-specific registry and handler configuration.
  • Work with one light-gateway binary and different product config sets.

Non-Goals

  • Do not add a second discovery protocol for light-gateway.
  • Do not require dynamic Rust plugins, inventory, or reflection for discovery.
  • Do not make each handler own a separate controller connection.
  • Do not require /ws/discovery for registered gateway instances.
  • Do not remove static fallback configuration from router-style deployments.
  • Do not make service discovery hide invalid product configuration. Startup validation and runtime errors should remain explicit.

Controller Protocol

The controller exposes two WebSocket endpoints:

/ws/microservice
/ws/discovery

light-gateway uses /ws/microservice.

The flow is:

light-gateway
  -> connect /ws/microservice
  -> JSON-RPC service/register
  <- registered runtimeInstanceId
  -> JSON-RPC discovery/lookup on the same websocket
  <- DiscoverySnapshot

The dedicated /ws/discovery endpoint is still useful for non-service clients that only need discovery. It is not needed by the gateway because both controller-rs and light-controller accept discovery JSON-RPC methods on the registered microservice socket after service/register succeeds.

The lookup request uses a DiscoverySubscription shape:

{
  "serviceId": "com.networknt.petstore-1.0.0",
  "envTag": "dev",
  "protocol": "https"
}

envTag and protocol are optional. When protocol is omitted, the controller can return all matching protocols and the caller decides which nodes are usable.

The response is a DiscoverySnapshot:

{
  "serviceId": "com.networknt.petstore-1.0.0",
  "envTag": "dev",
  "protocol": "https",
  "nodes": [
    {
      "runtimeInstanceId": "...",
      "serviceId": "com.networknt.petstore-1.0.0",
      "envTag": "dev",
      "environment": "dev",
      "version": "1.0.0",
      "protocol": "https",
      "address": "petstore",
      "port": 8443,
      "tags": {},
      "connectedAt": "...",
      "lastSeenAt": "...",
      "connected": true
    }
  ]
}

Only connected nodes with a non-zero port should be used as upstream targets. Handlers should ignore protocols they cannot proxy.

Runtime Configuration

Registry participation is controlled by server.yml:

serviceId: ${server.serviceId:com.networknt.light-gateway-1.0.0}
advertisedAddress: ${server.advertisedAddress:127.0.0.1}
enableRegistry: ${server.enableRegistry:true}
startOnRegistryFailure: ${server.startOnRegistryFailure:true}
environment: ${server.environment:dev}

Controller connection settings come from portal-registry.yml:

portalUrl: ${portalRegistry.portalUrl:https://localhost:8438}
portalToken: ${light_portal_authorization:}
controllerDiscoveryToken: ${portalRegistry.controllerDiscoveryToken:}

Current light-gateway discovery uses the microservice registration token from LIGHT_PORTAL_AUTHORIZATION or portalToken. The token is sent in the service/register payload. controllerDiscoveryToken is reserved for clients that use the dedicated /ws/discovery endpoint and is not part of the current gateway lookup path.

The runtime converts portalUrl to /ws/microservice, strips any query string, and starts the shared PortalRegistryClient when registry is enabled. The client must be connected and registered before discovery lookup can succeed.

Gateway Invocation Path

Startup path:

config-server/local config
  -> light-runtime loads server.yml, client.yml, portal-registry.yml
  -> RuntimeConfig.service_identity is built from server/bootstrap config
  -> RuntimeConfig.registry_client is created when registry is enabled
  -> runtime startup registers the gateway with controller
  -> light-gateway builds Pingora proxy state from RuntimeConfig

Request-time path:

incoming request
  -> handler.yml selects a handler chain
  -> handler resolves direct target, serviceId, or static target
  -> handler calls PortalRegistryClient.lookup_discovery when serviceId discovery is needed
  -> controller returns DiscoverySnapshot
  -> handler converts nodes to Pingora ProxyTarget or base URL
  -> Pingora proxies the request

PortalRegistryClient.lookup_discovery sends JSON-RPC method discovery/lookup over the registered websocket and waits for a response. If the websocket is not connected, lookup fails with a registry client connection error.

Handler Usage

Router

The router handler supports both direct routing and service discovery.

Resolution order:

  1. service_url request routing, when configured and present.
  2. service_id from query/header/path-prefix logic.
  3. Controller discovery with serviceId and optional envTag.
  4. direct-registry.directUrls using serviceId|envTag, then serviceId.

Direct registry is the standard static fallback service map when the controller cannot resolve the target.

WebSocket Router

The websocket handler resolves the target service from header, query, or pathPrefixService. It checks direct-registry.directUrls first, then passes serviceId, optional envTag, and protocol to discovery. Connected http and https nodes are converted to upstream WebSocket targets and Pingora handles the upgrade proxying.

MCP Router

The mcp handler can route tools by direct targetHost or discovered serviceId.

Resolution order:

  1. Tool targetHost.
  2. direct-registry.directUrls using serviceId|envTag, then serviceId.
  3. Tool serviceId through controller discovery.

When a tool uses serviceId, portal registry is only required if no direct URL mapping exists. The tool can also specify envTag and protocol to constrain direct URL and discovery lookup.

Token Handler

The token handler can resolve the OAuth token server by direct oauth.token.server_url or by oauth.token.serviceId.

Resolution order:

  1. Direct token server URL.
  2. direct-registry.directUrls using token server serviceId.
  3. Token server serviceId through controller discovery.

The selected node prefers https and then falls back to http. If discovery is required and portal registry is not enabled, token injection fails explicitly.

SPA Auth

The stateless SPA auth and MSAL exchange token clients use the same token-server resolution model as the token handler:

  1. Direct token server URL.
  2. direct-registry.directUrls using token server serviceId.
  3. Token server serviceId through controller discovery.

This keeps BFF deployments independent from fixed OAuth hostnames when the token service is registered with the controller.

Direct URLs And Fallbacks

Discovery should not override an explicit direct URL selected by a handler. Direct URLs are operator intent and should remain authoritative. The standard shared direct URL map is direct-registry.directUrls.

Static fallback is handler-specific:

  • The router checks portal-registry discovery before direct-registry.directUrls.
  • Other service-id paths can check direct-registry.directUrls before controller discovery when they need local/static overrides.
  • MCP, token, SPA auth, JWK, and WebSocket service-id routing can use direct-registry.directUrls without per-handler duplicate maps.

This keeps failure behavior predictable. Product configs that require dynamic discovery should fail requests loudly when the controller connection is down instead of silently choosing an unrelated target.

Load Balancing

The controller returns a list of matching nodes. The handler is responsible for choosing one.

Current behavior is intentionally simple:

  • drop disconnected nodes
  • drop nodes with port 0
  • drop unsupported protocols
  • prefer https for token-server resolution
  • round-robin or index-based selection where the handler already has an index

Future hardening can add weighted selection, zone preference, health score, least-connections, or sticky routing. Those policies should live in the handler or a shared target-selection helper, not in the controller protocol.

Failure Semantics

Startup behavior is controlled by startOnRegistryFailure:

  • true: the gateway can start if initial controller registration times out; the registry client keeps retrying in the background.
  • false: initial controller registration timeout fails startup.

Request-time behavior depends on handler fallback:

  • with direct URL: discovery is bypassed
  • with usable static fallback: handler may continue
  • with discovery-only config: return an explicit gateway error

The runtime should continue reconnecting the registry websocket. Once the client is registered again, new discovery lookups can succeed without restarting the gateway.

Security

The gateway registers through /ws/microservice with the portal registry token. The controller validates the registration token and then allows discovery RPCs on that registered socket.

Security expectations:

  • Use TLS for controller connections outside local development.
  • Keep hostname verification enabled outside local development.
  • Prefer environment-provided token values over static config files.
  • Mask portalToken and controllerDiscoveryToken in module-registry output.
  • Do not pass registry tokens to downstream services.
  • Do not trust discovery data from an untrusted controller.

Discovery returns transport endpoints. Authentication, authorization, rate limit, CORS, header mutation, token injection, and access-control decisions remain normal handler-chain responsibilities.

Config Server Model

In production, light-portal owns product configuration and config-server delivers resolved files at startup.

A product that needs controller-backed discovery should include:

  • server.yml with enableRegistry: true
  • portal-registry.yml with portalUrl and a valid portal token source
  • direct-registry.yml or values.yml entries under direct-registry.directUrls for transition services that are not registered in the controller yet
  • handler-specific config that uses serviceId instead of direct host URLs
  • handler.yml chains that include the relevant handler IDs

For local Docker Compose, the Rust gateway must not keep the default https://localhost:8438 controller URL because localhost is the gateway container. Use portalRegistry.portalUrl: https://controller:8438, pass LIGHT_PORTAL_AUTHORIZATION, and keep static transition mappings in direct-registry.directUrls.

The same binary can therefore run as:

  • gateway
  • sidecar
  • proxy server
  • proxy client
  • balancer
  • BFF

The product identity comes from config, not from a separate executable.

Compatibility Notes

The current Rust and Java controllers are compatible with the gateway discovery path because both support:

  • /ws/microservice
  • service/register
  • discovery lookup on the registered microservice socket
  • serviceId, envTag, and protocol filters
  • DiscoverySnapshot.nodes
  • connected-node metadata with address, port, and protocol

The gateway does not currently depend on /ws/discovery, although that endpoint can remain available for external discovery clients.

Future Work

  • Add optional discovery subscriptions for handlers that benefit from a local in-memory discovery cache.
  • Add shared target-selection policies for weighted, sticky, or zone-aware routing.
  • Expose discovery health through the module registry or an admin endpoint.
  • Add an integration test that starts a controller, registers a backend, starts light-gateway, and verifies an end-to-end proxied request through discovery.
  • Decide whether controllerDiscoveryToken should be used by any standalone discovery-only client in light-fabric.
  • Document operational examples for gateway, sidecar, WebSocket, MCP, token handler, and BFF product profiles.

Tracing

Light-Fabric uses Rust tracing for application logs and runtime diagnostics. The same tracing events must support two different consumers:

  • operators and developers reading live logs from the console or control plane
  • log platforms such as Splunk that ingest structured JSON

The logging design should keep one source of truth for emitted events and make the output format configurable at the edge of the process.

Goals

  • Preserve the current human-readable console format for local development and controller-streamed logs.
  • Support newline-delimited JSON logs for Splunk and other log ingestion systems.
  • Allow deployments to choose text or JSON console output without changing application code.
  • Allow authorized control-plane users to change log levels and logger targets without restarting the service.
  • Avoid coupling Light-Fabric services directly to Splunk availability, credentials, retry policy, or backpressure handling.
  • Keep log fields stable enough for portal-view, controller, and Splunk queries.

Non-Goals

  • Implement a Splunk HTTP Event Collector client inside every Light-Fabric service.
  • Mix human text logs and JSON logs on the same stream.
  • Use values.yml to mutate process environment variables. Environment variables are startup inputs; runtime changes should use an explicit logging configuration model.

Current State

The application binaries initialize tracing_subscriber locally. The current format is text-oriented and is easy to read in a terminal, Docker logs, or a controller stream. Some binaries also support an ANSI toggle so container logs can avoid escape sequences.

This works well for humans, but it is less reliable for Splunk field extraction. Splunk can ingest text logs, but structured JSON gives predictable fields for filtering, dashboards, alerts, and correlation.

Output Formats

Light-Fabric should support the following output formats:

FormatIntended ConsumerNotes
texthumans, local development, controller live log streamExisting behavior. Best for direct reading.
jsonSplunk, OpenTelemetry Collector, Kubernetes log collectorsNewline-delimited JSON. Best for machine ingestion.

The output should be selected with an environment variable:

LIGHT_LOG_FORMAT=text

or:

LIGHT_LOG_FORMAT=json

If the variable is absent, the default should remain text to preserve existing operator behavior.

RUST_LOG should continue to provide the startup filter:

RUST_LOG=info
RUST_LOG=light_gateway=debug,info
RUST_LOG=light_workflow=debug,info

Single Console Stream

For most deployments, the preferred model is a single console stream with a configurable format:

application tracing event
        |
        v
tracing_subscriber fmt layer
        |
        +-- stdout/stderr as text or JSON

This has the lowest runtime overhead because each event is formatted and written once. It also keeps container logging simple: the platform captures the process console stream, and the customer chooses whether that stream is text or JSON.

When LIGHT_LOG_FORMAT=json, the console output should be newline-delimited JSON:

{"timestamp":"2026-06-03T14:12:41.233Z","level":"INFO","target":"light_gateway","fields":{"message":"proxy request completed","method":"GET","path":"/api/customer","status":200,"elapsed_ms":18,"correlation_id":"abc-123"}}

Raw JSON is readable, but it is not as pleasant as the text format. For the control plane, portal-view should parse JSON log lines and render a human projection:

14:12:41.233  INFO  light_gateway  proxy request completed
method=GET path=/api/customer status=200 elapsed_ms=18 correlation_id=abc-123

This lets Splunk receive structured logs while portal-view remains readable for operators.

Portal-View Rendering

The controller should stream log lines without needing to understand every field. Portal-view can detect whether a line is JSON:

  1. Trim the line.
  2. If it starts with {, try to parse it as JSON.
  3. If parsing succeeds, render common fields in a stable layout.
  4. If parsing fails, render the original line as plain text.

The renderer should treat JSON parsing as an enhancement, not a hard requirement. This keeps mixed historical output, startup messages, and unrelated tool output usable.

Recommended display fields:

JSON FieldDisplay Use
timestampleading timestamp
levelseverity badge/text
targetmodule or service source
fields.messagemain message
fields.correlation_idrequest correlation
fields.request_idrequest identifier, when present
fields.statusHTTP or operation status
fields.elapsed_mslatency

Unknown fields can be shown in an expandable details view or appended as key=value pairs.

Splunk Ingestion

A log file is not the only option for Splunk ingestion.

Console JSON in Containers

For Kubernetes and container deployments, console JSON is usually the best default. The service writes JSON to stdout/stderr, and the platform logging agent collects the container log stream. Splunk Connect for Kubernetes, OpenTelemetry Collector, or an equivalent customer-managed collector can parse the JSON and send it to Splunk HTTP Event Collector.

This avoids application-level Splunk credentials and keeps retry, batching, and backpressure in the collector.

JSON Log File

For VM or bare-metal deployments where the customer already uses Splunk Universal Forwarder, a JSON log file is also valid. In that mode the application would write newline-delimited JSON to a rotating file, and the forwarder or OpenTelemetry filelog receiver would tail it.

This mode is useful when stdout is reserved for human-readable controller logs, but it formats and writes each event through an additional sink if text console output remains enabled.

Direct Splunk HEC

Direct HTTP Event Collector delivery from the application is possible but should not be the default. It adds Splunk endpoint configuration, token management, retry policy, buffering, and failure handling to every service. A collector or forwarder is a cleaner boundary for production deployments.

Dual Sink Option

If a deployment must keep text console logs and produce JSON at the same time, Light-Fabric can use multiple tracing layers:

application tracing event
        |
        v
tracing subscriber registry
        |
        +-- text layer -> stdout/stderr
        |
        +-- JSON layer -> rolling file

This preserves the current control-plane stream and gives Splunk a clean JSON source. The tradeoff is extra formatting and I/O work per event.

Use this mode only when a single JSON console stream is not acceptable for the operator experience.

Configuration

The design supports both single-stream and dual-sink logging through configuration. The two common deployment profiles are:

DeploymentConsole OutputJSON FileTypical Splunk Path
Kubernetes/containerjsondisabledcontainer log collector to Splunk HEC
Bare metal/VM with human consoletextenabledSplunk Universal Forwarder or filelog receiver tails the JSON file
Local developmenttextdisabledterminal or controller stream only

The minimal configuration should be:

LIGHT_LOG_FORMAT=text
LIGHT_LOG_ANSI=false
RUST_LOG=info

JSON console mode:

LIGHT_LOG_FORMAT=json
LIGHT_LOG_ANSI=false
RUST_LOG=info

Optional dual-sink file mode:

LIGHT_LOG_FORMAT=text
LIGHT_LOG_ANSI=false
LIGHT_LOG_JSON_FILE_ENABLED=true
LIGHT_LOG_JSON_FILE_DIR=/var/log/light-fabric
LIGHT_LOG_JSON_FILE_NAME=light-gateway.jsonl
LIGHT_LOG_JSON_FILE_ROTATION=daily
RUST_LOG=info

In this dual-sink mode, the application emits the same tracing event to both sinks: text to stdout/stderr for humans and controller-streamed logs, and JSON to the configured file for Splunk ingestion.

Service-specific aliases such as GATEWAY_LOG_ANSI, AGENT_LOG_ANSI, or WORKFLOW_LOG_ANSI can remain during migration, but the long-term interface should converge on LIGHT_LOG_* variables shared by all Light-Fabric binaries.

Runtime Logging Control

Light-Fabric should support the Java control-plane behavior where an authorized operator changes log levels and logger targets from portal-view without restarting the service.

Rust can support this through tracing_subscriber::reload. Instead of installing a fixed EnvFilter, the runtime should wrap the filter in a reloadable layer and keep a reload handle in a shared logging controller:

application tracing event
        |
        v
reloadable EnvFilter
        |
        v
text/json formatting layers

The reloadable part is the filter only. A filter can change the global level and individual logger targets:

info
debug
info,light_gateway=debug
info,light_gateway=debug,light_pingora::security=trace
info,light_pingora::security=off

This matches the practical Java use case: enable debug or trace for one logger while keeping the rest of the service at info.

Dynamic Versus Restart-Only Settings

SettingDynamicReason
Global log levelyesUpdates the reloadable EnvFilter.
Per-target logger levelyesUpdates the reloadable EnvFilter.
Disable a target with target=offyesUpdates the reloadable EnvFilter.
Console format text/jsonnoRequires rebuilding formatter layers.
JSON file enabled/disablednoRequires adding or removing a writer layer.
JSON file directory/name/rotationnoRequires replacing the appender and guard.
ANSI settingnoFormatter setting; treat as startup-only.

Startup Precedence

The startup filter should use this precedence:

  1. RUST_LOG, when present.
  2. logging.filter from values.yml.
  3. The service default, such as info or light_workflow=debug,info.

This preserves existing RUST_LOG behavior for local and container deployments while allowing managed deployments to define a persistent default filter in config.

Example values.yml:

logging.filter: info

More targeted example:

logging.filter: info,light_gateway=debug,light_pingora::security=trace

values.yml should not overwrite environment variables and should not be the normal path for day-to-day control-plane log-level changes. It should provide the baseline filter that the logging module reads at startup. If an operator wants to restore that baseline after a live debugging change, reload_modules can reload runtime/logging from the latest resolved values.

Changing config server values and then triggering reload is therefore a persistence/reset workflow, not the primary live-control workflow.

MCP Tools

The runtime MCP tool surface should expose logging control alongside existing runtime tools such as get_service_info, get_modules, and reload_modules.

Recommended tools:

ToolPurpose
get_logging_filterReturn the current effective filter and startup source.
set_logging_filterValidate and apply a new live filter immediately. This is the normal portal-view control path.
reload_modules with runtime/loggingReset the live filter from the configured baseline in values.yml or remote values.

Example live filter update:

{
  "name": "set_logging_filter",
  "arguments": {
    "filter": "info,light_gateway=debug"
  }
}

Example reset from the configured baseline:

{
  "name": "reload_modules",
  "arguments": {
    "modules": ["runtime/logging"]
  }
}

The service response should include the active filter and status:

{
  "status": "success",
  "filter": "info,light_gateway=debug"
}

Invalid filters should be rejected without changing the current filter:

{
  "status": "error",
  "message": "invalid logging filter: ..."
}

Portal-View Flow

The portal-view control plane should follow the same route used for other runtime management tools:

portal-view
  -> controller
  -> portal-registry/runtime instance connection
  -> service runtime MCP handler
  -> logging control

The UI can offer:

  • a global level selector: off, error, warn, info, debug, trace
  • per-target rows for Rust targets such as light_gateway or light_pingora::security
  • an advanced filter text box for the full EnvFilter expression
  • an apply action that calls set_logging_filter
  • a reset action that reloads runtime/logging from the configured baseline
  • an optional "save as default" action that persists the filter to config server

The advanced filter is important because Rust logger targets are module paths, and operators may need precise target-level control during incident debugging.

The default portal-view workflow should be:

operator changes filter
  -> portal-view calls set_logging_filter
  -> service updates the reloadable EnvFilter immediately

Portal-view should not require this slower path for a temporary debug change:

operator changes filter
  -> portal-view updates config server
  -> portal-view calls reload_modules
  -> service reloads values.yml

That slower path is still useful when the operator intentionally wants the new filter to survive service restart or redeploy.

JSON Field Shape

JSON logs should be stable enough for both portal-view rendering and Splunk searches. Recommended fields include:

FieldMeaning
timestampevent time in UTC
levelERROR, WARN, INFO, DEBUG, or TRACE
targetRust module or logical component
fields.messagehuman message
fields.servicelogical service name, such as light-gateway
fields.instance_idruntime instance, when known
fields.host_idtenant/host context, when safe to log
fields.correlation_idcross-service request correlation
fields.request_idrequest identifier
fields.methodHTTP method, when applicable
fields.pathrequest path without sensitive query string
fields.statusresponse or operation status
fields.elapsed_msoperation duration

Sensitive values must not be logged in either format. This includes tokens, API keys, session cookies, full authorization headers, raw secrets, and request or response payload fields that may contain PII.

Implementation Notes

Use tracing_subscriber as the formatting boundary. The JSON format requires the json feature:

tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }

File output should use tracing_appender:

tracing-appender = "0.2"

If non-blocking file output is used, the returned WorkerGuard must be kept alive until process shutdown so buffered log lines are flushed.

The implementation should move per-binary init_tracing() logic into a shared runtime helper so light-gateway, light-agent, light-workflow, and light-deployer expose the same behavior.

For dynamic filtering, the shared helper should:

  1. Build the initial EnvFilter from RUST_LOG, logging.filter, or the service default.
  2. Install the filter through tracing_subscriber::reload.
  3. Keep the reload handle in a LoggingControl value.
  4. Register a reloadable module named runtime/logging with ModuleRegistry.
  5. Add runtime MCP handlers for get_logging_filter and set_logging_filter.
  6. Reject invalid filter expressions before swapping the active filter.

Recommendation

Start with configurable single-stream console output:

  • default LIGHT_LOG_FORMAT=text
  • production/Splunk option LIGHT_LOG_FORMAT=json
  • portal-view JSON parsing and human-friendly rendering
  • no direct Splunk dependency in the application

Add dual-sink JSON file output only for customers who cannot change the console stream to JSON but still require structured Splunk ingestion.

Release Workflow

Light-Fabric already has a release.sh script that builds Linux binaries, packages release archives, and creates or updates a GitHub release. The current release page uses a static note string, so operators can download artifacts but cannot easily see what changed between tags.

This design introduces a cascading polyrepo release orchestrated by light-workflow. It automates release-notes, changelog flow, binary generation, Docker image pushes, and downstream dependency propagation across both public (light-fabric, light-example-rs) and private (controller-rs, portal-service) repositories.

The implementation should start with a small dependency-free git-log script and leave room to adopt a more structured changelog generator later. It should also centralize Docker image publishing so binary archives and container images use the same release version.

Goals

  • Generate release notes from commits between the previous release tag and the current release tag.
  • Use the same generated notes for GitHub release creation and release updates.
  • Maintain a checked-in CHANGELOG.md so release history is visible without opening GitHub.
  • Preserve the current release.sh VERSION [-l|--local] [--skip-build] operator workflow.
  • Release Linux binary archives and Docker images with the same version tag and the same compiled Linux binaries.
  • Support Apple Silicon and Windows binary artifacts through CI runners that match those operating systems.
  • Add one repo-root build.sh for all Docker images while preserving app-level build script compatibility.
  • Allow manual edits before publishing when release notes need customer-facing cleanup.
  • Avoid requiring Conventional Commit messages on day one.

Non-Goals

  • Replace GitHub releases as the artifact distribution point.
  • Require every commit message to follow feat:, fix:, or another convention immediately.
  • Generate perfect marketing release notes without review.
  • Upload changelog files as separate release artifacts.
  • Remove existing app-level build.sh entrypoints immediately.
  • Build macOS binaries from a normal Linux Docker builder. Apple toolchains and SDKs require a macOS build runner.
  • Build Windows MSVC binaries from a normal Linux Docker builder. Use a Windows runner for the official Windows artifacts.
  • Publish Windows container images as part of the first release flow. Windows container images require Windows base images and a Windows container builder.

Current State

release.sh currently performs these steps:

  1. Parse release options and target version.
  2. Build light-agent, light-deployer, light-gateway, and light-workflow for Linux GNU and Linux musl targets.
  3. Package the binaries into dist/light-fabric-${VERSION}-${TARGET}.tar.gz.
  4. If --local is not set, create a GitHub release or upload artifacts to an existing release.

When creating a new GitHub release, the script uses a static note body:

Light-Fabric Linux release binaries

When the release already exists, the script uploads artifacts but does not update the release notes.

Docker image builds are currently handled by app-level scripts:

apps/light-agent/build.sh
apps/light-deployer/build.sh
apps/light-gateway/build.sh
apps/light-workflow/build.sh

Most app scripts use this shape:

./build.sh 0.3.0
./build.sh 0.3.0 --local
./build.sh 0.3.0 --no-cache

Those scripts build and optionally push networknt/<app>:${VERSION} and networknt/<app>:latest. light-deployer has a simpler custom script, so the app-level workflow is not completely consistent.

release.sh does not currently build or push Docker images. As a result, binary archives and Docker images can drift if they are released in separate manual steps or with different version strings.

Options

Option 1: GitHub Generated Notes

GitHub CLI can generate release notes:

gh release create "$VERSION" --generate-notes --notes-start-tag "$PREVIOUS_TAG"

This is the least code, and it works well for the GitHub release page. The tradeoff is that it does not update CHANGELOG.md in the repository unless an additional script calls the GitHub API and copies the generated notes back into the repo.

This option is useful as a fallback, but it should not be the primary design if the repo changelog is a required output.

Option 2: Dependency-Free Git-Log Script

A local script can generate release notes from the git history:

git log "${PREVIOUS_TAG}..${TARGET_REF}" --pretty=format:"- %s (%h)"

The script can write a markdown file and use that same file for both CHANGELOG.md and gh release create --notes-file.

This option is simple, reviewable, and fits the current Bash release script. It does not require new tooling or commit-message conventions. The initial output will be commit-oriented rather than category-oriented, but it can be improved incrementally.

Option 3: git-cliff

git-cliff can generate structured changelogs from Conventional Commit messages and custom templates. It can group entries into sections such as features, fixes, documentation, and breaking changes.

This gives the best long-term release notes, but it adds a release-tool dependency and works best only after the team consistently writes conventional commit messages.

This can be adopted later without changing the overall release flow: replace the internal git-log generator with a git-cliff invocation that writes the same release notes file.

Proposed Design

Start with Option 2.

Add a helper script:

scripts/release-notes.sh

The script should generate:

dist/release-notes-${VERSION}.md

It should optionally update:

CHANGELOG.md

release.sh should call the helper before publishing the GitHub release. The generated notes file becomes the release page source:

gh release create "$VERSION" "${ARCHIVES[@]}" \
  --title "$VERSION" \
  --notes-file "$NOTES_FILE"

For an existing release, the script should update the release body as well as uploading artifacts:

gh release edit "$VERSION" --notes-file "$NOTES_FILE"
gh release upload "$VERSION" "${ARCHIVES[@]}" --clobber

Use Docker as the official Linux release builder. The controlled Docker builder environment should compile Linux binaries once per Linux platform, export those binaries into dist/, and use the same binaries when assembling runtime Docker images. Local host builds remain useful for development, but they should not be the official release source for Linux artifacts.

Add a repo-root Docker image script:

build.sh

The root script should become the source of truth for building and publishing all Light-Fabric app images:

./build.sh 0.3.0
./build.sh 0.3.0 --local
./build.sh 0.3.0 --app light-agent
./build.sh 0.3.0 --image-org networknt --no-cache

The script should build these images by default:

networknt/light-agent:0.3.0
networknt/light-deployer:0.3.0
networknt/light-gateway:0.3.0
networknt/light-workflow:0.3.0

Unless --skip-latest is set, it should also tag and push:

networknt/light-agent:latest
networknt/light-deployer:latest
networknt/light-gateway:latest
networknt/light-workflow:latest

Existing app-level build scripts should remain, but they should become thin wrappers around the root script:

../../build.sh "$@" --app light-agent

This preserves established operator muscle memory and removes duplicated Docker publish logic.

release.sh should call the root build.sh with the same VERSION. For Linux targets, the release should build once per platform and reuse the output:

Docker/BuildKit Linux builder
        |
        +-- dist/linux/<target>/bin/<app>       -> GitHub release tarballs
        |
        +-- dist/linux/<target>/bin/<app>       -> Docker runtime images

This makes one command release both binary artifacts and Docker images without compiling the same Linux binaries twice.

Changelog Format

CHANGELOG.md should use reverse chronological release sections:

# Changelog

## 0.3.0 - 2026-06-03

- Add JSON file logging support to `light-runtime` (abc1234)
- Wire runtime logging control into `light-gateway` (def5678)
- Document Splunk ingestion options for tracing (123abcd)

## 0.2.0 - 2026-05-20

- ...

The generated release notes file should contain the same section body:

## 0.3.0 - 2026-06-03

### Changes

- Add JSON file logging support to `light-runtime` (abc1234)
- Wire runtime logging control into `light-gateway` (def5678)
- Document Splunk ingestion options for tracing (123abcd)

### Artifacts

- `light-fabric-0.3.0-x86_64-unknown-linux-gnu.tar.gz`
- `light-fabric-0.3.0-x86_64-unknown-linux-musl.tar.gz`
- `light-fabric-0.3.0-aarch64-unknown-linux-gnu.tar.gz`
- `light-fabric-0.3.0-aarch64-unknown-linux-musl.tar.gz`
- `light-fabric-0.3.0-aarch64-apple-darwin.tar.gz`
- `light-fabric-0.3.0-x86_64-pc-windows-msvc.zip`
- `networknt/light-agent:0.3.0`
- `networknt/light-deployer:0.3.0`
- `networknt/light-gateway:0.3.0`
- `networknt/light-workflow:0.3.0`

The release notes file can include artifact names because it is used directly for the GitHub release page. CHANGELOG.md should focus on changes and can omit artifact details.

Docker images should be listed in the GitHub release body even though they are published to Docker Hub instead of attached to the release page. This gives operators one place to see every artifact produced by a release.

Docker image platform variants should also be visible:

networknt/light-agent:0.3.0       linux/amd64, linux/arm64
networknt/light-deployer:0.3.0    linux/amd64, linux/arm64
networknt/light-gateway:0.3.0     linux/amd64, linux/arm64
networknt/light-workflow:0.3.0    linux/amd64, linux/arm64

Tag Range Selection

The release-notes script needs a deterministic commit range.

Inputs:

  • VERSION: target tag, for example 0.3.0 or v0.3.0
  • optional --from PREVIOUS_TAG
  • optional --target-ref TARGET_REF

Default behavior:

  1. Unless --from is supplied, fetch tags from origin and stop if they cannot be synchronized.
  2. If --target-ref is supplied, use it as the end of the range.
  3. Else if the VERSION tag exists locally, use VERSION.
  4. Else use HEAD.
  5. If --from is supplied, use it as the start of the range.
  6. Else find the newest semver-like tag before VERSION.
  7. If no previous tag exists, use the first commit as the start.

For existing releases, this allows regenerating the notes for the exact tag. For new releases, this allows generating notes before the tag exists.

Recommended git command:

git log --no-merges --pretty=format:"- %s (%h)" "${PREVIOUS_TAG}..${TARGET_REF}"

If merge commits are important for the team, the script can add a --include-merges option.

Release Script Flow

The updated release.sh flow should be:

  1. Parse release options.
  2. Validate build and publish dependencies.
  3. Generate release notes into dist/release-notes-${VERSION}.md.
  4. Build Linux binaries with the Docker release builder unless --skip-build or --host-build is set.
  5. Package release archives.
  6. Build Docker images unless --skip-docker is set.
  7. Print generated archive names, Docker image names, and release notes path.
  8. If --local is set, stop before GitHub and Docker Hub publishing.
  9. If the GitHub release exists:
    • update the release body from the generated notes file
    • upload archives with --clobber
  10. If the GitHub release does not exist:
  • create it with --notes-file
  • upload archives during creation
  1. Push Docker images unless --skip-docker or --local is set.

The release notes should be generated before publishing, but the changelog update should be explicit. A release engineer may want to review and commit CHANGELOG.md before publishing.

Recommended flags:

--update-changelog       prepend the generated section to CHANGELOG.md
--notes-only             generate notes and optionally update changelog without building
--from TAG               override previous tag selection
--target REF             override release notes target ref
--include-merges         include merge commits in generated commit list
--skip-docker            release binary archives only
--docker-only            build and publish Docker images only
--skip-latest            publish VERSION image tags without updating latest
--host-build             use local cargo builds for Linux binaries instead of the Docker release builder
--app APP                restrict Docker image work to one app
--image-org ORG          Docker image namespace, default networknt
--platform PLATFORM      restrict Docker image platform, default linux/amd64,linux/arm64
--skip-macos             skip macOS binary artifacts in CI release mode
--skip-windows           skip Windows binary artifacts in CI release mode

--local should still build and package locally. It may generate release notes, but it should not call gh or push Docker images.

--docker-only should skip binary archive packaging and GitHub release asset upload. It should still generate release notes by default so the same version context is visible in the command output. If --local is also set, it should build images locally without pushing them.

Automated Polyrepo Release Workflow

Because controller-rs, portal-service, and light-example-rs depend on light-fabric crates, they must be released sequentially in a Cascading Release Pipeline. Attempting to release them manually is error-prone.

We will dogfood light-workflow as our Release Orchestrator to automate this across the public and private repository boundaries.

The Release-Train Workflow Template

The light-workflow template acts as the overarching controller:

  1. Step 1: Upstream Release (light-fabric)

    • Task A: The workflow runs cargo release (or equivalent) to bump versions, tag, and publish the public light-fabric crates to crates.io.
    • Task B: The workflow invokes the build.sh script to compile Linux binaries and push light-fabric Docker images.
    • Task C: The workflow calls release.sh to generate the changelog and publish the GitHub Release page.
  2. Step 2: The Sync Barrier (Wait Step)

    • The workflow pauses for a short duration (e.g., 2 minutes) to ensure crates.io indexing has completed, preventing downstream builds from failing to find the new crate versions.
  3. Step 3: Downstream Dependency Propagation

    • The workflow clones controller-rs, portal-service, and light-example-rs.
    • It runs cargo update -p light-fabric to point the downstream repositories to the newly published version.
    • It pushes these changes to their respective main branches.
  4. Step 4: Parallel Downstream Releases

    • The workflow uses a parallel execution pattern to trigger releases for the downstream repositories simultaneously:
      • Branch 1 (controller-rs): Build private binaries, push private Docker images, and tag the private repo.
      • Branch 2 (portal-service): Build private binaries, push private Docker images, and tag the private repo.
      • Branch 3 (light-example-rs): Publish any downstream public crates, push public Docker images, and create the GitHub Release.

By wrapping the individual release.sh and build.sh scripts in a light-workflow execution, we gain stateful retries, full pipeline visibility, and automated propagation without exposing secure tokens on developer workstations.

Root Docker Build Script

The repo-root build.sh should own Linux Docker image build and push behavior for all apps.

Recommended app metadata:

AppImageDockerfile
light-agentnetworknt/light-agentapps/light-agent/docker/Dockerfile
light-deployernetworknt/light-deployerapps/light-deployer/Dockerfile
light-gatewaynetworknt/light-gatewayapps/light-gateway/docker/Dockerfile
light-workflownetworknt/light-workflowapps/light-workflow/docker/Dockerfile

The Docker build context should remain the workspace root because the Dockerfiles copy workspace-level Cargo.toml, Cargo.lock, crates, frameworks, and app directories.

The script should support:

build.sh [VERSION] [-l|--local] [--no-cache] [--app APP] [--image-org ORG] [--platform PLATFORM] [--skip-latest]

Default behavior:

  1. Build all app images for linux/amd64 and linux/arm64.
  2. Tag each image as ${IMAGE_ORG}/${APP}:${VERSION}.
  3. Tag each image as ${IMAGE_ORG}/${APP}:latest unless --skip-latest is set.
  4. Use the Linux binaries produced by the release Docker builder instead of compiling Rust again inside each runtime image build.
  5. If --local is set, stop after local image builds.
  6. Otherwise push all generated tags and multi-platform manifests.

The script should print the full list of image tags it built and pushed. This list should be available to release.sh so the GitHub release notes can include the Docker image artifacts.

When build.sh is called from release.sh, it should receive the exported binary directory explicitly:

./build.sh "$VERSION" --binary-dir "dist/build"

When build.sh is called directly without --binary-dir, it can either invoke the Docker release builder for the requested platforms or fall back to the current Dockerfile builder stages. The preferred direct behavior is to invoke the same Docker release builder so local and CI image builds stay aligned.

Recommended implementation:

  1. Add a release builder Dockerfile, for example:
docker/Dockerfile.release
  1. Add a builder target that compiles all apps for one Linux target and exports binaries:
docker buildx build \
  --target export-binaries \
  --platform linux/amd64 \
  --output type=local,dest=dist/build/linux-amd64 \
  .
  1. Repeat for linux/arm64 if multi-architecture Linux images are enabled.
  2. Package the exported binaries into GitHub release tarballs.
  3. Build runtime images from those exported binaries, not from another cargo build.

The runtime image Dockerfiles can use a binary-only context or a release target that copies prebuilt binaries:

COPY dist/build/linux-amd64/bin/light-gateway /app/light-gateway

For multi-platform images, docker buildx build --platform linux/amd64,linux/arm64 can publish one image tag with a manifest list. The important point is that each platform-specific image must use the binary built for that platform.

Cross-Platform Binary Strategy

"Build once" means build once per target platform, then reuse that output everywhere that platform can run. It does not mean one binary can serve every operating system and CPU architecture.

Recommended artifact matrix:

ArtifactTargetBuilder
Linux x86_64 binary archivex86_64-unknown-linux-gnu or x86_64-unknown-linux-muslDocker/BuildKit Linux builder
Linux arm64 binary archiveaarch64-unknown-linux-gnu or aarch64-unknown-linux-muslDocker/BuildKit Linux builder
Linux Docker image for Intel/AMDlinux/amd64Docker/BuildKit Linux builder
Linux Docker image for Apple Silicon Docker Desktoplinux/arm64Docker/BuildKit Linux builder
Apple Silicon macOS binary archiveaarch64-apple-darwinmacOS arm64 runner
Windows binary archivex86_64-pc-windows-msvcWindows runner

Apple Silicon has two different release meanings:

  • Docker image support for Apple Silicon machines is a Linux arm64 container image. Docker Desktop on Apple Silicon runs Linux containers, so linux/arm64 is the right image platform.
  • Native Apple Silicon binaries are macOS binaries targeting aarch64-apple-darwin. These should be built on a macOS runner, not inside a normal Linux Docker build.

Windows binaries and Windows container images are also separate concerns:

  • Windows binary archives should target x86_64-pc-windows-msvc and should be built on a Windows runner for the official release.
  • Windows container images require Windows base images and a Windows container builder. They should be treated as a later phase unless customers explicitly need Windows containers.

In CI, these builds can run at the same time as separate jobs:

linux-release:
  Docker/BuildKit builds Linux binaries and Linux Docker images.

macos-release:
  macOS runner builds aarch64-apple-darwin binaries.

windows-release:
  Windows runner builds x86_64-pc-windows-msvc binaries.

The release publish job should collect all artifacts and update the same GitHub release page. Docker Hub publishing should remain in the Linux release job because the Docker images are Linux container images.

CHANGELOG Update Strategy

The changelog update should be idempotent.

Rules:

  • If CHANGELOG.md does not exist, create it with # Changelog.
  • If a section for VERSION already exists, replace that section.
  • If no section for VERSION exists, insert the new section immediately after the # Changelog heading.
  • Preserve older release sections as-is.
  • Never rewrite unrelated content below older release sections.

This makes rerunning the release script safe during release preparation.

Manual Review Workflow

For a normal release:

./release.sh 0.3.0 --notes-only --update-changelog
git diff CHANGELOG.md dist/release-notes-0.3.0.md

The release engineer reviews and edits CHANGELOG.md if needed, commits it, then publishes:

./release.sh 0.3.0 --skip-build

If binaries also need to be rebuilt:

./release.sh 0.3.0

By default, the official Linux binaries and Linux Docker images should be built from Docker and published together. If a developer needs the old host-build path for local troubleshooting:

./release.sh 0.3.0 --host-build --local

If CI is producing all OS artifacts, the release job should collect the platform-specific archives before publishing:

dist/light-fabric-0.3.0-x86_64-unknown-linux-gnu.tar.gz
dist/light-fabric-0.3.0-aarch64-unknown-linux-gnu.tar.gz
dist/light-fabric-0.3.0-aarch64-apple-darwin.tar.gz
dist/light-fabric-0.3.0-x86_64-pc-windows-msvc.zip

If only Docker images need to be rebuilt and pushed with the same release tag:

./release.sh 0.3.0 --docker-only

If only one Docker image needs to be rebuilt locally:

./build.sh 0.3.0 --app light-gateway --local

If the release page already exists and only the notes need refreshing:

./release.sh 0.3.0 --notes-only
gh release edit 0.3.0 --notes-file dist/release-notes-0.3.0.md

The final implementation can make the last command part of release.sh when --local is not set.

GitHub Release Body

The GitHub release body should be generated from the same release notes file. For new releases:

gh release create "$VERSION" "${ARCHIVES[@]}" \
  --title "$VERSION" \
  --notes-file "$NOTES_FILE"

For existing releases:

gh release edit "$VERSION" --notes-file "$NOTES_FILE"
gh release upload "$VERSION" "${ARCHIVES[@]}" --clobber

This keeps release reruns predictable. Re-uploading artifacts should not leave stale release notes behind.

Future Conventional Commit Mode

If the team later adopts Conventional Commits, the helper script can switch from plain git log output to grouped output:

### Features

- add JSON tracing output

### Fixes

- preserve ANSI toggle in demo services

### Documentation

- document Splunk ingestion options

At that point, git-cliff is a good fit. The public contract can remain the same:

scripts/release-notes.sh VERSION --update-changelog

Only the internals of the generator change.

Risks And Mitigations

RiskMitigation
Commit messages are too noisy for customer-facing notesGenerate notes early, then review and edit before publishing.
Previous tag detection picks the wrong tagSupport --from TAG override and print the selected range.
Release script rerun duplicates changelog sectionsReplace existing VERSION section instead of blindly prepending.
Existing GitHub release has stale notes after artifact uploadAlways call gh release edit --notes-file for existing releases.
Local builds unexpectedly modify CHANGELOG.mdRequire explicit --update-changelog for file mutation.
Binary archives publish but Docker push failsBuild and push images before or immediately after GitHub release publication, print clear recovery commands, and support --docker-only reruns.
Docker image tags drift from GitHub release versionHave release.sh call root build.sh with the same VERSION; do not ask operators to type the image version separately.
Full release builds take longer because Dockerfiles rebuild RustUse Docker/BuildKit as the release builder and make runtime images copy exported binaries instead of running another cargo build.
App-level build scripts diverge againConvert them to wrappers around repo-root build.sh.
Apple Silicon image support is confused with macOS binary supportDocument that Docker Desktop on Apple Silicon needs linux/arm64 images, while native macOS binaries need aarch64-apple-darwin.
Windows artifacts are expected from a Linux Docker buildBuild official Windows MSVC binaries on a Windows runner; treat Windows container images as a separate later phase.

Implementation Plan

  1. Add CHANGELOG.md with a short heading and no release entries.
  2. Add scripts/release-notes.sh with dependency-free git-log generation.
  3. Add idempotent changelog insertion or replacement.
  4. Add docker/Dockerfile.release or equivalent release-builder targets for Linux binaries.
  5. Add repo-root build.sh for all app Docker images and Linux image platforms.
  6. Convert app-level build scripts into compatibility wrappers.
  7. Update release.sh to generate dist/release-notes-${VERSION}.md.
  8. Update release.sh to call root build.sh with the same VERSION, unless --skip-docker is set.
  9. Update runtime image builds to copy binaries exported by the Docker release builder instead of compiling Rust again.
  10. Add CI matrix jobs for macOS Apple Silicon and Windows binary archives.
  11. Update publish_release() to use --notes-file for both new and existing releases.
  12. Add README release documentation for the new flags and review workflow.
  13. Validate changelog generation locally with:
./release.sh 0.3.0 --notes-only --update-changelog --local
git diff --check
  1. Validate Docker image builds locally with:
./build.sh 0.3.0 --local
./build.sh 0.3.0 --app light-gateway --local
  1. Validate combined local release packaging with:
./release.sh 0.3.0 --local
  1. Validate CI artifact collection for Linux, macOS, and Windows archives.
  2. Validate GitHub and Docker Hub publishing on a test tag or draft release before using it for a production release.

Light-Workflow Runner

Status

Proposed design.

light-workflow-runner is a tenant-side execution agent primarily introduced for workflow tasks that must run near tenant systems, tenant repositories, private tools, local gateways, sidecars, or sandboxed release workspaces. The same controller, lease, fencing, and backend substrate can also execute standalone agent turns or actions submitted by light-agent. It is not a second workflow or agent engine and it must not consume workflow start events or own interactive agent sessions.

The SaaS-owned light-workflow instance remains authoritative for workflow subjects. light-agent remains authoritative for standalone agent sessions, turns, and actions. Tenant runners register with controller-rs, receive server-issued fenced execution leases, execute only the leased attempt, and report normalized results for the authenticated origin service to reconcile.

For effectful work, the runner uses a capability-described ExecutionBackend as defined in the Execution Backends And Sandbox Execution design. The backend may be a microVM sandbox, shared-kernel container, Kubernetes Job, dedicated VM, host-integrated environment, or fixed external action. Backend credentials, lifecycle calls, logs, and artifact transfer belong to the runner. light-workflow must not implement competing direct backend protocols.

The interactive agent ownership and placement model is defined in Light-Agent Execution.

Problem

For SaaS deployments, Light owns the main workflow control plane. Tenants may run APIs, gateways, sidecars, deployers, and other services in their own networks. Some workflow tasks need to execute inside those tenant environments instead of inside the SaaS control plane.

Examples:

  • release workflows running in a prepared VM or sandbox with many repositories checked out,
  • command-line tasks that need local files or private repository access,
  • build and test tasks that need tenant-specific toolchains,
  • deployment tasks that need access to private clusters,
  • MCP servers or sidecars running only in the tenant network,
  • AI repair tasks that need to inspect and patch a local sandbox workspace.

Running multiple full light-workflow instances would create control-plane ambiguity:

  • more than one instance may see the same workflow start event,
  • tenant-side config can be changed through environment variables or local values.yml,
  • a tenant runtime could claim work outside its intended scope,
  • workflow definition loading and event consumption become hard to audit,
  • duplicate workflow starts require more complex idempotency and broker ACLs.

The platform needs a runner model that lets tenant-side services execute approved tasks without letting them own workflow orchestration.

Goals

  • Keep one authoritative SaaS light-workflow orchestrator for workflow start events and workflow state.
  • Add a tenant-side light-workflow-runner executable for command, sandbox, deployment, MCP, and local tool execution.
  • Register tenant runners through controller-rs.
  • Enforce task visibility with server-side leases, not runner-side local config.
  • Support release runners in prepared VMs or sandboxes with checked-out repos and approved toolchains.
  • Support per-tenant runner pools, execution profiles, capabilities, and network placement.
  • Let controller-rs periodically audit effective runtime configuration.
  • Reuse workflow-core task models and result contracts where possible.
  • Keep the runner transport origin-neutral so workflow tasks and standalone agent turns can share execution infrastructure without sharing domain ownership.

Non-Goals

  • Do not create a second workflow orchestrator that consumes workflow start events.
  • Do not let tenant runners load arbitrary workflow definitions from local config.
  • Do not trust tenant-side environment variables or local values.yml as the enforcement boundary.
  • Do not expose all workflow tasks to all registered runners.
  • Do not let AI or command tasks bypass publish, signing, or human approval gates.
  • Do not turn a standalone agent turn into a fake workflow task merely to use a runner.
  • Do not let controller-rs or a runner advance workflow or agent domain state.

Current Runtime Boundary

The current light-workflow executable starts the workflow event consumer, task executor, and rule API in one process. The executor actively handles control-plane task types such as ask, assert, call, set, and switch.

workflow-core already models run.container, run.script, run.shell, and run.workflow. These task definitions are the right surface for runner-backed execution, but they still need a runtime executor boundary.

This design keeps the workflow model shared and adds a separate runner executable for effectful execution.

Domain Event                         Interactive Client
  |                                      |
  v                                      v
light-workflow                       light-agent
  | workflow task                       | agent turn/action
  | origin-owned policy and attempt     | origin-owned policy and attempt
  +------------------+-------------------+
                     |
                     v
                controller-rs
  |
  | registration, fenced leases, heartbeat, quarantine
  v
light-workflow-runner
  |
  | approved execution and backend interaction
  v
ExecutionBackend
  |
  | declared isolation, lifecycle, resource, network, workspace,
  | credential, log, and artifact policy
  v
Tenant Runtime Environment

The split is:

  • light-workflow: Authoritative orchestrator. It sees workflow start events, loads workflow definitions, persists immutable policy snapshots, creates task attempts, owns retry and cancellation decisions, and records state.
  • light-agent: Authoritative orchestrator for standalone authenticated sessions, turns, and agent actions. It owns model-loop and memory state, creates action intent, and reconciles results without advancing workflow state.
  • controller-rs: Runtime control plane. It authenticates runners, records runner capabilities, issues and renews fenced execution leases, rejects stale reports, audits runtime config, and quarantines mismatched runners.
  • light-workflow-runner: Tenant-side execution agent. It claims only leased attempts, validates the effective policy and command template, executes in the approved environment, streams bounded logs, safely exports artifacts, and reports normalized results.
  • ExecutionBackend: Backend-specific adapter used by the runner for capability discovery, effective-configuration validation, idempotent prepare and execute, inspection, cancellation, log cursors, artifact copy, optional checkpoints, and cleanup.
  • Execution environment: A microVM sandbox, shared-kernel container, Kubernetes Job, dedicated VM, host-integrated environment, or fixed external action selected for the task's purpose and minimum isolation requirement.

The runner can run beside tenant APIs, gateways, sidecars, and deployers. It may also run in a prepared release VM or sandbox with approved tools and repository workspaces.

A local deployment may colocate these components, but it must retain the same durable attempt, policy, lease, fencing, result, and audit contracts. Colocation must not create a second execution model.

Execution Origin And Subject

The runner wire contract and controller capacity queue are origin-neutral. They carry a generic execution subject instead of requiring every execution to be a workflow task:

executionId
origin.service
origin.instance
subject.kind = workflow-task | agent-turn | agent-action
subject.id
subject.attempt
optional workflow or agent correlation

The authenticated origin service owns the domain state:

  • light-workflow may create and reconcile workflow-task subjects;
  • light-agent may create and reconcile agent-turn and agent-action subjects;
  • controller-rs reserves capacity, transports leases, and stores fenced execution observations, but cannot complete a workflow task or agent turn;
  • the runner executes a lease and cannot change origin-owned state.

Origin authorization is server-owned. A caller cannot select another origin kind in the payload. Tenant, host, origin service, and allowed subject kinds come from validated identity and registration.

Workflow and agent domain tables remain separate. Common scheduling, execution-attempt, lease, backend, session, artifact, and runtime-audit records may share the generic subject identity.

A runner-backed agent_action_attempt_t references the shared execution_attempt_t row. Agent-domain tool, model-iteration, approval, budget, and conversation fields remain outside the common runner table, matching the separation between task_info_t and runner execution state.

Origin Result Wakeup

The common execution_attempt_t row is the durable source of truth. The controller transaction that conditionally stores a newly terminal result also emits a versioned PostgreSQL execution_result_ready_v1 notification. Its bounded payload contains only attempt ID, authenticated origin, subject kind, and correlation ID—never result bytes, tenant content, or authorization.

The named origin uses the notification only to wake its reconciler, reloads the authoritative row, verifies origin/subject/fencing bindings, and conditionally accepts the result into its own domain transaction. Every origin also performs an indexed startup and periodic scan of unaccepted terminal attempts because notifications can be missed, duplicated, or reordered. A later typed callback may be another wakeup, but neither a callback nor the runner may directly update workflow or agent domain tables.

The listener uses a dedicated PostgreSQL connection. On initial startup and reconnect it establishes LISTEN first, then runs the catch-up scan, so a terminal commit in that handoff window is either found by the query or queued as a notification.

Event Visibility

Workflow start events should be visible only to the SaaS-owned light-workflow orchestrator.

Recommended flow:

  1. A domain event is published.
  2. The SaaS light-workflow consumer evaluates matching workflow definitions.
  3. It creates one workflow instance per matching definition.
  4. It creates tasks with runner requirements.
  5. controller-rs exposes only eligible task leases to registered runners.
  6. Runners execute leased tasks and return results.

This avoids duplicate starts and avoids tenant-side event subscription authorization problems.

If a future deployment requires separate workflow clusters, route start events by lane and enforce broker ACLs:

workflow.start.main
workflow.start.release
workflow.start.deployment
workflow.start.tenant.<tenantId>

Even with event lanes, the workflow database should enforce idempotency on a source-event key such as:

tenant_id + source_event_id + workflow_definition_id

For the SaaS model, task leases are the cleaner boundary than exposing start events to tenant runtimes.

Runner Registration

A runner must register before it can claim work.

Registration should include:

{
  "runnerId": "release-runner-01",
  "tenantId": "tenant-a",
  "hostId": "host-a",
  "runnerKind": "release",
  "runnerPools": ["release"],
  "executionProfiles": ["release-sandbox"],
  "capabilities": [
    "git",
    "maven",
    "cargo",
    "rootless-buildkit",
    "event-importer"
  ],
  "executionBackends": [
    {
      "backendId": "cube-prod-east",
      "kind": "microvm",
      "implementation": "cubesandbox",
      "version": "approved-version",
      "capabilityDigest": "sha256:...",
      "isolationBoundary": "microvm",
      "supportsUntrustedCode": true,
      "sessionScopes": ["task", "workflow"],
      "workspaceModes": ["ephemeral", "copy-on-write", "workflow"],
      "networkEnforcement": ["deny-by-default", "http-l7"],
      "credentialDelivery": ["brokered", "proxy-injected"],
      "lifecycle": ["inspect", "reconnect", "cancel", "destroy"]
    },
    {
      "backendId": "docker-sbx-local",
      "kind": "microvm",
      "implementation": "docker-sandboxes",
      "version": "approved-version",
      "capabilityDigest": "sha256:...",
      "isolationBoundary": "microvm",
      "supportsUntrustedCode": true,
      "sessionScopes": ["task", "workflow"],
      "workspaceModes": ["clone"],
      "networkEnforcement": ["deny-by-default", "http-l7"],
      "credentialDelivery": ["proxy-injected"],
      "containerEngineAccess": "private-daemon",
      "lifecycle": ["inspect", "reconnect", "cancel", "destroy"]
    },
    {
      "backendId": "toolbx-local",
      "kind": "host-integrated",
      "implementation": "toolbx",
      "version": "approved-version",
      "capabilityDigest": "sha256:...",
      "isolationBoundary": "host-integrated",
      "supportsUntrustedCode": false,
      "sessionScopes": ["none"],
      "hostExposure": [
        "home",
        "dbus",
        "devices",
        "network",
        "ssh-agent",
        "system-journal",
        "host-sockets"
      ]
    }
  ],
  "imageDigest": "sha256:...",
  "configHash": "sha256:...",
  "commandAllowlistHash": "sha256:...",
  "workspacePolicy": "release-workspace-v1",
  "workspaceChangePolicyDigests": ["sha256:..."],
  "trustBundleDigests": ["sha256:..."],
  "provenanceModes": ["slsa-provenance-v1-signed"],
  "localCleanup": {
    "watchdog": true,
    "durableJournal": true,
    "backendResourceScan": true,
    "policyDigest": "sha256:..."
  },
  "networkZone": "tenant-private",
  "version": "0.3.0"
}

controller-rs validates the registration against server-side runtime policy. If accepted, it creates a runner session and issues short-lived credentials for heartbeat and task claim operations.

Local runner config can request capabilities, but the server decides the effective capabilities. A runner cannot claim work merely because it sets an environment variable or local values.yml value.

Registration is an admission request, not attestation by itself. Backend self-report does not establish a security boundary. Server-owned compatibility records and conformance tests decide which capabilities are trusted. Server policy must still constrain every lease, and the runner must prove the selected backend, immutable template or image, command, resource, network, workspace, host-exposure, workspace-change, credential, provenance, trust-bundle, and local cleanup settings for each attempt. A runner without a healthy watchdog and durable cleanup journal cannot claim backend-creating work. Unsupported or unverifiable required controls fail closed.

Execution Lease Model

The execution lease is the enforcement object. The runner should execute an attempt only when it has a valid lease issued by the control plane. Workflow correlation is present for a workflow subject; agent correlation is present for an agent subject.

Lease example:

{
  "executionId": "01970f5d-0000-7000-8000-000000000000",
  "leaseId": "01970f5d-0000-7000-8000-000000000001",
  "fencingToken": 17,
  "origin": {
    "service": "light-workflow",
    "instance": "workflow-main-east"
  },
  "subject": {
    "kind": "workflow-task",
    "id": "01970f5d-0000-7000-8000-000000000020",
    "attempt": 1
  },
  "tenantId": "tenant-a",
  "hostId": "host-a",
  "runnerId": "release-runner-01",
  "policySnapshotId": "01970f5d-0000-7000-8000-000000000010",
  "policyDigest": "sha256:...",
  "workflow": {
    "wfInstanceId": "release-2026.06.0",
    "taskId": "01970f5d-0000-7000-8000-000000000020",
    "wfTaskId": "build-java-products"
  },
  "operationType": "run.shell",
  "runnerPool": "release",
  "executionProfile": "release-sandbox",
  "profileVersion": 7,
  "capabilities": ["git", "maven"],
  "commandTemplateId": "light-fabric-release-build",
  "commandIdempotencyKey": "release-2026.06.0/build-java-products/1",
  "executionRequirements": {
    "minimumBoundary": "microvm",
    "allowedHostExposure": [],
    "workloadTrust": "untrusted"
  },
  "executionBackend": {
    "backendId": "cube-prod-east",
    "kind": "microvm",
    "implementation": "cubesandbox",
    "version": "approved-version",
    "capabilityDigest": "sha256:..."
  },
  "sandbox": {
    "templateId": "tpl-immutable-id",
    "templateDigest": "sha256:...",
    "sessionScope": "workflow",
    "workspaceMode": "copy-on-write"
  },
  "networkPolicy": "release-egress-v3",
  "trustBundleRef": "trust-bundle://enterprise-egress-v3",
  "trustBundleDigest": "sha256:...",
  "resourcePolicy": "release-build-medium-v1",
  "artifactPolicy": "release-artifacts-v2",
  "inputRefs": [
    {
      "kind": "skill-package",
      "id": "skill-package://coding/rust-review/7",
      "digest": "sha256:...",
      "size": 18432,
      "mountMode": "read-only"
    }
  ],
  "provenancePolicy": {
    "format": "slsa-provenance-v1",
    "mode": "signed",
    "policyDigest": "sha256:..."
  },
  "credentialRefs": [],
  "approvalRef": null,
  "deadlineAt": "2026-06-08T19:25:00Z",
  "environmentExpiresAt": "2026-06-08T19:25:00Z",
  "cleanupDeadlineAt": "2026-06-08T19:30:00Z",
  "expiresAt": "2026-06-08T19:10:30Z",
  "heartbeatIntervalSeconds": 15
}

Server-side validation must check:

  • runner session is active,
  • runner is not quarantined,
  • tenant and host match,
  • subject runner pool matches the registered pool,
  • subject execution profile is allowed,
  • required capabilities are a subset of effective runner capabilities,
  • policy snapshot and digest match the active origin-owned subject,
  • attempt number and fencing token match the active execution attempt,
  • command template is approved,
  • selected backend compatibility, workload trust, minimum isolation boundary, immutable template or image, host-exposure, network, resource, artifact, workspace, workspace-change, trust-bundle, provenance, lifecycle, local cleanup, and credential policies are supported,
  • required runtime approval is valid and bound to the exact attempt inputs,
  • execution and lease deadlines have not expired.

The runner reports execution start, logs, progress, and final result using the lease. The control plane rejects reports that do not match the active attempt, lease, and fencing token.

Attempt And Fencing

Remote runner execution is at-least-once. Every execution uses a durable attempt with a monotonically increasing attempt number and fencing token. The lease is short-lived but renewable while the subject is active. Renewal proves runner liveness; it does not extend the execution wall-clock deadline.

Start, progress, log, artifact, and result messages include the execution origin, subject, attempt, lease ID, and fencing token. Result acceptance uses compare-and-set semantics against the active attempt. An expired runner or late backend result cannot overwrite a newer attempt or transition origin-owned state.

If the runner loses contact after backend dispatch, the attempt becomes UNKNOWN. The runner or control-plane reconciler inspects the backend operation before the origin service decides to accept a result, wait, cancel, retry, or require operator intervention. It must not assume that a transport failure means the command did not run.

The subject idempotencyKey and lease commandIdempotencyKey are propagated to the backend and external action where supported. Side-effecting command templates must define an external idempotency or reconciliation contract before automatic retry is allowed.

Cancellation And Lease Loss

Cancellation and policy revocation fence the attempt before asking the runner to stop. The runner cancels the backend operation, destroys or quarantines the execution environment as required, revokes execution credential handles, and reports cleanup state. A completion received after fencing is retained only as diagnostic evidence.

If cleanup cannot be confirmed, the attempt enters cleanup-pending and an orphan reconciler continues inspecting backend resources. Lease loss alone does not prove that the command stopped.

Local Watchdog And Disconnected Cleanup

The runner must be able to clean tenant-local resources without contacting controller-rs. A supervisor separate from the execution worker writes a durable local cleanup journal before backend preparation, stops new work when the control-plane session is lost, and lets active work continue only until the locally tracked lease expiry. A connectivity grace period cannot extend the lease or execution deadline.

At lease expiry or the earlier execution or environment deadline, the supervisor locally fences the attempt, revokes execution credential handles, cancels the backend operation, and destroys or quarantines the environment. It uses a monotonic deadline bounded by the authenticated absolute lease deadline. Backend resources carry owner and expiry tags and use a native TTL where the backend supports one, so cleanup does not depend on the runner host restarting.

Runner startup and periodic sweeps replay incomplete journal records and inspect tagged resources with bounded cleanup backoff. Reconnection reports outcome and cleanup evidence, but it cannot make an expired result valid. External actions that may already have taken effect remain UNKNOWN and are reconciled rather than blindly retried. The detailed journal and watchdog contract is defined in the Execution Backends And Sandbox Execution design.

Capacity Scheduling And Claim Backoff

controller-rs keeps execution subjects with no eligible slot in a bounded, per-tenant fair PENDING_CAPACITY queue. light-workflow retains authoritative workflow-task state and light-agent retains authoritative agent-turn/action state. The controller atomically reserves runner and backend capacity and returns a short-lived reservation token; idempotent, fenced attempt creation and lease issuance bind that token. Temporary saturation therefore does not consume an origin retry, create an execution environment, or cause every runner to race for the same subject.

Claims use long polling or server push. An empty claim or temporary backend capacity response includes retryAfter; clients apply capped exponential backoff with jitter, and the controller wakes only a bounded number of eligible waiters when capacity returns. Hard quota or policy failures are terminal admission denials until configuration changes. Queue timeout, origin deadline, cancellation, and policy revocation remove pending work without ever dispatching it.

Task Routing

light-workflow should execute pure control-plane tasks locally:

ask
assert
set
switch
context merge
workflow branching
workflow persistence
approved internal call tasks

light-workflow-runner should execute effectful or tenant-local tasks:

run.shell
run.script
run.container
call.mcp to tenant-local servers
deployment commands
release build and test commands
AI repair with filesystem access
browser automation
external tool processes

Some call.* tasks can run on either side. The routing decision should come from effective task policy:

TaskDefault RuntimeNotes
call.http internal SaaS APIlight-workflowUse host-side service credentials.
call.http tenant-private APIrunnerNeeds tenant network access.
call.mcp approved SaaS gatewaylight-workflowGateway enforces tool access.
call.mcp tenant-local serverrunnerLocal sidecar or private MCP server.
call.agent no toolslight-workflowBounded model call.
call.agent with file/toolsrunnerRequires sandbox/tool policy.

Agent Call Placement

Workflow agent calls need an explicit placement decision. The same workflow can use more than one agent execution mode, but the placement must come from server-side policy and task metadata, not tenant-side local config.

Use three agent execution modes.

Native Workflow Agent

Native call: agent stays in the SaaS-owned light-workflow process. This is the current bounded agent task model: light-workflow resolves the portal agent, skill, and tool metadata, builds a constrained prompt from workflow context, calls the configured model provider, validates structured output, and continues the workflow.

Use native workflow agents for bounded reasoning:

  • classify a request or command result,
  • summarize API responses or logs,
  • choose a workflow branch,
  • draft a customer-facing explanation,
  • decide whether human review is required,
  • produce JSON output that must match a schema.

Native workflow agents should not receive filesystem access, local network access, release secrets, or dynamic tool execution. API orchestration should remain explicit workflow tasks such as call.http, call.mcp, assert, switch, and ask.

By default, native workflow agents use SaaS-approved model providers and model credentials managed by the Light control plane. Tenant-private repository content, tenant-local logs, local files, and private network data should not be sent to this path unless the tenant policy explicitly allows it.

Runner Agent

Runner agents execute through light-workflow-runner under a server-issued execution lease. Use this mode when the agent needs access to tenant-local state or effectful tools:

  • checked-out repositories,
  • command output plus working directory inspection,
  • private tenant network access,
  • local MCP servers,
  • sandbox tools,
  • AI repair of source code,
  • test reruns,
  • branch or pull-request creation.

The origin service still creates the workflow task or standalone agent action and records the result. controller-rs issues a lease only to a runner whose effective capabilities, runner pool, execution profile, command allowlist, workspace policy, and audit state match the execution requirements.

Runner agent lease example:

{
  "executionId": "01970f5d-3333-7000-8000-000000000001",
  "origin": {
    "service": "light-workflow",
    "instance": "workflow-main-east"
  },
  "subject": {
    "kind": "workflow-task",
    "id": "01970f5d-3333-7000-8000-000000000020",
    "attempt": 1
  },
  "operationType": "call.agent",
  "agentPlacement": "runner",
  "runnerPool": "release",
  "executionProfile": "release-sandbox",
  "profileVersion": 7,
  "executionRequirements": {
    "minimumBoundary": "microvm",
    "allowedHostExposure": [],
    "workloadTrust": "untrusted"
  },
  "executionBackend": {
    "backendId": "docker-sbx-local",
    "kind": "microvm",
    "implementation": "docker-sandboxes",
    "version": "approved-version",
    "capabilityDigest": "sha256:..."
  },
  "sandbox": {
    "sessionScope": "task",
    "isolationClass": "agent-call",
    "workspaceMode": "clone"
  },
  "modelProviderScope": "tenant",
  "modelAccessMode": "brokered-proxy",
  "modelProxyRef": "tenant-model-proxy-eastus",
  "workloadIdentityRef": "attempt://model-access",
  "dataBoundary": "tenant-network",
  "runtimeToolManifestDigest": "sha256:...",
  "allowedTools": [
    {
      "toolRef": "runner://command/cargo-test",
      "modelAlias": "cargo_test",
      "schemaDigest": "sha256:...",
      "capability": "command.cargo.test"
    }
  ],
  "workspaceAccess": "copy-on-write-release-workspace",
  "workspaceBaseRevision": "git:...",
  "workspaceChangePolicyId": "agent-source-only-v1",
  "workspaceChangePolicyDigest": "sha256:...",
  "networkPolicy": "release-egress",
  "credentialPolicy": "brokered-task-scoped",
  "maxRepairAttempts": 2,
  "requiresHumanApprovalBefore": ["publish", "sign", "tag"]
}

The runner agent can inspect files and propose or apply bounded patches inside the approved workspace. It must not publish artifacts, sign releases, push final tags, read unrestricted secrets, or expand its own permission scope.

By default, runner agents use tenant-approved model providers and tenant-owned credentials. This keeps private workspace data and private network context inside the tenant boundary and avoids exposing SaaS model credentials to tenant-side runtimes.

Runner-local tools do not appear in light-gateway tools/list. Before worker startup, the controller intersects catalog entries placed on the runner with the server-approved runtime compatibility record, execution profile, lease allowedTools, and immutable runtime-tool manifest. The worker may narrow that set using live local availability or sandbox-local MCP tools/list, but cannot add authority. Each model alias remains bound to one stable internal tool reference, schema digest, placement, and dispatcher; cross-placement name collisions fail closed. Broker/control sockets and backend lifecycle operations are never exposed as tools.

Agent Workspace Change Boundary

The lease for a write-capable agent includes the immutable base commit or tree and a server-owned workspaceChangePolicyId and digest. The policy intersects allowed paths with protected-path denies and limits file count, bytes, types, creation, deletion, rename, mode, submodule, nested-repository, and binary changes. Workflow metadata and agent output cannot weaken it.

The default policy denies CI/CD definitions, reusable automation, workflow definitions, CODEOWNERS and approval policy, .git internals and hooks, and release, publish, signing, deployment, credential, runner, and execution-policy configuration. Repository-specific equivalents are added by operator policy.

Write interception inside the sandbox is defense in depth. After execution, a trusted runner component computes the authoritative diff from the immutable base in a fresh trusted checkout without repository-provided hooks or mutable Git configuration. It normalizes case, Unicode, and separators according to repository rules, detects link and rename tricks, and creates an immutable canonical patch whose digest is checked. A violation fails with workspace_change_denied; no branch, pull request, artifact, publish, or signing action may consume the patch.

The agent receives no push credential. Branch or pull-request creation is a separate fixed action over the immutable accepted patch and its policy result, never the mutable agent workspace. Even an accepted agent patch remains untrusted: tests run under the same isolation, and a release rebuilds from the reviewed and merged immutable commit rather than publishing artifacts directly from the repair workspace.

Runner Agent Execution Isolation

The runner itself is a tenant-side execution agent. For stronger isolation, the runner can launch the agent task inside a separate environment such as Cube Sandbox, Docker Sandboxes, a dedicated VM, or a Kubernetes Job using an approved runtime class. This should be a tenant-selectable policy because the runner is deployed in the tenant namespace, but the effective choice must still meet the server-owned minimum boundary and be recorded in the execution lease.

Recommended isolation levels:

Session ScopeIsolation ClassUse CaseDefault Policy
nonebounded-runnerModel call with no tools, files, or private-network mutationAllowed only for explicitly approved low-risk profiles
workflowrelease-buildBuild, test, or diagnosis sharing one checkout and cacheUseful for release workflows
agent-sessioninteractive-agentCoding workspace reused across authenticated turnsExplicit TTL and identical principal/policy/base required
taskagent-callAI repair, generated patches, dynamic tools, or untrusted scriptsPreferred for high-risk agent tasks
taskpublishFixed publish or signing action over immutable artifactsRequired for high-value credentials

Backend purpose and execution session scope are separate. Recommended defaults are:

Execution needMinimum boundaryCandidate backendImportant constraint
Trusted local developmenthost-integratedRunner operating in Fedora ToolbxToolbx is not a sandbox and cannot run untrusted or secret-bearing tasks
Trusted CI, tests, or packagingshared-kernel-containerRootless Docker or Podman; ordinary Kubernetes JobNo privileged mode, host namespaces, or host container-engine socket
Autonomous agent or untrusted codemicrovmCube Sandbox; Docker Sandboxes; Kubernetes only with an approved stronger runtimeDocker Sandboxes use clone workspace mode; no fallback to a shared-kernel container
Privileged or long-running tenant workdedicated-vmApproved tenant-dedicated VMPin and attest the image, network, identity, limits, and teardown policy
Publishing, signing, or deploymentexternal-serviceFixed typed action or dedicated serviceAccept immutable inputs; do not expose a general shell with release credentials

For a release workflow, the runner should usually orchestrate a separate task sandbox with the agent-call isolation class for AI repair. The runner provides only the leased copy-on-write workspace, approved tools, network policy, and opaque credential handles allowed by the task policy. It collects bounded logs, artifacts, patches, and structured output, then destroys the sandbox. Freezing or checkpointing is allowed only when retention policy permits it and no raw credential entered the sandbox.

For a reused workflow or agent session, effective expiry is the earliest of the origin session idle/max expiry, execution-session policy, credential or broker grant expiry, and backend-native TTL. Closing, revoking, or expiring the origin session creates a durable idempotent common cleanup request in the same origin transaction. controller-rs fences and cancels active attempts and dispatches cleanup; the runner destroys the physical session and records evidence. Cleanup retries across restarts. Backend-native TTL is the final fail-safe, not the expected way to reclaim an abandoned session.

Action ownership and session retention are separate. Ending an action lease removes executable authority, broker access, and action credentials. It does not by itself delete a compatible reused session. An origin may create a durable IDLE_APPROVAL_HOLD for a non-secret workspace with an explicit hold ID, reason, policy digest, holdUntil, checkpoint/patch evidence, and cost policy. The runner pauses or checkpoints where supported. The hold cannot extend the session idle/fixed maximum or survive origin close, revocation, policy mismatch, or cleanup request. Controller and runner reconcilers use this session state rather than treating zero active attempts as abandonment.

This creates a layered boundary:

SaaS light-workflow or light-agent
  -> controller-rs fenced execution lease
  -> tenant light-workflow-runner
  -> ExecutionBackend
  -> task execution environment, isolationClass=agent-call
  -> model, tools, files, network

Tenants may choose Cube Sandbox, Docker Sandboxes, dedicated VM isolation, an approved Kubernetes runtime, or no additional environment for explicitly trusted profiles. Fedora Toolbx and ordinary shared-kernel containers must not satisfy a microVM requirement. Runner registration advertises supported execution backends, isolation boundaries, session scopes, workspace modes, host exposures, and enforcement capabilities. If the approved compatibility record cannot satisfy the task requirements, controller-rs must not issue the lease; it must not silently choose a weaker backend.

Local runner config can select among tenant-approved profiles, but it cannot weaken a task requirement. The lease contains the final effective executionBackend identity, implementation, version, capability digest, sandbox.sessionScope, sandbox.isolationClass, immutable template or image, workspace, host-exposure, network, resource, tool, artifact, lifecycle, and credential policy. Heartbeat, backend inspection, and audit snapshots should prove the runner is still operating under that profile.

Execution Backend Boundary

Backend-specific execution lives behind ExecutionBackend in the runner, not in light-workflow. The core operations are capability discovery, effective-configuration validation, idempotent environment preparation, environment and operation inspection, reconnect, idempotent execution, resumable logs, cancellation, safe artifact copy, and cleanup. Checkpoint and session operations are optional capabilities rather than assumptions every backend must emulate. Backends also report measured execution evidence where supported; the trusted runner or control-plane attestor, not the tenant task, constructs the final provenance statement.

The runner fails closed when the backend cannot enforce a required isolation, host-exposure, resource, network, credential, lifecycle, or inspection control. A backend timeout or transport error is not automatically a command failure; the runner records an unknown outcome and reconciles it through backend inspection.

Trusted Input And Skill-Package Staging

Immutable context, workspace bases, trust bundles, and skill packages are resolved into digest-bound input records before dispatch. The trusted runner, outside the sandbox payload boundary, downloads them with runner authority, checks kind, size, digest, signature/provenance where required, and archive safety, and stages them before backend creation. The backend exposes only the selected bytes as read-only mounts with nodev, nosuid, and noexec unless an approved entrypoint requires execution.

light-agent-worker may revalidate the mounted manifest, but neither it nor generated code downloads a package or receives artifact-store credentials. Input verification or staging failure prevents sandbox start. Staging paths are attempt/session scoped, journaled without secrets, and removed through the same idempotent cleanup contract as the backend environment.

The complete policy, lifecycle, Cube production baseline, artifact, secret, and failure contracts are defined in the Execution Backends And Sandbox Execution design.

Agent Service

Containerized light-agent services should be invoked explicitly. They are the right runtime for interactive or independently scaled agents:

  • chat and session memory,
  • dynamic tools/list and tools/call loops,
  • long-lived specialist agents,
  • independently deployed model/tool runtime,
  • local catalog caching.

Do not silently change native call: agent to call a containerized light-agent service. Use an explicit contract such as call: agent-service or call: agent with mode: service so operators can audit which runtime path was used.

When an interactive light-agent session needs local execution, it submits an agent-turn or agent-action execution subject through the same controller and runner substrate. It does not create a fake workflow task. Session, turn, tool authorization, memory, and model-provider rules remain governed by the Light-Agent Execution design.

For a workspace-aware coding or external-agent turn, the runner starts the small sandbox-side light-agent-worker with a pinned runtime adapter. It does not start the public light-agent service inside the sandbox. The worker owns only the leased local loop and normalized event stream; light-agent remains the agent-domain authority.

Model Provider Boundary

Agent placement and model-provider placement should be decided together.

Recommended defaults:

native call: agent in SaaS light-workflow
  -> SaaS-approved model provider
  -> SaaS workflow context data boundary

leased runner agent in tenant workflow runner
  -> tenant-approved model provider
  -> tenant network/workspace data boundary

containerized light-agent service
  -> service-owned or tenant-approved model provider
  -> explicit service data boundary

The default SaaS model is useful for bounded reasoning over workflow-safe context, such as classification, summaries, branch decisions, and structured JSON output. It should not be the default path for tenant-local source code, private command logs, local files, or private network data.

The default runner model is useful when the task needs tenant-local context. The runner owns an attempt-scoped model broker outside the untrusted payload boundary. It binds a protected local channel to the subject, adapter, approved model, data-boundary and policy digests, token/cost budget, rate, audience, and expiry. Provider keys and reusable proxy bearer tokens never appear in a sandbox environment variable, argument, prompt, workspace, or persistent file. SaaS model credentials must not be sent to tenant runners.

The control plane should still make this policy-driven instead of hard-coding it. Some tenants may require every agent call, including bounded summaries, to use their own provider or regional model endpoint. In that case, the workflow task should be routed to a runner or to an approved tenant model gateway even if the reasoning itself is small.

Lease examples:

{
  "agentPlacement": "workflow",
  "modelProviderScope": "saas",
  "modelProviderRef": "light-managed-default",
  "credentialRef": "saas-secret://llm-provider",
  "dataBoundary": "saas-workflow-context"
}
{
  "agentPlacement": "runner",
  "modelProviderScope": "tenant",
  "modelAccessMode": "brokered-proxy",
  "modelProxyRef": "tenant-model-proxy-eastus",
  "workloadIdentityRef": "attempt://model-access",
  "dataBoundary": "tenant-network"
}

The sandbox worker receives only a runner-created preconnected descriptor, peer-credential-checked Unix-domain socket, vsock, or backend-equivalent local channel. A socket path is not sufficient authority: the broker authenticates the peer and attempt and independently enforces model and budget policy. The worker/runtime and generated payload use separate identities and process/mount namespaces; ptrace and cross-process /proc access are denied, and the worker does not pass its broker descriptor to child payloads. An adapter that requires an extractable provider key is ineligible for an untrusted runner profile.

Recommended placement rule:

bounded reasoning over workflow context -> native call: agent in light-workflow
agent needs one isolated local effect -> leased agent-action
workspace-aware coding or external agent loop -> light-agent-worker in leased agent-turn sandbox
interactive session or dynamic tool loop -> containerized light-agent service

For release workflows, use native call: agent to summarize and classify a failed command. Use a runner agent for repo inspection, patch generation, test rerun, and pull-request creation. Human approval remains required before publish, signing, or final tag creation, but approval waiting is a durable light-workflow orchestration state and never an active runner lease.

Effective Policy

Workflow definitions and tasks can request runner execution through metadata, but the control plane computes the effective policy.

Workflow-level example:

document:
  dsl: "1.0.3"
  namespace: release
  name: java-release
  version: "0.1.0"
  metadata:
    lightWorkflow:
      runner:
        runnerPool: release
        capabilities:
          - git
          - maven
          - rootless-buildkit
      security:
        schemaVersion: 1
        executionProfile: release-sandbox
        profileVersion: 7
        placement: runner
        isolation:
          minimumBoundary: microvm
          allowedHostExposure: []
          workloadTrust: untrusted
        sandbox:
          sessionScope: workflow
        workspace:
          mode: copy-on-write

Task-level example:

do:
  - build-java:
      run:
        shell:
          command: light-release-build
          arguments:
            - "${ .release.version }"
      metadata:
        lightWorkflow:
          runner:
            runnerPool: release
            commandTemplateId: light-fabric-release-build
          security:
            isolation:
              minimumBoundary: microvm
            sandbox:
              sessionScope: workflow

Runtime policy resolution:

  1. Operator-approved immutable profile definitions set the base allowed commands, workload trust, minimum isolation, host exposure, execution backends, templates or images, networks, resources, mounts, session scopes, workspace modes and change policies, trust bundles, model-provider scopes, data boundaries, artifact provenance, local cleanup, and credentials.
  2. SaaS service policy intersects the profiles and approved backend compatibility records available in the deployment.
  3. Tenant policy further restricts the allowed set.
  4. The workflow requests one profile and immutable version.
  5. Task metadata may request stricter isolation or a subset of capabilities; it cannot downgrade operator-derived workload trust.
  6. light-workflow persists the effective workflow policy snapshot and derives an effective task-policy digest for each attempt.
  7. controller-rs validates the registered runner and selected backend against the server-owned compatibility record and capability digest.
  8. The fenced execution lease contains the selected backend identity and final allowed execution scope.

For an agent origin, light-agent performs the analogous immutable agent/turn/action policy resolution described in Light-Agent Execution. The controller and runner consume the same final execution-policy fields without taking ownership of how the origin derived them.

Policy merging is field-specific. Allowlists intersect, explicit denies win, numeric limits use the lowest permitted maximum, and task-scoped isolation may strengthen workflow-scoped isolation. The selected backend must meet the minimum boundary and every required capability; there is no fallback from a microVM to a shared-kernel or host-integrated environment. Backend and template choices must be members of the approved compatibility set. A task cannot weaken the effective policy.

The policy snapshot, execution session, execution attempt, backend operation, and lease must use dedicated runtime tables. They must not be stored in mutable workflow or agent context. Profile versions and backend compatibility records are immutable; emergency revocation fences new and active attempts and records the reason.

Runtime Configuration Audit

Tenant-controlled local configuration cannot be the source of truth. A runner can load local config for its own startup, but the server must verify and audit the effective runtime state.

controller-rs should audit at three points.

Startup Admission

On registration, the runner reports:

  • binary version,
  • image digest or VM image ID,
  • effective config hash,
  • command allowlist hash,
  • enabled execution profiles,
  • runner pools,
  • mounted workspace paths,
  • supported execution session scopes, isolation boundaries, and isolation classes,
  • backend IDs, kinds, implementations, versions, capability digests, and server-approved enforcement capabilities,
  • host exposures, workspace modes, workspace-change policy digests, container-engine access, and immutable template or image IDs and digests,
  • local watchdog and cleanup-journal health and policy digest,
  • trust-bundle digests and supported language-runtime adapters,
  • provenance formats, modes, and trusted attestor identity where applicable,
  • allowed model provider scopes,
  • network zone,
  • resource, artifact, and credential-delivery policies,
  • host and tenant identity.

controller-rs compares this report with approved server-side policy before allowing claims.

Heartbeat

Each heartbeat should include:

{
  "runnerId": "release-runner-01",
  "sessionId": "01970f5d-1111-7000-8000-000000000001",
  "status": "ready",
  "configHash": "sha256:...",
  "commandAllowlistHash": "sha256:...",
  "imageDigest": "sha256:...",
  "watchdog": {
    "status": "healthy",
    "lastSweepAt": "2026-06-08T18:59:45Z",
    "cleanupPending": 0,
    "policyDigest": "sha256:..."
  },
  "activeAttempts": [
    {
      "leaseId": "01970f5d-0000-7000-8000-000000000001",
      "attempt": 1,
      "fencingToken": 17,
      "policyDigest": "sha256:...",
      "backendId": "cube-prod-east",
      "backendOperationId": "backend-op-123",
      "leaseExpiresAt": "2026-06-08T19:00:30Z"
    }
  ],
  "timestamp": "2026-06-08T19:00:00Z"
}

If a hash changes unexpectedly, the controller marks the runner suspicious and stops issuing new leases.

Periodic Deep Audit

Periodically, controller-rs should request an effective runtime snapshot from the runner and compare it with the approved policy. For high-risk runners, the snapshot should include command allowlist, immutable sandbox template, mount list, host exposure, workspace mode and change policy, resource, network, and trust-bundle policy, backend effective configuration, artifact and provenance policy, local cleanup journal health, tagged-resource scan result, and credential binding names without values. Where the backend permits it, the control plane should compare runner claims with backend inspection rather than relying only on runner self-reporting.

On mismatch:

  1. Mark the runner as quarantined and stop issuing new leases.
  2. Fence active task attempts so late results cannot transition workflows.
  3. Revoke claim and task credentials.
  4. Request cancellation and backend cleanup for affected operations.
  5. Emit an append-only runtime audit event.
  6. Create an operator task when outcome or cleanup remains unknown.

Audit is not the only enforcement mechanism. It detects drift after admission. The fenced task lease is the primary runtime authorization boundary, while the backend must independently enforce its declared isolation, resource, network, workspace, lifecycle, and credential policy.

Release Runner Mode

A release runner is a specialized light-workflow-runner profile.

It can run in:

  • an approved dedicated VM,
  • a Cube Sandbox or Docker Sandboxes microVM,
  • a Kubernetes Job with a recorded runtime class and node policy,
  • a rootless shared-kernel container for explicitly trusted tasks,
  • a controlled bare-metal or Toolbx environment for trusted local helper tasks.

The last two options are operational environments, not substitutes for a microVM boundary. A runner using them cannot claim untrusted-code, isolated agent, publish, signing, or secret-bearing tasks unless a separate eligible backend performs that task.

Recommended default for release workflows:

  • one workflow-scoped sandbox or VM workspace for checkout, build, test, and package steps,
  • task-scoped agent-call isolation for AI repair, source inspection, generated patches, and test reruns driven by an agent,
  • immutable artifact export through controlled storage with trusted-side hashing and trusted-side in-toto/SLSA provenance,
  • server-owned protected-path policy plus trusted post-export diff validation for every agent patch,
  • a task-scoped fixed publish action or separate release service for publishing,
  • an external signing service or task-scoped fixed signing action,
  • human approval bound to the exact artifact digest, release target, version, command template, policy snapshot, and expiry,
  • clean checkout inside the runner rather than writable host repository mounts,
  • AI repair limited to sandbox workspace changes, with branch or PR creation performed by a separate fixed action,
  • a clean release rebuild from the reviewed and merged immutable commit rather than publishing an artifact directly from an agent-repair workspace.

Writable host mounts should be avoided for AI repair and release commands. If host repositories must be mapped, default to read-only mounts and copy the repo into a runner-owned working directory before mutation.

Publish and signing actions must not execute arbitrary scripts from the mutable build workspace. They consume only immutable artifact records and use operator-owned command templates. They verify artifact provenance and approval bindings before use. Prefer brokered short-lived identity or backend-side credential injection so the raw credential never enters arbitrary workflow code. Per-task isolation limits exposure but does not make untrusted code safe to receive a release token.

Do not mount a host Docker socket into tenant-authored runners or sandboxes. Container image builds use an approved rootless builder or remote build service with pinned builder and base-image digests.

Approval Ownership And Lease Handoff

Human approval is owned entirely by the authenticated origin service: light-workflow for workflow tasks and light-agent for standalone agent actions. The runner never polls a person. Before an approval wait, the origin commits any known result and immutable evidence, terminalizes the current attempt, ends its action lease, closes its model-broker channel, and revokes task credentials. A task-scoped environment is cleaned.

For a reusable non-secret session workspace, WAITING_APPROVAL may coexist with the distinct bounded IDLE_APPROVAL_HOLD described above. The hold is not an action lease, carries no executable authority, is preferably paused or checkpointed, and consumes observable retained-resource quota. If a safe hold cannot be established, export an immutable approved patch/checkpoint and clean the environment; important uncommitted work must not depend only on a live sandbox.

The origin transaction that enters WAITING_APPROVAL also persists exactly one session disposition—cleanup or policy-valid bounded hold. If origin and common session state later use separate databases, an idempotent transactional outbox provides that handoff. A session reconciler must never observe an ended action lease without the durable disposition and guess whether to delete the workspace.

When policy knows approval is required before execution, the origin records the bound intent but creates no common execution attempt. If a running runtime discovers an approval boundary, it returns a known approval_required terminal result and its attempt is fenced and cleaned or explicitly checkpointed under non-secret retention policy.

After approval, the origin revalidates the exact operation, arguments, artifacts/provenance where applicable, destination, policy digest, expiry, and single-use nonce. It consumes the approval into a new numbered domain attempt and a new common execution_attempt_t; controller-rs issues a fresh lease, monotonic fencing token, and fresh task-scoped grants. The pre-approval attempt, lease, backend handle, and grants remain immutable and cannot be reused. Rejection or expiry changes only origin orchestration state and dispatches no runner work.

If the held physical workspace still exists after approval, the new action may reuse it only after principal/base/runtime/policy/expiry and cleanup-state revalidation. Otherwise it starts in a fresh environment and restores only a verified policy-permitted checkpoint or patch.

A non-secret workflow session may be checkpointed or retained during approval only under explicit maximum-lifetime, cost, and retention policy; approval must not depend on it. The default release flow cleans the build environment after export. An unknown prior side effect is reconciled before approval can authorize another attempt.

Runner API

The first runner API can be small.

POST /runner/register
POST /runner/heartbeat
POST /runner/claim
POST /runner/execution/{leaseId}/started
POST /runner/execution/{leaseId}/renew
POST /runner/execution/{leaseId}/progress
POST /runner/execution/{leaseId}/log
POST /runner/execution/{leaseId}/complete
POST /runner/execution/{leaseId}/fail
POST /runner/execution/{leaseId}/unknown
POST /runner/execution/{leaseId}/cancelled
POST /runner/execution/{leaseId}/cleanup
POST /runner/execution-session/{executionSessionId}/hold
POST /runner/execution-session/{executionSessionId}/resume
POST /runner/execution-session/{executionSessionId}/cleanup
POST /runner/audit-snapshot
POST /runner/drain

controller-rs can expose these APIs directly or mediate them over its existing persistent connection model. For private tenant networks, outbound runner registration and polling is preferable to inbound SaaS calls into the tenant environment.

The claim response should include only the origin-neutral subject envelope and payload needed for execution, not a full workflow definition, agent session, or conversation history. /runner/claim supports long polling and an empty response includes retryAfter; the runner applies capped exponential backoff with jitter. A subject waiting for capacity has no lease and is not returned until the controller has atomically reserved an eligible slot.

Every execution API request includes a unique message ID, origin, subject, attempt number, lease ID, and fencing token. Repeated delivery of the same message is idempotent. Lease renewal extends execution ownership only up to the execution deadline. Cancellation can be delivered through the persistent connection or returned from heartbeat and renewal calls.

There is no runner API for waiting on human approval. Approval is handled by the origin service, and only a newly created post-approval attempt appears through /runner/claim. Session hold and resume are idempotent lifecycle commands from the controller; they contain no human decision and cannot create or renew an action lease. They carry the session state version/fence, policy digest, bounded holdUntil, and checkpoint/patch policy.

Command Result Contract

Runner results should use a normalized command result so light-workflow, light-agent, human tasks, AI diagnosis, and audit do not depend on raw console parsing.

{
  "executionId": "01970f5d-0000-7000-8000-000000000000",
  "leaseId": "01970f5d-0000-7000-8000-000000000001",
  "fencingToken": 17,
  "origin": {
    "service": "light-workflow",
    "instance": "workflow-main-east"
  },
  "subject": {
    "kind": "workflow-task",
    "id": "01970f5d-0000-7000-8000-000000000020",
    "attempt": 1
  },
  "workflow": {
    "taskId": "01970f5d-0000-7000-8000-000000000020",
    "wfTaskId": "build-java-products"
  },
  "runnerId": "release-runner-01",
  "policyDigest": "sha256:...",
  "commandTemplateId": "light-fabric-release-build",
  "backendId": "cube-prod-east",
  "backendKind": "microvm",
  "backendImplementation": "cubesandbox",
  "backendVersion": "approved-version",
  "backendOperationId": "backend-op-123",
  "status": "failed",
  "outcome": "known",
  "exitCode": 1,
  "startedAt": "2026-06-08T19:10:00Z",
  "completedAt": "2026-06-08T19:18:30Z",
  "summary": "Maven test failure in db-provider",
  "stdoutRef": "artifact://release/2026.06.0/build/stdout.log",
  "stderrRef": "artifact://release/2026.06.0/build/stderr.log",
  "artifacts": [
    {
      "artifactId": "01970f5d-0000-7000-8000-000000000030",
      "name": "surefire-reports.zip",
      "sha256": "sha256:...",
      "size": 42000,
      "storeUri": "artifact://release/2026.06.0/build/surefire-reports.zip",
      "provenanceRef": "provenance://release/2026.06.0/build",
      "provenanceDigest": "sha256:..."
    }
  ],
  "resourceUsage": {
    "wallTimeSeconds": 510,
    "peakMemoryBytes": 2147483648
  },
  "cleanupState": "complete",
  "approvalRef": null,
  "workspaceBaseRevision": null,
  "workspaceChangePolicyDigest": null,
  "patchDigest": null,
  "changedFiles": [],
  "aiDiagnosisAllowed": true
}

The runner streams bounded, ordered log chunks with sequence numbers and resumable cursors. Full logs are stored as tenant-scoped artifacts only when policy allows it. Origin domain context keeps summaries and immutable references, not unbounded stdout or stderr.

Artifact names and paths are untrusted. The runner must enforce canonical-root and no-follow extraction, reject traversal and special files, apply count and byte limits, and compute the authoritative digest after bytes cross the sandbox trust boundary.

For an agent task, changedFiles is the canonical manifest produced by the trusted post-export diff, not a list supplied by the agent. The result is accepted only when the base commit, patch digest, and workspace-change policy digest match the lease. For a build requiring provenance, command success is not sufficient: failure to generate or authenticate the required in-toto/SLSA statement fails the attempt before any publish action can consume its artifacts.

If command outcome is unknown, the runner sends status: "unknown" with the backend operation ID and diagnostic reference instead of fabricating a failure. A later reconciliation report uses the same attempt and fencing token unless the control plane has already fenced it.

Security Requirements

  • Runners authenticate to controller-rs with tenant-scoped credentials.
  • Runner and backend control-plane traffic is encrypted and mutually authenticated where it crosses a host boundary.
  • Execution leases are short-lived, renewable, scoped to one attempt, and protected by a monotonically increasing fencing token.
  • A durable local watchdog stops new work on disconnect, locally fences work at lease expiry, revokes credential handles, and cleans tagged backend resources; backend-native expiry protects against runner-host failure where available.
  • Runners never see workflow start events unless they are explicitly deployed as trusted orchestrators in a non-SaaS topology.
  • Runners receive bounded execution payloads, not complete workflow definitions or agent sessions.
  • Server-side policy decides runner pools, execution profiles, minimum isolation boundaries, approved backend compatibility records, immutable templates or images, capabilities, command templates, resources, networks, host exposures, mounts, workspace modes, session scopes, model provider scopes, data boundaries, artifacts, and credentials.
  • Required backend controls and the effective rendered backend configuration are verified before execution; missing controls fail closed. Backend self-report alone cannot upgrade its trusted capability record.
  • Temporary capacity shortage remains in a bounded fair queue with retryAfter and jittered claim backoff; it does not create an execution attempt or consume an origin retry.
  • SaaS model credentials must not be sent to tenant-side runners.
  • Tenant-private source code, local files, and private command logs should use tenant-approved model providers unless tenant policy explicitly allows SaaS model processing.
  • Leases contain logical credential references or opaque redemption handles, never raw credential values.
  • Immutable skill packages and other external inputs are downloaded and verified by trusted runner code before sandbox creation, then mounted read-only. Sandbox code receives no artifact-store credential or package download authority.
  • Prefer backend-side credential injection and short-lived workload identity. Raw secret fallback cannot use a shared or checkpointed execution session.
  • Raw tokens are forbidden in environment variables, argv, process titles, shell history, and persistent files. Use an attempt-bound local credential broker or an attempt-unique read-only tmpfs file when the process must receive a token; environment variables may carry only non-secret endpoint or path references.
  • Secrets are task-scoped and never included in workflow context, logs, artifacts, snapshots, or AI prompts.
  • Sandboxed model access uses a runner-owned, peer/attempt-bound local broker with no reusable bearer visible to the worker or generated payload. Separate process identities/namespaces and descriptor controls prevent generated code from stealing the worker's model capability; broker-side policy enforces model, budget, rate, cancellation, and expiry.
  • TLS interception uses an operator-owned immutable trust-bundle digest and approved runtime adapters. Workflow code cannot add a CA or disable certificate verification.
  • AI repair runs only in approved runner profiles and cannot publish, sign, or receive push credentials. A trusted diff enforces the server-owned protected path policy before a fixed branch or pull-request action can consume a patch.
  • Publish and signing use fixed actions over immutable artifacts and require verified provenance, digest-bound human approval, and task-scoped isolation. Light-workflow dispatches typed publish and sign requests to a dedicated release-action service; branch and pull-request requests use a separate repository-action service. These credential-owning services receive exact immutable bindings and an idempotency key, while agents, sandboxes, runners, and workflow context receive no platform or signing credential.
  • Human approval waiting occurs only in the origin service, light-workflow or light-agent; no action lease, model channel, action credential, or secret-bearing task environment remains active. An eligible non-secret session workspace may use a separate bounded hold/checkpoint, and approval creates a fresh common attempt and fencing token.
  • Origin session close, revocation, or expiry creates a durable common cleanup request and promptly reclaims its physical sandbox; backend TTL is a last-resort bound rather than routine cleanup.
  • Required build provenance is constructed and authenticated by trusted runner or control-plane code. Provenance signing material is inaccessible to tenant-controlled build steps.
  • Tenant-authored jobs cannot mount host container-engine sockets.
  • CPU, memory, disk, process, time, network, output, artifact, and concurrency limits are backend-enforced. A host-integrated backend that cannot prove a required limit cannot claim the task.
  • Runtime drift causes quarantine, attempt fencing, credential revocation, cancellation, and backend cleanup.
  • Unknown backend outcomes are reconciled before retry.
  • All task results include runner identity, attempt, fencing token, effective policy digest, command template ID, backend identity and operation ID, artifact and provenance digests, workspace-change policy and patch digests where applicable, cleanup state, and approval references.

Implementation Plan

Phase 1: Contracts And Persistence

  • Create apps/light-workflow-runner.
  • Reuse workflow-core models for run.* task payloads.
  • Define strict versioned security metadata and immutable policy snapshots.
  • Define local-cleanup, workspace-change, trust-bundle, credential-projection, capacity-queue, and provenance policy contracts.
  • Define origin-neutral execution IDs, origins, and workflow-task, agent-turn, and agent-action subject types in the first protocol version.
  • Add common scheduling request, execution attempt, execution session, immutable input, execution-session cleanup request, artifact, and append-only runtime-audit persistence. Keep workflow approval and agent approval/domain state under their origin services.
  • Persist tenant, trigger principal, correlation ID, and policy snapshot at workflow start.
  • Define runner registration, heartbeat, claim, renewal, cancellation, reconciliation, cleanup, and result APIs.
  • Define the transactional identifiers-only result-ready PostgreSQL wakeup and authoritative startup/periodic origin catch-up query.
  • Keep the existing light-workflow event consumer as the only workflow start consumer.
  • Keep unsupported run.* tasks disabled.

Phase 2: Lease, Attempt, And Fencing

  • Add attempt numbers, short-lived renewable leases, fencing tokens, and compare-and-set result acceptance.
  • Add execution deadlines, cancellation, unknown-outcome state, backend operation IDs, and reconciliation.
  • Add the durable local cleanup journal, disconnected watchdog, startup resource scan, and backend-native expiry handling.
  • Add bounded per-tenant fair capacity queues, atomic slot reservation, long-poll claims, and capped jittered backoff.
  • Add normalized results and bounded resumable log streaming.
  • Emit the result-ready wakeup in the same transaction that stores a newly terminal common attempt; prove lost/duplicate notification recovery through indexed conditional origin acceptance.
  • Prove that stale or duplicate reports cannot transition workflow or agent domain state and that a disconnected runner cleans resources without control-plane reachability.

Phase 3: Minimal Per-Task Execution

  • Add server-side runner pools, execution profiles, capability matching, and approved command templates.
  • Implement one run.shell template in a task-scoped sandbox.
  • Start with no credentials, no irreversible external effects, deny-all egress, an ephemeral workspace, and hard resource limits.
  • Define the ExecutionBackend interface, capability document, server-owned compatibility record, and boundary-specific conformance suite.
  • Implement Cube Sandbox as the first task-scoped microVM backend, including idempotent prepare and execute, inspection, cancellation, and cleanup.

Phase 4: Artifacts, Sessions, And Additional Backends

  • Add safe artifact export, trusted-side hashing, immutable storage, and trusted-side in-toto/SLSA provenance generation and authentication.
  • Add workflow-scoped sessions with single-writer enforcement and copy-on-write isolation for parallel branches.
  • Support agent-turn task scope and explicitly bounded agent-session reuse without assuming equal lifetimes; origin close/revoke/expiry must still trigger prompt backend cleanup.
  • Add an execution-session state/fence and bounded IDLE_APPROVAL_HOLD lifecycle distinct from action leases. Missing an active attempt must not clean a valid held session; hold expiry must not extend the fixed maximum.
  • Add idempotent pause/checkpoint/hold/resume commands and retained-resource quota, cost, evidence, and cleanup metrics.
  • Compute effective session expiry as the minimum of origin, execution policy, broker/grant, and backend limits. Add durable origin-driven cleanup requests so close/revoke/expiry fences active work and destroys the sandbox promptly.
  • Make trusted runner code download, verify, safely extract, stage, and mount immutable skill packages and other input records before sandbox creation; workers only revalidate mounted content.
  • Add backend-specific production-baseline validation, including Cube authentication, private control-plane access, restricted inbound traffic, and deny-by-default egress.
  • Add Docker Sandboxes for approved local or managed agent execution, requiring clone workspace mode for untrusted code and prohibiting the host Docker socket.
  • Add a rootless OCI backend for explicitly trusted tasks and a Kubernetes Job backend only for approved runtime classes and node policies.
  • Permit a runner to operate inside Toolbx only as a declared host-integrated environment; do not initially require a separate Toolbx adapter or allow it to claim isolated tasks.
  • Add immutable TLS trust-bundle projection and approved Java, Node, Python, OpenSSL, and OS-store adapters.
  • Add brokered credential delivery, attempt-bound local metadata service, read-only tmpfs fallback, and prohibit secret-bearing checkpoints.
  • Add protected runner-broker transports using a preconnected descriptor, peer-checked Unix-domain socket, vsock, or backend equivalent; separate the trusted worker/runtime from generated payload processes and prevent broker descriptor inheritance.
  • Add periodic effective runtime snapshots.
  • Compare runner-reported config with server-approved policy.
  • Quarantine drifted runners, fence attempts, revoke credentials, and clean up backend resources.

Phase 5: Release And AI Workflows

  • Add release-runner profile.
  • Execute Java and Rust release build/test tasks through the runner.
  • Add ConfigProfile manifest and event-importer dry-run tasks.
  • Add AI failure analysis and bounded repair loops.
  • Add server-owned runtime-tool manifests and placement-bound tool references. Gateway tools intersect gateway tools/list; runner tools intersect execution policy, lease allowedTools, trusted manifest, and live local enumeration before the independently authorized sets are combined.
  • Add runner-staged immutable skill packages and runner-owned model inference brokering with model/data-boundary/budget enforcement outside the payload.
  • Add protected-path policies, trusted post-export diff validation, and fixed branch or pull-request creation over accepted patches.
  • Export immutable artifact sets with signed provenance and rebuild releases from the reviewed immutable commit after AI repair.

Phase 6: Publish And Signing

  • Add fixed publish actions or a separate release service.
  • Add an external signing service or fixed task-scoped signing action.
  • Bind human approval to the artifact set, target, version, command template, policy digest, expiry, and single-use nonce.
  • End the build action lease before the origin enters WAITING_APPROVAL; create a fresh numbered domain/common fixed-action attempt, lease, monotonic fencing token, and grants only after approval. Apply the same contract to light-agent approvals.
  • Reconcile unknown outcomes before any retry or approval reuse.

Open Questions

  • Should runner registration and task claim be direct HTTP APIs, WebSocket messages through controller-rs, or both?
  • Where should long-running task logs and artifacts be stored for SaaS deployments?
  • How should the control plane attest VM-based runners that do not have a container image digest?
  • Which backend-native TTLs and local-watchdog deadlines are required for each compatibility record?
  • Which protected runner-local broker transport is supported first on each backend: preconnected descriptor, peer-checked Unix-domain socket, vsock, or backend-native equivalent?
  • Which protected repository paths belong in the default agent policy, and how are repository-specific additions approved?
  • Which attestor, signing identity, storage convention, and target SLSA Build level should release profiles use?
  • Which portal or service owns immutable execution profiles, command templates, backend compatibility records, conformance evidence, and their approval lifecycle?
  • Should all publish and signing operations use a separate release service, or should a small set of fixed runner actions be supported?
  • How much of the existing TaskExecutor should move into shared crates so light-workflow and light-workflow-runner can share evaluation and result handling without sharing orchestration responsibilities?

Recommendation

Create light-workflow-runner as a separate executable and keep light-workflow as the single SaaS-owned orchestrator. The runner should be a fenced leased execution agent, not a workflow starter or workflow definition loader. Integrations such as Cube Sandbox, Docker Sandboxes, rootless OCI, approved Kubernetes runtimes, dedicated VMs, and fixed external actions belong behind ExecutionBackend in the runner. Toolbx is recorded as a trusted host-integrated runner environment, not advertised as a sandbox.

This gives tenants a practical way to run workflow tasks near their own APIs, gateways, repositories, clusters, and sandboxes while keeping workflow start events, policy decisions, task visibility, and audit under the SaaS control plane. Publish and signing remain fixed, approval-bound operations over immutable artifacts rather than arbitrary commands with release credentials.

References

Light-Axum Implementation

Implementation plans in this section cover concrete service examples and runtime integration work built on light-axum.

Insurance Claim MCP Server Example

This plan describes a new light-example-rs MCP server example for the insurance claim workflow demo. The server should be built on light-axum so it uses the same runtime startup, config-server bootstrap, service registration, logging control, TLS, and graceful shutdown pattern as the existing REST demo APIs.

Source Review

The product workflow doc at docs/src/product/light-workflow/insurance-claim-agentic-workflow.md defines two execution variants:

  • the REST variant calls demo APIs directly
  • the MCP variant calls the same capabilities through light-gateway

The existing light-example-rs apps provide two REST APIs:

  • apps/demo-customer-profile-api
  • apps/demo-offer-decision-api

The current MCP workflow definition, apps/light-workflow/examples/insurance-claim-mcp-v1.yaml, currently calls MCP tools for capabilities that already exist as REST endpoints. That workflow should be replaced. The new version should deliberately mix both integration styles:

  • REST calls remain responsible for existing demo API capabilities.
  • MCP calls cover only the functional gaps that are not already implemented by the REST APIs.

Functional Gaps

The workflow doc names a broader insurance tool set than the existing REST demo APIs currently provide. The gaps fall into three groups.

1. No Native Backend MCP Server

light-gateway can expose REST APIs as MCP tools and can proxy backend MCP servers, but light-example-rs does not yet include a backend MCP server. This means the demo does not prove the end-to-end path:

light-workflow call:mcp
  -> light-gateway /mcp
  -> backend MCP server built with light-axum
  -> tool implementation

The new example should fill this first.

2. Coverage And Liability Are Still Agent Mock Output

The workflow currently uses a native coverage-liability-agent task with mockOutput for:

  • coverage status
  • liability status
  • risk level
  • estimated loss
  • deductible
  • adjuster review flag
  • SIU review flag

The product doc lists coverage-review tools such as evaluate_coverage, score_claim_risk, and classify_liability, but the concrete workflow does not call those as MCP tools yet. A backend MCP server can make this part deterministic and testable.

3. Settlement Support Is Still Too Coarse

The workflow uses a native settlement-agent with mockOutput, then calls recommendSettlement. The existing offer decision API returns a settlement recommendation, but it does not expose separate tools for:

  • required documents
  • customer-facing summary generation
  • repair versus total-loss explanation
  • denial-draft explanation

The MCP server should fill those smaller support functions. It should not reimplement recommendSettlement, because that endpoint already exists in demo-offer-decision-api.

Decisions

  • The backend MCP server is stateful.
  • Session state is in memory for the demo.
  • The server returns and validates Mcp-Session-Id.
  • The first implementation exposes camelCase tool names only.
  • The MCP server implements only gap-filling tools.
  • Existing REST API capabilities stay in the REST APIs and are not duplicated.
  • Small duplicated fixtures or deterministic rule tables are acceptable if they make the example faster to deliver.
  • The existing insurance-claim-mcp-v1.yaml workflow should be replaced rather than copied into a second MCP workflow version.

Proposed App

Create a new app in light-example-rs:

apps/demo-insurance-claim-mcp-server/
  Cargo.toml
  src/main.rs
  config/
    client.yml
    portal-registry.yml
    server.yml
    startup.yml
    values.yml

Suggested service identity:

server.serviceId: com.networknt.demo.insurance-claim-mcp-1.0.0
server.environment: demo
server.httpPort: 8087
server.enableHttp: true
server.enableRegistry: true

For local standalone development, server.enableRegistry can be overridden to false. For the full demo, it should register with controller discovery so light-gateway can resolve it by serviceId.

Runtime Shape

The app should follow the same pattern as the REST examples:

#![allow(unused)]
fn main() {
#[derive(Clone, Default)]
struct InsuranceClaimMcpApp;

#[async_trait]
impl AxumApp for InsuranceClaimMcpApp {
    async fn router(&self, _context: ServerContext) -> Result<Router, RuntimeError> {
        Ok(build_router())
    }
}
}

The server should expose:

  • GET /health
  • POST /mcp
  • DELETE /mcp for session cleanup

Use LightRuntimeBuilder::new(AxumTransport::new(InsuranceClaimMcpApp)) and the same config-dir environment override pattern used by the REST demos.

MCP Protocol Scope

Keep the first server deliberately small:

  • support JSON-RPC initialize
  • support notifications/initialized
  • support tools/list
  • support tools/call
  • issue an in-memory Mcp-Session-Id from initialize
  • require later tools/list and tools/call requests to send a known Mcp-Session-Id
  • support DELETE /mcp to remove the in-memory session
  • return JSON-RPC errors for unknown methods, unknown tools, invalid arguments, and tool execution failures

Streaming can be deferred. The first version can return normal JSON responses from POST /mcp.

Tool Catalog

Implement only the tools that fill gaps in the current demo.

ToolPurpose
evaluateCoverageDetermine whether the incident date, policy status, and vehicle coverage allow the claim to continue.
classifyLiabilityClassify liability as clear, unclear, contested, or external-party based on claim facts.
scoreClaimRiskProduce risk level and SIU recommendation from prior claims, injury, drivable status, and claim facts.
listRequiredDocumentsReturn required documents for repair, total-loss review, denial draft, or more-information path.
generateCustomerSummaryProduce a deterministic customer-facing summary from claim, coverage, triage, and settlement context.

Do not implement these existing REST API capabilities in the MCP server:

  • getCustomerProfile
  • getCustomerPreferences
  • getCustomerPolicies
  • getCoveredVehicle
  • listPriorClaims
  • triageClaim
  • recommendSettlement

The workflow should still show bounded agents. The MCP tools provide deterministic coverage, liability, risk, document, and summary support that the agents can reason over.

Data Strategy

Because the MCP server does not duplicate REST endpoints, it does not need to own the full customer, policy, vehicle, prior-claim, triage, or settlement data sets. The workflow passes the REST API outputs into MCP gap tools as tool arguments.

Small duplicated constants are acceptable for speed, for example:

  • coverage rule thresholds
  • liability classification labels
  • risk scoring thresholds
  • document templates
  • customer summary text templates

Avoid creating a shared fixture crate unless duplication becomes hard to maintain.

Gateway Configuration

The full demo path should configure light-gateway with an apiType: mcp backend target. The gateway remains the public MCP endpoint used by light-workflow; the new server is the backend MCP implementation.

Conceptual target:

mcp-router.enabled: true
mcp-router.path: /mcp
mcp-router.tools:
  - name: evaluateCoverage
    apiType: mcp
    serviceId: com.networknt.demo.insurance-claim-mcp-1.0.0
    envTag: demo
    path: /mcp

Repeat the tool entries for the gap-filling tools. Access-control rules and response filtering should remain enforced at light-gateway.

Implementation Phases

Phase 1: App Skeleton

  • add apps/demo-insurance-claim-mcp-server
  • add workspace membership in light-example-rs/Cargo.toml
  • implement light-axum startup, config-dir overrides, tracing, /health
  • add config files and config-registry values
  • add release/build wiring consistent with the two existing demo APIs

Phase 2: Minimal MCP Protocol

  • define JSON-RPC request, response, error, and MCP content/result structs
  • implement in-memory session storage
  • implement initialize with Mcp-Session-Id
  • implement notifications/initialized
  • implement tools/list
  • implement tools/call
  • validate Mcp-Session-Id on later requests
  • implement DELETE /mcp session cleanup
  • add request validation and JSON-RPC error mapping
  • add unit tests for protocol errors

Phase 3: Gap-Filling Tools

  • implement evaluateCoverage
  • implement classifyLiability
  • implement scoreClaimRisk
  • implement listRequiredDocuments
  • implement generateCustomerSummary
  • keep output fields aligned with the replacement workflow assertions
  • add handler tests for one success and one failure path per tool group
  • verify with direct POST /mcp tools/list and tools/call

Phase 4: Gateway And Workflow Integration

  • add demo mcp-router.yml entries that point to the MCP server by serviceId
  • enable registry for the MCP server in the full demo environment
  • verify light-gateway can initialize the backend MCP server
  • replace insurance-claim-mcp-v1.yaml so it calls REST APIs for existing demo API capabilities and MCP tools for gap-filling capabilities
  • run the replaced insurance-claim-mcp-v1.yaml flow through light-gateway
  • keep the existing REST workflow unchanged for comparison

Tests And Verification

Minimum verification:

cargo check -p demo-insurance-claim-mcp-server
cargo test -p demo-insurance-claim-mcp-server

Direct protocol checks:

curl -sS http://127.0.0.1:8087/health

curl -i -sS -X POST http://127.0.0.1:8087/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"demo","version":"1.0.0"},"capabilities":{}}}'

curl -sS -X POST http://127.0.0.1:8087/mcp \
  -H 'Content-Type: application/json' \
  -H 'Mcp-Session-Id: <session-id-from-initialize>' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Gateway checks:

curl -k -sS -X POST https://localhost:8443/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Workflow checks:

  • start insurance-claim-mcp-v1
  • confirm customer-context outputs are loaded through REST API calls
  • confirm triageClaim and recommendSettlement are still REST API calls
  • confirm evaluateCoverage, classifyLiability, scoreClaimRisk, listRequiredDocuments, and generateCustomerSummary run through MCP
  • complete the adjuster and claimant human tasks
  • verify the final workflow output is CLAIM_APPROVED for the happy path

Asymmetric Decryptor

asymmetric-decryptor decrypts RSA encrypted configuration values.

It is used by config-loader when a service loads encrypted values that use the CRYPT:RSA: prefix. The crate supports RSA private keys in PKCS#8 and PKCS#1 PEM formats and decrypts payloads with RSA-OAEP using SHA-256.

Main Types

  • AsymmetricDecryptor: owns the RSA private key and decrypts supported payloads.
  • AsymmetricError: error type for prefix, base64, key, and decrypt failures.
  • CRYPT_RSA_PREFIX: the required CRYPT:RSA: payload prefix.

Usage

#![allow(unused)]
fn main() {
use asymmetric_decryptor::AsymmetricDecryptor;

let decryptor = AsymmetricDecryptor::from_pem(private_key_pem)?;
let plaintext = decryptor.decrypt("CRYPT:RSA:...")?;
}

Notes

This crate is intentionally small. It does not fetch keys, rotate keys, or perform configuration merging. Those concerns belong to config-loader and the runtime layer.

Config Loader

config-loader loads, merges, resolves, and decrypts service configuration.

It provides the common configuration behavior used by fabric services and runtime modules. Configuration can be loaded from YAML, JSON, or TOML files, merged across layers, expanded from values maps, and decrypted when encrypted values are present.

Main Types

  • ConfigLoader: loads files and resolves ${key:default} style values.
  • ConfigManager<T>: stores hot-swappable typed configuration behind an atomic reference.
  • ConfigError: shared error type for IO, parse, decrypt, and conversion failures.

Resolution Model

The loader supports:

  • merging multiple config files in order
  • external overlays through LIGHT_RS_CONFIG_DIR
  • whole-value variable replacement
  • embedded variable expansion inside strings
  • typed deserialization through Serde
  • symmetric encrypted values through symmetric-decryptor
  • asymmetric encrypted values through asymmetric-decryptor

Usage

#![allow(unused)]
fn main() {
use config_loader::ConfigLoader;
use std::collections::HashMap;

let loader = ConfigLoader::from_values(HashMap::new(), None, None)?;
let config: MyConfig = loader.load_typed(["config/my-service.yml"])?;
}

Consumers

light-runtime uses this crate for service bootstrap and runtime config. Application crates can also use it for app-specific policy or domain config.

Hindsight Client

hindsight-client provides a small client abstraction for persistent agent memory.

It stores and recalls memory units from PostgreSQL. The current implementation uses sqlx and pgvector for vector similarity search.

Main Types

  • HindsightMemory: trait used by applications that need memory retention and recall without coupling to a specific database implementation.
  • PgHindsightClient: PostgreSQL-backed implementation of HindsightMemory.
  • MemoryUnit: returned memory record with content, type, metadata, and bank identity.

Usage

#![allow(unused)]
fn main() {
use hindsight_client::{HindsightMemory, PgHindsightClient};

let memory = PgHindsightClient::new(pool);
let unit_id = memory
    .retain(host_id, bank_id, "User prefers concise answers", "fact", None, metadata)
    .await?;
}

Data Model

The PostgreSQL implementation writes to agent_memory_unit_t and uses host_id plus bank_id to isolate memory between tenants, users, or sessions.

Consumers

light-agent uses this crate to persist and recall agent conversation memory.

Light Rule

light-rule is the Rust rule engine for evaluating rule definitions and executing registered actions.

It is designed to align with the rule.yaml specification while remaining runtime-neutral. Java services can use yaml-rule; Rust services use this crate.

Main Types

  • RuleEngine: evaluates rule conditions and determines action execution.
  • MultiThreadRuleExecutor: executes rules with runtime state.
  • RuntimeState: input/output state passed through rule evaluation.
  • ActionRegistry: registry for action plugins.
  • RuleActionPlugin: trait implemented by Rust action handlers.
  • Rule, RuleCondition, RuleAction, RuleConfig, EndpointConfig: rule model types.

Action Model

Rules reference actions by actionRef. In Rust, actionRef resolves to a registered RuleActionPlugin; it is not a Java class name. This keeps the rule format portable across Java and Rust executors.

Usage

#![allow(unused)]
fn main() {
use light_rule::{ActionRegistry, RuleEngine};

let registry = ActionRegistry::default();
let engine = RuleEngine::new(registry);
}

See Light-Rule for the rule format and its relationship to workflow assertions and portal rule management.

Light Runtime

light-runtime is the shared service runtime for Light Fabric applications.

It owns bootstrap, configuration loading, transport startup, graceful shutdown, and optional portal registry registration. Apps such as light-agent and light-deployer should start through this crate instead of binding sockets directly.

Main Types

  • LightRuntimeBuilder: builds a runtime from a transport.
  • LightRuntime: configured runtime before start.
  • RunningRuntime: running service handle with shutdown support.
  • Module: lifecycle hook abstraction.
  • RuntimeConfig: resolved runtime configuration.
  • ServerConfig: HTTP/HTTPS bind and service identity settings.
  • BootstrapConfig: remote config bootstrap settings.
  • PortalRegistryConfig: portal registry connection settings.

Startup Pattern

#![allow(unused)]
fn main() {
use light_axum::AxumTransport;
use light_runtime::LightRuntimeBuilder;

let runtime = LightRuntimeBuilder::new(AxumTransport::new(app))
    .with_config_dir("config")
    .build();

let running = runtime.start().await?;
running.shutdown().await?;
}

Configuration

At minimum, runtime services need server.yml. Optional files include startup.yml, client.yml, and portal-registry.yml.

light-runtime is transport-neutral. light-axum supplies the Axum transport implementation.

MCP Client

mcp-client is a client for calling MCP-compatible gateway endpoints.

It provides a small API for listing and invoking tools through a configured MCP gateway path. It is intentionally focused on the client side; MCP server implementations live in applications or framework layers.

Main Types

  • McpGatewayClient: gateway client used by applications.
  • McpTool: tool metadata returned by the gateway.
  • McpContent: content item returned by MCP tool calls.
  • McpToolCallResult: structured result for a tool invocation.

Usage

#![allow(unused)]
fn main() {
use mcp_client::McpGatewayClient;

let client = McpGatewayClient::new(gateway_url, path, timeout_ms);
let result = client.call_tool("tool.name", arguments).await?;
}

Consumers

light-agent uses this crate when an agent session needs to discover or invoke tools exposed through an MCP gateway.

Model Provider

model-provider defines a common abstraction over LLM providers and implements multiple provider adapters.

The goal is to let agent and workflow code depend on one Provider trait while supporting local models, hosted APIs, and provider-specific features.

Main Types

  • Provider: async trait implemented by model providers.
  • ChatRequest, ChatResponse, ChatMessage: common chat data model.
  • ToolSpec, ToolCall: tool-calling model.
  • ProviderCapabilities: capability metadata.
  • TokenUsage: usage accounting.
  • ReliableProvider: reliability wrapper.
  • RouterProvider: route requests across multiple providers.

Provider Implementations

Current modules include:

  • Anthropic
  • Azure OpenAI
  • Bedrock
  • Claude Code
  • Codex
  • OpenAI-compatible providers
  • Copilot
  • Gemini
  • Gemini CLI
  • GLM
  • Kilo Code CLI
  • Ollama
  • OpenAI
  • OpenRouter
  • Telnyx

Consumers

light-agent uses this crate to send chat requests and tool specs without hard-coding a single LLM provider.

Portal Registry

portal-registry provides client support for registering services with Light Portal or Light Controller.

It uses a JSON-RPC style WebSocket protocol for service registration, metadata updates, discovery, and cache-management control. Runtime services normally use this through light-runtime, but applications can also use the client directly when they need custom registry behavior.

Main Types

  • PortalRegistryClient: WebSocket client for registry communication.
  • RegistryHandler: trait for handling registry callbacks and messages.
  • RegistrationState: client registration state.
  • RegistrationBuilder: helper for constructing registration parameters.
  • ServiceRegistrationParams: service identity and advertised endpoint.
  • ServiceMetadataUpdate: metadata update payload.

Usage

#![allow(unused)]
fn main() {
use portal_registry::RegistrationBuilder;

let registration = RegistrationBuilder::new(
    "com.networknt.service-1.0.0",
    "1.0.0",
    "http",
    "127.0.0.1",
    8080,
)
.with_env("dev")
.with_jwt(token)
.build();
}

Runtime Integration

light-runtime can register a service automatically when server.yml enables registry support and portal-registry.yml supplies the portal connection.

Symmetric Decryptor

symmetric-decryptor decrypts legacy symmetric encrypted configuration values.

It supports payloads with the CRYPT prefix and decrypts AES-256-CBC data with a key derived from the configured password using PBKDF2-HMAC-SHA256.

Main Types

  • Decryptor: trait implemented by decryptors.
  • SymmetricDecryptor: password-based decryptor.
  • DecryptError: error type for prefix, format, hex, and cipher failures.
  • CRYPT_PREFIX: required CRYPT payload prefix.

Usage

#![allow(unused)]
fn main() {
use symmetric_decryptor::{Decryptor, SymmetricDecryptor};

let decryptor = SymmetricDecryptor::new("password");
let plaintext = decryptor.decrypt("CRYPT:...")?;
}

Consumers

config-loader uses this crate when it encounters symmetric encrypted values and a config password is available.

Workflow Builder

workflow-builder provides fluent builders for creating Agentic Workflow definitions programmatically.

It depends on workflow-core for the actual model types and layers a builder API on top so applications and tests can construct valid workflows without manually assembling nested maps.

Main Areas

  • workflow metadata construction
  • authentication definitions
  • task definitions
  • nested do, for, fork, try, and other task structures
  • YAML/JSON serialization through workflow-core model types

Usage

#![allow(unused)]
fn main() {
use workflow_builder::services::workflow::WorkflowBuilder;

let workflow = WorkflowBuilder::new()
    .use_dsl("1.0.0")
    .with_namespace("lightapi")
    .with_name("example")
    .with_version("1.0.0")
    .build();
}

Relationship To Workflow Core

Use workflow-core when you need direct access to the schema model. Use workflow-builder when you want an ergonomic construction API.

Workflow Core

workflow-core contains the Rust model for the Agentic Workflow DSL.

The crate is schema-oriented: its structs and enums represent workflow documents, tasks, authentication blocks, durations, timeouts, errors, and supporting map types.

Main Areas

  • workflow document metadata
  • task definitions
  • call task protocol definitions
  • ask and assert task definitions
  • duration and timeout models
  • error definitions
  • ordered map support for workflow task lists

Usage

#![allow(unused)]
fn main() {
use workflow_core::models::workflow::{
    WorkflowDefinition,
    WorkflowDefinitionMetadata,
};

let document = WorkflowDefinitionMetadata::new(
    "lightapi",
    "example",
    "1.0.0",
    Some("Example".to_string()),
    None,
    None,
    None,
);
let workflow = WorkflowDefinition::new(document);
}

Consumers

workflow-builder builds on this crate. light-workflow and workflow-related services use the model for loading, validating, and executing workflow documents.

Light-Axum

light-axum adapts Axum applications to light-runtime.

Applications implement AxumApp and return an axum::Router. The framework owns binding, optional TLS, runtime metadata resolution, and graceful shutdown through the runtime transport contract.

Main Types

  • AxumApp: trait implemented by an application.
  • AxumTransport: transport passed to LightRuntimeBuilder.
  • ServerContext: runtime context passed into the app when building routes.
  • AxumBoundHandle: running Axum server handle.

Pattern

#![allow(unused)]
fn main() {
use light_axum::{AxumApp, AxumTransport, ServerContext};
use light_runtime::LightRuntimeBuilder;

#[derive(Clone)]
struct App;

impl AxumApp for App {
    fn router(&self, _context: ServerContext) -> axum::Router {
        axum::Router::new()
    }
}

let runtime = LightRuntimeBuilder::new(AxumTransport::new(App))
    .with_config_dir("config")
    .build();
}

Consumers

light-agent and light-deployer use this framework.

Building REST APIs With Light-Axum

light-axum lets a service use normal Axum routing while delegating listener binding, TLS, config loading, runtime metadata, logging control, and graceful shutdown to light-runtime.

The application owns the HTTP API shape. The framework owns how the service is started and managed.

Working Examples

The light-example-rs repository contains two REST API demos built with light-axum:

DemoPurposeLocal portOpenAPI
apps/demo-customer-profile-apiCustomer profile, preferences, policies, vehicles, and prior claims8085apps/demo-customer-profile-api/openapi.yaml
apps/demo-offer-decision-apiOffer search, offer decisions, claim triage, and settlement recommendations8086apps/demo-offer-decision-api/openapi.yaml

Both demos follow the same service pattern:

  1. Define request and response models with serde.
  2. Build a standard Axum Router.
  3. Implement AxumApp and return that router.
  4. Start the app through LightRuntimeBuilder::new(AxumTransport::new(app)).
  5. Keep runtime config in the app config/ directory.
  6. Publish an OpenAPI document for endpoint import and API management.

Dependencies

A minimal REST API needs these crates:

[dependencies]
anyhow = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
light-axum = { workspace = true }
light-runtime = { workspace = true }
serde = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

Add serde_json when handlers accept or return dynamic JSON values.

Application Shape

Create a service type and implement AxumApp. The runtime passes a ServerContext into router. Most simple REST APIs do not need it, but it is available when routes need runtime metadata.

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use axum::{Json, Router, routing::get};
use light_axum::{AxumApp, ServerContext};
use light_runtime::RuntimeError;
use serde::Serialize;

#[derive(Clone, Default)]
struct CustomerProfileApp;

#[async_trait]
impl AxumApp for CustomerProfileApp {
    async fn router(&self, _context: ServerContext) -> Result<Router, RuntimeError> {
        Ok(build_router())
    }
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct HealthResponse {
    status: &'static str,
    service: &'static str,
}

fn build_router() -> Router {
    Router::new().route("/health", get(health))
}

async fn health() -> Json<HealthResponse> {
    Json(HealthResponse {
        status: "UP",
        service: "demo-customer-profile-api",
    })
}
}

Everything inside build_router is standard Axum. Use Path, Query, State, Json, HeaderMap, middleware, extractors, and response types the same way you would in a standalone Axum service.

Runtime Startup

Start the service through LightRuntimeBuilder instead of binding a TcpListener directly.

use anyhow::{Context, Result};
use light_axum::AxumTransport;
use light_runtime::{LightRuntimeBuilder, TracingOptions, init_tracing};
use tracing::info;

const CONFIG_DIR_ENV: &str = "CUSTOMER_PROFILE_CONFIG_DIR";
const EXTERNAL_CONFIG_DIR_ENV: &str = "CUSTOMER_PROFILE_EXTERNAL_CONFIG_DIR";
const LOG_ANSI_ENV: &str = "CUSTOMER_PROFILE_LOG_ANSI";
const DEFAULT_CONFIG_DIR: &str = "apps/demo-customer-profile-api/config";
const DEFAULT_EXTERNAL_CONFIG_DIR: &str =
    "apps/demo-customer-profile-api/config-cache";

#[tokio::main]
async fn main() -> Result<()> {
    let tracing_guard = init_tracing(
        TracingOptions::new("demo-customer-profile-api")
            .with_legacy_ansi_env(LOG_ANSI_ENV),
    )
    .context("failed to initialize tracing")?;

    let config_dir =
        std::env::var(CONFIG_DIR_ENV).unwrap_or_else(|_| DEFAULT_CONFIG_DIR.to_string());
    let external_config_dir = std::env::var(EXTERNAL_CONFIG_DIR_ENV)
        .unwrap_or_else(|_| DEFAULT_EXTERNAL_CONFIG_DIR.to_string());

    let runtime = LightRuntimeBuilder::new(AxumTransport::new(CustomerProfileApp))
        .with_config_dir(config_dir)
        .with_external_config_dir(external_config_dir)
        .with_logging_control(tracing_guard.logging_control())
        .build();

    let running = runtime
        .start()
        .await
        .context("failed to start demo customer profile API")?;

    info!("demo customer profile API started");

    tokio::signal::ctrl_c()
        .await
        .context("failed to listen for shutdown signal")?;

    running
        .shutdown()
        .await
        .context("failed to shut down demo customer profile API")?;

    Ok(())
}

This startup path gives the application the same runtime behavior as other Light services:

  • listener configuration comes from server.yml
  • local and external config directories are resolved by light-runtime
  • TLS is controlled by runtime config, not by route code
  • graceful shutdown goes through the runtime handle
  • logging can be controlled by the runtime logging control object

Routing Patterns

The customer profile demo shows read-only REST endpoints:

#![allow(unused)]
fn main() {
fn build_router() -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/customers/{customer_id}", get(get_customer))
        .route(
            "/customers/{customer_id}/preferences",
            get(get_customer_preferences),
        )
        .route(
            "/customers/{customer_id}/policies",
            get(get_customer_policies),
        )
        .route(
            "/customers/{customer_id}/vehicles/{vehicle_id}",
            get(get_covered_vehicle),
        )
        .route(
            "/customers/{customer_id}/prior-claims",
            get(get_prior_claims),
        )
        .with_state(AppState::seeded())
}
}

The offer decision demo shows query parameters, request bodies, headers, and shared mutable state:

#![allow(unused)]
fn main() {
fn build_router() -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/offers", get(search_offers))
        .route("/offer-decisions", post(record_offer_decision))
        .route("/claim-triage", post(triage_claim))
        .route("/settlement-recommendations", post(recommend_settlement))
        .with_state(AppState::seeded())
}
}

Use typed handlers for predictable API behavior:

#![allow(unused)]
fn main() {
async fn search_offers(
    State(state): State<AppState>,
    Query(query): Query<OfferQuery>,
) -> Json<Vec<Offer>> {
    Json(state.search_offers(&query))
}
}

For request bodies:

#![allow(unused)]
fn main() {
async fn record_offer_decision(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(request): Json<OfferDecisionRequest>,
) -> Result<Json<OfferDecisionResponse>, ApiError> {
    // validate request and return a typed API response
}
}

Errors

Define one service error type and implement IntoResponse. This keeps handlers small and ensures failures return stable JSON.

#![allow(unused)]
fn main() {
use axum::{
    Json,
    http::StatusCode,
    response::{IntoResponse, Response},
};
use serde::Serialize;

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ErrorResponse {
    code: &'static str,
    message: String,
}

#[derive(Debug)]
struct ApiError {
    status: StatusCode,
    code: &'static str,
    message: String,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (
            self.status,
            Json(ErrorResponse {
                code: self.code,
                message: self.message,
            }),
        )
            .into_response()
    }
}
}

The customer profile API returns 404 with CUSTOMER_NOT_FOUND. The offer decision API returns 400 with INVALID_DECISION_REQUEST when request content is invalid.

Configuration

Each application should keep its runtime configuration under its own config/ directory:

apps/<service-name>/
  Cargo.toml
  openapi.yaml
  src/main.rs
  config/
    client.yml
    portal-registry.yml
    server.yml
    startup.yml
    values.yml

server.yml controls the listener and service identity:

ip: ${server.ip:0.0.0.0}
advertisedAddress: ${server.advertisedAddress:127.0.0.1}
httpPort: ${server.httpPort:8085}
enableHttp: ${server.enableHttp:true}
httpsPort: ${server.httpsPort:8443}
enableHttps: ${server.enableHttps:false}
tlsCertPath: ${server.tlsCertPath:}
tlsKeyPath: ${server.tlsKeyPath:}
serviceId: ${server.serviceId:com.networknt.demo.customer-profile-1.0.0}
enableRegistry: ${server.enableRegistry:false}
startOnRegistryFailure: ${server.startOnRegistryFailure:true}
dynamicPort: ${server.dynamicPort:false}
environment: ${server.environment:demo}
shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}

Use a unique server.serviceId per service. The demos use:

  • com.networknt.demo.customer-profile-1.0.0
  • com.networknt.demo.offer-decision-1.0.0

values.yml supplies defaults for template variables:

server.serviceId: com.networknt.demo.customer-profile-1.0.0
server.environment: demo
server.ip: 0.0.0.0
server.advertisedAddress: 127.0.0.1
server.httpPort: 8085
server.enableHttp: true
server.enableHttps: false
server.enableRegistry: false
server.startOnRegistryFailure: true

Enable registry integration only when the service should register with Portal or Controller discovery. For local standalone development, keep server.enableRegistry: false.

OpenAPI

Keep the OpenAPI document beside the service source. The OpenAPI file is the contract used by API management and workflow tooling, while src/main.rs is the runtime implementation.

The demo specs are:

  • light-example-rs/apps/demo-customer-profile-api/openapi.yaml
  • light-example-rs/apps/demo-offer-decision-api/openapi.yaml

When adding or changing a route, update both the Axum router and openapi.yaml. Use operation IDs that match the business action, such as getCustomerProfile, searchOffers, or recordOfferDecision.

Running Locally

From the light-example-rs repository:

cargo run -p demo-customer-profile-api

Then verify the health endpoint:

curl http://127.0.0.1:8085/health

Run the offer decision API the same way:

cargo run -p demo-offer-decision-api
curl http://127.0.0.1:8086/health

Override config locations with environment variables when running from a different working directory:

CUSTOMER_PROFILE_CONFIG_DIR=/path/to/config \
CUSTOMER_PROFILE_EXTERNAL_CONFIG_DIR=/path/to/config-cache \
cargo run -p demo-customer-profile-api

Checklist

Use this checklist when creating a new REST API with light-axum:

  • create an app crate under apps/
  • add axum, light-axum, light-runtime, tokio, serde, and async-trait
  • define typed request, response, and error models
  • build routes with a standard Axum Router
  • implement AxumApp for the service type
  • start the service with LightRuntimeBuilder and AxumTransport
  • add config/server.yml, startup.yml, portal-registry.yml, client.yml, and values.yml
  • assign a stable server.serviceId
  • add or update openapi.yaml
  • verify /health and one representative business endpoint locally

Light-Axum IPv6 Support

Problem

light-axum binds application listeners from server.yml through the shared light-runtime server configuration. The bind IP is configured with server.ip, and most product templates default it to 0.0.0.0.

IPv4 wildcard binding works for IPv4-only networks, but dual-stack container and Kubernetes networks can publish both IPv4 and IPv6 service addresses. If a client resolves an Axum service name to IPv6 first, the service must either listen on IPv6 or the client must retry an IPv4 address. Relying on client fallback is not enough for gateway and service-to-service traffic.

Before IPv6 support, the transport built bind addresses with string concatenation:

#![allow(unused)]
fn main() {
format!("{}:{port}", config.server.ip).parse()
}

That works for 0.0.0.0:8080, but fails for IPv6 wildcard binding because :: plus port becomes :::8080 instead of [::]:8080.

Goals

  • Support IPv4 and IPv6 bind addresses for all applications using AxumTransport.
  • Keep the existing default of server.ip: 0.0.0.0.
  • Preserve current runtime configuration names and deployment templates.
  • Fail early with a clear error when server.ip is not a valid IP address.

Non-Goals

  • Do not enable IPv6 by default.
  • Do not change advertised address resolution or portal-registry registration.
  • Do not change TLS behavior or application routing.
  • Do not add client-side IPv4 fallback in this change.

Configuration

The bind address remains the existing server.ip property.

IPv4 wildcard:

server.ip: 0.0.0.0

IPv6 wildcard:

server.ip: "::"

Specific IPv4 address:

server.ip: 172.16.1.9

Specific IPv6 address:

server.ip: "fdd0:0:0:1::9"

External templates should continue projecting this value into server.yml:

ip: ${server.ip:0.0.0.0}

Implementation

light-axum parses server.ip as an IpAddr and constructs the listener with SocketAddr::new(ip, port).

This keeps IPv4 output unchanged and produces bracketed IPv6 socket addresses where required by Rust networking APIs:

0.0.0.0 + 8080 -> 0.0.0.0:8080
:: + 8080      -> [::]:8080

The change lives in the framework transport, so it applies to products using AxumTransport, including portal-service, light-agent, and light-deployer.

Deployment Guidance

Only set server.ip: "::" when the host or container network is intended to serve IPv6 traffic. In dual-stack deployments, verify both sides:

  • the runtime receives an IPv6 address;
  • DNS or service discovery returns reachable addresses;
  • dependent clients or gateways can connect to the IPv6 endpoint;
  • health checks cover the selected address family.

If the environment is IPv4-only, keep server.ip: 0.0.0.0.

Verification

For an Axum service configured with IPv6 wildcard binding:

server.ip: "::"

verify from a peer in the same network:

getent ahosts <service-name>
curl -k -g https://[<service-ipv6>]:<port>/health

If access goes through service DNS:

curl -k -v https://<service-name>:<port>/health

The first resolved address family must be reachable, or the caller must have a retry/fallback strategy.

Light-Pingora

light-pingora adapts Pingora proxy services to light-runtime.

It is the framework layer for high-performance gateway and proxy products. The crate keeps runtime concerns such as configuration and service lifecycle separate from Pingora-specific proxy behavior.

Role

  • bridge Pingora services into the common runtime lifecycle
  • expose transport metadata to light-runtime
  • support gateway products without duplicating bootstrap code

Consumers

light-gateway uses this framework.

MSAL Exchange

The msal-exchange handler is a BFF security handler for SPA applications that authenticate with Microsoft Authentication Library, MSAL, and need an internal light-oauth security profile for gateway authorization.

The SPA obtains Azure MSAL tokens in the browser. It sends the MSAL ID token to the gateway for light-oauth token exchange. In the Azure authorization placement pattern, it also sends the MSAL access token during the exchange so the gateway can store it in a secure BFF cookie. The internal light-oauth token set is stored in secure BFF cookies and is used on later requests together with CSRF protection.

This page documents the current behavior and the token placement extension for deployments that must keep the Azure MSAL access token in the downstream Authorization header while forwarding the light-oauth token in a separate header.

Use Cases

Use msal-exchange when:

  • The UI is a browser SPA using MSAL.js.
  • Azure Entra ID is the identity provider for the browser login.
  • The gateway must exchange the Azure token for a light-oauth token containing the enterprise security profile and custom claims.
  • The gateway must protect browser requests with HttpOnly cookies and CSRF.
  • Downstream routing needs either the light-oauth token or the Azure MSAL token in the Authorization header.

Handler Placement

Enable the handler in the gateway handler chain before downstream routing and before handlers that depend on the authenticated principal.

Example:

handlers:
  - exception
  - cors
  - msal-exchange
  - header
  - prefix
  - router

chains:
  bff:
    - exception
    - cors
    - msal-exchange
    - header
    - prefix
    - router

paths:
  - path: /auth/ms/exchange
    method: POST
    exec:
      - bff
  - path: /auth/ms/exchange
    method: OPTIONS
    exec:
      - bff
  - path: /auth/ms/logout
    method: POST
    exec:
      - bff
  - path: /auth/ms/logout
    method: OPTIONS
    exec:
      - bff

Exchange and logout are POST-only. Keep the OPTIONS routes permanently, with cors before msal-exchange, so preflight is handled before the auth method guard.

When the handler is active, the gateway needs these resolved config files:

  • msal-exchange.yml
  • security-msal.yml
  • security.yml
  • client.yml

security-msal.yml validates Azure MSAL tokens. security.yml validates the light-oauth tokens stored in BFF cookies. client.yml provides the light-oauth token-exchange client configuration.

Exchange Flow

The exchange endpoint receives the Azure MSAL ID token from the SPA and creates the BFF session.

POST /auth/ms/exchange
Authorization: Bearer <azure-msal-id-token>

  -> read the Azure MSAL ID token
  -> verify the ID token with security-msal.yml
  -> generate a CSRF value
  -> call light-oauth with the token-exchange grant
  -> verify the returned light-oauth access token with security.yml
  -> set BFF cookies
  -> return { "scopes": [...] }

The token-exchange request uses client.yml oauth.token.token_exchange. The outgoing form body contains:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<azure-msal-id-token>
subject_token_type=urn:ietf:params:oauth:token-type:jwt
csrf=<generated-csrf>

subjectTokenType can be set in msal-exchange.yml. When it is blank, the shared token client default from client.yml is used.

On success, the response body contains the scopes from the light-oauth token:

{
  "scopes": ["scope1", "scope2"]
}

The exchange body is optional. A zero-length request is valid even when a shared client declares Content-Type: application/json.

Session Cookies

The handler uses the same cookie contract as the stateless SPA auth handler.

CookieHttpOnlyDescription
accessTokentruelight-oauth access token
refreshTokentruelight-oauth refresh token, when returned
msalAccessTokentrueAzure MSAL access token when authorizationToken is azure-msal
csrffalseGenerated CSRF value
userIdfalseUser id from uid, user_id, or sub
userTypefalseUser type from userType
rolesfalseBase64 encoded role value, default user
hostfalseHost claim
emailfalseEmail claim from eml
eidfalseEnterprise id claim

accessToken and refreshToken are HttpOnly so browser JavaScript cannot read the light-oauth tokens. The SPA reads the non-HttpOnly csrf cookie and sends it back with protected requests.

CSRF Validation

For normal protected requests, the handler validates the request CSRF value against the csrf claim in the light-oauth access token.

CSRF source order:

  1. X-CSRF-TOKEN request header.
  2. Sec-WebSocket-Protocol value starting with csrf. for WebSocket requests.
  3. csrf query parameter.

If the CSRF value is missing or does not match the JWT claim, the request is rejected.

Token Placement

authorizationToken selects which token owns the downstream Authorization header after the BFF session has been established.

Supported values:

ValueAuthorization headerLight-oauth token locationUse case
light-oauthBearer <light-oauth-token>AuthorizationExisting enterprise BFF pattern
azure-msalBearer <azure-msal-access-token>lightTokenHeader, default X-Light-TokenAzure-whitelisted downstream systems, such as AWS Agent Core

authorizationToken: light-oauth

This is the current default behavior.

After the exchange, the SPA calls the gateway with cookies and CSRF:

GET /api/orders
Cookie: accessToken=...; csrf=...
X-CSRF-TOKEN: <csrf>

The handler:

  -> reads the light-oauth accessToken cookie
  -> verifies it with security.yml
  -> validates CSRF
  -> refreshes the token if it is close to expiry
  -> injects Authorization: Bearer <light-oauth-token>
  -> continues the handler chain

Downstream services receive:

Authorization: Bearer <light-oauth-token>

This mode is appropriate when downstream services and MCP tools trust light-oauth directly and expect fine-grained security claims in the normal Authorization header.

authorizationToken: azure-msal

This token placement pattern uses both Azure and light-oauth tokens downstream.

At exchange time, the SPA sends the MSAL ID token in Authorization and the MSAL access token in msalAccessTokenHeader, which defaults to X-MSAL-Access-Token:

POST /auth/ms/exchange
Authorization: Bearer <azure-msal-id-token>
X-MSAL-Access-Token: Bearer <azure-msal-access-token>

  -> verify the MSAL ID token with security-msal.yml
  -> verify the MSAL access token with security-msal.yml
  -> exchange the ID token for a light-oauth token
  -> store the light-oauth token in accessToken
  -> store the MSAL access token in msalAccessToken

For later protected requests, the SPA sends cookies and CSRF. The SPA does not need to put the Azure access token in the browser request Authorization header because the gateway reads it from the HttpOnly msalAccessToken cookie:

GET /agent/chat
Cookie: accessToken=...; msalAccessToken=...; csrf=...
X-CSRF-TOKEN: <csrf>

The handler:

  -> read the MSAL access token from the msalAccessToken cookie
  -> verify the MSAL access token with security-msal.yml
  -> read the light-oauth accessToken cookie
  -> verify the light-oauth token with security.yml
  -> validate CSRF
  -> refresh the light-oauth token if it is close to expiry
  -> inject Authorization: Bearer <azure-msal-access-token>
  -> inject X-Light-Token: Bearer <light-oauth-token>
  -> continue the handler chain

Downstream systems receive both tokens:

Authorization: Bearer <azure-msal-access-token>
X-Light-Token: Bearer <light-oauth-token>

This mode is intended for systems that only allow Azure as the OAuth provider for the normal Authorization header, while still needing the light-oauth security profile for API and MCP authorization decisions.

The SPA should not read or send X-Light-Token itself. The gateway should derive that header from the HttpOnly light-oauth cookie after CSRF validation. That keeps the light-oauth token out of browser JavaScript.

If a downstream light-gateway is responsible for fine-grained authorization, it must be configured to verify X-Light-Token as the light-oauth token or to promote X-Light-Token to Authorization at a trusted boundary before the normal security/access-control handlers run.

Configuration

Example default configuration:

enabled: ${msal-exchange.enabled:true}
exchangePath: ${msal-exchange.exchangePath:/auth/ms/exchange}
logoutPath: ${msal-exchange.logoutPath:/auth/ms/logout}
logoutCsrfEnforced: ${msal-exchange.logoutCsrfEnforced:false}
cookieDomain: ${msal-exchange.cookieDomain:localhost}
cookiePath: ${msal-exchange.cookiePath:/}
cookieSecure: ${msal-exchange.cookieSecure:false}
sessionTimeout: ${msal-exchange.sessionTimeout:3600}
rememberMeTimeout: ${msal-exchange.rememberMeTimeout:604800}
renewBeforeSeconds: ${msal-exchange.renewBeforeSeconds:90}
refreshSingleFlightWaitMs: ${msal-exchange.refreshSingleFlightWaitMs:5000}
refreshSingleFlightCacheMs: ${msal-exchange.refreshSingleFlightCacheMs:3000}
refreshSingleFlightMaxEntries: ${msal-exchange.refreshSingleFlightMaxEntries:10000}
cookieSameSite: ${msal-exchange.cookieSameSite:None}
cookieTimeoutUri: ${msal-exchange.cookieTimeoutUri:/}
subjectTokenType: ${msal-exchange.subjectTokenType:}
authorizationToken: ${msal-exchange.authorizationToken:light-oauth}
lightTokenHeader: ${msal-exchange.lightTokenHeader:X-Light-Token}
msalAccessTokenHeader: ${msal-exchange.msalAccessTokenHeader:X-MSAL-Access-Token}
msalAccessTokenCookie: ${msal-exchange.msalAccessTokenCookie:msalAccessToken}

Fields:

FieldDefaultDescription
enabledtrueEnables or disables the handler once it is active in the chain.
exchangePath/auth/ms/exchangeEndpoint that receives the Azure MSAL ID token and creates the BFF session.
logoutPath/auth/ms/logoutEndpoint that clears BFF cookies.
logoutCsrfEnforcedfalseEnforces readable csrf cookie versus X-CSRF-TOKEN validation on logout after environment-specific observe-only qualification.
cookieDomainlocalhostCookie domain for session cookies.
cookiePath/Cookie path for session cookies.
cookieSecurefalseAdds the Secure cookie attribute. Use true for HTTPS deployments.
sessionTimeout3600Default max age in seconds for session cookies.
rememberMeTimeout604800Max age in seconds for long-lived refresh-token cookies when light-oauth returns remember-me behavior.
renewBeforeSeconds90Refresh the light-oauth access token when it expires within this window.
refreshSingleFlightWaitMs5000Maximum wait time for concurrent refresh requests sharing the same refresh token.
refreshSingleFlightCacheMs3000Short cache window for a successful refresh result.
refreshSingleFlightMaxEntries10000Maximum refresh single-flight cache entries.
cookieSameSiteNoneCookie SameSite attribute. Supported values are None, Lax, and Strict.
cookieTimeoutUri/URI returned when the session expires and cannot be refreshed.
subjectTokenTypeblankOptional token-exchange subject token type override.
authorizationTokenlight-oauthToken to place in downstream Authorization: light-oauth or azure-msal.
lightTokenHeaderX-Light-TokenHeader used for the light-oauth token when authorizationToken is azure-msal.
msalAccessTokenHeaderX-MSAL-Access-TokenHeader that carries the Azure MSAL access token on the exchange request when authorizationToken is azure-msal.
msalAccessTokenCookiemsalAccessTokenHttpOnly cookie used to store the Azure MSAL access token after exchange when authorizationToken is azure-msal.

Invalid authorizationToken values should fail startup. lightTokenHeader should not be Authorization; use authorizationToken: light-oauth for that case. In azure-msal mode, msalAccessTokenHeader must not be Authorization because Authorization carries the MSAL ID token on the exchange endpoint. msalAccessTokenHeader must also be different from lightTokenHeader.

Security Configuration

security-msal.yml validates Azure MSAL tokens. It is required when the handler is active.

Example:

enableVerifyJwt: ${security-msal.enableVerifyJwt:true}
ignoreJwtExpiry: ${security-msal.ignoreJwtExpiry:false}
enableRelaxedKeyValidation: ${security-msal.enableRelaxedKeyValidation:false}
issuer: ${security-msal.issuer:}
audience: ${security-msal.audience:}
jwt:
  clockSkewInSeconds: ${security-msal.jwt.clockSkewInSeconds:60}

Recommended settings:

  • Set issuer to the Azure tenant issuer when the tenant is known.
  • Set audience to the SPA client id or the expected Azure access-token audience.
  • Keep ignoreJwtExpiry: false in production.
  • Use the configured Microsoft JWK supported by the gateway security runtime.

security.yml remains the normal light-oauth verifier. It validates the light-oauth access token stored in the accessToken cookie and provides the principal used by gateway authorization logic.

SPA Integration

Initial exchange:

await fetch("/auth/ms/exchange", {
  method: "POST",
  credentials: "include",
  headers: {
    Authorization: `Bearer ${azureMsalIdToken}`
  }
});

Initial exchange with authorizationToken: azure-msal:

await fetch("/auth/ms/exchange", {
  method: "POST",
  credentials: "include",
  headers: {
    Authorization: `Bearer ${azureMsalIdToken}`,
    "X-MSAL-Access-Token": `Bearer ${azureMsalAccessToken}`
  }
});

Subsequent requests with the existing light-oauth authorization pattern:

await fetch("/api/orders", {
  credentials: "include",
  headers: {
    "X-CSRF-TOKEN": csrf
  }
});

Subsequent requests with the Azure MSAL authorization pattern:

await fetch("/agent/chat", {
  credentials: "include",
  headers: {
    "X-CSRF-TOKEN": csrf
  }
});

In both patterns, the SPA must send cookies with credentials: "include". In the Azure MSAL authorization pattern, MSAL.js is responsible for obtaining the Azure access token before calling /auth/ms/exchange. The gateway stores that access token in the HttpOnly msalAccessToken cookie, validates it on later BFF requests, injects it into Authorization, and injects the light-oauth token into lightTokenHeader.

Logout

Logout clears all BFF cookies managed by the handler:

POST /auth/ms/logout
Cookie: accessToken=...; csrf=...
X-CSRF-TOKEN: <csrf>

Send credentials and no request body. A zero-length request is also accepted when a shared client sets Content-Type: application/json. On success the handler returns 204 No Content, no response content type or body, and deletion cookies for every cookie name the runtime can set.

A legacy GET or any other unsupported exchange/logout method returns 405, ERR10008, and Allow: POST before token-server, cookie, or proxy side effects. OPTIONS remains routed to CORS.

Error Handling

Important error codes:

CodeMeaning
ERR11647Required Azure MSAL bearer token is missing on the exchange endpoint or in the MSAL access-token cookie.
ERR11648light-oauth token exchange failed.
ERR10000Azure MSAL token or light-oauth token verification failed.
ERR10036CSRF token is missing from the request.
ERR10038CSRF claim is missing from the light-oauth token.
ERR10039Request CSRF and token CSRF do not match.
ERR10052Token response does not contain expires_in and the JWT has no usable exp.
ERR10008Method is not allowed for the exchange or logout endpoint.
ERR11649Logout CSRF cookie/header validation failed without exposing either value.

Implementation Notes

Rust light-pingora and Java light-spa-4j use the same token placement contract:

  • authorizationToken: light-oauth preserves the existing behavior and injects the light-oauth token into Authorization.
  • authorizationToken: azure-msal verifies the exchange request's MSAL ID token and MSAL access token with security-msal.yml, stores the MSAL access token in msalAccessToken, injects it into downstream Authorization, and injects the light-oauth token into lightTokenHeader.
  • lightTokenHeader defaults to X-Light-Token and must not be Authorization when authorizationToken is azure-msal.
  • msalAccessTokenHeader defaults to X-MSAL-Access-Token and is used only on the exchange endpoint.
  • msalAccessTokenCookie defaults to msalAccessToken and is HttpOnly.

In azure-msal placement, the gateway requires the MSAL access-token cookie only when a BFF session cookie is present. Requests without accessToken or refreshToken cookies keep the existing pass-through behavior so public endpoints are not forced to authenticate at this handler.

Light-Agent

light-agent is the interactive agent service in Light Fabric.

It provides a WebSocket chat interface, integrates with model providers, invokes MCP tools through mcp-client, and stores conversation memory through hindsight-client. The current executable implements the enterprise API/MCP-oriented service path. Coding and personal-assistant support extend the same durable agent domain through additional runtime profiles rather than forking separate agent engines.

Execution Model

light-agent is a long-lived interactive session service. A logical agent does not automatically receive its own container or VM.

Remote model calls and gateway-only API/MCP tools can remain in the service. Turns that need a local CLI model, shell, browser, filesystem, repository, private local MCP server, or other effectful tenant execution use a runner-managed backend selected from server-owned policy. High-value publish, signing, deployment, branch, and pull-request operations use fixed structured actions.

Tool availability is placement-specific: gateway catalog entries intersect live gateway tools/list, while runner-local shell/filesystem/browser/local-MCP entries intersect the execution profile, lease allowlist, approved runtime manifest, and live local availability. The independently authorized sets can be combined for the model, but a tool remains bound to one server-owned placement and dispatcher.

Human approval ends the current action lease and credentials. A task sandbox is cleaned; an eligible non-secret coding-session workspace may instead use a separate bounded pause/checkpoint hold. The hold is not executable authority and cannot extend the session maximum lifetime.

The target profiles are:

  • enterprise business agents: long-lived light-agent reasoning plus typed light-gateway API/MCP tools;
  • coding agents: a bounded light-agent-worker inside a runner-managed workspace sandbox, using a native or external agent runtime adapter;
  • personal assistants: light-agent reasoning plus a separately deployed light-agent-channel for messaging and proactive triggers, with an optional personal edge runner for local-device effects.

Codex, Pi, Claude Code, Gemini CLI, Kilo, Hermes, OpenClaw, and similar harnesses are integration candidates behind an agent-runtime adapter. They are not launched directly by the shared light-agent service or by light-workflow. Centralized skills are materialized for the selected profile, but never grant execution authority by themselves.

See Light-Agent Execution for session and turn durability, tool authorization, sandbox placement, deployment profiles, runtime adapters, channel ingress, workflow handoffs, and the origin-neutral runner contract shared with workflow execution. See Centralized Skills for profile-specific skill materialization.

Key Dependencies

  • light-runtime
  • light-axum
  • model-provider
  • mcp-client
  • hindsight-client
  • portal-registry

Runtime

The app follows the standard runtime pattern:

  • load config from config/
  • implement an Axum app
  • start through LightRuntimeBuilder
  • optionally register through portal registry

Deploy Native

This page describes the recommended VM deployment model for the Rust light-agent native binary.

Use this model when a customer wants to run an agent service on a VM and expose the chat UI/WebSocket endpoint outside Kubernetes. The agent serves the local chat UI, connects to an LLM provider, calls MCP tools through light-gateway, stores conversation memory in Postgres, and registers with controller.

Deliver a versioned install bundle, not an ad hoc runtime script.

The bundle should contain:

  • light-agent native binary.
  • public/ static assets for the chat UI.
  • Minimal bootstrap config files.
  • A systemd unit.
  • An install script for filesystem setup.
  • A root-owned environment file for secrets.

Use systemd to run the service:

  • It restarts the process on failure.
  • It keeps logs in the host journal.
  • It avoids shell-history and process-list leakage from command-line secrets.
  • It gives the customer a standard operational surface: start, stop, restart, status, and journalctl.

Do not use a long-running shell wrapper to pass the bootstrap token, database URL, or model configuration. Use config files and an environment file instead.

Runtime Layout

light-agent uses relative runtime paths:

  • config
  • public

The systemd service should therefore set WorkingDirectory to the installed application directory.

Recommended VM layout:

/opt/light-agent/
  light-agent -> releases/2.2.1/light-agent
  releases/
    2.2.1/
      light-agent
  config -> /etc/light-agent
  public/
    index.html

/etc/light-agent/
  startup.yml
  server.yml
  portal-registry.yml
  client.yml
  mcp-client.yml
  ollama.yml
  values.yml
  ca.pem
  light-agent.env

/var/lib/light-agent/
  config-cache/

The local config directory contains bootstrap and agent-specific config. Runtime config downloaded from config-server should be written to /var/lib/light-agent/config-cache by setting externalConfigDir in startup.yml.

Keep /etc/light-agent readable by the service user. Keep /var/lib/light-agent/config-cache writable by the service user.

Build Artifact

Build a release binary from light-fabric:

cargo build --release -p light-agent

The artifact is:

target/release/light-agent

For a static Linux build that matches the Docker build target:

rustup target add x86_64-unknown-linux-musl
cargo build --release -p light-agent --target x86_64-unknown-linux-musl

The static artifact is:

target/x86_64-unknown-linux-musl/release/light-agent

Build on a compatible Linux distribution for the customer VM. If the customer fleet has mixed Linux versions, prefer a static or target-compatible build so the binary does not fail on an older glibc.

Package with a versioned filename:

light-agent-<version>-linux-amd64.tar.gz

Include the static assets from:

apps/light-agent/public/

Runtime Dependencies

The VM must be able to reach:

  • Controller, through portalRegistry.portalUrl.
  • Config-server, through startup.configServerUri.
  • light-gateway, through mcp-client.gatewayUrl and mcp-client.path.
  • The model provider, currently Ollama by default.
  • Postgres, through DATABASE_URL.

The Postgres database must contain the Hindsight memory tables used by light-agent, including:

  • agent_memory_bank_t
  • agent_memory_unit_t
  • agent_session_history_t

LIGHT_AGENT_HOST_ID must be a valid host UUID for the target tenant/host. The agent stores memory and session history under this host id.

Agent Roles

The same binary can run different logical agents. Use a different service id, port, install directory, and systemd unit for each concurrently running role.

Common service ids are:

com.networknt.agent.account-1.0.0
com.networknt.agent.advisor-1.0.0
com.networknt.agent.tech-support-1.0.0

For a single account agent, keep the service name light-agent. For multiple agents on the same VM, use names such as:

light-agent-account
light-agent-advisor
light-agent-tech-support

Each role needs a unique listener port if they run on the same VM.

Bootstrap Config

The local bootstrap config needs enough information to reach config-server, controller, light-gateway, Ollama, and Postgres.

Example values.yml for an account agent:

startup.host: customer.example.com
startup.timeout: 3000
startup.connectTimeout: 3000
startup.bootstrapCaCertPath: config/ca.pem
startup.externalConfigDir: /var/lib/light-agent/config-cache

light-config-server-uri: https://config-server.customer.example.com:8435

server.serviceId: com.networknt.agent.account-1.0.0
server.environment: prod
server.ip: 0.0.0.0
server.advertisedAddress: agent-account-01.customer.example.com
server.httpPort: 8083
server.enableHttp: true
server.httpsPort: 8443
server.enableHttps: false
server.enableRegistry: true
server.startOnRegistryFailure: true

portalRegistry.portalUrl: https://controller.customer.example.com:8438

client.verifyHostname: true

mcp-client.gatewayUrl: https://mcp-gateway.customer.example.com
mcp-client.path: /mcp
mcp-client.timeoutMs: 5000

ollama.ollamaUrl: http://ollama.customer.example.com:11434
ollama.model: llama3.1:8b

server.advertisedAddress must be a stable address that controller and clients can use to reach the VM agent. Do not advertise 127.0.0.1 or 0.0.0.0.

Example startup.yml:

host: ${startup.host:dev.lightapi.net}
serviceId: ${server.serviceId:com.networknt.agent.account-1.0.0}
envTag: ${server.environment:dev}
acceptHeader: application/yaml
timeout: ${startup.timeout:3000}
connectTimeout: ${startup.connectTimeout:3000}
configServerUri: ${light-config-server-uri:https://local.localhost}
authorization: ${light_portal_authorization:}
bootstrapCaCertPath: ${startup.bootstrapCaCertPath:config/ca.pem}
externalConfigDir: ${startup.externalConfigDir:/var/lib/light-agent/config-cache}

Example server.yml:

ip: ${server.ip:0.0.0.0}
advertisedAddress: ${server.advertisedAddress:127.0.0.1}
httpPort: ${server.httpPort:8083}
enableHttp: ${server.enableHttp:true}
httpsPort: ${server.httpsPort:8443}
enableHttps: ${server.enableHttps:false}
serviceId: ${server.serviceId:com.networknt.agent.account-1.0.0}
enableRegistry: ${server.enableRegistry:true}
startOnRegistryFailure: ${server.startOnRegistryFailure:true}
dynamicPort: ${server.dynamicPort:false}
environment: ${server.environment:dev}
shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}

Example portal-registry.yml:

portalUrl: ${portalRegistry.portalUrl:https://localhost:8438}
portalToken: ${light_portal_authorization:}
controllerDiscoveryToken: ${portalRegistry.controllerDiscoveryToken:}

Example client.yml:

tls:
  verifyHostname: ${client.verifyHostname:true}

Example mcp-client.yml:

gatewayUrl: ${mcp-client.gatewayUrl:https://mcp-gateway.customer.example.com}
path: ${mcp-client.path:/mcp}
timeoutMs: ${mcp-client.timeoutMs:5000}

Example ollama.yml:

ollamaUrl: ${ollama.ollamaUrl:http://localhost:11434}
model: ${ollama.model:llama3.1:8b}

For the current light-agent implementation, keep ollama.yml and mcp-client.yml in the local bootstrap config. They are read during process initialization before the runtime completes remote config bootstrap.

Secrets

Keep secrets in a root-owned environment file or in the customer's secret manager. Do not pass secrets in command-line arguments.

Example /etc/light-agent/light-agent.env:

LIGHT_PORTAL_AUTHORIZATION=Bearer <token>
light_4j_config_password=<config-password-if-needed>
LIGHT_AGENT_HOST_ID=<host-uuid>
DATABASE_URL=postgres://agent_user:<password>@postgres.customer.example.com:5432/configserver
RUST_LOG=info
AGENT_LOG_ANSI=false

Permissions:

chown root:light-agent /etc/light-agent/light-agent.env
chmod 0640 /etc/light-agent/light-agent.env

LIGHT_PORTAL_AUTHORIZATION is used for config-server bootstrap and controller registration. It is not the end-user chat token. If downstream MCP tools require caller identity, the browser or BFF should send the user's Authorization header to the agent WebSocket endpoint so the agent can forward it to light-gateway.

Systemd Unit

Example /etc/systemd/system/light-agent.service:

[Unit]
Description=Light Agent
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=light-agent
Group=light-agent
WorkingDirectory=/opt/light-agent
EnvironmentFile=/etc/light-agent/light-agent.env
ExecStart=/opt/light-agent/light-agent
Restart=on-failure
RestartSec=5
LimitNOFILE=65535

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/light-agent/config-cache

[Install]
WantedBy=multi-user.target

Install and start:

systemctl daemon-reload
systemctl enable light-agent
systemctl start light-agent
systemctl status light-agent

View logs:

journalctl -u light-agent -f

Install Script Scope

An install script is useful, but keep it deterministic and small.

It should:

  • Create the light-agent user and group.
  • Create /opt/light-agent, /etc/light-agent, and /var/lib/light-agent/config-cache.
  • Install the binary with executable permissions.
  • Install the public/ static assets.
  • Install bootstrap config files.
  • Install or update the systemd unit.
  • Set file ownership and permissions.
  • Print the next operator steps for adding secrets and starting the service.

It should not:

  • Embed bearer tokens.
  • Pass tokens to ExecStart.
  • Rewrite customer config-server state.
  • Start the process before secrets, CA files, and database access are ready.

Startup Flow

The expected runtime flow is:

systemd
  -> /opt/light-agent/light-agent
  -> read local config/values.yml, ollama.yml, and mcp-client.yml
  -> connect to Postgres with DATABASE_URL
  -> build the MCP client for light-gateway
  -> call config-server with LIGHT_PORTAL_AUTHORIZATION
  -> write downloaded runtime config into /var/lib/light-agent/config-cache
  -> start the Axum HTTP/WebSocket server
  -> register the agent with controller using portalRegistry.portalUrl
  -> serve the chat UI from public/
  -> forward tool discovery and tool calls to light-gateway

When startup.yml configures config-server, the runtime tries to download the latest values.yml before starting. If that download fails for any reason, the runtime continues startup with the available local and cached config, including config-cache/values.yml when present.

Endpoints

The native service exposes:

GET /health
GET /
GET /chat

/chat upgrades to WebSocket. The static chat UI is served from public/.

For local testing on the VM:

curl -i http://127.0.0.1:8083/health

Upgrade And Rollback

Use versioned binary releases:

/opt/light-agent/releases/2.2.1/light-agent
/opt/light-agent/releases/2.2.2/light-agent
/opt/light-agent/light-agent -> releases/2.2.2/light-agent

Upgrade:

systemctl stop light-agent
ln -sfn /opt/light-agent/releases/2.2.2/light-agent /opt/light-agent/light-agent
systemctl start light-agent

Rollback:

systemctl stop light-agent
ln -sfn /opt/light-agent/releases/2.2.1/light-agent /opt/light-agent/light-agent
systemctl start light-agent

Do not delete config-cache during a normal binary rollback. It is the local cache of the config-server-delivered runtime state.

Validation Checklist

Before handing the VM to the customer:

  • systemctl status light-agent is active.
  • journalctl -u light-agent shows successful config-server bootstrap.
  • journalctl -u light-agent shows successful controller registration.
  • The controller shows the agent registered with the expected service id, environment, address, and port.
  • curl http://127.0.0.1:8083/health returns 200 OK.
  • The chat UI loads from the VM address.
  • The chat WebSocket connects to /chat.
  • Logs show that the agent can connect to Postgres.
  • Logs do not show MCP tools/list failures from light-gateway.
  • A chat request can discover and call a tool through light-gateway.
  • Restarting the VM starts the agent automatically.

Security Checklist

  • Store bearer tokens, config passwords, and database passwords outside the install bundle.
  • Use a customer CA file instead of disabling TLS verification in production.
  • Use a stable DNS name for server.advertisedAddress.
  • Restrict inbound VM firewall rules to the required agent port.
  • Restrict outbound VM firewall rules to config-server, controller, light-gateway, Ollama, and Postgres.
  • Run as the dedicated light-agent user.
  • Keep /etc/light-agent/light-agent.env readable only by root and the service group.
  • Keep /etc/light-agent writable only by administrators.
  • Keep only /var/lib/light-agent/config-cache writable by the service.
  • Rotate LIGHT_PORTAL_AUTHORIZATION through the customer secret process.

Deploy Kubernetes

This page describes the recommended Kubernetes deployment model for the Rust light-agent image from light-fabric/apps/light-agent.

Use this model when an agent service runs in a cluster and exposes the chat UI/WebSocket endpoint through a Kubernetes Service, Ingress, or Gateway API. The agent serves the local chat UI, connects to an LLM provider, calls MCP tools through light-gateway, stores conversation memory in Postgres, and registers with controller.

Deploy the agent as a normal single-container Kubernetes workload:

  • Deployment for the agent pod.
  • Service for stable in-cluster access.
  • ConfigMap for bootstrap config and non-secret values.
  • Secret for bearer tokens, config passwords, host id, and database URL.
  • emptyDir or PersistentVolumeClaim for config-cache.
  • ConfigMap or custom image layer for public/ chat UI assets.
  • Optional Ingress, Gateway API, NodePort, or LoadBalancer for external browser access.

Keep runtime policy and shared platform configuration in config-server. The Kubernetes bootstrap config should only contain enough information for startup, trust, model/provider selection, light-gateway access, database access, and controller registration.

Image

Build the image from the workspace root:

./apps/light-agent/build.sh 2.2.1

For local testing without pushing:

./apps/light-agent/build.sh 2.2.1 --local

Use immutable tags in Kubernetes. Avoid latest for customer deployments.

The current runtime image uses:

/app/light-agent
/app/config -> /config

The process runs as the image user agent. Mount /config for bootstrap config and make /app/config-cache writable.

The current Dockerfile does not copy apps/light-agent/public/ into the runtime image. For Kubernetes, either mount the public/ files from a ConfigMap or build a custom image that includes them under /app/public.

Runtime Paths

Recommended container layout:

/config/
  startup.yml
  server.yml
  portal-registry.yml
  client.yml
  mcp-client.yml
  ollama.yml
  values.yml
  ca.pem

/app/config-cache/
  values.yml
  downloaded certs and files

/app/public/
  index.html

Use a read-only projected volume for /config. Use a writable volume for /app/config-cache.

For most deployments, use emptyDir for config-cache. This gives each pod a fresh cache and avoids accidentally keeping stale config across pod replacement.

Use a PersistentVolumeClaim only when the customer explicitly wants the agent to restart from the last downloaded config during a config-server outage. A persistent cache improves outage tolerance but can also preserve stale runtime state.

Runtime Dependencies

The pod must be able to reach:

  • Controller, through portalRegistry.portalUrl.
  • Config-server, through startup.configServerUri.
  • light-gateway, through mcp-client.gatewayUrl and mcp-client.path.
  • The model provider, currently Ollama by default.
  • Postgres, through DATABASE_URL.

The Postgres database must contain the Hindsight memory tables used by light-agent, including:

  • agent_memory_bank_t
  • agent_memory_unit_t
  • agent_session_history_t

LIGHT_AGENT_HOST_ID must be a valid host UUID for the target tenant/host. The agent stores memory and session history under this host id.

Agent Roles

The same image can run different logical agents. Use a different service id, deployment name, Service name, and port for each concurrently running role.

Common service ids are:

com.networknt.agent.account-1.0.0
com.networknt.agent.advisor-1.0.0
com.networknt.agent.tech-support-1.0.0

For a single account agent, a conventional Kubernetes name is light-agent-account. For multiple agents in the same namespace, use names such as:

light-agent-account
light-agent-advisor
light-agent-tech-support

Each role needs a unique Service name. If they share one namespace and expose through one Ingress host, route each role by host or path.

Registration Address

In Kubernetes, do not register the pod IP. Pod IPs are ephemeral.

If controller and callers are inside the same cluster, advertise the Service DNS name:

server.advertisedAddress: light-agent-account.light-agent

The pattern is:

<service-name>.<namespace>

The port is still registered separately from the host/address.

If controller or callers are outside the cluster, advertise the externally reachable DNS name instead, such as the Ingress or LoadBalancer hostname:

server.advertisedAddress: account-agent.customer.example.com

Bootstrap Config

Example values.yml for an in-cluster controller, config-server, gateway, Ollama, and Postgres:

startup.host: customer.example.com
startup.timeout: 3000
startup.connectTimeout: 3000
startup.bootstrapCaCertPath: config/ca.pem
startup.externalConfigDir: /app/config-cache

light-config-server-uri: https://config-server.lightapi.svc.cluster.local:8435

server.serviceId: com.networknt.agent.account-1.0.0
server.environment: prod
server.ip: 0.0.0.0
server.advertisedAddress: light-agent-account.light-agent
server.httpPort: 8083
server.enableHttp: true
server.httpsPort: 8443
server.enableHttps: false
server.enableRegistry: true
server.startOnRegistryFailure: true

portalRegistry.portalUrl: https://controller.lightapi.svc.cluster.local:8438

client.verifyHostname: true

mcp-client.gatewayUrl: https://ai-microgateway.light-gateway:8443
mcp-client.path: /mcp
mcp-client.timeoutMs: 5000

ollama.ollamaUrl: http://ollama.ai.svc.cluster.local:11434
ollama.model: llama3.1:8b

Example startup.yml:

host: ${startup.host:dev.lightapi.net}
serviceId: ${server.serviceId:com.networknt.agent.account-1.0.0}
envTag: ${server.environment:dev}
acceptHeader: application/yaml
timeout: ${startup.timeout:3000}
connectTimeout: ${startup.connectTimeout:3000}
configServerUri: ${light-config-server-uri:https://local.localhost}
authorization: ${light_portal_authorization:}
bootstrapCaCertPath: ${startup.bootstrapCaCertPath:config/ca.pem}
externalConfigDir: ${startup.externalConfigDir:/app/config-cache}

Example server.yml:

ip: ${server.ip:0.0.0.0}
advertisedAddress: ${server.advertisedAddress:127.0.0.1}
httpPort: ${server.httpPort:8083}
enableHttp: ${server.enableHttp:true}
httpsPort: ${server.httpsPort:8443}
enableHttps: ${server.enableHttps:false}
serviceId: ${server.serviceId:com.networknt.agent.account-1.0.0}
enableRegistry: ${server.enableRegistry:true}
startOnRegistryFailure: ${server.startOnRegistryFailure:true}
dynamicPort: ${server.dynamicPort:false}
environment: ${server.environment:dev}
shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}

Example portal-registry.yml:

portalUrl: ${portalRegistry.portalUrl:https://localhost:8438}
portalToken: ${light_portal_authorization:}
controllerDiscoveryToken: ${portalRegistry.controllerDiscoveryToken:}

Example client.yml:

tls:
  verifyHostname: ${client.verifyHostname:true}

Example mcp-client.yml:

gatewayUrl: ${mcp-client.gatewayUrl:https://ai-microgateway.light-gateway:8443}
path: ${mcp-client.path:/mcp}
timeoutMs: ${mcp-client.timeoutMs:5000}

Example ollama.yml:

ollamaUrl: ${ollama.ollamaUrl:http://ollama.ai.svc.cluster.local:11434}
model: ${ollama.model:llama3.1:8b}

For the current light-agent implementation, keep ollama.yml and mcp-client.yml in the local bootstrap config. They are read during process initialization before the runtime completes remote config bootstrap.

Use the customer CA in ca.pem. Do not disable hostname verification in production to work around certificate SAN problems.

Secrets

Store the portal bearer token, optional config password, host id, and database URL in a Kubernetes Secret.

Example:

apiVersion: v1
kind: Secret
metadata:
  name: light-agent-account-secret
  namespace: light-agent
type: Opaque
stringData:
  LIGHT_PORTAL_AUTHORIZATION: "Bearer <token>"
  light_4j_config_password: "<config-password-if-needed>"
  LIGHT_AGENT_HOST_ID: "<host-uuid>"
  DATABASE_URL: "postgres://agent_user:<password>@postgres.lightapi.svc.cluster.local:5432/configserver"
data:
  ca.pem: <base64-ca-pem>

LIGHT_PORTAL_AUTHORIZATION is used for config-server bootstrap and controller registration. It is not the end-user chat token. If downstream MCP tools require caller identity, the browser or BFF should send the user's Authorization header to the agent WebSocket endpoint so the agent can forward it to light-gateway.

Do not store real bearer tokens, database passwords, or customer CA material in Git, ConfigMaps, Helm values committed to the repo, or rendered deployment examples.

Example Manifests

Example ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: light-agent-account-config
  namespace: light-agent
  labels:
    app.kubernetes.io/name: light-agent-account
    app.kubernetes.io/component: agent
data:
  values.yml: |
    startup.host: customer.example.com
    startup.timeout: 3000
    startup.connectTimeout: 3000
    startup.bootstrapCaCertPath: config/ca.pem
    startup.externalConfigDir: /app/config-cache
    light-config-server-uri: https://config-server.lightapi.svc.cluster.local:8435
    server.serviceId: com.networknt.agent.account-1.0.0
    server.environment: prod
    server.ip: 0.0.0.0
    server.advertisedAddress: light-agent-account.light-agent
    server.httpPort: 8083
    server.enableHttp: true
    server.httpsPort: 8443
    server.enableHttps: false
    server.enableRegistry: true
    server.startOnRegistryFailure: true
    portalRegistry.portalUrl: https://controller.lightapi.svc.cluster.local:8438
    client.verifyHostname: true
    mcp-client.gatewayUrl: https://ai-microgateway.light-gateway:8443
    mcp-client.path: /mcp
    mcp-client.timeoutMs: 5000
    ollama.ollamaUrl: http://ollama.ai.svc.cluster.local:11434
    ollama.model: llama3.1:8b
  startup.yml: |
    host: ${startup.host:dev.lightapi.net}
    serviceId: ${server.serviceId:com.networknt.agent.account-1.0.0}
    envTag: ${server.environment:dev}
    acceptHeader: application/yaml
    timeout: ${startup.timeout:3000}
    connectTimeout: ${startup.connectTimeout:3000}
    configServerUri: ${light-config-server-uri:https://local.localhost}
    authorization: ${light_portal_authorization:}
    bootstrapCaCertPath: ${startup.bootstrapCaCertPath:config/ca.pem}
    externalConfigDir: ${startup.externalConfigDir:/app/config-cache}
  server.yml: |
    ip: ${server.ip:0.0.0.0}
    advertisedAddress: ${server.advertisedAddress:127.0.0.1}
    httpPort: ${server.httpPort:8083}
    enableHttp: ${server.enableHttp:true}
    httpsPort: ${server.httpsPort:8443}
    enableHttps: ${server.enableHttps:false}
    serviceId: ${server.serviceId:com.networknt.agent.account-1.0.0}
    enableRegistry: ${server.enableRegistry:true}
    startOnRegistryFailure: ${server.startOnRegistryFailure:true}
    dynamicPort: ${server.dynamicPort:false}
    environment: ${server.environment:dev}
    shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}
  portal-registry.yml: |
    portalUrl: ${portalRegistry.portalUrl:https://localhost:8438}
    portalToken: ${light_portal_authorization:}
    controllerDiscoveryToken: ${portalRegistry.controllerDiscoveryToken:}
  client.yml: |
    tls:
      verifyHostname: ${client.verifyHostname:true}
  mcp-client.yml: |
    gatewayUrl: ${mcp-client.gatewayUrl:https://ai-microgateway.light-gateway:8443}
    path: ${mcp-client.path:/mcp}
    timeoutMs: ${mcp-client.timeoutMs:5000}
  ollama.yml: |
    ollamaUrl: ${ollama.ollamaUrl:http://ollama.ai.svc.cluster.local:11434}
    model: ${ollama.model:llama3.1:8b}

Create the public/ ConfigMap from the repo asset:

kubectl -n light-agent create configmap light-agent-account-public \
  --from-file=index.html=apps/light-agent/public/index.html \
  --dry-run=client -o yaml

Example Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: light-agent-account
  namespace: light-agent
  labels:
    app.kubernetes.io/name: light-agent-account
    app.kubernetes.io/component: agent
    app.kubernetes.io/part-of: lightapi
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: light-agent-account
  template:
    metadata:
      labels:
        app.kubernetes.io/name: light-agent-account
        app.kubernetes.io/component: agent
        app.kubernetes.io/part-of: lightapi
    spec:
      securityContext:
        fsGroup: 999
        fsGroupChangePolicy: OnRootMismatch
      containers:
        - name: light-agent
          image: networknt/light-agent:2.2.1
          imagePullPolicy: IfNotPresent
          env:
            - name: LIGHT_PORTAL_AUTHORIZATION
              valueFrom:
                secretKeyRef:
                  name: light-agent-account-secret
                  key: LIGHT_PORTAL_AUTHORIZATION
            - name: light_4j_config_password
              valueFrom:
                secretKeyRef:
                  name: light-agent-account-secret
                  key: light_4j_config_password
                  optional: true
            - name: LIGHT_AGENT_HOST_ID
              valueFrom:
                secretKeyRef:
                  name: light-agent-account-secret
                  key: LIGHT_AGENT_HOST_ID
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: light-agent-account-secret
                  key: DATABASE_URL
            - name: RUST_LOG
              value: info
            - name: AGENT_LOG_ANSI
              value: "false"
          ports:
            - name: http
              containerPort: 8083
              protocol: TCP
            - name: https
              containerPort: 8443
              protocol: TCP
          readinessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 30
            periodSeconds: 30
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: 1000m
              memory: 768Mi
          volumeMounts:
            - name: bootstrap-config
              mountPath: /config
              readOnly: true
            - name: config-cache
              mountPath: /app/config-cache
            - name: public
              mountPath: /app/public
              readOnly: true
      volumes:
        - name: bootstrap-config
          projected:
            sources:
              - configMap:
                  name: light-agent-account-config
              - secret:
                  name: light-agent-account-secret
                  items:
                    - key: ca.pem
                      path: ca.pem
        - name: config-cache
          emptyDir: {}
        - name: public
          configMap:
            name: light-agent-account-public

Example Service:

apiVersion: v1
kind: Service
metadata:
  name: light-agent-account
  namespace: light-agent
  labels:
    app.kubernetes.io/name: light-agent-account
    app.kubernetes.io/component: agent
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: light-agent-account
  ports:
    - name: http
      port: 8083
      targetPort: http
      protocol: TCP
    - name: https
      port: 8443
      targetPort: https
      protocol: TCP

External Access

For local testing with a ClusterIP Service:

kubectl -n light-agent port-forward svc/light-agent-account 8083:8083

Health check:

curl -i http://127.0.0.1:8083/health

If exposing through Ingress, make sure WebSocket upgrade is supported and idle timeouts are long enough for chat sessions.

Example NGINX Ingress annotations:

nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"

If downstream MCP tools require caller identity, put the agent behind a BFF or authenticated reverse proxy that forwards the user's Authorization header to the WebSocket request. A browser-created WebSocket from the embedded static UI does not directly set arbitrary authorization headers.

Deploy Through Light-Deployer

The repo template lives at:

apps/light-agent/k8s/light-agent

Use the same template rules as light-gateway.

When light-deployer runs outside the cluster and has LIGHT_DEPLOYER_TEMPLATE_BASE_DIR set, repoUrl: "local" can point to local templates.

When light-deployer runs inside Kubernetes, use a real Git URL:

{
  "template": {
    "repoUrl": "https://github.com/networknt/light-fabric.git",
    "ref": "main",
    "path": "apps/light-agent/k8s/light-agent"
  }
}

Do not use repoUrl: "local" for an in-cluster deployer unless the template repo is mounted into the deployer container and LIGHT_DEPLOYER_TEMPLATE_BASE_DIR points to it.

Keep Namespace out of templates rendered by light-deployer if the deployer policy blocks cluster-scoped resources. Create the namespace separately:

kubectl create namespace light-agent

Config-Server Requirements

Before deploying the agent pod, config-server should already have config for the tuple used by startup:

host = startup.host
serviceId = server.serviceId
envTag = server.environment

At minimum, config-server should return runtime config for:

  • values.yml
  • server.yml when listener or registration settings are centrally managed.
  • portal-registry.yml when controller URLs or registry settings are centrally managed.
  • client.yml when TLS verification behavior is centrally managed.

For the current light-agent, keep mcp-client.yml and ollama.yml in the local bootstrap ConfigMap even if other runtime config comes from config-server. They are loaded before remote bootstrap completes.

Startup Flow

Expected runtime flow:

Kubernetes starts pod
  -> /app/light-agent
  -> read local /config/values.yml, ollama.yml, and mcp-client.yml
  -> connect to Postgres with DATABASE_URL
  -> build the MCP client for light-gateway
  -> call config-server with LIGHT_PORTAL_AUTHORIZATION
  -> write downloaded runtime config into /app/config-cache
  -> start the Axum HTTP/WebSocket server
  -> register the agent with controller using portalRegistry.portalUrl
  -> serve the chat UI from /app/public
  -> forward tool discovery and tool calls to light-gateway

When startup.yml configures config-server, the runtime tries to download the latest values.yml before starting. If that download fails for any reason, the runtime continues startup with the available local and cached config, including /app/config-cache/values.yml when present.

Upgrade And Rollback

Use Kubernetes rolling updates with immutable image tags:

kubectl -n light-agent set image deploy/light-agent-account \
  light-agent=networknt/light-agent:2.2.2
kubectl -n light-agent rollout status deploy/light-agent-account

Rollback:

kubectl -n light-agent rollout undo deploy/light-agent-account

For production, prefer changing only one variable at a time: either image tag or config-server runtime config, not both in the same rollout.

Validation Checklist

After deployment:

  • kubectl -n light-agent rollout status deploy/light-agent-account succeeds.
  • Pods are ready and restart count is stable.
  • Logs show successful Postgres connection.
  • Logs show successful config-server bootstrap.
  • Logs show successful controller registration.
  • Controller shows the agent registered with the expected service id, environment, host, and port.
  • curl http://127.0.0.1:8083/health succeeds through port-forward or Ingress.
  • The chat UI loads.
  • The chat WebSocket connects to /chat.
  • MCP tools/list reaches light-gateway.
  • MCP tools/call reaches the backend MCP server through light-gateway.
  • A pod restart still starts cleanly with the selected cache policy.

Security Checklist

  • Keep bearer tokens, config passwords, database passwords, and host ids in Kubernetes Secret, not ConfigMap.
  • Use customer CA trust and keep client.verifyHostname: true in production.
  • Use immutable image tags and image pull credentials from Kubernetes secrets when the registry is private.
  • Run as the non-root image user.
  • Make /config read-only.
  • Make only /app/config-cache writable.
  • Restrict ingress traffic to required agent ports.
  • Restrict egress traffic to config-server, controller, light-gateway, Ollama, and Postgres.
  • Rotate LIGHT_PORTAL_AUTHORIZATION through the customer secret process.

Light-Deployer

light-deployer is the cluster-local Kubernetes deployment executor for Light Portal.

It renders Kubernetes templates, validates manifests, applies resources through kube-rs, reports rollout status, and exposes deployment tools through an MCP JSON-RPC endpoint for local and MicroK8s testing.

Key Capabilities

  • MCP JSON-RPC endpoint at POST /mcp
  • AST-based YAML template rendering
  • Git template fetching with gix
  • Kubernetes dry-run, apply, delete, status, and prune
  • redacted manifest summaries and diffs
  • SSE deployment events

Runtime

light-deployer uses light-runtime, light-axum, config-loader, and portal-registry so it follows the same service boot model as light-agent.

Testing Path

Use these pages in order when testing locally:

  1. Build Local
  2. Prepare Config
  3. Run Standalone
  4. Run Kubernetes

Start with standalone noop mode to validate template rendering. Then move to MicroK8s real mode once the render request and target templates are correct.

For MCP clients, Light Portal, and AI agents, use POST /mcp with JSON-RPC methods such as tools/list and tools/call. The /mcp/tools/* routes are kept only as local debugging conveniences.

Build Local

This page builds the light-deployer binary and container image from the Light Fabric workspace.

Run all commands from the repository root:

cd ~/workspace/light-fabric

Rust Build

Use cargo check first for a quick compile validation:

cargo check -p light-deployer

Run the deployer tests:

cargo test -p light-deployer

Build a debug binary:

cargo build -p light-deployer

Build a release binary:

cargo build --release -p light-deployer

The release binary is written to:

target/release/light-deployer

Docker Image

Build the local image:

./apps/light-deployer/build.sh latest

The default image name is:

networknt/light-deployer:latest

To override the image name:

IMAGE=localhost:32000/light-deployer:latest ./apps/light-deployer/build.sh latest

Verify the image exists:

docker image inspect networknt/light-deployer:latest

What The Image Contains

The Dockerfile copies:

  • /usr/local/bin/light-deployer
  • /app/config

The container runs from /app, so the default runtime config directory is:

/app/config

The default HTTP port is 7088, configured in:

apps/light-deployer/config/server.yml

Expected Result

Before moving on, these commands should pass:

cargo check -p light-deployer
cargo test -p light-deployer
./apps/light-deployer/build.sh latest
docker image inspect networknt/light-deployer:latest

Prepare Config

light-deployer uses two kinds of configuration:

  • runtime config loaded by light-runtime
  • deployment request data sent through MCP tools/call at POST /mcp

Runtime Config Files

Default config lives in:

apps/light-deployer/config

Files:

  • server.yml: HTTP/HTTPS bind settings and service identity
  • deployer.yml: local deployer policy
  • portal-registry.yml: future portal/controller registry settings

When running from the workspace root, the deployer automatically uses:

apps/light-deployer/config

When running inside the Docker image, it uses:

/app/config

Override the config directory with:

LIGHT_DEPLOYER_CONFIG_DIR=/path/to/config

Server Config

The default server config listens on HTTP port 7088:

ip: ${server.ip:0.0.0.0}
httpPort: ${server.httpPort:7088}
enableHttp: ${server.enableHttp:true}
enableHttps: ${server.enableHttps:false}
serviceId: ${server.serviceId:com.networknt.light-deployer-0.1.0}
enableRegistry: ${server.enableRegistry:false}

To change the port without editing the file, provide values through the normal runtime values mechanism, or use a copied config directory for local testing.

Deployer Policy

The default policy is permissive enough for local testing:

deployerId: ${deployer.deployerId:local-light-deployer}
clusterId: ${deployer.clusterId:local}
allowedNamespaces: []
allowedRepoHosts: []
allowedRepoPrefixes: []
allowedImageRegistries: []
devInsecure: ${deployer.devInsecure:false}

Empty allow lists mean the policy does not restrict that dimension. For production, configure explicit values.

Example tighter policy:

deployerId: petstore-microk8s
clusterId: microk8s-local
allowedNamespaces:
  - petstore-dev
allowedRepoHosts:
  - github.com
allowedRepoPrefixes:
  - https://github.com/networknt/
allowedImageRegistries:
  - networknt
devInsecure: false
prune:
  enabled: true
  maxDeletePercent: 30
  sensitiveKinds:
    - PersistentVolumeClaim
  overrideRequired: true

Git Access

Public repositories do not need credentials.

For private HTTPS repositories, set:

LIGHT_DEPLOYER_GIT_TOKEN=...

Defaults:

  • GitHub username: x-access-token
  • Bitbucket Cloud username: x-token-auth

For Bitbucket app passwords or other Git servers:

LIGHT_DEPLOYER_GIT_USERNAME=my-user
LIGHT_DEPLOYER_GIT_TOKEN=my-token-or-app-password

Only HTTPS token auth is supported in Phase 1. SSH auth is deferred.

Template Repository Requirements

The target application repository should contain a k8s/ directory with YAML templates. The deployer reads all .yaml and .yml files under the requested template path.

Example template reference:

{
  "template": {
    "repoUrl": "https://github.com/networknt/openapi-petstore.git",
    "ref": "master",
    "path": "k8s"
  }
}

For local testing without Git clone, set:

LIGHT_DEPLOYER_TEMPLATE_BASE_DIR=/home/steve/workspace/openapi-petstore

Then use:

{
  "template": {
    "repoUrl": "local",
    "ref": "master",
    "path": "k8s"
  }
}

Request Values

The request values object supplies placeholder values for templates.

Example for openapi-petstore:

{
  "name": "openapi-petstore",
  "image": {
    "repository": "networknt/openapi-petstore",
    "tag": "latest",
    "pullPolicy": "IfNotPresent"
  },
  "service": {
    "name": "openapi-petstore",
    "type": "ClusterIP"
  },
  "resources": {
    "requests": {
      "memory": "64Mi",
      "cpu": "250m"
    },
    "limits": {
      "memory": "256Mi",
      "cpu": "500m"
    }
  }
}

The current renderer replaces placeholders inside YAML string scalar values. Avoid placeholders in Kubernetes fields that must be numeric unless the template keeps those fields as fixed numbers.

Run Standalone

Standalone mode is the fastest way to test light-deployer before using a real Kubernetes cluster.

Use noop mode first. It validates config, HTTP endpoints, template loading, rendering, resource summaries, and response shape without mutating Kubernetes.

Run all commands from:

cd /home/steve/workspace/light-fabric

Start With Built-In Sample

Start the deployer with the sample template directory:

LIGHT_DEPLOYER_TEMPLATE_BASE_DIR=apps/light-deployer/examples/petstore \
LIGHT_DEPLOYER_KUBE_MODE=noop \
cargo run -p light-deployer

The service listens on:

http://127.0.0.1:7088

Check health from another terminal:

curl -fsSL http://127.0.0.1:7088/health

Expected output:

ok

List Tools With MCP JSON-RPC

The MCP endpoint is JSON-RPC 2.0 over HTTP at:

POST /mcp

List all deployment tools:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "tools-list-1",
    "method": "tools/list",
    "params": {}
  }'

Call a tool through MCP:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "render-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.render",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "local",
        "namespace": "light-deployer",
        "values": {
          "name": "petstore",
          "image": {
            "repository": "nginx",
            "tag": "1.27"
          },
          "containerPort": 80
        },
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

For local debugging, the deployer also exposes REST-style convenience endpoints:

curl -fsSL http://127.0.0.1:7088/mcp/tools/list
curl -fsSL http://127.0.0.1:7088/mcp/tools
curl -fsSL http://127.0.0.1:7088/mcp/tools/deployment.render

Use POST /mcp for MCP clients and AI agents.

Render The Built-In Sample

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "render-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.render",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "values": {
          "name": "petstore",
          "replicas": 1,
          "image": {
            "repository": "nginx",
            "tag": "1.27"
          },
          "containerPort": 80,
          "service": {
            "port": 80
          }
        },
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

Expected response shape:

{
  "jsonrpc": "2.0",
  "result": {
    "isError": false,
    "structuredContent": {
      "action": "render",
      "status": "rendered",
      "deployerId": "local-light-deployer",
      "clusterId": "local",
      "resources": [
        {
          "kind": "Deployment",
          "name": "petstore"
        },
        {
          "kind": "Service",
          "name": "petstore"
        }
      ]
    }
  }
}

The exact requestId and manifestHash will differ.

Render openapi-petstore Locally

If /home/steve/workspace/openapi-petstore is available and has a k8s/ folder, run:

LIGHT_DEPLOYER_TEMPLATE_BASE_DIR=/home/steve/workspace/openapi-petstore \
LIGHT_DEPLOYER_KUBE_MODE=noop \
cargo run -p light-deployer

Render request:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "render-openapi-petstore-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.render",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "openapi-petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "petstore-dev",
        "values": {
          "name": "openapi-petstore",
          "image": {
            "repository": "networknt/openapi-petstore",
            "tag": "latest",
            "pullPolicy": "IfNotPresent"
          },
          "service": {
            "name": "openapi-petstore",
            "type": "ClusterIP"
          },
          "resources": {
            "requests": {
              "memory": "64Mi",
              "cpu": "250m"
            },
            "limits": {
              "memory": "256Mi",
              "cpu": "500m"
            }
          }
        },
        "template": {
          "repoUrl": "local",
          "ref": "master",
          "path": "k8s"
        }
      }
    }
  }'

Expected resources:

  • Deployment/openapi-petstore
  • Service/openapi-petstore

Test Git Fetch

Stop the local-template run and restart without LIGHT_DEPLOYER_TEMPLATE_BASE_DIR:

LIGHT_DEPLOYER_KUBE_MODE=noop \
cargo run -p light-deployer

Render from GitHub:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "render-git-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.render",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "openapi-petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "petstore-dev",
        "values": {
          "name": "openapi-petstore",
          "image": {
            "repository": "networknt/openapi-petstore",
            "tag": "latest"
          }
        },
        "template": {
          "repoUrl": "https://github.com/networknt/openapi-petstore.git",
          "ref": "master",
          "path": "k8s"
        }
      }
    }
  }'

For a private repository:

LIGHT_DEPLOYER_GIT_TOKEN=... \
LIGHT_DEPLOYER_KUBE_MODE=noop \
cargo run -p light-deployer

For Bitbucket app-password style auth:

LIGHT_DEPLOYER_GIT_USERNAME=my-user \
LIGHT_DEPLOYER_GIT_TOKEN=my-app-password \
LIGHT_DEPLOYER_KUBE_MODE=noop \
cargo run -p light-deployer

Dry Run And Diff In Noop Mode

Noop mode can also exercise the request path for these tools:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "dry-run-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.dryRun",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "values": {
          "name": "petstore",
          "replicas": 1,
          "image": {
            "repository": "nginx",
            "tag": "1.27"
          },
          "containerPort": 80,
          "service": {
            "port": 80
          }
        },
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'
curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "diff-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.diff",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "values": {
          "name": "petstore",
          "replicas": 1,
          "image": {
            "repository": "nginx",
            "tag": "1.27"
          },
          "containerPort": 80,
          "service": {
            "port": 80
          }
        },
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

These calls do not validate against Kubernetes unless real mode is enabled.

Stop The Service

Press Ctrl-C in the terminal running cargo run.

Run Kubernetes

This page runs light-deployer inside MicroK8s and uses the in-cluster ServiceAccount with kube-rs.

Prerequisites

MicroK8s should be running and microk8s kubectl should work:

microk8s status --wait-ready
microk8s kubectl get nodes

Build the image first:

cd /home/steve/workspace/light-fabric
./apps/light-deployer/build.sh latest

Import Image Into MicroK8s

docker save networknt/light-deployer:latest | microk8s ctr image import -

If your MicroK8s install requires elevated permissions:

docker save networknt/light-deployer:latest | sudo microk8s ctr image import -

Verify the image is available:

microk8s ctr images ls | grep light-deployer

Install Deployer

Apply the included manifests:

microk8s kubectl apply -f apps/light-deployer/k8s/namespace.yaml
microk8s kubectl apply -f apps/light-deployer/k8s/rbac.yaml
microk8s kubectl apply -f apps/light-deployer/k8s/deployment.yaml
microk8s kubectl apply -f apps/light-deployer/k8s/service.yaml

Wait for the pod:

microk8s kubectl -n light-deployer rollout status deploy/light-deployer
microk8s kubectl -n light-deployer get pods

Check logs:

microk8s kubectl -n light-deployer logs deploy/light-deployer

The deployment sets:

LIGHT_DEPLOYER_KUBE_MODE=real

So the service uses real Kubernetes API calls from inside the cluster.

Port Forward

microk8s kubectl -n light-deployer port-forward svc/light-deployer 7088:7088

In another terminal:

curl -fsSL http://127.0.0.1:7088/health

Expected:

ok

List Tools

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "tools-list-1",
    "method": "tools/list",
    "params": {}
  }'

The response contains the deployer's tool names, descriptions, input schemas, and invocation metadata. Light Portal can use this JSON-RPC response to populate MCP tools for the API details view.

Render In Kubernetes

Rendering does not mutate the cluster:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "render-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.render",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "values": {
          "name": "petstore",
          "replicas": 1,
          "image": {
            "repository": "nginx",
            "tag": "1.27"
          },
          "containerPort": 80,
          "service": {
            "port": 80
          }
        },
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

Dry Run In Kubernetes

Dry-run renders the manifest and asks the Kubernetes API to validate it without persisting resources:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "dry-run-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.dryRun",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "values": {
          "name": "petstore",
          "replicas": 1,
          "image": {
            "repository": "nginx",
            "tag": "1.27"
          },
          "containerPort": 80,
          "service": {
            "port": 80
          }
        },
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

Expected status:

{
  "jsonrpc": "2.0",
  "result": {
    "isError": false,
    "structuredContent": {
      "status": "validated"
    }
  }
}

Deploy Sample

The sample request deploys into the light-deployer namespace so it matches the included namespace-scoped RBAC.

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "apply-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.apply",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "values": {
          "name": "petstore",
          "replicas": 1,
          "image": {
            "repository": "nginx",
            "tag": "1.27"
          },
          "containerPort": 80,
          "service": {
            "port": 80
          }
        },
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

The response should return quickly with an accepted/applying-style status. The operation continues in the deployer.

Watch Kubernetes resources:

microk8s kubectl -n light-deployer get deploy,svc,pods

Stream Events

Use the requestId from the deployment response:

curl -N "http://127.0.0.1:7088/events?request_id=<requestId>"

The event stream reports deployment progress and failures for that request.

Check Status

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "status-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.status",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

Undeploy Sample

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "delete-sample-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.delete",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "template": {
          "repoUrl": "local",
          "ref": "main",
          "path": "k8s"
        }
      }
    }
  }'

Then verify resources:

microk8s kubectl -n light-deployer get deploy,svc,pods

Deploy openapi-petstore From Git

After the openapi-petstore repository has a k8s/ folder committed, use a request like this:

curl -fsSL http://127.0.0.1:7088/mcp \
  -H 'content-type: application/json' \
  -d '{
    "jsonrpc": "2.0",
    "id": "apply-openapi-petstore-1",
    "method": "tools/call",
    "params": {
      "name": "deployment.apply",
      "arguments": {
        "hostId": "local-host",
        "instanceId": "openapi-petstore-dev",
        "environment": "dev",
        "clusterId": "microk8s-local",
        "namespace": "light-deployer",
        "values": {
          "name": "openapi-petstore",
          "image": {
            "repository": "networknt/openapi-petstore",
            "tag": "latest",
            "pullPolicy": "IfNotPresent"
          },
          "service": {
            "name": "openapi-petstore",
            "type": "ClusterIP"
          }
        },
        "template": {
          "repoUrl": "https://github.com/networknt/openapi-petstore.git",
          "ref": "master",
          "path": "k8s"
        }
      }
    }
  }'

For private Git access, set LIGHT_DEPLOYER_GIT_TOKEN on the deployer pod. In Kubernetes this should be injected from a Secret, not written directly into the deployment manifest.

Update The Deployer Image

After rebuilding locally:

./apps/light-deployer/build.sh latest
docker save networknt/light-deployer:latest | microk8s ctr image import -
microk8s kubectl -n light-deployer rollout restart deploy/light-deployer
microk8s kubectl -n light-deployer rollout status deploy/light-deployer

Remove The Deployer

microk8s kubectl delete -f apps/light-deployer/k8s/service.yaml
microk8s kubectl delete -f apps/light-deployer/k8s/deployment.yaml
microk8s kubectl delete -f apps/light-deployer/k8s/rbac.yaml
microk8s kubectl delete -f apps/light-deployer/k8s/namespace.yaml

Light-Gateway

light-gateway is the Pingora-based gateway product in Light Fabric.

It is intended to host gateway behavior such as routing, proxying, and eventually AI/MCP gateway integrations while using the shared runtime and config model.

Key Dependencies

  • light-runtime
  • light-pingora
  • config-loader

Runtime

The gateway uses light-pingora as its transport framework and light-runtime for lifecycle, bootstrap, and service configuration.

Endpoint Identity

Status

  • Decision state: Accepted for implementation
  • Owner: Light Gateway maintainers
  • Decision date: 2026-08-06
  • Revised: 2026-08-06
  • Tracking issue: networknt/light-fabric#297

Purpose

Normal HTTP requests need the method in their endpoint identity. A path alone cannot distinguish operations such as GET /v1/models and POST /v1/models.

The generated access-control snapshot already uses method-qualified keys:

/v1/models@get
/v1/chat/completions@post

The gateway previously sent only /v1/models to access control, so the generated /v1/models@get rule could not match. This design fixes that mismatch. It does not introduce schema versions, capability negotiation, legacy modes, dual rule formats, or changes to generated rules.

Terms

ValueExampleUsed for
Request path/v1/accounts/123Routing, URI rewriting, rate limiting, and path-prefix checks
Path template/v1/accounts/{accountId}Stable endpoint and metrics dimensions
HTTP methodGETTransport behavior and endpoint qualification
HTTP endpoint/v1/accounts/{accountId}@getAccess control, response filtering, logs, audit, and endpoint metrics

Paths and endpoints are different values. Code that routes or rewrites a URI uses a path. Code that identifies an operation uses an endpoint.

Identity Rules

Normal HTTP

A normal HTTP endpoint is:

{matched-path-template-or-request-path}@{lowercase-method}

Examples:

/v1/models@get
/v1/chat/completions@post
/v1/accounts/{accountId}@patch

The matched handler template is preferred because it avoids concrete IDs in policies, logs, and metric dimensions. The query string is never part of the endpoint.

HTTP methods are distinct. A GET rule must not authorize POST, PUT, PATCH, DELETE, HEAD, or OPTIONS on the same path.

Portal Hybrid Requests

Portal query and command requests multiplex operations over shared transport paths. Their access-control identity remains the generated logical operation ID already derived from the request envelope:

lightapi.net/service/getApi/0.1.0

The Portal server accepts GET and POST transports with the same semantics, so the transport method is not added to this logical ID. Existing Portal rules remain unchanged and match exactly.

MCP and OpenAPI Tools

Existing tool endpoint rules remain unchanged:

  • Native MCP operations use their configured @call identity, such as weather@call.
  • OpenAPI-backed tools use the proxied HTTP identity, such as /offers@get.

This change does not make MCP catalog fields mandatory and does not couple access-control rule loading to catalog loading.

WebSocket

WebSocket connection authorization uses path@connect, including the existing controller endpoint:

/ctrl/mcp@connect

The controller identity is anchored to the concrete /ctrl/mcp request path, not to a matched handler template. This preserves the controller route's fail-closed behavior even when handler configuration uses a template or wildcard that also matches the controller path.

WebSocket routing still uses the upgrade path. It must not use an HTTP @get endpoint as its connection-policy identity.

Access-Control Matching

Access control compares the operation as well as the selector:

  1. Exact endpoint match.
  2. Template or parent-path match only when both identities have the same operation suffix.

For example:

RuleRequest endpointMatch
/v1/models@get/v1/models@getYes
/v1/models@get/v1/models@postNo
/v1/accounts/{id}@get/v1/accounts/123@getYes
/v1/accounts/{id}@get/v1/accounts/123@deleteNo

Methodless logical IDs, such as Portal operation IDs, match exactly. They are not implicitly converted to @call, and a qualified HTTP lookup never falls back to a methodless path rule.

Endpoint parsing splits on the final @, allowing selectors such as /users/[email protected]@get.

defaultDeny keeps its existing meaning after lookup:

  • true: an unmatched endpoint is denied;
  • false: an unmatched endpoint is allowed.

The fix is to make the generated rule and runtime endpoint agree, not to alter that policy setting.

Consumer Boundaries

ConsumerInput
Access controlEndpoint identity
Request and response filteringEndpoint identity
Endpoint metricsEndpoint identity plus existing method field
Logs and auditEndpoint identity
Router selection and rewritesRequest path
Upstream URI constructionRequest path and query
Rate limitingRequest path
skipPathPrefixesRequest path

Routing code must never append @method to an upstream URI. The current router matches query-rewrite rules against the request path first and accepts endpoint only as a secondary lookup key; it constructs the upstream URI exclusively from the original and rewritten path. That existing fallback does not append the endpoint to the path and does not need to change for this issue.

Metrics

The endpoint dimension includes the operation:

endpoint=/v1/accounts/{accountId}@get
method=GET
pathTemplate=/v1/accounts/{accountId}

The separate method field remains useful for method-wide aggregation. pathTemplate provides path-oriented aggregation without parsing the endpoint. When no template matches, use the bounded <unmatched> value rather than the concrete request path.

Adding pathTemplate is an observability change only. It does not change rule or routing configuration.

Request Flow

HTTP request
  -> preserve request path and method
  -> resolve handler and matched path template
  -> render path-template@lowercase-method
  -> authorize and filter with that endpoint
  -> record endpoint metrics
  -> route and build the upstream URI from path values

Portal, MCP, and WebSocket handlers replace or select the access-control identity at their existing protocol boundary as described above.

Development Cutover

There is one endpoint contract, with no transition mode:

  • normal HTTP uses path@method;
  • Portal logical IDs remain methodless;
  • MCP uses existing @call identities;
  • WebSocket uses @connect.

The current generated snapshot already follows this contract. No rule or configuration changes are required. Deploy the gateway code and restart the development environment together. If a development snapshot contains a methodless normal HTTP key, regenerate it instead of adding a runtime fallback.

Required Tests

ScenarioExpected result
GET /v1/models with /v1/models@get ruleAllowed when its rule permits access
POST /v1/models with only a GET ruleDoes not match the GET rule
Template route /v1/accounts/{id}Uses /v1/accounts/{id}@method
Response filteringUses the same endpoint as authorization
Portal GET and POST transportsResolve to the same generated logical ID
Native MCP toolKeeps its configured @call identity
OpenAPI-backed toolKeeps its proxied HTTP identity
WebSocket upgradeUses path@connect for policy
Router and upstream URINever receive an @operation suffix
MetricsEmit endpoint, method, and stable path template

Tests cover both defaultDeny values so a method mismatch cannot be mistaken for a successful rule match.

Decisions

  • Only normal HTTP endpoint construction changes for issue #297.
  • Existing generated rules and configuration are not changed.
  • No endpoint schema version or gateway capability is introduced.
  • No legacy identity mode or dual lookup is implemented.
  • HTTP endpoint methods are lowercase.
  • Access control and response filtering match the exact operation.
  • Portal, MCP, and WebSocket retain their existing protocol identities.
  • Routing, URI rewriting, rate limiting, and path-prefix behavior remain path-based.

Light Rule In Light-Gateway

light-gateway uses Light-Rule to enforce deterministic policy decisions in the Pingora request path. Rules are written as inline CEL expressions and evaluated entirely within the gateway process — no external policy service is required.

The first production use is MCP tool authorization (req-acc) and response filtering (res-fil) for the mcp handler.

This lets a gateway route agent MCP traffic to downstream MCP servers or API servers while enforcing fine-grained authorization locally from configuration delivered by config-server.

When It Runs

Light-Rule is invoked by light-gateway when all of these are true:

  • handler.yml includes the mcp handler in the matched chain.
  • mcp-router.yml enables the MCP router and defines tools.
  • access-control.yml and/or rule.yml are available from local config or config-server.
  • A client sends tools/call to the configured MCP endpoint, normally /mcp.

The dependency path is:

light-gateway
  -> light-pingora
  -> light-rule

light-gateway links light-pingora, and light-pingora links light-rule. The rule engine is part of the gateway binary; there is no dynamic plugin loading step.

Request Flow

For MCP traffic, the runtime flow is:

POST /mcp
  -> handler.yml selects mcp
  -> mcp-router parses JSON-RPC tools/call
  -> access-control runtime builds rule context
  -> light-rule evaluates req-acc CEL expressions
  -> denied: return JSON-RPC error -32001
  -> allowed: call downstream HTTP or MCP tool
  -> light-rule evaluates optional res-fil CEL expressions
  -> return JSON-RPC result

Authorization happens before the downstream call. Response filtering happens after the downstream response and before the MCP JSON-RPC response is returned to the agent.

Required Files

handler.yml

The mcp handler must be in the execution chain for the MCP path:

handlers:
  - correlation
  - security
  - mcp

paths:
  - path: /mcp
    method: POST
    exec:
      - correlation
      - security
      - mcp

defaultHandlers: []

The security handler must run before mcp so that JWT claims are decoded and available in the rule context when CEL expressions are evaluated.

mcp-router.yml

mcp-router.yml exposes the MCP endpoint and maps tools to downstream APIs or downstream MCP servers:

enabled: true
path: /mcp
maxSessions: 10000
maxSessionsPerClient: 100
tools:
  - name: weather
    description: Get current weather for a city.
    targetHost: http://weather-api:8080
    path: /weather
    method: GET
    endpoint: weather@call
    apiType: http
    inputSchema:
      type: object
      properties:
        city:
          type: string
      required:
        - city

The endpoint field is the stable policy key used in rule.yml. If it is omitted, the gateway derives one from the tool name and method, such as weather@call.

maxSessions caps the total in-memory MCP frontend sessions for this gateway process. maxSessionsPerClient caps sessions for one authenticated client or, when no principal is available, one MCP clientInfo.name and clientInfo.version pair.

For downstream MCP servers, set apiType: mcp. For downstream REST API servers, use apiType: http or omit it when the default is acceptable.

access-control.yml

access-control.yml controls whether policy is active and how rules combine:

enabled: true
accessRuleLogic: any
defaultDeny: true
defaultInclude: false
skipPathPrefixes: []
logFullCelContext: false

Fields:

  • enabled: turns access-control evaluation on or off.
  • accessRuleLogic: any (allow if any rule passes) or all (allow only if every rule passes) for req-acc rule IDs on an endpoint.
  • defaultDeny: when true, deny calls with no matching endpoint rule.
  • defaultInclude: when false, a response row filter with no matching caller role, group, position, attribute, or user entry returns no rows. Set true only to preserve the legacy include-all row-filter behavior.
  • skipPathPrefixes: endpoint prefixes that bypass access control entirely.
  • logFullCelContext: controls CEL context values in light_rule::cel trace events. The default false reports only statically referenced paths and structural metadata. Set it to true only for local or development debugging to include the bounded values of statically referenced properties. This property does not enable trace logging; use a filter such as RUST_LOG=light_rule::cel=trace,info.

The file name is access-control.yml. The loader also accepts access-control.yaml.

rule.yml

rule.yml holds the CEL rule bodies and maps them to endpoints:

ruleBodies:
  allow-scp-group.lightapi.net:
    ruleId: allow-scp-group.lightapi.net
    ruleName: Allow request when scp claim contains the required group
    ruleType: req-acc
    conditionLanguage: cel
    conditionSecurityProfile: strict
    version: "1.0.0"
    common: "Y"
    actions: []
    expression: |
      'scp' in auditInfo.subject_claims.ClaimsMap
      && 'groups' in permission
      && permission.groups in auditInfo.subject_claims.ClaimsMap.scp

endpointRules:
  weather@call:
    req-acc:
      - allow-scp-group.lightapi.net
    permission:
      groups: weather.r

Key fields in each rule body:

FieldRequiredDescription
ruleIdyesUnique identifier, referenced from endpointRules.
ruleNameyesHuman-readable description.
ruleTypeyesreq-acc for request authorization, res-fil for response filtering.
conditionLanguageyesMust be cel.
conditionSecurityProfileyesMust be strict (see Security Profile).
expressionyesCEL expression that must return true to allow the request.
versionyesSemantic version string.
commonno"Y" marks the rule as shared across hosts.
actionsyesMust be an empty list [] — action-based dispatch is not supported.

Key fields in each endpoint rule entry:

FieldDescription
req-accList of rule IDs evaluated before calling the downstream tool.
res-filList of rule IDs evaluated after the downstream response.
permissionArbitrary key/value map injected into the CEL context as permission. Keeps rule bodies generic and reusable.

The file name is rule.yml. The loader also accepts rule.yaml.

Rule Context

For every MCP tool call the gateway builds a CEL evaluation context containing the following top-level variables:

VariableTypeDescription
auditInfomapDecoded JWT claims and correlation metadata.
permissionmapThe per-endpoint permission object from endpointRules.
headersmapNormalised (lowercased) HTTP request headers.
toolNamestringMCP tool name from the tools/call request.
toolArgumentsmapTool call arguments from the tools/call request.
endpointstringEndpoint identifier, e.g. weather@call.
correlationIdstringCorrelation ID when one is present.

JWT claims are nested under auditInfo.subject_claims.ClaimsMap. The gateway normalises common fields automatically:

CEL pathJWT sourceType
auditInfo.subject_claims.ClaimsMap.scpscplist<string>
auditInfo.subject_claims.ClaimsMap.rolesroleslist<string>
auditInfo.subject_claims.ClaimsMap.positionspositionslist<string>
auditInfo.subject_claims.ClaimsMap.groupsgroupslist<string>
auditInfo.subject_claims.ClaimsMap.attributesattributesmap<string,string>
auditInfo.subject_claims.ClaimsMap.subsubstring
auditInfo.subject_claims.ClaimsMap.client_idclient_id / azpstring
auditInfo.subject_claims.ClaimsMap.uiduser ID injected by gatewaystring
auditInfo.subject_claims.ClaimsMap.rolerole (singular)string

For a full reference with worked examples for every claim type, see Request Access Control Rules.

Security Profile

Rules must declare conditionSecurityProfile: strict. The strict profile:

  • Restricts available functions and macros to a safe, well-known subset.
  • Prevents access to undeclared variables, guarding against injection.
  • Causes the expression to return an error (treated as denied) if it references a missing variable rather than silently returning false.

Always guard list membership with an in check before accessing a key. Claims absent from the token will be missing from ClaimsMap, and an unguarded access will be denied:

# WRONG — will error if 'scp' is not in the token
permission.groups in auditInfo.subject_claims.ClaimsMap.scp

# CORRECT
'scp' in auditInfo.subject_claims.ClaimsMap
&& permission.groups in auditInfo.subject_claims.ClaimsMap.scp

Common CEL Patterns

Scope (scp) — OAuth 2.0 access token

expression: |
  'scp' in auditInfo.subject_claims.ClaimsMap
  && 'groups' in permission
  && permission.groups in auditInfo.subject_claims.ClaimsMap.scp

Role

expression: |
  'roles' in auditInfo.subject_claims.ClaimsMap
  && 'role' in permission
  && permission.role in auditInfo.subject_claims.ClaimsMap.roles

Position

expression: |
  'positions' in auditInfo.subject_claims.ClaimsMap
  && 'position' in permission
  && permission.position in auditInfo.subject_claims.ClaimsMap.positions

Attribute

expression: |
  'attributes' in auditInfo.subject_claims.ClaimsMap
  && 'attributeKey' in permission
  && 'attributeValue' in permission
  && permission.attributeKey in auditInfo.subject_claims.ClaimsMap.attributes
  && auditInfo.subject_claims.ClaimsMap.attributes[permission.attributeKey] == permission.attributeValue

AND — require both a scope group and a role

expression: |
  'scp' in auditInfo.subject_claims.ClaimsMap
  && 'roles' in auditInfo.subject_claims.ClaimsMap
  && permission.groups in auditInfo.subject_claims.ClaimsMap.scp
  && permission.role in auditInfo.subject_claims.ClaimsMap.roles

For OR logic and a fully generic multi-claim rule, see Request Access Control Rules.

Endpoint Matching

When the gateway looks up the rule list for an incoming request it checks:

  1. Exact endpoint key — e.g. weather@call.
  2. Path templates — e.g. accounts/{id}@get.
  3. Parent path — e.g. accounts@get matches accounts/123@get.

For MCP tools, always set endpoint explicitly in mcp-router.yml so the policy key remains stable even if the downstream path changes.

Reload Behavior

light-gateway supports live reload for MCP and access-control config:

  • Reloading mcp-router.yml rebuilds the MCP router runtime.
  • Reloading access-control.yml or rule.yml rebuilds the MCP and WebSocket policy runtimes.

This matches the product model where light-portal manages configuration and config-server delivers the resolved files.

Operational Notes

  • If access-control.yml is missing, MCP tools are allowed unless another handler blocks the request.
  • If access-control.yml is enabled and defaultDeny: true, a tool call with no matching req-acc endpoint rule is denied.
  • If access-control.yml is enabled and defaultInclude: false, a res-fil row filter with no matching caller claim returns no rows rather than all rows.
  • If the security handler does not run before mcp, JWT claims are absent and CEL expressions that reference auditInfo will deny.
  • Rule execution is local to the gateway. No database call is made per request.
  • x-mask and x-mask-pattern in MCP tool inputSchema are applied before the downstream call. x-tokenize is reserved for the tokenization service integration.

Verification

Useful checks:

cargo tree -p light-gateway -i light-rule
cargo test -p light-pingora access_control
cargo test -p light-gateway gateway_loads_mcp_router_when_mcp_handler_is_active

The first command verifies the binary linkage. The test commands verify the MCP access-control path, default deny behavior, CEL-based allow behavior, and gateway MCP runtime loading.

See Also

  • Request Access Control Rules — full reference for CEL req-acc rules: JWT claim paths, permission object structure, worked examples for scopes, roles, positions, attributes, subject, client ID, combined conditions, and a generic dynamic rule pattern.

LLM Gateway Design

Status

Implemented through REL-1 and the request-scoped PII profile. Production enablement remains fail-closed pending committed PERF-3/PERF-4 measurements, live Python/TypeScript SDK evidence against both provider formats, and live canary/rollback evidence.

The checked-in implementation gates are evidence validators, not substitutes for those external runs. llm-router.enabled remains false, release canaryAllowed remains false, and PII promotion remains independently gated by functional, security, durability, and performance lanes.

Implementation And Qualification Status

ContractCurrent implementationRemaining promotion evidence
LF-1 through LF-6BDeterministic baselines, canonical provider contract, OpenAI/Anthropic codecs, compiled single-attempt runtime, accounting/circuits/replay, buffered HTTP, and early SSE are implemented.PERF-1 measurements remain an external architecture-checkpoint input.
PDB-1, LP-1, GC-1/GQ-1, PV-1Host-scoped schema, event persistence, commands/queries, atomic publication, governed-alias UI, defensive secret redaction, and component-level control-plane tests are implemented.Operational Portal deployment and publication approval.
DIST-1, LF-7, LA-1Monotonic projection, two-replica convergence contracts, secret rotation, retained runtime state, and agent alias isolation are implemented. Production deployment resources require a complete current conformance result.Captured provider evidence for the exact physical deployments.
LF-8, LF-9, PERF-2Durable WAL/sink, ownership lock, replay/reclamation, accounting-aware streaming, deadlines, and protocol checks are implemented.Declared external performance captures.
PERF-3, OBS-1, SEC-1, REL-1Qualification contracts, bounded telemetry, SSRF/body-access controls, rollout stages, and monotonic rollback are implemented. Release evidence is bound to the current commit and critical-source digests.Five-run PERF-3, live SDK/provider smoke, canary, and rollback-drill evidence.
PII-1, PERF-4Authenticated request-scoped placeholders, exact fragmented-stream recovery, typed promotion identity, vault boundary, and four independent promotion lanes are implemented.Functional, security, durability, and PERF-4 lane evidence; session/host scope stays unavailable until the durable-vault lane passes.

Production projection defaults requiredConformanceProvenance to captured_sanitized. Synthetic corpus results remain useful regression evidence but cannot make a production deployment eligible. A PASS Portal deployment stores the complete canonical conformanceResult; compact state or capability flags alone are quarantined or rejected.

The live SDK closure harness pins the official OpenAI Python and TypeScript packages and exercises /v1/models, buffered chat, streaming with usage, and tool calls against both an OpenAI deployment and an Anthropic-backed governed alias. Its sanitized evidence is bound to the release commit, projection digest, and both conformance digests.

Decision Summary

Add an LLM inference handler to light-gateway with these initial decisions:

  • Expose an OpenAI-compatible client API. Implement GET /v1/models and POST /v1/chat/completions first, including Server-Sent Events (SSE) streaming. Add POST /v1/responses after the provider abstraction can preserve its richer content and event model.
  • Treat the request model as a public, governed model alias. Clients do not select provider credentials, provider base URLs, or physical deployments.
  • Keep the wire protocol separate from the provider abstraction. The gateway translates OpenAI-compatible requests into a provider-neutral internal representation and translates normalized provider results back to the selected public protocol.
  • Reuse crates/model-provider implementations, but do not expose the current Provider trait directly as the HTTP contract. It needs typed errors, streaming events, structured content blocks, cancellation, richer request options, and per-model capabilities before it is a production gateway boundary.
  • Reuse the existing Light handler chain for correlation, authentication, authorization, request rate limits, metrics, and common traffic policy. Add LLM-specific routing, token and cost budgets, provider health, and usage accounting in a dedicated runtime.
  • Keep LLM inference and MCP tool execution as separate protocol boundaries. The LLM gateway can accept tool definitions and return tool calls, but the client agent remains responsible for executing those calls through the MCP router and returning tool results to the model.
  • Keep configuration and administration in the Light control plane. Do not add a second gateway-specific administration UI or public mutation API in the first implementation.
  • Store the host-scoped model catalog, deployments, public aliases, routing policy, capability snapshots, and pricing metadata in the Light Portal control plane. Agent definitions reference a governed alias or model policy; they do not own provider credentials or select a physical provider model.
  • Separate control-plane, inference-record, and reversible-PII storage. Portal PostgreSQL remains authoritative for configuration and canonical agent-domain events. A dedicated local or regional audit store owns gateway inference records, while a separately credentialed PII vault is used only when token mappings must survive the request.
  • Keep request-scoped PII mappings in memory by default. Use a local bounded WAL/spool for audit delivery, not as the authoritative audit corpus or a replica-local long-lived PII vault. Distinguish bounded-async admission from local-durable pre-dispatch commit; never claim that queue capacity is crash durability.
  • Represent the client format, logical operation, and selected upstream format separately. Preserve unknown fields in a bounded compatibility envelope for same-format forwarding, and upgrade to fully typed canonical content only when policy mutation or cross-provider conversion requires it.
  • Pre-bind provider dispatch, resolved alias policy, eligible priority groups, pricing references, and content-access requirements into an immutable runtime snapshot. Static enum and preconstructed dynamic dispatch are both acceptable; the benchmark decides. A request must not repeatedly lock configuration stores, merge policy layers, construct a provider client, or look up provider implementations by string.
  • Publish one small atomic root containing structurally shared routing, provider, policy, and pricing sub-snapshots. A pricing-only or single-alias update reuses unchanged Arc graphs, while one root load still gives each request a generation-consistent view.
  • Treat every upstream credential as an authorized quota and billing principal. Credentials in one deployment set are lifecycle versions within the same quota group; separately approved accounts/capacity are separate deployments. The gateway must not rotate keys to evade a provider's RPM/TPM, account, contract, or abuse limits.
  • Make performance a release contract, not an implementation claim. The gateway must meet an absolute latency and capacity SLO and must also equal or outperform a pinned Bifrost build under the same open-loop workload, hardware limits, protocol, payloads, provider mock, and enabled features.

Context

light-gateway already has most of the surrounding gateway capabilities:

  • Pingora HTTP and HTTPS listeners and proxy transport.
  • Ordered handler chains configured by handler.yml.
  • JWT, API key, basic, unified-security, and agent-delegation authentication.
  • Access control, request rate limits, correlation, metrics, headers, and CORS.
  • MCP request handling through the mcp application handler.
  • Browser-to-agent WebSocket routing through the websocket traffic handler.
  • Config registration, config-server bootstrap, atomic ConfigManager swaps, and reloadable modules.

crates/model-provider already contains provider clients for OpenAI, Azure OpenAI, Anthropic, Bedrock, Gemini, GLM, Ollama, OpenRouter, Telnyx, and generic OpenAI-compatible endpoints. It also contains account- or CLI-oriented clients such as Codex, Copilot, Claude Code, Gemini CLI, and Kilo CLI, plus two wrapper providers:

  • RouterProvider resolves a hint:<name> to a configured provider and model.
  • ReliableProvider performs retries and walks provider/model fallback chains.

The current common types are intentionally small and agent-oriented:

  • ChatMessage has a string role and string content.
  • ChatRequest has messages and optional tools.
  • ChatResponse is buffered text, tool calls, usage, and optional reasoning content.
  • ProviderCapabilities contains only native tool calling, vision, and prompt caching flags.
  • Provider errors are returned as anyhow::Error.

That is enough for the current light-agent and light-workflow call paths, but it cannot faithfully implement a public LLM gateway. For example, it has no common incremental stream, typed provider status and Retry-After, structured multimodal content blocks, response-format contract, cancellation signal, or per-operation capability declaration.

Three open source gateways provide useful feature signals:

  • Bifrost emphasizes an OpenAI-compatible API, provider-native compatibility adapters, retry and fallback, weighted routing, virtual-key governance, hierarchical budgets, semantic caching, plugins, observability, and MCP integration.
  • LiteLLM exposes OpenAI-format and native endpoints across many providers and adds proxy authentication, virtual keys, spend tracking, rate limits, routing, fallback, caching, guardrails, and logging.
  • agentgateway is a Rust multi-protocol gateway with purpose-built local and xDS configuration, an LLM model router, OpenAI and provider-native formats, typed provider conversion, virtual models, health-aware priority failover, guardrails, token/cost telemetry, and an atomically replaceable pricing catalog. The implementation review in this document is based on commit 857281d.

The Light design should adopt the durable product capabilities without copying any reference project's control plane. Light already has a portal, config server, controller, security handlers, access control, and an MCP router.

Goals

  • Give applications and agents one stable base URL and one common API across supported model providers.
  • Let existing OpenAI SDK users migrate by changing the base URL and client credential rather than rewriting request and response handling.
  • Support buffered and streaming chat, tool calling, structured output, and supported multimodal input without losing provider semantics silently.
  • Route public model aliases to one or more physical provider deployments.
  • Let each organization/host register only the models and deployments it is authorized to use, and manage their routing metadata through GenAI Admin.
  • Provide retry, fallback, load balancing, circuit breaking, health-aware routing, and cancellation with well-defined streaming behavior.
  • Enforce model access, data-boundary constraints, token limits, cost budgets, concurrency limits, and request rate limits per authenticated identity.
  • Record normalized usage, cost, latency, time to first token, route decisions, retry/fallback activity, and policy outcomes.
  • Deliver audit records without adding synchronous Portal-database work to the normal inference path, and support governed content capture for later audit, evaluation, and curated dataset export.
  • Tokenize policy-selected PII before cloud-provider dispatch and recover only exact authorized placeholders before returning the model response.
  • Protect provider credentials and prevent clients from choosing arbitrary upstream URLs or passing provider secrets through the gateway.
  • Reload provider, alias, route, and policy snapshots atomically without interrupting in-flight requests.
  • Make provider conformance measurable so an alias is offered only when every eligible target can satisfy its declared capabilities.
  • Sustain Bifrost-class request rates without entering a queueing collapse: keep the hot path typed and allocation-conscious, reuse upstream clients, shed excess load promptly, and verify comparative throughput and tail latency before release.

Non-Goals

  • Do not run an autonomous agent loop in the LLM gateway. It does not execute model-returned tool calls or decide when an agent task is complete.
  • Do not replace the MCP router or merge MCP JSON-RPC with the LLM HTTP API.
  • Do not proxy arbitrary client-supplied provider base URLs, API keys, or cloud credentials.
  • Do not expose every provider-specific option through the common API. Provider-native compatibility endpoints can be added deliberately when a real client need justifies their maintenance cost.
  • Do not support fine-tuning, training, file storage, assistants, or durable conversation state in the first implementation.
  • Do not enable account- or CLI-oriented providers in a shared gateway until their credential isolation, licensing, concurrency, and multi-tenant behavior have passed a separate security review.
  • Do not log prompts, completions, images, tool arguments, or reasoning content by default.
  • Do not use the Portal database, a gateway replica's embedded database, or the inference content store as the production reversible-PII security boundary.
  • Do not treat operational audit content as an automatically approved training dataset.
  • Do not promise identical model output after fallback. Fallback preserves the API and required capabilities, not model behavior.
  • Do not advertise a fixed multiple such as 40x or 50x. Those ratios depend on the benchmark definition and can be dominated by overload queueing. Report gateway-added latency, sustainable throughput, success rate, and resource use from a reproducible benchmark instead.

Reference Feature Comparison

The comparison is a requirements input, not a compatibility promise.

CapabilityBifrost signalLiteLLM signalagentgateway signalLight direction
Common inference APIOpenAI-compatible API plus provider SDK adapters.OpenAI input/output format plus native endpoints.OpenAI Completions/Responses plus Anthropic Messages, embeddings, rerank, realtime, token count, detect, and opaque routes.OpenAI-compatible API first; preserve source-format identity so selected native adapters can be added without flattening through Chat Completions.
Provider abstractionMany hosted and local providers.Broad provider and endpoint coverage.Rust enum dispatch with typed request/response conversions and provider-format selection.Reuse model-provider, gated by per-operation conformance tests; pre-bind provider execution in the runtime snapshot and let allocation benchmarks choose enum or dynamic dispatch.
RoutingProvider/model/key routing and weighted strategies.Deployment router and load-balancing strategies.Public/internal concrete models plus weighted, conditional, and health-aware priority-failover virtual models.Public alias to eligible deployment targets with weighted, priority-, health-, policy-, and capability-aware selection.
ReliabilityRetries, key rotation, and sequential fallbacks.Retries, cooldowns, and cross-deployment fallback.Generic HTTP retry integrates with endpoint outlier eviction so the next attempt can move to the next priority group.One typed attempt coordinator, Retry-After, health/outlier state, and no fallback after visible stream output.
Tenant credentialsVirtual keys.Virtual keys and proxy keys.General gateway authentication and authorization policies apply to LLM routes/models.Reuse Light authentication; map the authenticated client, user, agent, and host to an LLM policy.
Cost governanceHierarchical budgets and rate limits.Spend tracking and budgets by several scopes.Token-aware rate limits and an ArcSwap pricing catalog with detailed usage classes and source overlays.Atomic token/cost reservation and usage reconciliation by configured Light policy scopes; publish pricing as an independent versioned projection.
CachingExact/provider and semantic caching.Configurable response caches.Provider prompt-caching policy and cache-token accounting.Exact cache later; semantic cache is opt-in and tenant/policy isolated.
GuardrailsPlugin-based request and response controls.Per-project guardrails and callbacks.Local regex/PII masking, external safety services, and bounded-window SSE/realtime response blocking.Ordered local/remote hooks, reversible PII profiles, and explicit buffered versus bounded-window streaming semantics.
ObservabilityMetrics, tracing, and request logging.Logging callbacks, usage, cost, and latency.CEL-selectable LLM attributes, normalized usage/cost, and streaming completion accounting.Existing correlation/metrics plus bounded-cardinality events and a dedicated durable audit pipeline.
Performance architectureCompiled Go, fasthttp, typed provider codecs, object pools, and per-provider workers.FastAPI/ASGI with a generic Python router, SDK dispatch, and callback pipeline.Compiled Rust, typed/minimally parsed codecs, reusable clients, bounded bodies, endpoint sets, and atomic pricing snapshots; some configuration reads and policy merging remain request-time work.Rust/Pingora, compatibility fast path, pre-bound provider dispatch, one structurally shared request snapshot, bounded admission, and benchmark-enforced parity or better.
MCPMCP gateway and tool filtering.MCP support and model-tool integration.MCP, A2A, HTTP, and LLM backends share the gateway policy/runtime.Keep the existing MCP router authoritative for tool discovery and execution while sharing identity, policy, and telemetry primitives.
AdministrationBuilt-in configuration and monitoring UI.Admin UI and APIs.Human-friendly watched local config or granular purpose-built xDS resources mapped to a shared IR.Use Light Portal, config server, and controller; publish granular resources but compile a complete request-ready runtime snapshot.

agentgateway Architecture Review

The reviewed agentgateway path is not merely "Rust instead of Python." Its architecture makes several explicit choices that reduce compatibility work and keep most LLM processing inside compiled code:

local YAML/JSON or purpose-built xDS resources
  -> shared gateway IR and targeted policies
  -> HTTP route -> LLM model router
  -> concrete model or weighted/conditional/failover virtual model
  -> provider endpoint set and merged backend policy
  -> client-format parser -> provider-format renderer
  -> reusable upstream transport
  -> provider stream parser -> client-format stream renderer
  -> optional bounded-window guard -> client

The following decisions are worth adopting or deliberately refining:

agentgateway choiceWhy it is usefulLight decision
Separate endpoint RouteType, client InputFormat, and provider ChatFormat.A Messages request may go to a Completions upstream while the gateway still knows which response contract it owes the client.Define ClientFormat, Operation, and ProviderFormat separately from day one. Never infer the response contract from the selected provider route.
Parse operated fields and preserve unknown fields in a flattened rest; upgrade to fully typed forms only for conversion.Same-format compatibility survives provider/API additions without requiring an immediate full schema update.Use a bounded compatibility envelope for same-format routes. Validate operated fields strictly; allow unknown fields only under an alias/provider allowlist and never forward an unknown extension across formats blindly.
Public/internal concrete models and virtual models with weighted, conditional, or priority-failover routing.Client aliases stay stable while internal targets and rollout policy change. Internal targets need not be directly invokable.Keep public aliases separate from internal deployments. Add priority groups and metadata-conditional routing after the ordered MVP, with expressions compiled at publication and evaluated only over an allowlisted, sanitized context.
Provider selection uses endpoint sets with health, latency, pending-work scoring, priority buckets, and outlier eviction.A retry can reselect after a bad target is ejected instead of repeatedly hitting the same endpoint.Treat retry and failover as one attempt coordinator. Feed typed outcomes into per-deployment health and reselect from the same immutable eligible plan, preserving capability and residency constraints.
Built-in providers use exhaustive enum dispatch and typed conversion code.Provider choice is resolved in compiled Rust without constructing a dynamic SDK/client per request.Preserve the pre-bound compiled path but benchmark sealed-enum and preconstructed trait-object implementations. Never do string-to-provider registry lookup or client construction on each attempt.
Streaming is translated to the client-visible SSE format before response guards inspect text. Held semantic frames are released in bounded windows with overlap.Binary or provider-native streams are not mistaken for OpenAI SSE, and patterns spanning chunks can be detected before held frames are released.Apply provider decoding first, then exact PII-token recovery and post-policy in a documented order over semantic events. Buffer complete frames plus a bounded overlap; a whole-response rule forces buffered mode.
Prompt/completion attributes are materialized only when a CEL expression asks for them, and the raw LLM request exists only during the LLM-policy phase.Large, sensitive content does not become a universal request-context cost or remain available to later logging accidentally.Make content access lazy, phase-scoped, and policy-authorized. Remove raw content from the general handler/log context after the content-policy phase; later stages receive normalized metadata or explicit encrypted references.
Pricing sources merge into a validated ArcSwap snapshot and retain the last valid catalog on reload failure.Pricing reads are wait-free and a broken file does not turn known prices into zero.Publish pricing as an independently versioned immutable projection, capture its version per attempt, support explicit source precedence, and keep unknown pricing fail-closed for hard budgets. External reference catalogs never authorize a host or activate a deployment.
Local/xDS resources closely mirror user resources, while policies remain separate and merge at runtime.Small control-plane changes avoid large route-list fan-out and keep control-plane translation mechanical.Preserve granular Portal/config events and delta publication, but compile affected alias/route policy combinations before activation. The LLM request path loads one request-ready snapshot and performs no shared-store policy merge.

There are also boundaries Light should not copy:

  • The reviewed gateway store uses shared RwLock-protected bind/discovery stores, and the HTTP/LLM path reacquires bind reads and clones/merges some policy layers during request processing. Light should spend the additional reload-time work to publish pre-resolved LLM plans behind ArcSwap.
  • agentgateway may inject stream_options.include_usage=true when the client omitted it, which adds a client-visible final SSE event. Light may request upstream usage internally, but it must remember the original client contract and suppress an injected usage frame unless the client requested it.
  • Its bounded-window streaming guardrail explicitly cannot provide full-stream accuracy, cannot retract earlier windows, and does not support streaming masking. Light policy publication must reject an incompatible streaming/policy combination or force buffering; reversible exact-token recovery is a separate bounded streaming transform, not ordinary masking.
  • Its local PII recognizers mask or reject content; they do not provide the separately scoped reversible-token vault required by this design. Its telemetry path also does not replace Light's logical-request/physical-attempt durable audit ledger and governed dataset export.
  • A provider response parsing helper in the reviewed source logs up to the first 1,024 response bytes on a parse failure. Light must never put raw provider error/response bodies into ordinary logs. Record only bounded error classification, byte length, content type, and a keyed digest unless an explicitly authorized encrypted-content policy captures the body.

These differences are opportunities to be faster as well as safer than the reference: agentgateway validates the value of typed Rust codecs, static provider dispatch, endpoint priority groups, and atomic catalog replacement; Light can combine those ideas with a stricter one-published-root request path and no request-time configuration merging.

Common Client API

API Choice

Use the OpenAI API shape as the public compatibility profile.

OptionStrengthLimitationDecision
OpenAI Chat CompletionsWidest existing SDK and agent-framework compatibility; maps closely to current provider clients; supports SSE and tool calls.Its message model is less expressive than newer event/item APIs.MVP.
OpenAI ResponsesBetter fit for reasoning models, structured content items, and richer streaming events.Requires a significantly richer internal contract and has less uniform third-party coverage.Phase 2, built on the same canonical internal types.
Provider-native APIsMaximum fidelity for one provider and drop-in support for provider SDKs.Multiplies codecs, tests, and long-term compatibility obligations.Add selected adapters later; not the canonical client API.
Light-specific inference APIFull control over versioning and semantics.Requires new SDKs and creates avoidable client migration work.Do not use as the primary external API.

The OpenAI-compatible contract is a compatibility profile, not a claim that every provider supports every OpenAI option. Capability checks and explicit errors are part of the contract.

Internally, keep three dimensions distinct:

  • ClientFormat is the request and response contract owed to the caller, for example OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages.
  • Operation is the semantic action, for example chat, responses, embeddings, rerank, token count, or realtime.
  • ProviderFormat is the selected upstream wire contract, for example OpenAI Completions, Anthropic Messages, Bedrock Converse, or a provider-native embedding route.

A provider selection can change ProviderFormat; it never changes ClientFormat. This prevents a fallback or cross-provider conversion from accidentally returning the upstream provider's shape to the client.

Endpoint Roadmap

EndpointPriorityNotes
GET /v1/modelsMVPReturn only public aliases authorized for the caller. Do not enumerate raw provider deployments.
POST /v1/chat/completionsMVPBuffered and SSE streaming; text, tool calls, supported image input, and structured output where the alias declares support.
POST /v1/responsesPhase 2Use native response items and events rather than flattening through Chat Completions.
POST /v1/embeddingsPhase 2Add only after an embedding operation exists in the canonical provider trait and pricing model.
POST /v1/moderationsPhase 2 or policy serviceDecide whether this is a provider operation or a Light policy endpoint before implementation.
Images, audio, rerank, batches, and filesLaterEach needs its own capability, size, cost, storage, streaming, and retention contract.
Realtime WebSocket/WebRTCLaterThis is provider realtime inference, not the existing UI-to-agent WebSocket router. Implement it as a separate protocol handler.

A later provider-native compatibility adapter may use one of three explicit processing modes:

  • normalized: strict typed validation, full policy support, and conversion to any conforming provider format.
  • detect: same-format forwarding with shallow LLM metadata and usage extraction. It is eligible only for policies whose required controls can be enforced without full content normalization.
  • opaque: bounded HTTP/WebSocket forwarding with no LLM interpretation. It cannot satisfy token, content-guardrail, reversible-PII, or normalized-audit requirements. If enabled for a separately governed compatibility route, it still enforces identity, destination allowlists, request/response bytes, request rate, concurrency, duration, egress, and a pessimistic fixed per-request cost reservation or externally reconciled account-spend ceiling. A policy requiring authoritative token or exact per-call cost accounting cannot select it.

The public OpenAI-compatible endpoints use normalized. detect and opaque are explicitly configured compatibility tools, never automatic fallbacks when normalization fails.

Opaque traffic is therefore not free or unlimited; its governance unit is a bounded request rather than a token. The audit record marks token usage and per-call realized cost as unknown, records the reserved fixed envelope and byte counts, and reconciles provider-account spend asynchronously when billing data is available. If no conservative envelope or authoritative account cap is configured, publication rejects the route.

Authentication And Headers

No Light-specific header is required for a normal SDK call.

  • Authorization: Bearer <credential> carries a Light-issued API key, JWT, or agent-delegation credential accepted by the configured handler chain. It is never a provider API key.
  • Content-Type: application/json is required for JSON request endpoints.
  • X-Correlation-Id and X-Traceability-Id use the existing Light correlation contract.
  • X-Light-Session-Id is an optional routing hint for session stickiness. It must be bounded, treated as untrusted input, and scoped by authenticated principal so two tenants cannot collide.
  • Idempotency-Key can enable request deduplication where the selected operation and storage policy support it. It cannot guarantee that a provider did not bill a timed-out upstream attempt.
  • x-request-id should be returned for OpenAI SDK diagnostics and linked to the Light correlation and trace identifiers in server-side telemetry.
  • Provider name, physical model, base URL, key ID, raw error body, and internal policy details are not returned by default. Authorized diagnostic tooling can retrieve them from audit events.

The OpenAI user field is optional attribution metadata. It does not establish identity and cannot override the authenticated principal.

Public Model Names

The request model is a logical alias such as chat-fast-v1, reasoning-standard-v1, or private-code-v2.

An alias defines:

  • Allowed operations and request features.
  • Maximum input and output sizes.
  • Data classification and residency requirements.
  • Eligible provider deployments and physical model identifiers.
  • Routing, retry, fallback, timeout, and budget policy.
  • Pricing policy and capability snapshot version.
  • Deprecation and replacement metadata.

Provider-prefixed names such as openai/gpt-x can be convenient for local development, but public production policy should disable them. Otherwise the client can bypass alias-level routing, residency, and lifecycle controls.

GET /v1/models returns only aliases visible to the caller. A separate authenticated control-plane view can show target deployments and detailed capabilities.

Chat Completions Compatibility Profile

The MVP should support these fields when the selected alias declares the required capability:

  • model
  • messages with text content and supported image_url content parts
  • temperature and top_p
  • max_tokens and max_completion_tokens, normalized to one internal output limit with a conflict error if both disagree
  • stop
  • stream and stream_options.include_usage
  • tools, tool_choice, and parallel_tool_calls
  • response_format for text, JSON object, and JSON Schema where supported
  • user and bounded metadata for attribution

The gateway must not silently drop a non-default option. The default unsupportedParameterPolicy is reject. A per-alias allowlist can permit provider-specific pass-through fields only when every eligible route handles them consistently. Unknown null or SDK-default fields can be ignored if the compatibility profile explicitly documents them.

Reasoning summaries may be exposed only through a documented public field or Responses event. Hidden chain-of-thought or raw provider reasoning content must not be logged or returned merely because a provider client captured it.

Example:

curl https://gateway.example.com/v1/chat/completions \
  -H 'Authorization: Bearer <light-credential>' \
  -H 'Content-Type: application/json' \
  -H 'X-Correlation-Id: example-request-1' \
  -d '{
    "model": "chat-fast-v1",
    "messages": [
      {"role": "user", "content": "Summarize the attached incident."}
    ],
    "temperature": 0.2,
    "stream": true
  }'

Streaming Contract

Chat Completions streaming uses text/event-stream, OpenAI-compatible chat.completion.chunk data frames, and a terminal data: [DONE] frame.

Streaming changes reliability semantics:

  1. Before the gateway emits the first semantic output event, it may retry or choose an eligible fallback according to policy.
  2. After any text, tool-call argument, or other semantic output is visible to the client, the gateway must not retry or switch providers. Doing so can duplicate text, corrupt incremental JSON arguments, or create a second tool call.
  3. A failure after streaming starts emits a sanitized error event when the compatibility profile permits it, then closes the stream without [DONE].
  4. A downstream disconnect cancels the provider request promptly and records the final known usage. Cancellation is best effort because a provider can continue billing work already accepted upstream.
  5. Time to first token, stream duration, client cancellation, and upstream cancellation outcome are recorded separately.
  6. The gateway preserves the client's stream_options.include_usage choice. A provider adapter may request usage upstream for accounting, but an internally injected usage event is removed from the client stream when the public contract did not request it.

The current MCP stream writer demonstrates that Pingora can write incremental frames, but LLM SSE framing, usage events, disconnect cancellation, and post-stream accounting need their own implementation and tests.

Error Contract

Map typed provider and gateway errors to the OpenAI error envelope:

{
  "error": {
    "message": "The selected model is temporarily unavailable.",
    "type": "server_error",
    "param": null,
    "code": "model_unavailable"
  }
}

The gateway should normalize at least these categories:

CategoryTypical HTTP statusRetry behavior
Invalid request or unsupported parameter400Never retry.
Authentication failure401Never retry.
Model or policy access denied403Never retry or reveal hidden aliases.
Unknown authorized model alias404Never retry.
Request or token limit exceeded413 or 422Never retry without changing the request.
Request/token/cost rate limit429Honor Retry-After; a different target is eligible only when policy permits.
Provider timeout504Retry or fallback only before visible stream output.
Provider unavailable or circuit open502 or 503Retry/fallback only to a capability-equivalent target.
Internal gateway failure500Do not expose raw provider or configuration details.

Proposed Architecture

Client application or agent
  -> Pingora listener
  -> handler chain
       correlation -> CORS -> unified-security -> limit -> access-control -> llm
  -> OpenAI-compatible HTTP codec
  -> client format + operation + bounded compatibility envelope
  -> authenticated LLM request context
  -> alias and policy resolver
  -> token/cost/concurrency reservation
  -> cache lookup when eligible
  -> capability, residency, health, and budget target filter
  -> route selection -> retry/fallback coordinator
  -> optional request-scoped PII tokenization
  -> statically selected model-provider adapter -> provider-format API
  -> provider decode -> client-format semantic events, usage, and typed errors
  -> exact-token PII recovery, policy post-processing, and quota reconciliation
  -> buffered JSON or SSE response
  -> metrics and trace
  -> bounded audit queue -> local spool when needed -> dedicated audit store

The control plane publishes immutable, versioned runtime snapshots to the gateway. Portal, config server, secret manager, audit database, and PII vault lookups are not part of the normal alias-resolution or routing path.

The llm application handler terminates the HTTP request inside the gateway; it is not an ordinary upstream proxy. Consequently, body-dependent security and transformation stages must execute inside the application-handler flow before provider dispatch. Merely listing access-control, tokenize, or another body handler earlier in handler.yml does not prove that Pingora's later proxy body filters will run.

Performance Architecture And Release Contract

Performance is part of the public reliability contract. A fast provider does not compensate for a gateway that consumes excessive CPU, accumulates an internal backlog, or delays stream chunks. Conversely, a microbenchmark that excludes JSON, middleware, or response processing does not represent what a client experiences.

Interpreting The Bifrost Reference

The published Bifrost comparison contains two distinct results:

  • At the advertised 500-RPS load, Bifrost reported about 9.5x LiteLLM's completed throughput, while the reported P50 and P99 latency ratios grew to approximately 48x and 54x after LiteLLM saturated and requests queued.
  • In a separate test with a 60-ms mock provider, the end-to-end medians were 60.99 ms and 100 ms, a 1.64x difference. The 40x claim comes from subtracting the assumed 60-ms mock time and comparing 0.99 ms with 40 ms.
  • Bifrost's 5,000-RPS internal-overhead figures exclude at least the upstream call and some codec work. They are useful implementation signals, but they are not directly comparable to an end-to-end proxy latency percentile.

The Light target is therefore not "be 50 times faster than LiteLLM." The target is to remain below the saturation knee, equal or exceed Bifrost's sustainable throughput, and equal or improve its gateway-added P50, P95, and P99 latency in a controlled comparison. Both the absolute and comparative gates below must pass.

Release Performance Gates

The first implementation establishes a checked-in benchmark manifest with the exact Light commit, Bifrost image digest or commit, load generator version, kernel and CPU architecture, instance limits, configuration, payload corpus, and mock-provider build. A result without those inputs is diagnostic only and cannot satisfy a release gate.

GateRequired result
Comparative non-inferiorityOn identical hardware and feature-equivalent profiles, Light sustainable throughput must be at least Bifrost's, and Light gateway-added P50, P95, and P99 must be no higher. Compare five or more steady-state runs and require the 95% confidence interval to remain inside a 5% non-inferiority margin. The engineering target is at least 10% better throughput or P99, not merely a statistical tie.
Rust architecture referenceRun the same compatible subset against a pinned agentgateway commit. Report results even though Bifrost remains the MVP release comparator. Any regression against agentgateway in routing-only, same-format, or streaming profiles requires an explained architectural cause and an accepted optimization plan.
500-RPS small-payload baselineOn 2 vCPU and 4 GiB RAM with keep-alive and a 60-ms mock provider, admit and complete the full 500-RPS offered load with 100% success, add no more than 1 ms at P50 and 5 ms at P99, and show no growing internal queue during the steady-state window.
5,000-RPS high-throughput profileOn 4 vCPU and 16 GiB RAM with a local mock provider and small buffered responses, admit and complete the full 5,000-RPS offered load with 100% success, keep P99 admission wait below 1 ms, and meet the comparative Bifrost latency and throughput gate without unbounded memory growth.
Production handler profileRepeat the comparison with correlation, cached authentication, authorization, request limits, metrics, routing, usage accounting, and metadata-only bounded-async audit enabled. No feature may be disabled only for Light if its equivalent remains enabled for Bifrost.
Durable-audit profileRun local-durable metadata audit on declared persistent storage and report commit-batch size, fdatasync duration, commit-wait P50/P95/P99, throughput, incomplete recovery, and overload behavior. It must meet its configured commit timeout with no unaudited dispatch; do not average it into or use it to weaken the normal 500/5,000-RPS gates.
Streaming profileAt matched concurrent streams and chunk cadence, Light time-to-first-byte overhead and P99 per-chunk processing delay must be no worse than Bifrost. Slow consumers must remain bounded and cancellation must release permits and upstream work promptly.
Overload profileIncrease fixed offered load beyond capacity. Admitted-request latency must remain bounded; excess requests must receive a prompt 429 or 503 instead of waiting in an unbounded queue. The report must show the capacity knee, rejection rate, queue wait, memory, and recovery after load falls.
Resource profileAt matched throughput, Light peak RSS and CPU per completed request must be no worse than Bifrost. Any optional pool or cache must have a configured bound and a measured benefit.

The numeric absolute targets are initial release floors. After the first stable baseline they may be tightened, but a configuration or feature addition cannot silently weaken them. If a stricter policy profile performs synchronous remote work by design, publish it as a separate profile with its own SLO rather than averaging it into the normal data-plane result.

Benchmark Method

  • Use a fixed-rate, open-loop generator for capacity and overload tests. A fixed number of virtual users is a separate closed-loop test and must not be labelled as RPS.
  • Measure the mock provider directly in the same run. Report complete end-to-end latency and gateway-added latency, but never use subtraction as the only release metric.
  • Warm DNS, TLS, connection pools, provider codecs, and lazy metrics before the measurement window. Report cold-start behavior separately.
  • Run small, 10-KiB, and tool/schema-heavy request profiles; buffered and SSE response profiles; HTTP/1.1 and HTTP/2 where supported; and TLS on and off.
  • Use the same upstream protocol, keep-alive policy, connection count, mock latency distribution, response payload, timeout, retry count, and logging policy for both gateways.
  • Record histograms rather than averages: P50, P95, P99, P99.9 and maximum for end-to-end latency, gateway-added latency, admission wait, route selection, request/response codecs, time to first token, and stream-chunk processing.
  • Record offered, admitted, completed, rejected, failed, retried, and cancelled requests separately. A rejected request is not a successful completion, and a request completed after the measurement window cannot inflate throughput.
  • Capture CPU, RSS, allocation rate, task count, open connections, queue depth, and upstream pool reuse throughout the run. Preserve raw results as CI artifacts so regressions can be investigated.

Hot-Path Rules

The normal request path follows these rules:

  1. Read, decompress, and bound the HTTP body once. Parse the operated routing and policy fields once into a typed compatibility envelope. For same-format forwarding, preserve allowlisted unknown fields without a second generic JSON parse/serialize cycle. Upgrade to full canonical typed content only when an enabled policy mutates content or the selected provider format differs. A failed typed parse never falls back to opaque forwarding.
  2. Capture one immutable Arc<LlmPublishedSnapshot> root at request admission. The root contains versioned Arc subgraphs for routing, provider bindings, effective policies, and pricing. Alias maps, capability masks, policy decisions, eligible route lists, weights, pricing references, and compiled hook lists are prepared during reload. Request processing does not scan configuration files or reacquire a config lock at each stage.
  3. Make the root read wait-free, for example with ArcSwap. A pricing-only or single-alias publication creates a small new root that reuses every unchanged subgraph; it does not rebuild one monolithic allocation. The current runtime ConfigManager uses an RwLock<Arc<T>>; it is a functional reload baseline, but the LLM data plane must not multiply read-lock acquisitions across routing, policy, provider, and streaming stages. The existing config-loader ArcSwap implementation is a reusable pattern.
  4. Reuse one configured HTTP client and connection pool per provider deployment. Never create a reqwest::Client, TLS configuration, DNS resolver, or credential object per request. Maintain separate bounded clients only when streaming timeouts or transport policy actually differ.
  5. Keep provider translation in Rust using typed request, response, error, and stream-event codecs. Do not route a request through a scripting runtime, generic SDK dispatcher, thread-pool bounce, or serialize/deserialize bridge.
  6. Compile optional hooks into the snapshot. A disabled hook creates no task, future, dynamic lookup, log object, or channel message on the hot path.
  7. Keep local routing and policy decisions in memory. Database, Redis, portal, config-server, secret-manager, and pricing refreshes run outside the normal request path. A dependency needed for fail-closed policy is warmed and projected into the snapshot before it becomes active.
  8. Update counters and histograms in process. Enqueue audit and usage records to bounded asynchronous sinks. bounded-async reserves envelope capacity but does not claim crash durability. local-durable waits only on the bounded single-writer WAL commit watermark defined by the audit contract; when capacity or durability is unavailable, fail before dispatch instead of performing an unbounded synchronous database write.
  9. Preserve byte buffers with bytes::Bytes or equivalent ownership where the Pingora and provider boundaries allow it. Allocate owned strings only for values that must outlive the input buffer or be transformed.
  10. Add pooling only after allocation profiles identify a benefit. Bifrost's large prewarmed pools trade memory for speed; Light should prefer bounded buffers, connection reuse, and fewer allocations over a large speculative object pool.
  11. Resolve provider dispatch while building the snapshot. The hot path invokes one pre-bound executor and does not hash a provider name, build a client, or construct a chain of provider wrappers. Benchmark a sealed enum/static executor against a preconstructed Arc<dyn InferenceProvider> under the 5,000-RPS and allocation profiles. Use dynamic dispatch when its confidence interval remains within the release margin; use static dispatch only when it provides a material measured benefit worth the maintenance cost.
  12. Compile policy precedence and content requirements at publication. Each alias plan contains its effective policy, compiled conditional expressions, priority groups, and whether prompt/completion materialization is needed. Request processing never reacquires a control-plane RwLock or clones and merges policy maps.

Publication limits build CPU, peak temporary memory, and retired generations. Unchanged nodes are structurally shared, dynamic health/in-flight counters stay outside immutable configuration snapshots, and only affected alias plans are recompiled. In-flight requests retain old Arc generations; cleanup drops large retired graphs incrementally on a non-request worker so a frequent update cannot cause a latency spike. A retirement manager keeps the final non-request reference until request references drain, ensuring an inference task is not the thread that recursively frees a large graph. If retained generations exceed a bound, publication is coalesced or backpressured rather than growing memory without limit.

The production benchmark covers the complete Light handler chain, not only crates/llm-gateway. If repeated shared-runtime lock acquisitions or body copies outside the LLM crate prevent the target, migrate those reads to a request-scoped immutable handler bundle or a wait-free snapshot. They cannot be excluded from the reported gateway overhead.

Admission, Concurrency, And Backpressure

Use bounded admission before expensive parsing, token counting, guardrails, or provider dispatch:

  • Maintain global, per-principal, per-alias, and per-target in-flight permits. Reuse the fail-fast semaphore pattern already used by MCP resource admission.
  • The default provider queue length is zero: dispatch immediately when a permit is available or return a sanitized 429/503. An explicitly enabled queue is bounded by both depth and wait deadline and exposes its wait time in metrics.
  • Reserve separate capacity for buffered requests and long-lived streams so a stream flood cannot starve short inference calls.
  • Apply per-principal and per-source limits before a stream acquires global capacity. Bound request-header/body read time, stream setup time, absolute stream lifetime, downstream write-progress time, and idle time separately; a heartbeat or one-byte read must not renew every deadline indefinitely.
  • Select a target only after a permit can be acquired, or retry selection from the remaining eligible targets. Do not select a saturated target and then build a deep hidden backlog behind it.
  • Token counting, JSON Schema compilation, DLP, and other CPU-heavy policies use bounded dedicated executors and admission limits. They must not block Pingora request processing or Tokio worker threads.
  • Release permits on every success, error, timeout, downstream disconnect, failed stream setup, and panic boundary. Tests must prove permit recovery.

For hard multi-replica budgets, acquire bounded token/cost leases from the authoritative store and reserve from local atomics. Refresh leases asynchronously before exhaustion. A policy that requires a central transaction for every request is a separately named strict-accounting profile and cannot be the default high-throughput path.

Streaming Data Path

  • Parse each upstream SSE event once and translate directly into one canonical event and one client frame. Do not accumulate the full completion unless a configured policy explicitly requires buffering.
  • Decode provider-native transport before applying client-visible response policy. A Bedrock event stream, Anthropic SSE event, or OpenAI chunk must first become a semantic client-format event; guardrails must not scrape arbitrary raw byte chunks.
  • Use a small bounded channel or direct backpressured writer between provider decoding and Pingora. A slow client must pause bounded upstream reads and eventually cancel; it must not create an unbounded per-stream queue.
  • Enforce a downstream write-progress deadline and a minimum sustained drain rate after a bounded grace period. When either is violated, close the client stream, cancel upstream work, finalize partial usage/audit evidence, and release all permits. The maximum stream lifetime is an absolute deadline; SSE comments, TCP trickle reads, and provider heartbeats do not extend it.
  • Avoid per-chunk task creation, tracing spans, JSON maps, and log writes. Maintain request-scoped counters and emit one summarized completion event.
  • Detect downstream closure promptly, cancel the upstream request, close the channel, release permits, and reconcile the best available usage evidence.
  • Keep stream event buffers bounded independently from maximum response bytes and test fragmented UTF-8, large tool arguments, rapid tiny chunks, provider stalls, and slow downstream consumers.
  • A bounded-window response guard holds complete semantic frames until its threshold or maximum held-byte limit, evaluates the pending text with a bounded overlap from the previous window, and then releases or blocks the held frames. Publication records the accepted false-negative/context tradeoff explicitly. Policies needing full-response context force buffered mode.
  • A text guard may prefer a sentence or punctuation boundary when one occurs inside its configured byte/time window, improving local DLP context without waiting for arbitrary raw chunks. This is only a flush heuristic: hard maximum bytes, maximum hold time, and overlap still apply because generated text and tool-call JSON may contain no sentence boundary. Exact PII placeholder recovery continues to use its bounded token-prefix state machine, not sentence segmentation.

Component Boundaries

apps/light-gateway

The application should own wiring rather than provider logic:

  • Register an llm application handler.
  • Load and hold an Arc<LlmRuntimeStore> that exposes one wait-free Arc<LlmPublishedSnapshot> root load per request. It may reuse the existing config-loader ArcSwap manager or a runtime-wide equivalent.
  • Register an llm-router.yml reloader.
  • Pass the existing authenticated principal, agent delegation, correlation, and trace context to the LLM runtime.
  • Delegate buffered and streaming response writing to the shared Pingora LLM integration.

The handler can be placed in a chain with existing security and traffic handlers. A typical inference chain is:

handlers:
  - correlation
  - metrics
  - cors
  - unified-security
  - limit
  - access-control
  - llm
MVP Application-Body Execution Contract

The current handler registry constructs descriptors whose executable contract is only PingoraHandler::id(). GatewayProxy::request_filter resolves the ordered IDs and dispatches behavior with a match, while body-dependent traffic and access-control work normally completes later in Pingora's request_body_filter. MCP is an application-handler precedent: it reads and answers its request directly from request_filter. Copying that pattern without an explicit LLM body-policy stage would let the application response bypass the later generic body filters.

For the MVP, do not make a repository-wide executable-handler-trait refactor a prerequisite. Register llm in the existing handler registry and add a narrow branch in GatewayProxy, but immediately delegate to a shared LlmHttpIntegration owned by light-pingora. That integration executes this contract exactly once:

  1. Pre-body handlers before llm run in configured order. Correlation, authentication, CORS, request-rate limits, and header policy populate the request context or terminate the request.
  2. llm verifies the method, route, media type, content encoding, declared length, and body-read deadline, then collects at most the configured body limit into one Bytes-backed capture. No downstream handler rereads the socket or independently buffers the JSON body.
  3. If access-control appeared earlier in the resolved chain, the integration invokes the existing endpoint authorization once with the authenticated principal, trusted headers, endpoint, correlation ID, and parsed request data. It does not rely on request_body_filter to perform that check later.
  4. The OpenAI codec validates the request and resolves the public alias. The protocol-neutral LLM runtime then applies host registration, alias/model policy, capability, data-boundary, and admission checks. Both authorization layers must allow the request before an audit marker or provider attempt is created.
  5. Buffered response policy runs before the response header/body is written. SSE aliases may use only streaming-safe LLM policy; a generic whole-response filter forces buffered mode or makes the alias invalid at publication.
  6. The shared writer emits buffered JSON or SSE, propagates disconnect cancellation, finalizes audit/usage, and releases every permit.

The LLM request context captures the active access-control runtime and the published LLM root once. A reload cannot change either decision halfway through the request. Generic tokenize/detokenize handlers are rejected in an LLM chain until they are explicitly adapted to this application-body contract; LLM-aware PII policy belongs in crates/llm-gateway and its normalized content pipeline. This prevents a configured handler from appearing active while silently doing nothing.

After the vertical slice is benchmarked, the same integration interface can be generalized for MCP and other application handlers. That refactor is accepted only if it preserves handler ordering and improves maintainability or measured copies/locks; it is not required to obtain the first LLM benchmark.

frameworks/light-pingora

The shared framework should own Pingora-specific integration:

  • Request body bounds and media-type checks.
  • One-pass bounded body collection with reusable byte buffers; downstream handlers receive the same captured body rather than independently reading or copying it.
  • HTTP route matching under /v1.
  • Extraction of authenticated and correlation context.
  • Buffered response and SSE frame writing.
  • Downstream disconnect detection and cancellation propagation.
  • Config registration and secret masking metadata.

Provider selection, retries, cost calculations, and cache semantics should not be embedded in apps/light-gateway/src/main.rs.

New crates/llm-gateway

A protocol-neutral crate should own the inference gateway runtime:

  • Public alias and deployment snapshots.
  • Precomputed, wait-free request snapshots and bounded admission permits.
  • Request policy and capability validation.
  • Route eligibility and selection.
  • Retry, fallback, circuit breaker, and concurrency coordination.
  • Exact/semantic cache interfaces.
  • Usage normalization, pricing, reservation, and reconciliation hooks.
  • Provider-neutral audit and metrics events.
  • Translation between normalized gateway requests and model-provider calls.

Keeping this logic outside Pingora makes it testable without a network server and reusable by a future sidecar or embedded inference client.

crates/model-provider

Provider clients should own provider-specific authentication, request encoding, response decoding, event parsing, and error classification. They should not own tenant policy or public alias routing.

The current server-oriented providers already construct and retain a reqwest::Client, which supplies connection pooling across calls. The gateway runtime must preserve that lifecycle. Runtime construction creates provider clients once per validated deployment snapshot; request execution borrows the client and must not rebuild transport state.

Add a gateway-capable operation contract while retaining an adapter for the current agent trait during migration. Each published ProviderBinding contains a preconstructed client, per-model capabilities, supported provider formats, typed request/response/stream codecs, and one pre-bound executor.

The contract does not mandate enum or trait-object dispatch before measurement:

  • A sealed built-in enum provides exhaustive compile-time dispatch and avoids a boxed async future, but centralizes provider variants and can increase maintenance coupling.
  • A preconstructed Arc<dyn InferenceProvider> keeps provider crates and optional extensions independent, but may add a virtual call and boxed future.
  • Both implementations must expose the same provider conformance suite and be benchmarked with real allocation profiles. Only a material, repeatable release-profile difference justifies making static dispatch mandatory.

Whichever representation wins, it is bound during publication. The request path never resolves a provider by string, constructs a trait object, creates a transport client, or stacks retry/routing wrapper providers dynamically.

The request codec owns the bounded compatibility envelope and canonical typed content. The provider renderer receives only validated fields and the allowlisted extensions for its exact ProviderFormat; it cannot forward a generic client JSON object to an unrelated provider.

The canonical contract needs:

AreaRequired types or behavior
ContentText, image URL/data, audio/file references when supported, tool calls, tool results, refusal, and public reasoning summary blocks.
RequestsOperation kind, messages/items, tools, tool choice, response format, sampling, output bound, stop conditions, metadata, and streaming.
EventsResponse start, content delta, tool-call delta, usage update, finish reason, error, and response complete.
UsageInput, output, cached input, reasoning, image/audio, and provider-specific billable units where available.
ErrorsStable category, HTTP status, retryable flag, provider request ID, sanitized message, Retry-After, and whether the provider may have accepted work.
CapabilitiesPer model and operation, including streaming, tools, parallel tools, vision, structured output, reasoning, embeddings, audio, and prompt caching.
ControlDeadline and cancellation propagation.

Capabilities must be per model/deployment when provider offerings differ. A provider-wide boolean is not enough for route safety.

Provider Eligibility

Initial gateway work should prioritize non-interactive, server-oriented providers. Account-login and local CLI providers remain disabled by default in shared deployments.

Before a deployment can serve an alias, it must pass a conformance profile for that alias's required features:

  • Buffered chat request and normalized response.
  • SSE stream parsing and termination.
  • Usage extraction for buffered and streaming calls.
  • Tool call name, ID, and incremental JSON argument preservation.
  • Multimodal content conversion where advertised.
  • Structured-output enforcement where advertised.
  • Timeout, rate-limit, authentication, invalid-request, and server-error classification.
  • Cancellation and body-size limits.
  • Secret redaction from errors and debug logs.

Strict typed codecs do not require brittle closed-world schemas. Request and response types distinguish fields the gateway operates on from bounded raw extensions, preserve unknown same-format fields, and use explicit Unknown(raw) handling for forward-compatible enum values where safe. Cross-format conversion remains strict because an unknown construct cannot be silently translated.

Provider drift is managed operationally as well as through releases:

  • Pin provider API versions where the provider permits it and record the negotiated/versioned contract in each deployment snapshot.
  • Run scheduled and pre-publication canary fixtures against provider sandboxes or mocks for required operations, errors, and streaming events.
  • Quarantine a deployment automatically when a required response shape or capability probe fails; aliases continue only through already conforming targets.
  • Preserve sanitized unknown response evidence by digest for diagnosis, never by logging raw content.
  • Track adapter compatibility and provider deprecation dates in GenAI Admin so an upgrade can be tested and rolled out before the upstream cutoff.

An alias must fail closed when no eligible deployment can satisfy every required capability. It must not quietly drop tools, images, JSON Schema, or a data-residency restriction to make a fallback succeed.

Control Plane And Model Catalog

The GenAI Admin LLM Model area is the authoritative administration surface. It should present model registration as related objects rather than one large record that mixes public policy, provider transport, credentials, and dynamic health.

Catalog Entities

The initial Portal projection should model these concepts. Exact table and aggregate names can follow existing Portal conventions, but the boundaries are contractual.

ConceptScope and responsibility
Model catalogPlatform or host-visible description of a physical provider model: provider type, provider model ID, family/version, lifecycle, context/output limits, modalities, supported operations, and declared capabilities. It contains no credential.
Model registrationHost authorization to use a catalog model. It records ownership, allowed environments and regions, data classifications, lifecycle state, and any host-specific capability restriction.
Provider deploymentHost-scoped callable endpoint with provider type, physical model ID, base URL, region, transport limits, provider-account/quota-group identity, versioned server-owned credential references, and conformance status. Credentials belong here, never on the agent definition.
Public model aliasClient-visible logical name such as chat-fast-v1, with allowed operations, required capabilities, token limits, data boundary, logging/PII policy, deprecation, and replacement metadata.
Alias routeOrdered or weighted alias-to-deployment relationship with priority, fallback-only status, residency constraints, and rollout/canary policy.
Pricing versionEffective-dated rates for input, output, cached input, reasoning, image, audio, service tier, and contract override. Unknown pricing remains explicit.
Model policyWhich principals, clients, agents, and product profiles may use an alias, plus budgets, content-logging mode, PII profile, caching, and provider-native extension policy.

Every mutable tenant record includes host_id. Platform-wide reference rows may be shared read-only, but a shared catalog entry does not grant a host the right to use it. The host registration, deployment, alias route, and policy jointly determine eligibility.

The existing agent_definition_t directly stores model_provider, model_name, and api_key_ref. Preserve those fields only for a bounded migration period. New definitions should reference a public alias or model policy ID. The existing agent_model_rate_t can seed pricing migration, but runtime accounting must bind a versioned pricing record rather than a mutable provider/model string pair.

GenAI Admin Workflow

The LLM Model menu should expose focused views over the same aggregates:

  • Catalog shows technically supported provider models, lifecycle, capabilities, context/output limits, modalities, and conformance status.
  • Host registrations and deployments shows which catalog models the selected host may use, regional endpoints, secret references, transport bounds, provider account/quota groups, credential lifecycle state, approved capacity, and enablement state. Secret values are never displayed.
  • Aliases and routes edits public names, required capabilities, eligible deployments, weights, fallback order, rollout percentage, and data boundary.
  • Pricing and policies manages effective-dated rates, budgets, allowed principals/agents, content mode, caching, and PII profile.

Provide actions to validate a deployment, run its conformance profile, preview the eligible routes for an alias and sample identity, publish a complete candidate, inspect its digest/version, and roll back to the last valid version. Runtime health and recent latency/error observations may be displayed read-only for operators, but editing or viewing them does not mutate catalog truth.

Agent-definition forms select only authorized public aliases or model policies for the current host_id. They do not offer free-form provider names, physical model IDs, base URLs, or API-key fields after migration.

Static And Dynamic Routing Metadata

Portal owns relatively stable, reviewable routing inputs:

  • Capabilities and conformance results.
  • Context, output, request-byte, and modality bounds.
  • Region, residency, data-classification, and provider allowlists.
  • Lifecycle, deprecation, replacement, and rollout state.
  • Effective-dated price and offline quality/evaluation scores.
  • Alias weights, priorities, fallback rules, budgets, and PII/logging profiles.

The gateway owns rapidly changing runtime observations:

  • Active/passive health and circuit state.
  • Current in-flight work and admission saturation.
  • EWMA latency, time to first token, error rate, and rate-limit signals.
  • Local lease capacity and recent provider throttling.

Do not update Portal rows for every inference. The gateway combines one immutable catalog/policy snapshot with local runtime observations, and exports bounded telemetry asynchronously. Portal may receive aggregated operational views, but those views are not the routing authority for an in-flight request.

Publication And Consistency

Catalog changes follow the existing Portal command/event and projection model:

  1. Validate aggregate references, host ownership, secret-reference shape, capability compatibility, and lifecycle transitions in the command path.
  2. Append the control-plane event and project the Portal read model.
  3. Build a complete gateway candidate containing provider deployments, aliases, routes, capabilities, pricing, and policy digests.
  4. Reject an invalid candidate without disturbing the last valid snapshot.
  5. Atomically publish the candidate and retain its version/digests in audit and agent policy snapshots.

Control-plane resource cardinality should mirror the Portal aggregates: one changed alias, route, deployment, model policy, or pricing version produces a small delta rather than republishing every route for a host. Parent resources should not embed unbounded child lists merely for transport convenience. However, the gateway's reload worker—not the request path—resolves those granular resources into affected request-ready plans. It validates all references, computes effective policy precedence, compiles expressions and wildcards, and builds provider priority groups. It rebuilds only affected subgraphs, structurally shares unchanged Arc data, and swaps one small LlmPublishedSnapshot root only after the candidate is valid. The root carries a publication manifest and compatible routing, provider, policy, and pricing versions, so one atomic load cannot observe a half-published combination.

Pricing may refresh more frequently than model authorization. Publish it as a separate immutable PricingSnapshot subgraph. A pricing-only update creates a new root pointing to the existing routing/provider/policy subgraphs and the new pricing Arc; it does not rebuild the routing graph. Multiple approved sources can overlay in declared precedence order; invalid or unreadable updates retain the last valid snapshot. A public catalog such as models.dev can seed proposed rates, but an operator-approved effective-dated version remains authoritative and a price entry never creates a model registration or route.

Rapid health, latency, in-flight, and circuit observations are bounded atomics owned by stable deployment-runtime objects, not reasons to republish configuration. Publication metrics include build duration, peak temporary bytes, reused/rebuilt nodes, active generations, and bytes retained by in-flight generations. The publisher coalesces superseded updates and stops admitting new generations when a configured retained-memory bound would be exceeded.

GET /v1/models reads the authorized public-alias view from that snapshot. It does not query Portal or enumerate physical deployments on demand.

Routing And Reliability

Selection Pipeline

For each request, filter targets in this order:

  1. Resolve the public alias from one immutable config snapshot.
  2. Apply caller model/operation allowlists.
  3. Enforce host, tenant, agent, data-classification, and region constraints.
  4. Require all request capabilities.
  5. Remove disabled, unhealthy, open-circuit, or concurrency-saturated targets.
  6. Remove targets that cannot fit the remaining token or cost budget.
  7. Apply routing priority, weight, session stickiness, or configured strategy.

The resulting route decision and snapshot version stay attached to the request for its entire lifetime. A config reload does not change an in-flight fallback chain.

Routing Strategies

Implement strategies incrementally:

  • Ordered primary/fallback chain for the MVP.
  • Priority groups in which lower-numbered healthy groups are preferred and equivalent targets within a group use weight or health/latency score.
  • Weighted random across equivalent deployments.
  • Least in-flight requests with a bounded weight bias.
  • Latency-aware selection using a rolling time-to-first-token and completion latency window.
  • Cost-aware selection subject to a minimum capability and quality tier.
  • Sticky routing by authenticated principal plus bounded session ID.
  • Canary and A/B allocation with an auditable stable hash.
  • Region and data-boundary routing as hard eligibility rules, not soft weights.
  • Conditional virtual aliases evaluated in declaration order over an allowlisted context such as authenticated claims, requested operation, region, bounded headers, and policy-derived classification. A final explicit fallback is required. Raw prompt access is disabled by default because it is both sensitive and expensive.

Quality-based or semantic routers can be explored later. They must be deterministic enough to audit, include the router's own latency and cost, and never weaken explicit policy constraints.

Retry, Fallback, And Circuit Rules

  • Retry connection failures, timeouts, 408, 429, and selected 5xx responses according to typed error policy.
  • Do not retry authentication, authorization, invalid-request, unsupported parameter, context-length, or safety-policy failures.
  • Honor provider Retry-After and apply exponential backoff with jitter.
  • Bound attempts by both count and the original request deadline.
  • Retain one replayable, bounded provider-neutral request or rendered attempt body for pre-output retries. Retry eligibility is explicit for every body size and operation; do not silently disable reliability at an arbitrary small replay-buffer constant.
  • Use a different credential or deployment only when policy allows it.
  • Open a circuit after a configurable failure threshold; probe with bounded half-open traffic.
  • Preserve required capabilities and data-boundary rules across fallback.
  • Never begin a fallback after semantic stream output is visible to the client.
  • Record each physical attempt separately but charge and report the complete logical request accurately.
  • Finalize each attempt's health signal before selecting the next target. A retryable unhealthy result can eject or penalize that deployment so reselection advances to another target or priority group rather than looping on the same endpoint.

The current ReliableProvider error-string heuristic and nested retry loops are useful prototype behavior, but gateway reliability must use typed errors and a single request-scoped attempt budget.

Security And Governance

Identity And Model Access

Reuse existing handler-chain authentication. The LLM runtime receives a trusted identity context containing the authenticated client, user, host, issuer, roles, agent delegation, and relevant policy snapshot.

LLM policy can then enforce:

  • Allowed model aliases and operations.
  • Maximum input, output, total, and reasoning tokens.
  • Maximum request bytes, images/files, tool count, and JSON Schema size/depth.
  • Requests per minute, tokens per minute, concurrent calls, and concurrent streams.
  • Per-request, per-window, and lifecycle cost budgets.
  • Allowed providers, regions, and data-classification boundaries.
  • Whether prompts or responses may be cached or content-logged.
  • Whether tools, multimodal input, structured output, or provider-native extensions are allowed.

The existing limit handler remains useful for request-count limits. Token, cost, and model concurrency limits require usage-aware LLM accounting.

Credential Isolation

  • Provider credentials are resolved only from server-owned configuration or a secret reference.
  • Secret values are masked in module registration, config inspection, errors, metrics, and logs.
  • A provider base URL is validated at config load. The client cannot override it, preventing an inference request from becoming an SSRF primitive.
  • Provider credentials should be scoped by deployment and environment rather than shared globally.
  • Key/deployment selection is audited by opaque ID; raw secret material never enters the request context or audit event.
  • Local CLI or account-session credentials require isolated single-user runner profiles and are not enabled in the shared gateway by default.

Credential rotation and capacity routing are different features. A deployment declares one opaque providerAccountId, quotaGroupId, region, and approved capacity, and may reference overlapping current/next credential versions for zero-downtime secret rotation. Every credential in that set inherits the same quota group, so adding a key cannot increase capacity. A separately approved provider account/quota is represented as another deployment and alias target. A 429 can move to that deployment only when policy permits it and the provider contract treats it as independent authorized capacity; ordinary key rotation never becomes quota striping.

The gateway must not cycle keys to bypass an upstream RPM/TPM, account tier, fair-use control, or abuse limit. Such behavior creates financial and provider account risk and is rejected during configuration review. The 5,000-RPS performance gate uses a controlled local mock to measure gateway capacity; it is not an instruction to send 5,000 RPS through one or many production provider keys.

Guardrails And Data Protection

Support ordered pre-provider and post-provider policy hooks:

  • Prompt and attachment size/type validation.
  • PII/tokenization or DLP policy.
  • Moderation and prohibited-content policy.
  • Prompt-injection and secret-exfiltration signals where configured.
  • Tool schema and tool-name allowlists.
  • Structured-output schema validation.
  • Output redaction and data-boundary checks.

Compile the effective hook order into the alias plan. The default normalized request order is: validate client fields; apply server-owned defaults and prompt enrichment; run local request guardrails and PII classification; tokenize protected spans; reserve the final token/cost bound; select an eligible target; apply only that target's typed provider transformation and authentication; then dispatch. Provider-specific remote guardrails declare whether they receive original, tokenized, or metadata-only content, and policy validation rejects a data-boundary violation before activation.

On response, decode the provider format into semantic events before applying content policy. Exact placeholder recovery runs only for the originating authorized scope. Local safety policy can run before or after recovery as its profile declares; a remote service receives recovered cleartext only when its data boundary explicitly permits it. Finally render the original ClientFormat and release buffered or approved streaming frames.

Streaming has a sharp policy boundary: a post-filter cannot retract bytes that have already reached the client. A policy requiring whole-response inspection must either force buffered mode, use an upstream/provider guardrail that runs before emission, or reject streaming for that alias. Chunk-local filtering is allowed only for policies explicitly designed and tested for bounded windows.

Privacy-Aware Logging

Default audit events contain metadata, not content:

  • Request/correlation/trace ID.
  • Authenticated policy scopes.
  • Public alias, internal route ID, and config snapshot version.
  • Timing, status, retry/fallback count, and cancellation reason.
  • Normalized usage and computed cost.
  • Cache and guardrail outcomes.

Content logging is separately authorized, sampled, redacted or tokenized, encrypted, and retention-bounded. Never put prompts, completions, tool arguments, user IDs, API keys, or model output into metric labels.

Content is not a general-purpose handler attribute. Materialize prompt, completion, tool, or raw-body views lazily only when the compiled policy proves that an authorized content hook needs them. Make the raw request available only inside that content-policy phase and remove it before general transformations, access logs, and telemetry expressions run. Later stages receive normalized metadata, policy outcomes, digests, or encrypted object references.

Parsing and provider-error paths follow the same rule. Ordinary logs never include raw request bodies, provider response/error bodies, malformed SSE data, or a "first N bytes" preview. Emit the normalized error category, provider and route IDs, status, content type, byte length, parser position, and a keyed digest. An authorized encrypted-content capture is an audit operation with its own purpose and retention, not a debug log statement.

Storage Ownership

Use storage according to the data's workload and security domain:

DataAuthoritative homeNotes
Model catalog, aliases, routes, policies, and pricingLight Portal PostgreSQLControl-plane data projected to immutable gateway snapshots.
Canonical agent conversation/action eventsPortal agent event ledgerBounded normalized event or content reference used to rebuild agent state; not a copy of every physical provider attempt.
Logical inference request and physical attempt metadataDedicated local/regional audit PostgreSQLAppend-oriented, time-partitioned, separately credentialed, and written asynchronously.
Authorized prompt/response bodies and multimodal objectsEncrypted content storeStore ciphertext or an immutable object-store reference plus digest; do not put large content in Portal OLTP rows.
Delivery backlog after sink interruptionGateway-local bounded WAL/spoolStore-and-forward only. It is not the only copy after acknowledgement and is not queried as the audit corpus.
Immediate reversible PII mappingRequest memoryDefault for synchronous inference; destroy after the response and audit finalization.
Durable reversible PII mappingSeparate regional PII vaultUsed only for asynchronous, multi-turn, restart/failover, or explicit retention requirements. Never colocate with the audit content corpus.

Here, "local" means within the organization's approved trust zone or region. It does not mean that each gateway replica owns the only durable copy. An embedded database such as SQLite is suitable for a single-writer spool, but replica loss, rescheduling, failover, and cross-replica queries make it a poor authoritative audit or PII store.

Audit Record Model

Represent one client call separately from its physical provider attempts:

  • Proposed llm_request_t is the time-partitioned logical-request table.
  • Proposed llm_attempt_t is the ordered physical-attempt table keyed to the logical request and partition period.
  • Proposed llm_content_object_t stores only encrypted-object metadata, immutable reference, digest, media type, size, encryption-key reference, retention class, and deletion state.
  • Proposed llm_dataset_export_t records a curated audit/evaluation/training export manifest, purpose, approvals, transformations, source partition range, content digests, and retention/deletion state.

At minimum, those records contain:

  • A logical request record contains request/correlation IDs, authenticated host/client/agent identities or opaque references, public alias, operation, policy/catalog/config versions, admission and completion timestamps, final status, usage/cost totals, retention class, content mode, PII profile, and content references/digests.
  • An attempt record contains logical request ID, attempt number, internal route and deployment IDs, physical model, retry/fallback reason, provider request ID, connect/provider/first-token/total timing, status, cancellation state, provider usage evidence, and pricing version.
  • A transformation manifest records whether request content was raw, tokenized, redacted, or omitted at provider dispatch and audit capture. It stores policy and detector versions plus digests, not reversible cleartext.

This distinction preserves evidence when a single logical request retries, falls back, times out after provider acceptance, or returns a partial stream. Streaming produces one summarized attempt record rather than one database row per chunk.

Content Modes And Purpose

Each resolved model policy selects one explicit content mode:

  • metadata-only: default; store no prompt, completion, or tool content.
  • tokenized-content: store the provider-visible tokenized exchange for approved audit/evaluation use.
  • encrypted-raw: exceptional; store envelope-encrypted pre-tokenization or post-recovery content under stricter authorization and shorter retention.
  • disabled: emit only the minimum operational counters allowed by policy and no durable request-level audit record where regulations require that mode.

Audit, evaluation, and training are different purposes. Operational audit data does not automatically become training data. A separately authorized export job creates a versioned, immutable dataset manifest, applies consent and retention rules, records source digests and transformations, and excludes data that is not approved for the requested purpose.

Delivery And Failure Semantics

Audit policy separates admission pressure from crash durability. A model policy selects one of these explicit profiles; required by itself must never be interpreted as an unspecified durability promise:

ProfileBefore provider dispatchCrash guaranteeIntended use
best-effortTry to enqueue; pressure may drop the record and increments a loss counter.None.Local development only; invalid for an alias requiring audit.
bounded-asyncReserve a complete bounded envelope and queue/spool budget or fail admission. The request does not wait for a disk commit.A declared tail window can be lost if the process or node fails before the writer commits it.Default metadata-only, high-throughput production profile and the feature-equivalent Bifrost comparison.
local-durableAppend the admitted/attempt-start event and wait for the WAL durable watermark before every provider attempt.A crash can leave an explicitly incomplete attempt, but cannot erase evidence that dispatch was authorized.Regulated workloads that require pre-dispatch evidence. It has a separately reported latency SLO.
remote-durableWait for an idempotent authoritative-sink transaction.Survives loss of the gateway node according to the sink's durability contract.Later strict-accounting profile; not part of the MVP fast path.

For bounded-async and local-durable, admission reserves the worst-case metadata budget for the logical request and configured maximum attempts before expensive parsing or dispatch. If the queue and allowed spool path cannot honor that reservation, fail before provider work. Optional content capture may be dropped independently while retaining required metadata.

The MVP spool is a single-writer, append-only segmented WAL, not an embedded query database. Its stable on-disk format is:

  • A segment header containing a fixed Light LLM-audit magic value, WAL format version, segment UUID, gateway-instance ID, and creation timestamp.
  • Immutable records encoded as [length][checksum][sequence][payload]. length is bounded, checksum covers the sequence and payload, and a corrupt or partial tail is truncated during recovery.
  • A UTF-8 JSON payload with its own schemaVersion, UUIDv7 eventId, logical request ID, optional attempt number, event kind, timestamp, published-snapshot digest, and metadata body. MVP WAL payloads never contain prompts, completions, tool arguments, provider error bodies, credentials, or PII.
  • Event kinds request_admitted, attempt_started, attempt_finished, and request_finished. Records are append-only; completion never overwrites a start record.

Serialization evolution is additive within a payload schema version. A reader must skip a bounded unknown event kind, but it must reject an unknown segment format version rather than guessing record boundaries. Segment and record size limits are validated before publication.

A dedicated writer batches by maximum records, bytes, and commit delay. In local-durable mode it calls fdatasync after the batch and advances a monotonic durable-sequence watermark. A request waiting to dispatch succeeds only when its start sequence is at or below that watermark; timeout, I/O error, read-only filesystem, or a full volume fails the request without an upstream attempt. This is bounded group commit, not per-request file opening or an unbounded synchronous database call.

For buffered local-durable requests, request_finished is also committed before a successful final response when terminalCommitBeforeResponse is enabled. For SSE, attempt_started is durable before response headers or the first semantic event; the terminal event is appended on normal completion. A crash after streaming begins therefore leaves a durable incomplete attempt rather than falsely recording success.

The sink consumes WAL events in sequence and writes them idempotently using the unique eventId plus logical request/attempt keys. A segment is deleted only after every record has an authoritative acknowledgement and the acknowledgement checkpoint is itself durable. Startup scans and verifies segments, truncates only a partial final record, replays unacknowledged events, and marks a durable start without a terminal event as incomplete. Duplicate delivery is expected and must not create duplicate request or attempt rows.

local-durable is valid only with a dedicated persistent volume whose deployment contract survives process and pod restart. Configuration must declare the persistence class; an ephemeral emptyDir, container filesystem, or undocumented host path is rejected for this profile. The directory is gateway-write-only, uses restrictive file permissions and encrypted storage, and has explicit capacity, retention, and alert thresholds. Node loss beyond the volume's durability boundary requires remote-durable rather than a stronger claim about a local WAL.

A background sink batches metadata into the dedicated audit database and places later authorized large encrypted content in the content store. It never performs an unbounded synchronous Portal or audit-database write on the normal high-throughput path.

Partition metadata by time and make retention removal a partition operation. Encrypt content with per-host or per-retention-class data keys, keep key references out of content rows, and use separate roles for gateway writes, auditor reads, dataset export, and deletion.

Reversible PII Tokenization

Reversible PII tokenization is feasible, but the LLM path needs content-aware token processing rather than only JSON-field replacement.

The existing light-pingora PII handler is a useful cryptographic and schema baseline: it scopes lookup by host_id, encrypts cleartext values, stores a keyed value hash, and can tokenize configured request fields and detokenize configured response fields. It is not the final LLM implementation because:

  • It replaces the complete string at a configured JSON path; it does not find multiple sensitive substrings inside a normal message or tool argument.
  • Response detokenization expects a field value to be exactly one stored token; it does not recover authenticated placeholders embedded in model prose.
  • Response transformation buffers the complete body, which is incompatible with transparent SSE streaming.
  • The current insert path does not assign an expiry, host-stable value reuse is linkable across requests, and the default cache can retain cleartext.
  • Its database URL can fall back to the shared application database, which is a deployment convenience rather than the desired production security boundary.

Policy And Transformation Flow

The resolved model policy includes a piiProfileId, token scope, detector and rule versions, allowed data classifications, failure mode, and whether streaming remains eligible. Apply it in this order:

  1. Normalize the client request and identify eligible message text, text content parts, structured fields, and tool arguments. Do not tokenize provider routing, schema names, tool names, or control metadata accidentally.
  2. Detect configured PII types using compiled local rules or a bounded detector profile. A remote DLP dependency is a separately named strict profile with its own admission and latency SLO.
  3. Replace each sensitive span with a high-entropy authenticated placeholder, using a short fixed ASCII grammar such as [LPII1_<base32-id>_<truncated-mac>], and keep the mapping in the request context by default. The exact grammar and tag length follow a security review; the example is illustrative.
  4. Serialize and send only the tokenized canonical request to a cloud provider.
  5. Scan normalized response text and tool arguments for exact placeholders, validate the MAC and host/request/session scope, and recover authorized values before returning the response to the originating agent.
  6. Record transformation digests and policy versions in audit metadata. Store content only according to the resolved content mode.
  7. Destroy request-scoped cleartext mappings after response delivery, audit finalization, and any required retry window.

Never use fuzzy token recovery. A missing, expired, altered, or unauthorized placeholder remains masked or fails the response according to policy. It must never trigger a broader lookup or reveal a value from another host, principal, request, or session.

Exact recovery is a security invariant, but a mangled token does not need to make the normal user experience brittle. Tokenization profiles therefore also define unresolvedTokenPolicy:

  • leave-masked is the default. Preserve the unresolved placeholder or replace an identifiable malformed token with a generic irreversible marker, record a near-miss outcome, and continue. Never guess the original value.
  • reject-buffered is for workflows that require complete recovery. It forces buffered output and rejects before any semantic bytes are emitted.
  • A streaming profile cannot select a policy that would fail the response after earlier content has reached the client.

The gateway adds a short server-owned instruction to copy placeholders verbatim when the alias permits prompt enrichment, and keeps placeholders in structured content/tool values where possible. Provider conformance measures exact preservation, alteration, omission, and hallucinated-token rates over a versioned corpus. A model/deployment that does not meet the profile threshold is ineligible for reversible PII; the alias must use irreversible redaction, buffered strict handling, or another deployment. Near-match detection may produce telemetry, but it never performs a vault lookup or detokenization.

Token Scope And Vault Boundary

Support these scopes explicitly:

  • request: default. The mapping exists only in request memory and avoids database I/O on the normal inference path.
  • session: opt-in for multi-turn tokenized history. Mappings expire with a bounded session retention and are available to authorized gateway replicas.
  • host: exceptional stable-token mode for a documented integration need. It increases linkability and requires explicit security approval.

A durable mapping is required for asynchronous/batch inference, multi-turn history containing placeholders, restart/failover recovery, or a response that may resume on another replica. Access it through a narrow PiiVault interface: insert with expiry, resolve one exact scoped token, revoke, and expire. The gateway role cannot scan or export the vault.

The production implementation can be a dedicated regional PostgreSQL vault, a vault service, or a Redis-compatible distributed KV deployment only when it meets the same security and durability contract: independent credentials and network boundary, TLS, encryption of values with external key references, atomic insert/resolve semantics, enforced TTL, bounded memory behavior, restart/failover persistence, backup/recovery objectives, access audit, and tested deletion. An ordinary volatile Redis/Dragonfly cache or an eviction policy that can discard live mappings is not a durable PII vault. PostgreSQL is the conservative durable default; a qualified KV implementation is an optional session-scale profile selected by measured latency and recovery requirements.

The durable vault entry includes at least host ID, opaque token ID, token format/version, scope kind, a non-reversible scope binding, PII type, encrypted value, nonce, key ID, creation/expiry timestamps, and active/deletion state. Request scope does not use the current host-stable value_hash uniqueness rule. Session/host deduplication, when explicitly required, uses a separate keyed hash and policy so linkability is visible and reviewable.

Using a separate schema and role in the Portal PostgreSQL cluster is an acceptable transition for development or an initial low-risk deployment, but it shares administrator, backup, and failure boundaries. Production PII mappings should not live in the Portal database, an audit-content database, or a replica-local embedded database. Colocating reversible mappings with logged content would defeat the intended breach separation.

Streaming Recovery

Provider streams can split a placeholder across arbitrary SSE and UTF-8 chunk boundaries. A streaming-compatible PII profile keeps a bounded suffix no larger than the maximum placeholder length, emits only bytes that cannot begin a placeholder, and validates/replaces complete placeholders before release. An altered candidate follows leave-masked; it is not recovered fuzzily and does not terminate a normal stream after earlier semantic output. A strict reject-buffered profile is never published as streaming-compatible.

If detection or response policy requires whole-message context, force buffered mode or reject streaming for that alias. Do not emit raw partial token syntax and attempt to retract it later. Tokenization and recovery benchmarks must be reported as named policy profiles; the metadata-only/no-PII profile remains the baseline performance contract.

Token And Cost Governance

Usage accounting must distinguish estimated, provider-reported, and locally counted values.

When a provider lacks a native token-count endpoint, a local tokenizer may answer an internal estimate or a later provider-native compatibility endpoint. The result is labelled local-estimate with tokenizer/model-table version; it can enforce a conservative admission bound but cannot be recorded as exact provider billing evidence. A native count response is also not inference usage and must not be charged as consumed prompt tokens.

Recommended flow:

  1. Validate the alias's maximum output and estimate or count input units.
  2. Atomically reserve the maximum allowed tokens and cost before provider dispatch.
  3. Reject the request when the authoritative budget cannot reserve capacity.
  4. Reconcile the reservation with trusted provider usage when the request finishes.
  5. Retain a conservative charge or explicitly mark accounting incomplete when a timeout/cancellation prevents authoritative usage.
  6. Store the pricing-table version and evidence source with the ledger entry.

Scopes should include host, customer/organization, team, client, user, agent, public alias, and provider deployment as needed. Multi-replica deployments need an authoritative shared quota store; process-local counters are not sufficient for hard budgets.

Provider-side capacity is tracked by declared providerAccountId and quotaGroupId, not merely credential ID. Local admission consumes RPM/TPM and concurrency leases for that group before dispatch and updates them from provider rate-limit signals. Adding or rotating another secret in the same group does not create more capacity.

Opaque routes use request/byte/duration/concurrency units plus the configured fixed cost envelope and account-spend ceiling. Because realized token usage is unknown, reconciliation cannot release a pessimistic reservation based on a guess; only authoritative later billing evidence may amend it. This makes opaque compatibility intentionally less efficient than normalized routing rather than a way around financial governance.

The pricing catalog must be versioned and support:

  • Input, output, cached input, and cache-creation rates.
  • Reasoning, image, audio, and other provider-specific billable units.
  • Tiered context pricing and provider service tiers.
  • Contract-specific overrides.
  • A clear unknown state. Unknown pricing must not silently become zero when a hard cost budget is configured.
  • Atomic replacement, declared source precedence, and last-valid retention on refresh failure. The logical request and every physical attempt capture the exact pricing snapshot and effective tier used for reconciliation.

Caching

Caching is valuable but should follow routing, security, and usage correctness.

Exact Response Cache

Add after the MVP with a cache key that includes at least:

  • Authenticated tenant/policy partition.
  • Public alias and alias snapshot version.
  • Normalized messages/items, tools, tool choice, response format, and relevant sampling parameters.
  • Data-boundary and guardrail policy version.

Do not cache tool-call responses, sensitive requests, or nondeterministic requests by default. Provider prompt caching and gateway response caching are different features and need separate metrics.

Semantic Cache

Semantic caching is a later, explicit opt-in because similar prompts are not necessarily interchangeable. It requires:

  • Tenant- and policy-isolated vector indexes.
  • A versioned embedding model and similarity threshold.
  • Alias, tool, structured-output, locale, and safety-policy compatibility.
  • No cross-tenant hits.
  • Auditability of the matched entry and score.
  • A deletion and retention model for source text and embeddings.

Its lookup is an explicit sub-operation in the request deadline and budget, not hidden preprocessing:

  1. Perform normal identity, alias, size, data-boundary, and fail-fast global admission first; check the cheaper exact cache before semantic work.
  2. Acquire separate bounded semantic-cache and embedding permits. Reserve the embedding token/cost envelope and a configured deadline slice without consuming all time needed for the primary LLM route.
  3. Call only a policy-approved embedding deployment, then run a bounded vector lookup. Account for both operations and audit their model/index versions.
  4. On a hit, apply current authorization and post-response policy before returning. On a miss, release cache permits and continue with the remaining LLM deadline and budget. A lookup timeout follows the alias's explicit cacheFailureMode, normally treat-as-miss; it never waits without bound.
  5. Coalesce identical in-flight cache fills and bound fill concurrency so a popular miss cannot create an embedding or provider stampede.

Client end-to-end time to first byte starts at gateway admission and therefore includes semantic lookup. Report semantic_embedding_duration, semantic_lookup_duration, time_to_cache_hit, and the later provider time-to-first-token separately. A cache hit has no provider TTFT. Release and performance gates use named semantic-cache profiles rather than hiding this latency inside routing.

Cached responses still pass current authorization and post-response policy. Usage and cost clearly distinguish cache hits from provider calls.

MCP And WebSocket Integration

The normal agent tool loop remains:

agent -> MCP router tools/list
agent -> LLM gateway chat request with selected tool schemas
LLM gateway -> model provider
model provider -> tool call
LLM gateway -> agent
agent -> MCP router tools/call
MCP router -> backend API or MCP server
agent -> LLM gateway with tool result

This separation preserves the MCP router as the execution, authorization, and audit boundary. The LLM gateway must not accept a model-generated target URL or execute a tool solely because the model emitted its name.

A later feature may let a policy-selected tool profile inject a small set of MCP schemas into an LLM request. Even then:

  • The tool set is selected by server-owned policy and the authenticated principal.
  • Tool execution still goes through the MCP router.
  • The client agent remains responsible for the tool loop unless a separately designed managed-agent service owns it.

The existing websocket handler routes browser UI traffic to agents. A future OpenAI-compatible Realtime API has different session, audio, provider, and billing semantics and must use a distinct handler and configuration rather than overloading the UI router.

Configuration Model

Use llm-router.yml for the data-plane projection. Provider credentials are masked values or secret references populated through the existing runtime configuration flow.

Illustrative configuration:

enabled: ${llm-router.enabled:false}
pathPrefix: ${llm-router.pathPrefix:/v1}
maxRequestBodyBytes: ${llm-router.maxRequestBodyBytes:4194304}
maxResponseBodyBytes: ${llm-router.maxResponseBodyBytes:16777216}
requestTimeoutMs: ${llm-router.requestTimeoutMs:120000}
streamIdleTimeoutMs: ${llm-router.streamIdleTimeoutMs:30000}
maxConcurrentRequests: ${llm-router.maxConcurrentRequests:1024}
maxConcurrentStreams: ${llm-router.maxConcurrentStreams:512}
unsupportedParameterPolicy: ${llm-router.unsupportedParameterPolicy:reject}

admission:
  maxQueuedRequests: ${llm-router.admission.maxQueuedRequests:0}
  maxQueueWaitMs: ${llm-router.admission.maxQueueWaitMs:0}
  overloadStatus: ${llm-router.admission.overloadStatus:503}
  streamBufferEvents: ${llm-router.admission.streamBufferEvents:32}
  streamSetupTimeoutMs: ${llm-router.admission.streamSetupTimeoutMs:5000}
  maxStreamLifetimeMs: ${llm-router.admission.maxStreamLifetimeMs:120000}
  downstreamWriteProgressTimeoutMs: ${llm-router.admission.downstreamWriteProgressTimeoutMs:10000}
  minStreamDrainBytesPerSecond: ${llm-router.admission.minStreamDrainBytesPerSecond:256}

publication:
  maxRetainedGenerations: ${llm-router.publication.maxRetainedGenerations:8}
  maxRetainedBytes: ${llm-router.publication.maxRetainedBytes:536870912}
  coalesceWindowMs: ${llm-router.publication.coalesceWindowMs:100}

opaqueDefaults:
  maxRequestBytes: ${llm-router.opaqueDefaults.maxRequestBytes:1048576}
  maxResponseBytes: ${llm-router.opaqueDefaults.maxResponseBytes:8388608}
  maxDurationMs: ${llm-router.opaqueDefaults.maxDurationMs:30000}
  fixedCostReservationUsd: ${llm-router.opaqueDefaults.fixedCostReservationUsd:}
  requireAccountSpendCeiling: ${llm-router.opaqueDefaults.requireAccountSpendCeiling:true}

telemetry:
  auditQueueCapacity: ${llm-router.telemetry.auditQueueCapacity:8192}
  usageQueueCapacity: ${llm-router.telemetry.usageQueueCapacity:8192}
  perChunkEvents: ${llm-router.telemetry.perChunkEvents:false}

audit:
  admissionPolicy: ${llm-router.audit.admissionPolicy:required}
  durability: ${llm-router.audit.durability:bounded-async}
  contentMode: ${llm-router.audit.contentMode:metadata-only}
  includeProviderRequestId: ${llm-router.audit.includeProviderRequestId:true}
  contentSampleRate: ${llm-router.audit.contentSampleRate:1.0}
  terminalCommitBeforeResponse: ${llm-router.audit.terminalCommitBeforeResponse:true}
  spool:
    enabled: ${llm-router.audit.spool.enabled:true}
    path: ${llm-router.audit.spool.path:/var/lib/light-gateway/llm-audit}
    persistenceClass: ${llm-router.audit.spool.persistenceClass:ephemeral}
    maxBytes: ${llm-router.audit.spool.maxBytes:1073741824}
    segmentBytes: ${llm-router.audit.spool.segmentBytes:67108864}
    maxRecordBytes: ${llm-router.audit.spool.maxRecordBytes:65536}
    maxBatchRecords: ${llm-router.audit.spool.maxBatchRecords:256}
    maxBatchBytes: ${llm-router.audit.spool.maxBatchBytes:1048576}
    maxCommitDelayMs: ${llm-router.audit.spool.maxCommitDelayMs:1}
    commitTimeoutMs: ${llm-router.audit.spool.commitTimeoutMs:10000}
  sink:
    type: ${llm-router.audit.sink.type:postgres}
    databaseUrl: ${llm-router.audit.sink.databaseUrl:}
    batchSize: ${llm-router.audit.sink.batchSize:256}
  contentStore:
    type: ${llm-router.audit.contentStore.type:none}
    bucket: ${llm-router.audit.contentStore.bucket:}

pii:
  defaultProfile: ${llm-router.pii.defaultProfile:none}
  vault:
    type: ${llm-router.pii.vault.type:none}
    url: ${llm-router.pii.vault.url:}
    credentialRef: ${llm-router.pii.vault.credentialRef:}
    durabilityProfile: ${llm-router.pii.vault.durabilityProfile:durable}
    maxConnections: ${llm-router.pii.vault.maxConnections:8}
    defaultTtlSeconds: ${llm-router.pii.vault.defaultTtlSeconds:86400}
  profiles:
    - id: cloud-request-scoped
      scope: request
      detector: local-rules-v1
      tokenFormat: authenticated-placeholder-v1
      unresolvedTokenPolicy: leave-masked
      streamingMode: bounded-token-window

providers:
  openai-primary:
    type: openai
    baseUrl: ${llm.providers.openaiPrimary.baseUrl:https://api.openai.com/v1}
    providerAccountId: openai-account-primary
    quotaGroupId: openai-tier-primary
    credentials:
      - id: openai-key-current
        secretRef: ${llm.providers.openaiPrimary.secretRef:}
        lifecycle: current
    connectTimeoutMs: 3000
    requestTimeoutMs: 90000
  anthropic-primary:
    type: anthropic
    baseUrl: ${llm.providers.anthropicPrimary.baseUrl:https://api.anthropic.com}
    providerAccountId: anthropic-account-primary
    quotaGroupId: anthropic-tier-primary
    credentials:
      - id: anthropic-key-current
        secretRef: ${llm.providers.anthropicPrimary.secretRef:}
        lifecycle: current
    connectTimeoutMs: 3000
    requestTimeoutMs: 90000

models:
  - name: chat-fast-v1
    clientFormats: [openai-chat-completions]
    operations: [chat]
    processingMode: normalized
    capabilities: [streaming, tools, vision]
    maxInputTokens: 128000
    maxOutputTokens: 8192
    dataClassifications: [public, internal]
    routes:
      - id: openai-fast-primary
        provider: openai-primary
        model: provider-physical-model-a
        providerFormat: openai-chat-completions
        priority: 0
        weight: 80
        regions: [ca, us]
      - id: anthropic-fast-fallback
        provider: anthropic-primary
        model: provider-physical-model-b
        providerFormat: anthropic-messages
        priority: 1
        weight: 100
        regions: [ca, us]

routing:
  strategy: priority-weighted
  maxAttempts: 3
  baseBackoffMs: 100
  maxBackoffMs: 2000
  retryStatuses: [408, 429, 500, 502, 503, 504]
  circuitBreaker:
    failureThreshold: 5
    resetTimeoutMs: 30000
    halfOpenRequests: 1
  sessionHeader: X-Light-Session-Id

This is a proposed shape, not a statement that these keys are already implemented. Portal source records keep secret-bearing deployments, public aliases/routes, model policies, audit-sink configuration, and PII profiles as separate security and lifecycle objects. llm-router.yml is their validated data-plane projection, not a second independently edited source of truth.

Configuration validation must reject:

  • Duplicate alias, provider, route, or deployment IDs.
  • Empty credential sets for an enabled provider unless its authentication mode explicitly allows them.
  • Deployments without provider-account/quota-group identity, invalid credential lifecycle overlap, or a capacity policy that would cycle keys inside one quota group to bypass upstream limits.
  • Invalid or unsafe provider base URLs.
  • Fallback targets missing an alias's required capability or region.
  • A route whose ProviderFormat cannot represent the alias's ClientFormat and operation without dropping a required field or event.
  • detect or opaque processing on an alias that requires content guardrails, reversible PII, normalized audit, cross-format fallback, authoritative token accounting, or exact realized per-call cost accounting.
  • An opaque route without bounded bytes, duration, request/concurrency limits, and either a pessimistic fixed cost reservation or an authoritative provider-account spend ceiling.
  • Routes to interactive providers in a shared profile.
  • Impossible token or timeout bounds.
  • Unknown strategy, operation, capability, or unsupported-parameter policy.
  • Unbounded or contradictory admission settings, including a non-zero queue without a positive queue-wait deadline.
  • Stream admission without positive setup, write-progress, idle, and absolute lifetime deadlines, or publication without retained-generation and retained-memory bounds.
  • Unbounded audit, usage, cache, stream-event, or provider concurrency buffers.
  • An alias that requires audit selecting best-effort, or an unknown admission/durability profile.
  • local-durable without a bounded WAL, positive commit timeout, valid segment/record/batch limits, and a declared persistent-volume class.
  • remote-durable without an authoritative idempotent sink and a bounded transaction timeout.
  • An alias or model policy referencing a missing/inactive deployment, pricing version, content mode, or PII profile.
  • A durable PII profile without a separately credentialed vault, expiry, key reference, and exact host/session authorization policy.
  • Required audit admission without a complete worst-case envelope reservation, or encrypted-raw content mode without an encrypted content store and retention class.
  • disabled audit selected by a policy that requires a durable request record, or sampling applied to required metadata instead of optional content.
  • Secret values that would be exported without a mask.

Reload builds and validates a complete candidate runtime before one atomic swap. The previous runtime remains active when candidate validation fails.

Feature Priorities

MVP: Compatible And Safe Inference

  • llm handler and reloadable llm-router.yml runtime.
  • OpenAI-compatible /v1/models and /v1/chat/completions.
  • Buffered and SSE streaming with disconnect cancellation.
  • Text, tool calling, usage, and provider-supported image input.
  • Public aliases and ordered primary/fallback routes.
  • Portal-backed host model registrations, deployments, aliases, model policies, pricing versions, and an atomic gateway projection.
  • Server-side credentials with masking and base-URL validation.
  • Existing Light authentication, access control, correlation, metrics, and request rate limits.
  • Request, token, response, timeout, concurrency, and attempt bounds.
  • Typed errors and explicit unsupported-parameter behavior.
  • Separate client/operation/provider-format types, with a same-format compatibility fast path and no parse-failure downgrade to opaque forwarding.
  • Provider conformance tests for the first supported deployment set.
  • Provider API-version pinning where available, forward-compatible same-format envelopes, and deployment quarantine on required contract drift.
  • Metadata-only logical-request and physical-attempt audit events, normalized usage, bounded-async delivery for the default performance profile, and a separately measured local-durable WAL profile feeding a dedicated audit sink.
  • Checked-in direct/mock/Bifrost benchmark harness, immutable benchmark manifests, and passing absolute and comparative performance gates.
  • One wait-free structurally shared published-snapshot root, reusable provider clients, fail-fast bounded admission, bounded streaming/audit/usage channels, and slow-consumer setup/write-progress/absolute deadlines.

Production Hardening

  • Weighted and least-in-flight routing.
  • Health/latency-scored priority groups and retry-driven outlier reselection.
  • Per-target circuit breakers and active/passive health signals.
  • Shared per-principal token, cost, and concurrency budgets.
  • Provider-account/quota-group capacity, governed credential lifecycle, and explicit prohibition of key cycling for limit evasion.
  • Versioned pricing and usage reconciliation.
  • Model/region/data-boundary policy.
  • Structured outputs and expanded multimodal conformance.
  • Exact response caching.
  • Pre/post guardrail hooks and strict streaming policy modes.
  • Policy-selected tokenized/encrypted content capture with purpose-specific retention and curated evaluation/training dataset export.
  • Request-scoped LLM content tokenization and exact response recovery, followed by a separately credentialed regional PII vault for session, asynchronous, and failover profiles.
  • Portal configuration, route inspection, usage, and budget views.
  • OpenTelemetry traces and operational dashboards.
  • Multi-replica chaos, failover, and stream soak testing.
  • Continuous capacity-regression testing for the production handler profile, including allocation, CPU, RSS, connection reuse, and overload recovery.

Endpoint And Intelligence Expansion

  • /v1/responses with native normalized events.
  • Embeddings, moderation, images, audio, rerank, and batch APIs when supported by dedicated provider operations.
  • Semantic caching.
  • Cost-, latency-, and quality-aware routing.
  • Canary/A-B routing and policy-controlled session stickiness.
  • Selected Anthropic or Google native compatibility adapters.
  • Realtime inference over a dedicated WebSocket/WebRTC handler.
  • Optional governed MCP tool-schema injection without in-gateway execution.

Observability

Emit bounded-cardinality metrics for:

  • Logical requests and physical attempts.
  • Successes and normalized error categories.
  • Active and queued requests/streams.
  • Request duration, provider latency, time to first token, and stream duration.
  • Input, output, cached, reasoning, and total tokens.
  • Estimated and reconciled cost.
  • Retries, fallback depth, circuit state changes, and route saturation.
  • Cache hit/miss/bypass and guardrail outcomes.
  • Downstream disconnects and upstream cancellation outcomes.
  • Slow-consumer write-progress/drain-rate terminations and stream permit hold time.
  • Snapshot publication build/retire duration, rebuilt/shared nodes, retained generations, and retained bytes.
  • PII placeholder preservation, alteration, omission, hallucination, unresolved handling, and recovery outcomes without token IDs or values as labels.
  • Semantic embedding/lookup duration, time to cache hit, coalesced fills, and cache-failure-mode outcomes.

Labels can include public alias, operation, route ID, provider type, status class, and environment when their value sets are bounded. Never label metrics with prompt text, user-provided model strings, user IDs, session IDs, raw provider error text, or provider request IDs.

Each trace should separate:

  • Authentication and policy evaluation.
  • Quota reservation.
  • Exact-cache lookup and, when enabled, semantic embedding/vector lookup.
  • Route selection.
  • Each provider attempt.
  • First-token wait and stream transfer.
  • Guardrail processing.
  • Usage and cost reconciliation.

Tracing is sampled and request-scoped. The default profile does not create a span or export event for every stream chunk. High-detail diagnostic tracing is time-bounded, rate-limited, and excluded from release benchmark comparisons unless enabled identically for every candidate.

Testing Strategy

Protocol Tests

  • Golden request/response fixtures from current OpenAI SDKs.
  • Chat message roles, content parts, tool calls, structured output, usage, and error envelopes.
  • SSE fragmentation at arbitrary byte boundaries, multi-byte UTF-8, tool-call argument deltas, final usage, [DONE], and midstream failure.
  • SDK smoke tests with at least Python and TypeScript clients using only a base URL and credential change.
  • Same-format unknown-field preservation, operated-field validation, and proof that cross-format conversion rejects unknown extensions it cannot represent.
  • Forward-compatible unknown enum/field fixtures prove safe same-format preservation while required or unsafe drift quarantines the deployment.
  • Proof that an internally requested streaming usage record is stripped when the client did not request stream_options.include_usage.

Provider Contract Tests

  • Provider mock servers for every supported success and error shape.
  • Capability conformance by physical model/deployment.
  • Authentication, rate-limit, invalid-request, context-limit, timeout, 5xx, malformed JSON, oversized body, and truncated stream behavior.
  • Provider request ID, Retry-After, usage, and cancellation extraction.
  • Secret redaction from every error and log path.
  • Malformed JSON/SSE and provider parse failures prove that raw body prefixes, prompts, completions, tool arguments, and provider error bodies never reach ordinary logs or traces.

Routing And Governance Tests

  • Deterministic alias resolution from one immutable published root and its generation-compatible sub-snapshots.
  • Host registration, deployment, alias-route, model-policy, pricing-version, and agent-definition referential validation.
  • Proof that a shared catalog row does not grant an unregistered host access and that /v1/models never exposes physical deployments.
  • Capability, policy, region, and data-boundary target filtering.
  • Weighted distribution and stable canary/session allocation.
  • Retry/fallback deadline and attempt limits.
  • Priority-group failover proves that a retryable unhealthy response updates outlier state before reselection and does not choose the same ejected target.
  • Replay tests cover requests above 64 KiB up to the configured maximum and prove that retry eligibility is explicit rather than silently disabled.
  • Proof that no retry or fallback starts after the first semantic stream event.
  • Atomic token/cost reservation under concurrency and correct reconciliation on success, error, timeout, and cancellation.
  • Cross-tenant cache and session-stickiness isolation.
  • Credential-rotation tests prove keys in one quota group share capacity and a 429 cannot trigger quota-evasion cycling; independent approved accounts can fail over only according to explicit policy.
  • Opaque routes enforce byte/request/duration/concurrency and fixed-cost/account ceilings, mark token/realized cost unknown, and cannot satisfy a normalized alias accidentally.

Runtime Tests

  • Candidate config rejection leaves the previous runtime active.
  • Config reload does not mutate in-flight route snapshots.
  • Pricing-only and one-alias publications structurally share unchanged subgraphs, preserve a generation-consistent root, coalesce rapid updates, and stay within retired-generation/byte bounds under long-lived requests.
  • High-concurrency buffered and streaming load tests.
  • Slow-client, disconnect, provider-stall, circuit-breaker, and provider-outage chaos tests.
  • Slowloris tests trickle request bytes and downstream reads, send heartbeats, and hold streams across setup/idle/write-progress/absolute deadlines; every path cancels upstream and recovers per-principal/global permits.
  • Multi-replica budget and cache tests against the selected shared stores.
  • Metrics cardinality and content-leak checks.

Audit And PII Tests

  • One logical audit record with all ordered physical attempts for retry, fallback, timeout-after-acceptance, partial stream, cancellation, and cache hit paths.
  • Required-audit admission failure when the complete logical-request/attempt envelope cannot be reserved; best-effort is rejected for a required alias.
  • WAL fixtures cover versioned segment headers, record length/checksum/sequence, maximum record and segment sizes, partial-tail truncation, mid-segment corruption rejection, and unknown payload event kinds.
  • local-durable proves that no provider mock observes a request before the corresponding start sequence reaches the durable watermark. Commit timeout, fdatasync failure, read-only/full volume, and writer termination all fail before dispatch and release reservations.
  • Recovery replays unacknowledged events without duplicate rows, preserves a durable start as an incomplete attempt when no terminal event exists, and deletes a segment only after a durable authoritative acknowledgement.
  • bounded-async tests and telemetry expose its documented crash-loss window; they never label an in-memory reservation as durable.
  • Time partition creation/retention, encrypted content references, key/role isolation, purpose-specific dataset export, and deletion evidence.
  • PII detection and replacement inside message text, content-part arrays, and tool arguments, including multiple values and repeated values.
  • Exact authenticated-token recovery, expiry, request/session/host scoping, unresolved-token policy, and proof that forged or cross-host tokens never reveal cleartext.
  • A versioned per-model preservation corpus measures exact, altered, omitted, and hallucinated placeholders; leave-masked never guesses or fails a partially emitted stream, while reject-buffered emits no partial content.
  • Placeholder fragmentation at every streaming byte boundary, including UTF-8 boundaries, slow consumers, cancellation, and maximum suffix-buffer bounds.
  • Request-scoped profiles perform no vault I/O and destroy cleartext state; durable profiles survive the documented multi-replica failover cases.
  • Every PiiVault implementation passes the same TTL, restart/failover, eviction, encryption, authorization, backup/restore, audit, and deletion contract; a volatile cache fails the durable profile.
  • Content-mode tests prove that metadata-only, tokenized-content, encrypted-raw, and disabled produce only the authorized records.

Performance And Capacity Tests

  • A direct mock-provider baseline plus Light and pinned-Bifrost runs generated from one versioned manifest.
  • Open-loop offered-load sweeps that locate the sustainable capacity knee and verify prompt load shedding above it.
  • The 500-RPS, 5,000-RPS, production-handler, streaming, overload, large-body, and cold-start profiles defined by the release performance gates.
  • Five or more steady-state repetitions with raw histograms, confidence intervals, CPU, RSS, allocation, connection, queue, and task telemetry.
  • Assertions that one request captures one published root, does not build an HTTP client, performs no synchronous control-plane I/O, and creates no disabled-hook task.
  • Assertions that the request path takes no control-plane/configuration RwLock, performs no policy-map merge, and does not resolve a provider by string. Measure normalized conversion and same-format compatibility paths separately.
  • Static-enum and preconstructed dynamic provider dispatch run under identical 5,000-RPS/allocation profiles; dispatch is not standardized until the confidence interval demonstrates whether either is materially better.
  • Snapshot-churn profiles vary pricing and alias publication rates while measuring build CPU, peak temporary/retained memory, generation retirement, allocator behavior, and request P99.
  • Named benchmark profiles for metadata-only audit, tokenized content, encrypted-raw content, request-scoped PII, durable-vault PII, and buffered whole-response policy. Do not average strict-policy remote I/O into the default fast-path result.
  • Run metadata-only audit separately as bounded-async and local-durable. The former remains enabled in the production-handler comparison; the latter reports WAL batch/commit-wait histograms and never dispatches before its durable watermark.
  • Slow-provider and slow-client tests proving bounded queues, cancellation, permit release, and recovery without a latency backlog.
  • Semantic-cache profiles include embedding and vector lookup in end-to-end latency/cost, exercise timeout-as-miss and stampede coalescing, and report cache-hit latency separately from provider TTFT.
  • A CI non-inferiority check against the last accepted Light baseline on every change to the handler, canonical types, router, provider codecs, admission, telemetry, or streaming path. Run the external Bifrost comparison on release candidates and scheduled performance infrastructure.

Rollout Plan

  1. Check in the benchmark harness, mock provider, payload corpus, benchmark manifest, and direct-provider baseline. Pin Bifrost and agentgateway references and record the first capacity curves before implementing the Light path; Bifrost remains the release non-inferiority comparator.
  2. Freeze the MVP application-body contract and audit durability profiles. Build focused prototypes for one-pass body capture/security ordering and the single-writer WAL/group-commit watermark. Measure body copies, handler locks, fdatasync, batch size, and commit wait before selecting implementation defaults. No provider request is part of this prototype.
  3. Add canonical inference types, typed errors, cancellation, and streaming to model-provider, preserving an adapter for existing light-agent and light-workflow callers.
  4. Build provider conformance tests and enable a small server-safe provider set. Start with at least two different provider formats so cross-provider normalization and fallback are exercised rather than assumed.
  5. Add crates/llm-gateway with a checked-in local llm-router.yml projection, immutable root, alias resolution, request validation, ordered fallback, usage normalization, and protocol-neutral tests. This local projection is a vertical-slice fixture, not a second control-plane authority.
  6. Add LlmHttpIntegration, register the Pingora llm branch, and implement /v1/models plus buffered Chat Completions. Prove that body-dependent endpoint authorization and LLM alias policy both execute before dispatch and that existing MCP/WebSocket paths are unchanged.
  7. Run the first 500-RPS direct/Light/Bifrost comparison on the buffered vertical slice. Profile full-handler-chain locks, body copies, allocations, and provider dispatch. Benchmark sealed-enum and preconstructed dynamic dispatch here; refactor the shared handler bundle only when measurements show it is needed.
  8. Add the Portal model catalog, host registration, deployment, public alias, alias-route, provider-account/quota group, credential lifecycle, pricing, and model-policy aggregates and projections against the now-exercised data-plane contract.
  9. Publish Portal deltas into the validated, structurally shared gateway root, replace the vertical-slice fixture as production authority, and migrate agent definitions toward alias/policy references while retaining bounded compatibility for existing fields.
  10. Add metadata-only logical-request/physical-attempt events, envelope reservation, bounded-async delivery, the versioned local WAL, local-durable group commit/recovery, and idempotent batched delivery to the dedicated audit store.
  11. Repeat the 500-RPS production-handler comparison with bounded-async audit enabled and publish the separate local-durable commit-wait/capacity profile. Neither profile may dispatch when its declared admission or durability contract is unavailable.
  12. Add SSE streaming and prove bounded buffering, cancellation, no-post-output-fallback rules, slow-consumer deadlines/permit recovery, and the streaming performance gate.
  13. Pass the 5,000-RPS, production-handler, streaming, and overload profiles before the MVP is declared production-ready.
  14. Add shared token/cost budgets, richer routing, guardrails, and exact caching.
  15. Add request-scoped LLM PII tokenization/recovery and tokenized-content capture. Measure placeholder preservation per model and default unresolved tokens to leave-masked. Add a durable regional PiiVault implementation only for session, asynchronous, and failover profiles, and prove its common security/recovery contract and separate performance SLO.
  16. Add governed encrypted-raw capture and curated evaluation/training dataset export after access, retention, deletion, and key-isolation reviews pass.
  17. Add /v1/responses without translating it through the less expressive Chat Completions representation.
  18. Expand operations and provider-native compatibility only from demonstrated client requirements and conformance coverage.
  19. Add semantic caching only with separate embedding/vector admission, deadline and cost slices, stampede control, and named performance profiles.

Production Projection And Agent Cutover

Production projection is opt-in under llm-router.yml at productionProjection.enabled. Config server delivers manifest.json and the referenced immutable files beneath config-cache/llm-projection. The gateway validates canonical digests, schema/compiler compatibility, host/environment, resource versions, and sequence before compiling and atomically swapping one root. A missing, bad, conflicting, or unsupported publication leaves the last valid root active; an enabled gateway with no valid bootstrap root fails closed.

Every replica writes an independent acknowledgement containing only host, environment, sequence, root digest, application time, gateway version, and instance ID. Configure a unique gatewayInstance per replica. If delivery of that acknowledgement fails after publication, the root remains active and the worker retries the same acknowledgement before processing another root. The credentialEnvironment map authorizes each opaque credential:// reference to one application-owned environment-variable name. Neither this map nor the projection contains secret values. Rotation is detected off the request path; only affected provider clients are replaced, while provider-account capacity, principal permits, in-flight roots, and unchanged subgraphs remain stable. A pricing-only root replaces the price-bearing deployment and alias views but retains their circuit, semaphore, usage-ledger, provider-client, account, and principal-stripe state.

Migrated light-agent definitions resolve a direct alias or exactly one policy default before a turn is persisted. The immutable turn stores provider gateway plus that alias, and the gateway client sends it in the ordinary OpenAI model field. INTERNAL_LEGACY aliases require an explicit agent binding (aliasVisibility: INTERNAL_LEGACY plus boundAgentDefId in Portal), are returned only to that agent, and remain absent from /v1/models. Endpoint URLs and service credentials come only from llm-gateway-client.yml; agent records cannot supply them.

Run scripts/run-llm-production-integration-gates.sh. Pass a disposable PostgreSQL URL (or set PORTAL_LLM_TEST_DATABASE_URL) to include the additive schema and internal-alias ownership gate.

Acceptance Criteria For The MVP

  • An OpenAI SDK can call an authorized public alias by changing only its base URL and credential.
  • The same request can route to at least two different provider types while returning the same public Chat Completions shape.
  • Buffered and streaming tool calls preserve IDs, names, and JSON arguments.
  • Fallback works before output and is proven not to run after output begins.
  • Provider credentials, base URLs, raw errors, and hidden model names do not leak to clients, logs, metrics, or module inspection.
  • Unauthorized callers cannot enumerate or invoke hidden aliases.
  • Portal host registration and model policy determine the visible aliases; agents cannot submit a provider URL, credential, or unregistered physical model to bypass them.
  • Request, token, output, timeout, concurrency, and attempt bounds fail closed.
  • Credential rotation preserves provider-account/quota-group limits and cannot be used to manufacture capacity. Any enabled opaque compatibility route has enforceable request/byte/duration/concurrency and financial envelopes.
  • Usage is recorded for success, failure, timeout, and cancellation with an explicit completeness/evidence state.
  • A bad llm-router.yml reload leaves the last valid runtime active.
  • Pricing-only and partial routing updates reuse unchanged snapshot subgraphs, publish one generation-consistent root, and remain within configured retired generation/memory bounds.
  • Every provider dispatch is represented by one logical request and its ordered physical attempts in the dedicated audit sink. Required audit fails before dispatch when its complete envelope cannot be reserved.
  • local-durable aliases never dispatch an attempt before its start event reaches the WAL durable watermark; recovery reports a durable start without a terminal event as incomplete and sink replay is idempotent.
  • The metadata-only MVP stores no prompt, completion, tool argument, or reversible PII in Portal, the audit metadata tables, metrics, or traces.
  • Existing MCP and WebSocket routes continue to work through their current handler chains.
  • The versioned release benchmark meets the absolute 500-RPS and 5,000-RPS gates and demonstrates throughput and P50/P95/P99 gateway-added latency no worse than the pinned Bifrost build under identical profiles.
  • Overload tests show bounded memory and queue wait, prompt 429/503 load shedding, and recovery without a residual latency backlog.
  • Slow or trickle-reading stream clients hit bounded write-progress and absolute-lifetime deadlines without leaking upstream work or permits.
  • Allocation and trace assertions prove that the steady-state path reuses provider clients, captures one immutable published root, performs no synchronous control-plane I/O, and creates no work for disabled hooks.
  • Request-path assertions prove there is no control-plane lock acquisition, policy-map merge, provider string lookup, or parse-failure fallback to an ungoverned detect/opaque path.

References

LLM Gateway API Contract

Status

  • Status: Proposed target contract
  • Date: 2026-08-07
  • Scope: public inference APIs, provider adapters, and authentication boundaries

This document defines the stable HTTP contract that agents and applications use to call llm-gateway. It also defines how the gateway selects a provider wire protocol and obtains the provider credential after policy and routing have selected a deployment.

This is a target contract, not a claim that every endpoint is implemented. The current Rust implementation supports GET /v1/models and POST /v1/chat/completions, including the existing OpenAI and Anthropic outbound codecs. The endpoint tables below distinguish required core work from optional and deferred compatibility profiles.

The terms MUST, MUST NOT, SHOULD, and MAY are normative.

Decisions

  1. The preferred agent API is the OpenAI Responses-compatible POST /v1/responses endpoint. It is the contract used by Codex and other agents that need typed input/output items, tool calls, and event streaming.
  2. POST /v1/chat/completions remains the broad application compatibility API. Existing OpenAI-compatible clients continue to work.
  3. The required public contract is the OpenAI-compatible API family: model listing, Chat Completions, Responses, and embeddings. It is the stable provider-neutral surface for Light-controlled agents and applications.
  4. Provider-native client facades are optional compatibility profiles, not provider-routing mechanisms. An Anthropic Messages profile is added only when Claude Code or another Anthropic-format client is a certified product requirement. A Gemini profile remains deferred until a Gemini-native client or feature requires it.
  5. Every request names a governed public alias. A client never supplies a provider URL, physical model ID, route ID, or provider credential.
  6. Client protocol, canonical operation, provider protocol, and provider authentication are separate types. The selected provider never determines the response contract owed to the client.
  7. Client authentication and provider authentication are separate trust boundaries. An inbound Light credential MUST NOT be forwarded upstream. A provider-delegated user credential MAY be forwarded only by an explicitly typed, owner-scoped delegated route to that credential's provider; it is never a Light credential and is never eligible for cross-provider fallback.
  8. Shared multi-user production routes use provider API or workload credentials. A personal deployment MAY define owner-scoped native session connectors where the provider supports that use. The connector is visible only to its owner and the owner's agents and is not eligible for a common multi-user route pool.
  9. The minimum generally available application surface is model listing, Chat Completions, and embeddings. Responses is an additional first-class agent surface, not a replacement that delays those three application endpoints.
  10. Portability applies only to features represented by the selected client contract and every eligible provider route. Unsupported or lossy conversion MUST fail before dispatch; the gateway does not silently drop a behavior-changing field to manufacture compatibility.

These decisions extend, but do not weaken, the accepted public compatibility ADR. OpenAI Chat Completions remains the first implemented compatibility surface; this document defines the additive target contract.

Goals

  • Give agents and applications stable APIs that do not change when routing moves between OpenAI, Anthropic, xAI, Google, or a local provider.
  • Support OpenAI-compatible SDKs and Codex through the required core profile.
  • Support off-the-shelf clients such as Claude Code through optional, explicitly certified compatibility profiles when product requirements justify them.
  • Preserve tools, structured content, reasoning metadata, usage, cancellation, and streaming semantics when both client and selected provider support them.
  • Make unsupported conversion explicit and actionable instead of silently dropping fields.
  • Keep provider keys, OAuth refresh material, workload credentials, and physical model names inside the gateway deployment boundary.

Non-goals

  • The inference API is not a public control-plane mutation API. Alias, deployment, pricing, credential-reference, and routing changes remain event-sourced Light Portal operations.
  • The gateway does not execute client-side tool calls. It returns tool calls to the agent, which may execute them through the MCP gateway and submit results in a later model request.
  • The initial contract does not promise lossless conversion of every provider-specific feature.
  • The gateway is not a complete clone of every provider API. A provider-native client surface is not implemented merely because the corresponding upstream provider is supported behind the OpenAI-compatible core.
  • Consumer subscription tokens and CLI credential caches are outside the gateway boundary. Personal workflow automation invokes the provider's CLI directly; gateway routes use API or workload credentials only.

Architectural Model

agent or application
        |
        | client protocol + Light credential
        v
client adapter -> canonical operation -> policy and alias router
                                             |
                                             v
                                  provider adapter + auth provider
                                             |
                                             | provider protocol + provider credential
                                             v
                                      provider model API

The implementation MUST model these dimensions independently:

DimensionPurposeInitial values
ClientProtocolRequest, response, stream, and error contract owed to the callerRequired: openai_responses, openai_chat, openai_embeddings; optional profiles: anthropic_messages, gemini_interactions, gemini_generate_content
OperationProvider-neutral intent used by policy and capability checksgenerate, embed, rerank, count_tokens, list_models, get_result, cancel_result, delete_result
ProviderProtocolWire contract used for the selected upstreamopenai_responses, openai_chat, anthropic_messages, xai_responses, xai_chat, gemini_interactions, gemini_generate_content, vertex_generate_content
ProviderProfileTypeCredential, transport, and eligibility class of the routeopenai, anthropic, xai, google_gemini, google_vertex
ProviderAuthModeHow upstream authorization headers are producedbearer_secret, x_api_key_secret, google_api_key_secret, oauth2_workload, google_adc

The canonical representation MUST retain typed text, image and document input, tool definitions and calls, tool results, structured-output constraints, usage, finish status, safety results, and provider extensions that policy explicitly allows. A conversion MUST fail before dispatch when a required feature cannot be represented by the selected provider protocol.

Public Base URLs

The OpenAI-compatible base URL is the required public surface. Optional native compatibility profiles use namespaced base URLs so their request, response, stream, error, and model-list contracts cannot be confused with the core.

ClientConfigured base URLExample effective endpoint
Codex and OpenAI-compatible agents/appshttps://gateway.example/v1POST /v1/responses
Claude Code and Anthropic SDKs, when the optional profile is enabledhttps://gateway.example/anthropicPOST /anthropic/v1/messages
Google Gen AI SDK and Gemini REST clients, when the deferred profile is enabledhttps://gateway.example/geminiPOST /gemini/v1beta/models/{alias}:generateContent

An enabled namespaced path is a client compatibility surface. It does not select an Anthropic or Google upstream. For example, an Anthropic Messages request MAY route to a Google model if the selected alias declares a conformant Messages conversion. Supporting an Anthropic or Google provider behind the core API does not require enabling the corresponding client facade.

Alias policy

The public model value MUST be a governed virtual alias such as coding-default, fast-chat, or embedding-default. Provider-prefixed names such as openai/gpt-4o, anthropic/claude-sonnet, or google/gemini-pro are deliberately not a second routing mechanism.

Provider-prefixed model names are convenient in a developer proxy, but in Light they would expose physical-provider choice, couple applications to a deployment, and let clients bypass alias policy and approved fallback groups. An administrator MAY create an alias whose display name contains a provider word for migration compatibility, but it is still an ordinary governed alias; the prefix has no routing semantics.

Endpoint Contract

The contract is divided into profiles so provider support does not imply an unbounded public API commitment:

ProfileRequirementPurpose
core_openaiRequiredStable provider-neutral API for Light-controlled applications, OpenAI-compatible SDKs, and Codex.
anthropic_messagesOptionalDrop-in Claude Code and Anthropic SDK compatibility after a client conformance gate passes.
gemini_nativeDeferred optionalDrop-in Google Gen AI SDK or Gemini CLI compatibility when a concrete client or native feature requires it.
retained_resultsDeferred optionalRetrieval, cancellation, and deletion after state ownership and retention are designed.
rerankOptional extendedProvider-neutral reranking for RAG applications.

Required OpenAI-compatible core

Method and pathStatusCanonical operationContract
GET /v1/modelsRequired core, implementedlist_modelsReturn only authorized public aliases in OpenAI model-list format.
GET /v1/models/{alias}Required core, plannedlist_modelsReturn one authorized public alias or an indistinguishable not-found result.
POST /v1/responsesRequired core, planned; preferred for agentsgenerateOpenAI Responses-compatible buffered or SSE generation, including typed items and tool calls.
GET /v1/responses/{response_id}Deferred retained_results profileget_resultRetrieve a stored or background response only when the alias and route support retained results.
DELETE /v1/responses/{response_id}Deferred retained_results profiledelete_resultDelete gateway-owned retained response state and request provider deletion where applicable.
POST /v1/chat/completionsRequired core, implementedgenerateOpenAI Chat Completions-compatible buffered or SSE generation.
POST /v1/embeddingsRequired core, plannedembedOpenAI-compatible embedding request and response.
POST /v1/rerankOptional extended profilererankCohere/Jina-style reranking after a canonical rerank operation and pricing contract exist.

POST /v1/responses is the standard agent contract. It MUST support, subject to alias capabilities:

  • string and typed item input;
  • system or developer instructions;
  • client-side function tools and tool results;
  • structured text output;
  • reasoning controls and summaries where representable;
  • previous_response_id only when retained state is enabled for the alias;
  • buffered JSON and OpenAI Responses SSE events;
  • client cancellation propagated to the active upstream request.

The first release of Responses support MAY require store: false. If retained responses are not enabled, store: true, previous_response_id, retrieval, and deletion MUST return unsupported_feature; they MUST NOT be silently ignored.

Optional Anthropic Messages profile

Method and pathStatusCanonical operationContract
POST /anthropic/v1/messagesOptional, planned only for certified clientsgenerateAnthropic Messages-compatible buffered or SSE generation.
POST /anthropic/v1/messages/count_tokensOptional, client-drivencount_tokensCount the canonical request using the resolved alias/model tokenizer.
GET /anthropic/v1/modelsDeferred compatibility conveniencelist_modelsReturn authorized public aliases in Anthropic model-list format.
GET /anthropic/v1/models/{alias}Deferred compatibility conveniencelist_modelsReturn one authorized alias in Anthropic model format.

This profile is required only when Claude Code, the Claude Agent SDK, or an existing Anthropic-format application is explicitly certified as a supported client. Enabling it does not constrain the selected upstream to Anthropic. When enabled, the gateway MUST support the headers and streaming events in its pinned client conformance profile. anthropic-version MUST be validated against an explicit supported-version list. anthropic-beta capabilities MUST be allowlisted per alias and MUST NOT be copied upstream blindly.

Claude Code is configured with an Anthropic-format base URL, for example:

export ANTHROPIC_BASE_URL=https://gateway.example/anthropic
export ANTHROPIC_AUTH_TOKEN="$LIGHT_LLM_TOKEN"

ANTHROPIC_AUTH_TOKEN is a Light-issued gateway credential in this setup. It is not an Anthropic API key. Claude Code sends it as an authorization header; the gateway authenticates the developer, removes the inbound credential, and later obtains the selected route's upstream credential.

Deferred optional Gemini-native profile

Method and pathStatusCanonical operationContract
POST /gemini/v1beta/interactionsDeferred gemini_native and retained_results profilesgenerateCreate a Gemini Interactions-compatible agent request; buffered, streamed, or background according to declared capabilities.
GET /gemini/v1beta/interactions/{id}Deferred retained_results profileget_resultRetrieve or resume a retained interaction.
POST /gemini/v1beta/interactions/{id}/cancelDeferred retained_results profilecancel_resultCancel a background interaction.
DELETE /gemini/v1beta/interactions/{id}Deferred retained_results profiledelete_resultDelete retained interaction state.
POST /gemini/v1beta/models/{alias}:generateContentDeferred gemini_native profilegenerateGemini GenerateContent-compatible buffered generation.
POST /gemini/v1beta/models/{alias}:streamGenerateContentDeferred gemini_native profilegenerateGemini GenerateContent-compatible SSE generation.
POST /gemini/v1beta/models/{alias}:embedContentDeferred gemini_native profileembedGenerate one embedding in Gemini format.
POST /gemini/v1beta/models/{alias}:batchEmbedContentsDeferred gemini_native profileembedGenerate multiple embeddings in Gemini format.
POST /gemini/v1beta/models/{alias}:countTokensDeferred gemini_native profilecount_tokensCount tokens for a Gemini-format request.
GET /gemini/v1beta/modelsDeferred compatibility conveniencelist_modelsReturn authorized public aliases in Gemini model-list format.

Gemini models remain eligible upstreams for the required OpenAI-compatible core even while this client profile is disabled. The profile is enabled only when a Google Gen AI SDK, Gemini CLI, or native-only feature is a certified requirement. If enabled, the {alias} path component is always a public alias even though the native Gemini API calls that component a model. The gateway MUST reject models/ resource names, provider project paths, and physical model identifiers that do not resolve to an authorized alias.

Gemini Interactions is not used as the gateway's internal canonical model. It remains behind both the gemini_native and retained_results profiles because its background and retained semantics require an explicit state design.

Deferred surfaces

The following APIs require separate capability and storage designs and are not part of the required OpenAI-compatible core:

  • POST /v1/images/generations and other image/video generation APIs;
  • POST /v1/audio/transcriptions, POST /v1/audio/speech, and realtime speech APIs;
  • provider-hosted files, vector stores, caches, and prompt resources;
  • asynchronous batch inference;
  • provider-hosted managed agents, sandboxes, skills, or environments;
  • provider-specific search, code execution, and hosted MCP tools.

They MAY be added later as typed operations. They MUST NOT be exposed through opaque pass-through routes that bypass Light authorization, policy, accounting, or audit controls.

POST /v1/rerank is ahead of media APIs in the roadmap because it has a small, bounded request/response contract and is directly useful to RAG applications. It still requires provider-neutral documents, scores, token/cost accounting, and an alias capability before it can be enabled.

Operational Endpoints

Operational endpoints are not inference endpoints and do not use a model alias. They SHOULD be exposed only on an internal listener or protected management network.

Method and pathStatusContract
GET /healthImplemented by light-gatewayProcess liveness only; it does not promise that an LLM route is eligible.
GET /readyzPlannedReadiness for accepting traffic, including a valid published snapshot; it MUST NOT fail merely because one optional provider is unhealthy.
GET /metricsPlanned Prometheus compatibilityBounded-cardinality request, stream, latency, usage, cost, route-health, and error metrics. No prompts, outputs, aliases with unbounded user input, or credential data.

The existing Light metrics handler and durable LLM audit pipeline remain the authoritative integration points. A Prometheus endpoint is an additional scrape format, not a replacement for accounting or durable audit delivery.

There is intentionally no public POST /v1/gateway/keys. Gateway client keys, aliases, deployments, budgets, and access policy are control-plane aggregates. They MUST be created through authorized event-sourced Light Portal commands so that projections, snapshot export, replay, and audit history stay consistent.

Request Rules

Model alias

  • OpenAI Chat, Responses, and embedding requests use the model body field.
  • An enabled Anthropic Messages profile uses the model body field.
  • An enabled Gemini GenerateContent or embedding profile uses {alias} in the path. Gemini Interactions uses the model field when the interaction is model-backed; managed agent resources are deferred.
  • The alias is resolved against the request's host, environment, subject, operation, and current immutable routing snapshot.
  • Responses MUST echo the requested public alias, not the physical provider model name, unless a protocol explicitly requires a distinct field. Physical names remain internal telemetry with restricted access.

Streaming

The gateway owes the caller the selected client protocol's stream:

  • Responses: named SSE events such as response.output_text.delta and a terminal response event;
  • Chat Completions: data: chunks ending in [DONE];
  • Anthropic Messages, when enabled: Anthropic message/content block SSE events;
  • Gemini GenerateContent, when enabled: Gemini SSE response objects;
  • Gemini Interactions, when enabled: Gemini interaction events with resumable event IDs when retained state is enabled.

Provider events are decoded and re-encoded; they are not copied as arbitrary bytes across different protocols. After semantic output begins, the gateway MUST NOT retry or fail over to another provider. Cancellation and disconnect MUST propagate upstream.

Headers

  • Authorization: Bearer <Light credential> is the canonical inbound authentication form.
  • An enabled Anthropic facade MAY accept x-api-key for SDK compatibility, but the value is a Light-issued credential, not a provider key.
  • An enabled Gemini facade MAY accept x-goog-api-key for SDK compatibility, but the value is a Light-issued credential, not a Google provider key.
  • traceparent, tracestate, and the Light correlation header MAY be accepted according to the common handler chain.
  • Provider-specific beta, organization, project, account, and routing headers MUST NOT be forwarded unless a typed, per-capability allowlist permits them.
  • All inbound Light credential headers MUST be stripped before provider dispatch. Raw inbound headers are never copied generically.

The gateway returns x-request-id on every response and SHOULD also return the client protocol's conventional request ID header where it differs.

Error Contract

Internally, every failure maps to a stable GatewayError category. The client adapter renders that category in the caller's native error envelope.

Internal codeTypical HTTP statusMeaning
invalid_request400The request does not conform to the selected client protocol.
unknown_alias404No authorized alias is visible to the caller.
unsupported_feature400The alias or selected conversion cannot preserve a requested feature.
authentication_failed401The Light client credential is absent or invalid.
access_denied403The authenticated subject cannot invoke the alias/operation.
budget_exceeded429A request, token, cost, or organizational budget rejected admission.
no_eligible_route503No active, priced, credentialed, healthy route can serve the operation.
provider_auth_failed502The selected upstream credential was rejected. Operators receive the route-safe diagnostic.
provider_rate_limited429 or 503The selected upstream quota is exhausted; retry metadata is sanitized.
provider_unavailable502 or 503The upstream failed before semantic output began.
deadline_exceeded504The request exceeded its effective deadline.
stream_interruptedprotocol terminal eventUpstream failed after semantic output began.

Errors MUST include the request ID and an actionable, sanitized message. They MUST NOT contain provider credentials, raw credential references, private provider response bodies, or physical route details. A bare GENERIC_EXCEPTION or “failed without an error response” is not a conformant public error.

Provider Adapter Contract

A provider adapter is selected only after alias authorization and route eligibility have succeeded. It owns:

  • canonical request validation for its protocol;
  • conversion to the physical provider request;
  • provider authentication headers;
  • buffered and streaming response decoding;
  • usage and finish-state normalization;
  • typed provider error classification;
  • cancellation and deadline propagation;
  • a declared capability set used before dispatch.

The adapter MUST NOT read a client-supplied provider name, URL, or provider credential. The provider base URL must be validated control-plane configuration and must pass the existing SSRF and authority controls.

Supported provider profiles

Provider profileProvider protocolDefault upstream baseProduction authenticationNotes
openaiopenai_responses, with openai_chat compatibilityhttps://api.openai.com/v1Authorization: Bearer from an OpenAI Platform API-key secret referenceShared or owner-scoped server-to-server route using Platform API billing.
anthropicanthropic_messageshttps://api.anthropic.comx-api-key from an Anthropic Console secret reference, or short-lived bearer token from approved workload identity; fixed anthropic-versionDirect Claude API. Cloud-hosted Claude needs a separate Bedrock, Vertex, or other cloud adapter because IAM and wire contracts differ.
xaixai_responses, with xai_chat compatibilityhttps://api.x.ai/v1Authorization: Bearer from an xAI API-key secret referenceGrok supports Responses and Chat Completions. Prefer Responses for agent routes.
google_geminigemini_interactions, gemini_generate_contenthttps://generativelanguage.googleapis.comx-goog-api-key from a Gemini API-key secret referenceDeveloper API upstream profile. Supporting it behind the OpenAI-compatible core does not enable the optional Gemini client facade.
google_vertexvertex_generate_contentvalidated regional or global Vertex AI authorityShort-lived OAuth bearer token obtained through ADC or workload identityProduction Google Cloud profile. The gateway refreshes tokens; Portal stores configuration and references, not access tokens.

Optional mTLS is a transport property layered on the provider profile. For example, xAI mTLS still requires its bearer API key. Certificate references must use the same secret-materialization boundary as other provider secrets.

Provider Authentication

Shared production routes

Shared routes MUST use credentials intended for server-to-server API access:

  • OpenAI Platform API key for OpenAI models;
  • Anthropic Console API key or approved workload-identity bearer token for the direct Claude API;
  • xAI API key for Grok;
  • Gemini API key for the Gemini Developer API;
  • Google ADC, service-account impersonation, or workload identity for Vertex AI.

Static values are loaded only through a local secret reference such as env:OPENAI_API_KEY; they are never published in the control-plane snapshot. Refreshable auth modes produce request headers at dispatch time and refresh before expiry without changing the published route generation.

Personal CLI automation boundary

Codex, Claude Code, Gemini CLI, and similar tools may authenticate with a personal subscription. Those sessions represent an individual product entitlement and are not provider API credentials. Light Gateway MUST NOT load, store, delegate, or proxy those sessions.

A personal workflow may invoke each supported CLI directly in its documented non-interactive or structured-output mode. The workflow owns process isolation, prompt and result conversion, tool execution, and retrying a task with another CLI. Such a retry is a workflow decision, not gateway route fallback, because it changes the agent runtime and subscription principal.

The same workflow may call Light Gateway when it wants API-backed routing. Those routes use configured API keys or workload credentials and may fail over between providers only under the normal capability, policy, accounting, and pre-output fallback rules.

Configuration Model

The following YAML is illustrative target configuration. The event-sourced Portal model remains authoritative; projection rows MUST be produced from events and secret values remain local to the gateway instance.

providerProfiles:
  openai-primary:
    providerType: openai
    protocol: openai_responses
    baseUrl: https://api.openai.com/v1
    scope: shared
    auth:
      mode: bearer_secret
      secretRef: env:OPENAI_API_KEY

  anthropic-primary:
    providerType: anthropic
    protocol: anthropic_messages
    baseUrl: https://api.anthropic.com
    auth:
      mode: x_api_key_secret
      secretRef: env:ANTHROPIC_API_KEY
    headers:
      anthropic-version: "2023-06-01"

  xai-primary:
    providerType: xai
    protocol: xai_responses
    baseUrl: https://api.x.ai/v1
    auth:
      mode: bearer_secret
      secretRef: env:XAI_API_KEY

  gemini-developer:
    providerType: google_gemini
    protocol: gemini_generate_content
    baseUrl: https://generativelanguage.googleapis.com
    auth:
      mode: google_api_key_secret
      secretRef: env:GEMINI_API_KEY

  gemini-vertex:
    providerType: google_vertex
    protocol: vertex_generate_content
    baseUrl: https://aiplatform.googleapis.com
    project: example-project
    location: global
    auth:
      mode: google_adc
      scopes:
        - https://www.googleapis.com/auth/cloud-platform

A deployment binds one provider profile to a physical model and declared capabilities. A public alias binds policy and pricing to one or more eligible deployments. API clients see only the alias. The persisted control-plane shape routes through deployment aggregates rather than directly from an alias to a provider profile.

Agent and CLI Profiles

Codex CLI

Codex can use the gateway as a custom Responses provider. The gateway token is supplied through a dedicated environment variable or a command-backed token helper, not through the user's OpenAI provider key.

model = "coding-default"
model_provider = "light_gateway"

[model_providers.light_gateway]
name = "Light LLM Gateway"
base_url = "https://gateway.example/v1"
wire_api = "responses"
env_key = "LIGHT_LLM_TOKEN"

Codex subscription authentication is not forwarded through this profile. A workflow that wants to use the personal Codex subscription invokes Codex CLI directly; a Codex CLI configured as a Light Gateway client uses the Light credential above and consumes an API-backed gateway route.

Claude Code

Claude Code requires the optional Anthropic Messages profile because it speaks the Anthropic gateway protocol. Light MUST advertise Claude Code compatibility only after the pinned client conformance gate passes. The gateway must then keep pace with documented required headers, stream events, beta headers, and message fields. Pointing Claude Code at a gateway credential replaces subscription billing for that session; the selected upstream account is billed. If Claude Code is not a committed product client, this profile remains disabled and creates no obligation to expose Anthropic-format endpoints.

Grok applications

Grok applications use the canonical OpenAI-compatible base URL and select a public alias routed to an xAI deployment. No Grok-specific client path is needed because xAI supports Responses and Chat Completions. The client receives OpenAI-compatible output while the provider adapter authenticates to xAI with the route's XAI_API_KEY reference.

Gemini applications

Light-controlled applications use /v1/responses, /v1/chat/completions, or /v1/embeddings with a Gemini-backed alias; no Gemini public client path is needed for that routing. A Gemini-native client uses the /gemini base URL and a Light-issued credential only after the optional profile is enabled and its client conformance gate passes. Vertex AI remains an upstream deployment profile, not a different required public client API.

Capability and Conversion Rules

Every deployment publishes a verified capability set. Route eligibility is the intersection of alias policy, requested client features, canonical operation, provider capabilities, credential readiness, price readiness, health, and environment.

At minimum, generation capabilities distinguish:

  • buffered and streaming output;
  • text, image, audio, document, and video input;
  • client-side function tools and parallel tool calls;
  • structured JSON output;
  • reasoning controls and summaries;
  • retained response/interaction state;
  • prompt caching controls;
  • safety configuration and safety-result visibility;
  • exact usage and provider cost reporting.

Unknown client fields may be preserved only for bounded same-format forwarding under an explicit compatibility allowlist. Cross-format conversion uses typed canonical fields. Required or behavior-changing fields that cannot be mapped cause unsupported_feature before provider dispatch.

Rust Implementation Alignment

The generic recommendation to start with Axum is sound for a new standalone service, but light-gateway is not a greenfield Axum application. It already uses Pingora listeners, the ordered Light handler chain, shared correlation and security handlers, and a compiled LLM runtime. The API work MUST extend that path instead of introducing a second HTTP server or middleware stack.

  • Reuse preconstructed provider clients and connection pools from the compiled runtime snapshot; do not construct an HTTP client per request.
  • Represent buffered and streaming results with async streams and typed codec events. Provider SSE is decoded incrementally and encoded into the client protocol without buffering the entire completion.
  • Use typed serde request models. Unknown fields are not globally lenient: they may enter only the existing bounded compatibility envelope for approved same-format forwarding. A malformed known field is a terminal parse error.
  • Normalize provider usage into canonical input, output, cached, reasoning, and total token fields before rendering OpenAI prompt_tokens/ completion_tokens, Anthropic input_tokens/output_tokens, or Gemini usage metadata.
  • Preserve the existing handler-chain order so authentication, authorization, admission limits, policy, accounting, audit, and provider dispatch cannot be bypassed by a new compatibility path.

Delivery Plan

  1. Contract foundation: generalize ClientProtocol, Operation, ProviderProtocol, capability validation, and provider auth without changing the existing Chat Completions behavior. Keep client protocol and upstream provider protocol independently selectable.
  2. Required application core: add GET /v1/models/{alias} and POST /v1/embeddings, with operation-specific capability, pricing, accounting, audit, and provider conformance gates.
  3. Responses and Codex: add POST /v1/responses, Responses SSE, OpenAI and xAI Responses adapters, and a Codex CLI smoke test.
  4. Optional Claude profile: only when Claude Code is a committed client, add namespaced Messages, required token counting, Anthropic SSE, and pinned Claude Code conformance fixtures. Keep the profile disabled otherwise.
  5. Optional Gemini profile: only when a Gemini-native client or native-only feature is committed, add the smallest GenerateContent, streaming, embedding, token-counting, and model-list surface required by its pinned conformance suite.
  6. Optional retained state: add Responses retrieval/deletion and Gemini Interactions only after retention ownership, route affinity, deletion, encryption, expiry, and audit rules are implemented.
  7. Optional rerank: add the provider-neutral rerank operation only after document limits, score semantics, pricing, accounting, and conformance are frozen.
  8. Workflow integration boundary: document and test that personal CLI sessions remain in workflow-owned adapters while Light Gateway provider profiles accept API keys or workload credentials only.

Acceptance Criteria

  • Official Codex CLI can complete a tool-calling turn through /v1/responses using a Light-issued bearer credential.
  • Provider configuration rejects personal subscription sessions, CLI credential caches, and delegated consumer credentials as provider auth.
  • Official OpenAI SDKs can call OpenAI-, Anthropic-, xAI-, and Gemini-backed aliases without seeing a physical provider model.
  • The required core passes model-list, Chat Completions, Responses, and embeddings conformance without enabling either native client facade.
  • If anthropic_messages is enabled, official Claude Code completes the buffered, streaming, tool-use, and required token-counting flows in the pinned conformance profile through /anthropic/v1 using a Light-issued credential.
  • If gemini_native is enabled, the pinned Google Gen AI SDK or Gemini CLI fixtures call the advertised /gemini surface with explicit Light authentication headers.
  • Inbound gateway credentials are proven absent from all recorded upstream requests; provider credentials are proven absent from logs, errors, audit payloads, and client responses.
  • Representative accepted and rejected payloads are parsed and validated for each client/provider pair; tests assert semantic output, errors, streaming order, tool-call identity, usage, and cancellation rather than text fixtures alone.
  • A requested feature that cannot survive conversion fails before dispatch with unsupported_feature and an actionable message.
  • A route is ineligible when credential, pricing, capability, environment, or health data is missing, with no_eligible_route explaining the missing category without revealing secrets.
  • Existing Chat Completions and model-list qualification gates remain green.
  • A disabled optional profile registers no public route and adds no request-path task, lookup, allocation, provider restriction, or fallback behavior.

Provider References

ADR 0001: LLM Public Compatibility Profile

  • Status: Accepted
  • Date: 2026-07-18
  • Gate: LF-2 before LF-3

Decision

The first public surface is OpenAI Chat Completions: buffered JSON, SSE, and GET /v1/models. Models are authorized aliases, never provider deployment identifiers. Errors use the OpenAI envelope while retaining a typed, sanitized internal category.

A typed parse failure is terminal. Same-format OpenAI forwarding may retain unknown fields only in a size-bounded compatibility envelope and only for an alias/deployment allowlist. Cross-format OpenAI-to-Anthropic routing uses canonical typed content and rejects unrepresentable fields. No detect-and-opaque fallback is allowed.

The checked-in corpus under benchmarks/llm-gateway/payloads is the Phase 0 compatibility baseline. Account/CLI providers are not eligible for the shared gateway.

Consequences

LF-3 codecs can define one closed canonical model. Compatibility behavior is observable and cannot turn parse failures into arbitrary upstream forwarding.

ADR 0002: One-Pass Application Body Contract

  • Status: Accepted
  • Date: 2026-07-18
  • Gate: LF-2 before buffered HTTP integration

Decision

Register one llm application handler and delegate to a typed integration. The integration runs pre-body handlers, validates route/method/media type, content encoding, declared length, and deadline, then captures/decompresses one bounded Bytes body exactly once.

If access control appeared earlier in the selected chain, body-aware authorization receives that captured byte sequence before LLM JSON parsing, alias policy, transforms, client selection, or provider work. Parsing and all later content adapters borrow or clone the same immutable Bytes; they do not read the downstream stream again. Every error and downstream disconnect cancels/finalizes the request.

Generic tokenize/detokenize handlers are not assumed to have consumed the body. Content transforms require an explicit LLM adapter.

Evidence

benchmarks/llm-gateway/evidence/body-capture.json records bounded, chunked, one-pass capture and proves authorization precedes parsing while both observe the same digest. The production gateway already demonstrates the relevant ordering in GatewayProxy::request_body_filter; LF-4 must bind this ADR to the new application handler with an integration test.

ADR 0003: One ArcSwap Runtime Root Per Request

  • Status: Accepted
  • Date: 2026-07-18
  • Gate: LF-2 before LF-5

Decision

The LLM runtime publishes one immutable Arc<CompiledLlmRoot> through ArcSwap. Request admission captures the root once and all routing, provider, policy, pricing, accounting, and client choices come from that Arc. The request path must not repeat current-config reads.

The reload worker builds and validates a complete candidate off-path, reuses unchanged Arc subgraphs, materializes clients/secrets, and performs one atomic store. A failed candidate leaves the previous root active. Dynamic counters and circuit state have stable identities and are not rebuilt merely because the configuration root changes. Retired roots live until the last in-flight Arc is dropped.

Evidence

benchmarks/llm-gateway/evidence/snapshot.json compares repeated light_runtime::ConfigManager RwLock reads with a single capture through the existing ArcSwap-backed config_loader::ConfigManager, and proves the captured root remains generation-coherent across publication.

ADR 0004: Config-Server File Projection Transport

  • Status: Accepted
  • Date: 2026-07-18
  • Gate: LF-2 before PDB-1, LP-1, and DIST-1

Decision

Production topology uses the existing config-server /files snapshot delivery seam. The runtime bootstrap already materializes remote files in config-cache; the LLM projection is delivered there as one root manifest and immutable resource files. Development may point to checked-in fixtures, but production may not read arbitrary local topology.

Resources and manifests use UTF-8 canonical JSON: object keys sorted lexicographically, no insignificant whitespace, and standard JSON scalar encoding. A resource SHA-256 covers every field except digest. A root SHA-256 covers every manifest field except rootDigest. Secret values are never included. Digests are computed from the in-memory canonical serialization, not from editor-specific line endings. Checked-in canonical fixtures may have trailing ASCII whitespace, which is excluded only when verifying the fixture's byte-for-byte canonical form.

Sequences are monotonic per host/environment. The next new publication must be exactly last-applied + 1. An identical sequence/digest is an idempotent duplicate; a conflicting duplicate or a gap rejects the delta and triggers a full resync. Deletes are explicit tombstoned manifest entries. Full resync fetches the manifest first, then every referenced immutable resource, with bounded pagination/artifact size, validates the complete graph, and publishes one root.

The acknowledgement is {hostId, environment, sequence, rootDigest, appliedAt, gatewayVersion}. Unknown schema versions or a minimumGatewayVersion newer than the runtime reject the candidate. The last valid root remains active on all fetch, digest, schema, ordering, compatibility, or compilation failures.

Fixtures

The schemas and canonical digest fixtures are under benchmarks/llm-gateway/schemas and manifests/projection-*.json.

ADR 0005: Off-Path Secret Materialization

  • Status: Accepted
  • Date: 2026-07-18
  • Gate: LF-2 before production provider publication

Decision

Portal resources carry only credential:// reference IDs. The first production resolver seam is the gateway's already resolved runtime configuration: config-loader decrypts CRYPT values, RuntimeConfig exposes resolved values to authorized module construction, and ModuleRegistry masks sensitive values in inspection output. A later provider integration must implement the same narrow SecretResolver contract rather than changing the request path.

Reload performs three stages: parse/validate the secret-free resource graph; authorize and resolve every enabled credential reference and construct reusable clients; publish only the fully materialized root. Resolution, decryption, token exchange, and client construction never occur during inference.

Missing, denied, expired, blank, or malformed references reject the candidate and preserve the last valid root. Runtime config reload is the rotation notification. Rotation rebuilds only affected provider subgraphs; in-flight requests may retain the old secret-bearing client Arc until their old root retires.

Ordinary logs, metrics, traces, audit events, projection/root digests, benchmark artifacts, crash reports, and module inspection contain neither secret values nor credential reference IDs. Repair-only operator diagnostics require explicit authorization and still prefer deployment/error IDs.

Evidence

projection-secret.json exercises success, missing, denied, rotation, redaction, last-valid-root, and zero request-time lookup assertions.

ADR 0006: MVP Accounting, Circuit, and Replay Defaults

  • Status: Accepted
  • Date: 2026-07-18
  • Gate: LF-2 before LF-5B

Decision

The initial measurable configuration keys are:

  • llm-router.accounting.estimatorId, estimatorVersion, safetyMarginBps, maxInputUnits, maxOutputUnits, maxReservedCostMicros, and unknownPricingMode;
  • llm-router.circuit.failureThreshold=5, openCooldownMs=30000, and halfOpenProbePermits=1;
  • llm-router.retry.maxAttempts=1 for the first buffered slice and maxReplayBytes=1048576.

Reservations are per replica and keyed by host/principal/alias; they are explicitly non-distributed. The conservative local estimator is identified by wire profile and is never reported as provider billing usage. Hard accounting fails closed on unknown pricing; observational profiles preserve unknown and incomplete evidence.

Timeout/cancellation reconciliation remains conservative. Passive circuits count configured transport, timeout, throttling, and provider 5xx categories. Retry-After cooldown belongs to provider-account/quota-group and deployment state. Any future multi-attempt policy whose canonical replay body exceeds maxReplayBytes is rejected at publication.

Numeric defaults remain manifest inputs and may change only with new benchmark and pricing evidence.

ADR 0007: Audit Durability Profiles and Group Commit

  • Status: Accepted
  • Date: 2026-07-18
  • Gate: LF-2 evidence; implementation before LF-8

Decision

The named profiles are best-effort, bounded-async, local-durable, and remote-durable. MVP implements bounded-async as the production default and local-durable under a separate SLO. required is an admission policy, not a durability level.

Bounded-async reserves the complete metadata envelope and bounded queue/spool capacity before dispatch but does not wait for disk commit. Local-durable waits until every attempt-start record reaches the WAL durable watermark. The WAL is single-writer, length-delimited, checksummed, sequence-numbered, and uses group fdatasync; recovery stops and reports any corrupt/truncated committed record. Full/read-only storage fails admission for required profiles rather than silently degrading.

Remote-durable is reserved for a later authoritative sink transaction. Best-effort is development-only and counts loss.

Evidence

benchmarks/llm-gateway/evidence/wal.json measures grouped synchronization, durable watermark/recovery, truncated-tail detection, and fail-closed capacity and read-only behavior. It is feasibility evidence, not the production WAL implementation.

Deploy Native

This page describes the recommended VM deployment model for the Rust light-gateway native binary.

Use this model when a customer wants to run light-gateway as a microgateway on a VM to protect backend MCP servers. The gateway starts from a small local bootstrap config, downloads runtime config from config-server, then registers itself with controller.

Deliver a versioned install bundle, not an ad hoc runtime script.

The bundle should contain:

  • light-gateway native binary.
  • Minimal bootstrap config files.
  • A systemd unit.
  • An install script for filesystem setup.
  • A root-owned environment file for secrets.

The install script can create users, directories, symlinks, permissions, and the systemd unit. It should not be the long-running process wrapper, and it should not pass secrets as command-line arguments.

Use systemd to run the service:

  • It restarts the process on failure.
  • It keeps logs in the host journal.
  • It avoids shell-history and process-list leakage from command-line secrets.
  • It gives the customer a standard operational surface: start, stop, restart, status, and journalctl.

Runtime Layout

light-gateway uses relative runtime paths:

  • config
  • config-cache

The systemd service should therefore set WorkingDirectory to the installed application directory.

Recommended VM layout:

/opt/light-gateway/
  light-gateway
  config -> /etc/light-gateway
  config-cache -> /var/lib/light-gateway/config-cache

/etc/light-gateway/
  startup.yml
  server.yml
  portal-registry.yml
  client.yml
  values.yml
  ca.pem
  light-gateway.env

/var/lib/light-gateway/
  config-cache/

The local config directory contains only bootstrap-time files. Runtime config downloaded from config-server is written to config-cache before Pingora starts. Keep config-cache writable by the light-gateway service user.

Build Artifact

Build a release binary from light-fabric:

cargo build --release -p light-gateway

The artifact is:

target/release/light-gateway

Build on a compatible Linux distribution for the customer VM. If the customer fleet has mixed Linux versions, prefer a static or target-compatible build so the binary does not fail on an older glibc.

Package with a versioned filename:

light-gateway-<version>-linux-amd64.tar.gz

For customers with package-management standards, wrap the same layout in a .deb or .rpm later. Start with tar.gz until the runtime contract is stable.

Bootstrap Config

The local bootstrap config only needs enough information to reach config-server, identify the gateway instance, and trust TLS.

Example values.yml:

startup.host: customer.example.com
startup.timeout: 3000
startup.connectTimeout: 3000
startup.bootstrapCaCertPath: config/ca.pem

light-config-server-uri: https://config-server.customer.example.com:8435

server.serviceId: com.customer.mcp-gateway-1.0.0
server.environment: prod
server.ip: 0.0.0.0
server.advertisedAddress: mcp-gateway-01.customer.example.com
server.httpPort: 8080
server.enableHttp: true
server.httpsPort: 8443
server.enableHttps: false
server.enableRegistry: true
server.startOnRegistryFailure: true

portalRegistry.portalUrl: https://controller.customer.example.com:8438

server.advertisedAddress must be a stable address that controller and clients can use to reach the VM gateway. Do not advertise 127.0.0.1 or 0.0.0.0.

Example startup.yml:

host: ${startup.host:dev.lightapi.net}
serviceId: ${server.serviceId:com.networknt.light-gateway-1.0.0}
envTag: ${server.environment:dev}
acceptHeader: application/yaml
timeout: ${startup.timeout:3000}
connectTimeout: ${startup.connectTimeout:3000}
configServerUri: ${light-config-server-uri:https://local.localhost}
authorization: ${light_portal_authorization:}
bootstrapCaCertPath: ${startup.bootstrapCaCertPath:config/ca.pem}

Example server.yml:

ip: ${server.ip:0.0.0.0}
advertisedAddress: ${server.advertisedAddress:127.0.0.1}
httpPort: ${server.httpPort:8080}
enableHttp: ${server.enableHttp:true}
httpsPort: ${server.httpsPort:8443}
enableHttps: ${server.enableHttps:false}
tlsCertPath: ${server.tlsCertPath:}
tlsKeyPath: ${server.tlsKeyPath:}
serviceId: ${server.serviceId:com.networknt.light-gateway-1.0.0}
enableRegistry: ${server.enableRegistry:true}
startOnRegistryFailure: ${server.startOnRegistryFailure:true}
dynamicPort: ${server.dynamicPort:false}
environment: ${server.environment:dev}
shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}

Example portal-registry.yml:

portalUrl: ${portalRegistry.portalUrl:https://localhost:8438}
portalToken: ${light_portal_authorization:}
controllerDiscoveryToken: ${portalRegistry.controllerDiscoveryToken:}

Example client.yml should include the customer CA path and hostname verification policy for outbound HTTPS calls:

tls:
  caCertPath: ${client.caCertPath:config/ca.pem}
  verifyHostname: ${client.verifyHostname:true}

Keep the full gateway behavior, including MCP routing, authentication, rule configuration, and downstream MCP targets, in config-server. The VM should not need local edits for normal policy or route changes.

Secrets

Keep secrets in a root-owned environment file or in the customer's secret manager. Do not pass secrets in command-line arguments.

Example /etc/light-gateway/light-gateway.env:

LIGHT_PORTAL_AUTHORIZATION=Bearer <token>
light_4j_config_password=<config-password-if-needed>
RUST_LOG=info

Permissions:

chown root:light-gateway /etc/light-gateway/light-gateway.env
chmod 0640 /etc/light-gateway/light-gateway.env

LIGHT_PORTAL_AUTHORIZATION is used for config-server bootstrap. The same token is also used by portal registry startup when portal-registry.yml resolves portalToken from light_portal_authorization.

Systemd Unit

Example /etc/systemd/system/light-gateway.service:

[Unit]
Description=Light Gateway
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=light-gateway
Group=light-gateway
WorkingDirectory=/opt/light-gateway
EnvironmentFile=/etc/light-gateway/light-gateway.env
ExecStart=/opt/light-gateway/light-gateway
Restart=on-failure
RestartSec=5
LimitNOFILE=65535

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/var/lib/light-gateway/config-cache

[Install]
WantedBy=multi-user.target

Install and start:

systemctl daemon-reload
systemctl enable light-gateway
systemctl start light-gateway
systemctl status light-gateway

View logs:

journalctl -u light-gateway -f

Install Script Scope

An install script is useful, but keep it deterministic and small.

It should:

  • Create the light-gateway user and group.
  • Create /opt/light-gateway, /etc/light-gateway, and /var/lib/light-gateway/config-cache.
  • Install the binary with executable permissions.
  • Install bootstrap config files.
  • Install or update the systemd unit.
  • Set file ownership and permissions.
  • Print the next operator steps for adding secrets and starting the service.

It should not:

  • Embed bearer tokens.
  • Pass tokens to ExecStart.
  • Rewrite customer config-server state.
  • Start the process before secrets and CA files are installed.

Startup Flow

The expected runtime flow is:

systemd
  -> /opt/light-gateway/light-gateway
  -> read local config/values.yml and startup.yml
  -> call config-server with LIGHT_PORTAL_AUTHORIZATION
  -> write downloaded config and files into config-cache
  -> start Pingora with resolved runtime config
  -> register gateway to controller using portalRegistry.portalUrl
  -> route protected MCP traffic to downstream MCP servers

When startup.yml configures config-server, the runtime tries to download the latest values.yml before starting. If that download fails for any reason, the runtime continues startup with the available local and cached config, including config-cache/values.yml when present.

Upgrade And Rollback

Use versioned binary releases:

/opt/light-gateway/releases/2.2.1/light-gateway
/opt/light-gateway/releases/2.2.2/light-gateway
/opt/light-gateway/light-gateway -> releases/2.2.2/light-gateway

Upgrade:

systemctl stop light-gateway
ln -sfn /opt/light-gateway/releases/2.2.2/light-gateway /opt/light-gateway/light-gateway
systemctl start light-gateway

Rollback:

systemctl stop light-gateway
ln -sfn /opt/light-gateway/releases/2.2.1/light-gateway /opt/light-gateway/light-gateway
systemctl start light-gateway

Do not delete config-cache during a normal binary rollback. It is the local cache of the config-server-delivered runtime state.

Validation Checklist

Before handing the VM to the customer:

  • systemctl status light-gateway is active.
  • journalctl -u light-gateway shows successful config-server bootstrap.
  • journalctl -u light-gateway shows successful controller registration.
  • The controller shows the gateway registered with the expected service id, environment, address, and port.
  • The gateway health endpoint responds from the VM network.
  • An MCP tools/list call reaches the gateway.
  • An MCP tools/call call reaches the configured backend MCP server.
  • Restarting the VM starts the gateway automatically.

Security Checklist

  • Store bearer tokens and config passwords outside the install bundle.
  • Use a customer CA file instead of disabling TLS verification in production.
  • Use a stable DNS name for server.advertisedAddress.
  • Restrict inbound VM firewall rules to required gateway ports.
  • Restrict outbound VM firewall rules to config-server, controller, and backend MCP server addresses.
  • Run as the dedicated light-gateway user.
  • Keep /etc/light-gateway/light-gateway.env readable only by root and the service group.
  • Rotate LIGHT_PORTAL_AUTHORIZATION through the customer secret process.

Deploy Kubernetes

This page describes the recommended Kubernetes deployment model for the Rust light-gateway image from light-fabric/apps/light-gateway.

Use this model when light-gateway runs as a microgateway in front of backend MCP servers. The pod starts from local bootstrap config, downloads runtime config from config-server into config-cache, starts Pingora, and registers the gateway with controller.

Deploy the gateway as a normal single-container Kubernetes workload:

  • Deployment for the gateway pod.
  • Service for stable in-cluster access.
  • ConfigMap for bootstrap config and non-secret values.
  • Secret for bearer tokens and config passwords.
  • emptyDir or PersistentVolumeClaim for config-cache.
  • Optional Ingress, Gateway API, NodePort, or LoadBalancer for external client access.

Keep gateway behavior such as MCP route definitions, access-control rules, backend MCP targets, and runtime TLS files in config-server. The Kubernetes bootstrap config should only contain enough information for startup, trust, and registration.

Image

Build the image from the workspace root:

./apps/light-gateway/build.sh 2.2.1

For local testing without pushing:

./apps/light-gateway/build.sh 2.2.1 --local

Use immutable tags in Kubernetes. Avoid latest for customer deployments.

The runtime image uses:

/app/light-gateway
/app/config -> /config
/app/config-cache

The process runs as the image user gateway. Mount /config for bootstrap config and make /app/config-cache writable.

Runtime Paths

Recommended container layout:

/config/
  startup.yml
  server.yml
  portal-registry.yml
  client.yml
  values.yml
  ca.pem

/app/config-cache/
  values.yml
  downloaded certs and files

Use a read-only ConfigMap for /config. Use a writable volume for /app/config-cache.

For most deployments, use emptyDir for config-cache. This gives each pod a fresh cache and avoids accidentally keeping stale config across pod replacement.

Use a PersistentVolumeClaim only when the customer explicitly wants the gateway to restart from the last downloaded config during a config-server download outage. On each startup, the gateway tries to download the latest values.yml before starting.

Registration Address

In Kubernetes, do not register the pod IP. Pod IPs are ephemeral.

If controller and callers are inside the same cluster, advertise the Service DNS name:

server.advertisedAddress: ai-microgateway.light-gateway

The pattern is:

<service-name>.<namespace>

The port is still registered separately from the host/address.

If controller or callers are outside the cluster, advertise the externally reachable DNS name instead, such as the Ingress or LoadBalancer hostname:

server.advertisedAddress: mcp-gateway.customer.example.com

For the Rust gateway, this is configured with server.advertisedAddress. The Java gateway template uses STATUS_HOST_IP; that is a light-4j-specific hook and is not the Rust gateway contract.

Bootstrap Config

Example values.yml for an in-cluster controller and config-server:

startup.host: customer.example.com
startup.timeout: 3000
startup.connectTimeout: 3000
startup.bootstrapCaCertPath: config/ca.pem

light-config-server-uri: https://config-server.lightapi.svc.cluster.local:8435

server.serviceId: com.customer.mcp-gateway-1.0.0
server.environment: prod
server.ip: 0.0.0.0
server.advertisedAddress: ai-microgateway.light-gateway
server.httpPort: 8080
server.enableHttp: true
server.httpsPort: 8443
server.enableHttps: false
server.enableRegistry: true
server.startOnRegistryFailure: true

portalRegistry.portalUrl: https://controller.lightapi.svc.cluster.local:8438
client.caCertPath: config/ca.pem
client.verifyHostname: true

Example startup.yml:

host: ${startup.host:dev.lightapi.net}
serviceId: ${server.serviceId:com.networknt.light-gateway-1.0.0}
envTag: ${server.environment:dev}
acceptHeader: application/yaml
timeout: ${startup.timeout:3000}
connectTimeout: ${startup.connectTimeout:3000}
configServerUri: ${light-config-server-uri:https://local.localhost}
authorization: ${light_portal_authorization:}
bootstrapCaCertPath: ${startup.bootstrapCaCertPath:config/ca.pem}

Example server.yml:

ip: ${server.ip:0.0.0.0}
advertisedAddress: ${server.advertisedAddress:127.0.0.1}
httpPort: ${server.httpPort:8080}
enableHttp: ${server.enableHttp:true}
httpsPort: ${server.httpsPort:8443}
enableHttps: ${server.enableHttps:false}
tlsCertPath: ${server.tlsCertPath:}
tlsKeyPath: ${server.tlsKeyPath:}
serviceId: ${server.serviceId:com.networknt.light-gateway-1.0.0}
enableRegistry: ${server.enableRegistry:true}
startOnRegistryFailure: ${server.startOnRegistryFailure:true}
dynamicPort: ${server.dynamicPort:false}
environment: ${server.environment:dev}
shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}

Example portal-registry.yml:

portalUrl: ${portalRegistry.portalUrl:https://localhost:8438}
portalToken: ${light_portal_authorization:}
controllerDiscoveryToken: ${portalRegistry.controllerDiscoveryToken:}

Example client.yml:

tls:
  caCertPath: ${client.caCertPath:config/ca.pem}
  verifyHostname: ${client.verifyHostname:true}

Use the customer CA in ca.pem. Do not disable hostname verification in production to work around certificate SAN problems.

Secrets

Store the portal bearer token and optional config password in a Kubernetes Secret.

Example:

apiVersion: v1
kind: Secret
metadata:
  name: light-gateway-secret
  namespace: light-gateway
type: Opaque
stringData:
  LIGHT_PORTAL_AUTHORIZATION: "Bearer <token>"
  light_4j_config_password: "<config-password-if-needed>"

LIGHT_PORTAL_AUTHORIZATION is used for config-server bootstrap. It is also used by portal registry startup when portal-registry.yml resolves portalToken from light_portal_authorization.

Do not store real bearer tokens in Git, ConfigMaps, Helm values committed to the repo, or rendered deployment examples.

Example Manifests

Create the namespace separately:

kubectl create namespace light-gateway

If deploying through light-deployer, keep Namespace out of the rendered bundle because deployer policy may block cluster-scoped resources.

Example bootstrap ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: light-gateway-bootstrap
  namespace: light-gateway
data:
  values.yml: |
    startup.host: customer.example.com
    startup.timeout: 3000
    startup.connectTimeout: 3000
    startup.bootstrapCaCertPath: config/ca.pem
    light-config-server-uri: https://config-server.lightapi.svc.cluster.local:8435
    server.serviceId: com.customer.mcp-gateway-1.0.0
    server.environment: prod
    server.ip: 0.0.0.0
    server.advertisedAddress: ai-microgateway.light-gateway
    server.httpPort: 8080
    server.enableHttp: true
    server.httpsPort: 8443
    server.enableHttps: false
    server.enableRegistry: true
    server.startOnRegistryFailure: true
    portalRegistry.portalUrl: https://controller.lightapi.svc.cluster.local:8438
    client.caCertPath: config/ca.pem
    client.verifyHostname: true
  startup.yml: |
    host: ${startup.host:dev.lightapi.net}
    serviceId: ${server.serviceId:com.networknt.light-gateway-1.0.0}
    envTag: ${server.environment:dev}
    acceptHeader: application/yaml
    timeout: ${startup.timeout:3000}
    connectTimeout: ${startup.connectTimeout:3000}
    configServerUri: ${light-config-server-uri:https://local.localhost}
    authorization: ${light_portal_authorization:}
    bootstrapCaCertPath: ${startup.bootstrapCaCertPath:config/ca.pem}
  server.yml: |
    ip: ${server.ip:0.0.0.0}
    advertisedAddress: ${server.advertisedAddress:127.0.0.1}
    httpPort: ${server.httpPort:8080}
    enableHttp: ${server.enableHttp:true}
    httpsPort: ${server.httpsPort:8443}
    enableHttps: ${server.enableHttps:false}
    tlsCertPath: ${server.tlsCertPath:}
    tlsKeyPath: ${server.tlsKeyPath:}
    serviceId: ${server.serviceId:com.networknt.light-gateway-1.0.0}
    enableRegistry: ${server.enableRegistry:true}
    startOnRegistryFailure: ${server.startOnRegistryFailure:true}
    dynamicPort: ${server.dynamicPort:false}
    environment: ${server.environment:dev}
    shutdownGracefulPeriod: ${server.shutdownGracefulPeriod:2000}
  portal-registry.yml: |
    portalUrl: ${portalRegistry.portalUrl:https://localhost:8438}
    portalToken: ${light_portal_authorization:}
    controllerDiscoveryToken: ${portalRegistry.controllerDiscoveryToken:}
  client.yml: |
    tls:
      caCertPath: ${client.caCertPath:config/ca.pem}
      verifyHostname: ${client.verifyHostname:true}
  ca.pem: |
    -----BEGIN CERTIFICATE-----
    <customer-ca-certificate>
    -----END CERTIFICATE-----

Example Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ai-microgateway
  namespace: light-gateway
  labels:
    app: ai-microgateway
spec:
  replicas: 2
  selector:
    matchLabels:
      app: ai-microgateway
  template:
    metadata:
      labels:
        app: ai-microgateway
    spec:
      securityContext:
        fsGroup: 999
        fsGroupChangePolicy: OnRootMismatch
      containers:
        - name: light-gateway
          image: networknt/light-gateway:2.2.1
          imagePullPolicy: IfNotPresent
          env:
            - name: LIGHT_PORTAL_AUTHORIZATION
              valueFrom:
                secretKeyRef:
                  name: light-gateway-secret
                  key: LIGHT_PORTAL_AUTHORIZATION
            - name: light_4j_config_password
              valueFrom:
                secretKeyRef:
                  name: light-gateway-secret
                  key: light_4j_config_password
                  optional: true
            - name: RUST_LOG
              value: info
          ports:
            - name: http
              containerPort: 8080
            - name: https
              containerPort: 8443
          readinessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 30
            periodSeconds: 30
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: "1"
              memory: 512Mi
          volumeMounts:
            - name: bootstrap-config
              mountPath: /config
              readOnly: true
            - name: config-cache
              mountPath: /app/config-cache
      volumes:
        - name: bootstrap-config
          configMap:
            name: light-gateway-bootstrap
        - name: config-cache
          emptyDir: {}

The example uses fsGroup: 999, which matches the default gateway group in the current image. Adjust it if the image user or group changes.

If HTTP is disabled and only HTTPS is enabled, change the probes to an HTTPS probe or a TCP probe.

Example Service:

apiVersion: v1
kind: Service
metadata:
  name: ai-microgateway
  namespace: light-gateway
spec:
  type: ClusterIP
  selector:
    app: ai-microgateway
  ports:
    - name: http
      port: 8080
      targetPort: http
    - name: https
      port: 8443
      targetPort: https

For external access, add an Ingress, Gateway API route, NodePort, or LoadBalancer according to the customer cluster standard. If external clients or controller use that external path, set server.advertisedAddress to the same externally reachable DNS name.

Apply With Kubectl

Apply manifests in this order:

kubectl apply -f namespace.yml
kubectl apply -f secret.yml
kubectl apply -f configmap.yml
kubectl apply -f deployment.yml
kubectl apply -f service.yml

Check rollout:

kubectl -n light-gateway rollout status deploy/ai-microgateway
kubectl -n light-gateway get pods -l app=ai-microgateway
kubectl -n light-gateway logs deploy/ai-microgateway

For local testing with a ClusterIP Service:

kubectl -n light-gateway port-forward svc/ai-microgateway 8080:8080 8443:8443

Deploy Through Light-Deployer

When light-deployer runs outside the cluster and has LIGHT_DEPLOYER_TEMPLATE_BASE_DIR set, repoUrl: "local" can point to local templates.

When light-deployer runs inside Kubernetes, use a real Git URL:

{
  "template": {
    "repoUrl": "https://github.com/networknt/light-fabric.git",
    "ref": "main",
    "path": "apps/light-gateway/k8s/light-gateway"
  }
}

Do not use repoUrl: "local" for an in-cluster deployer unless the template repo is mounted into the deployer container and LIGHT_DEPLOYER_TEMPLATE_BASE_DIR points to it.

The in-cluster deployer checks out repoUrl at ref and reads manifests from template.path.

Keep Namespace out of templates rendered by light-deployer if the deployer policy blocks cluster-scoped resources. Create the namespace separately:

kubectl create namespace light-gateway

Config-Server Requirements

Before deploying the gateway pod, config-server should already have config for the tuple used by startup:

host = startup.host
serviceId = server.serviceId
envTag = server.environment

At minimum, config-server should return runtime config for:

  • handler.yml
  • mcp-router.yml
  • access-control.yml and rule.yml when MCP authorization is enabled.
  • security.yml, unified-security.yml, or other active auth config.
  • websocket-router.yml when WebSocket MCP/BFF routing is enabled.
  • Any downstream client, token, or registry config required by the selected handlers.

The pod bootstrap files should stay small and stable. Normal route, policy, and backend changes should go through config-server and controller reload flows.

Startup Flow

Expected runtime flow:

Kubernetes starts pod
  -> /app/light-gateway
  -> read /app/config -> /config bootstrap files
  -> call config-server with LIGHT_PORTAL_AUTHORIZATION
  -> write downloaded config and files into /app/config-cache
  -> start Pingora with resolved runtime config
  -> register gateway to controller using portalRegistry.portalUrl
  -> advertise server.advertisedAddress and configured port
  -> route protected MCP traffic to backend MCP servers

When startup.yml configures config-server, the runtime tries to download the latest values.yml before starting. If that download fails for any reason, the runtime continues startup with the available local and cached config, including /app/config-cache/values.yml when present.

Upgrade And Rollback

Use Kubernetes rolling updates with immutable image tags:

kubectl -n light-gateway set image deploy/ai-microgateway \
  light-gateway=networknt/light-gateway:2.2.2
kubectl -n light-gateway rollout status deploy/ai-microgateway

Rollback:

kubectl -n light-gateway rollout undo deploy/ai-microgateway

For production, prefer changing only one variable at a time: either image tag or config-server runtime config, not both in the same rollout.

Validation Checklist

After deployment:

  • kubectl -n light-gateway rollout status deploy/ai-microgateway succeeds.
  • Pods are ready and restart count is stable.
  • Logs show successful config-server bootstrap.
  • Logs show successful controller registration.
  • Controller shows the gateway registered with the expected service id, environment, host, and port.
  • server.advertisedAddress is reachable from the controller.
  • The Service responds on /health.
  • MCP tools/list reaches the gateway.
  • MCP tools/call reaches the backend MCP server.
  • A pod restart still starts cleanly with the selected cache policy.

Security Checklist

  • Keep bearer tokens in Kubernetes Secret, not ConfigMap.
  • Use customer CA trust and keep client.verifyHostname: true in production.
  • Use immutable image tags and image pull credentials from Kubernetes secrets when the registry is private.
  • Run as the non-root image user.
  • Make /config read-only.
  • Make only /app/config-cache writable.
  • Restrict ingress traffic to required gateway ports.
  • Restrict egress traffic to config-server, controller, token/key services, and backend MCP servers.
  • Rotate LIGHT_PORTAL_AUTHORIZATION through the customer secret process.

Kubernetes Gateway API Design

Status

Proposal.

This page captures how the current light-gateway work can be reused for Kubernetes Gateway API without turning the microgateway product into a catch-all Kubernetes control plane. The recommended direction is a separate light-k8s-gateway product built on light-pingora for north/south ingress, with a later sidecar or mesh product for transparent east/west traffic.

Context

The current Kubernetes deployment model runs light-gateway as a normal Deployment with a ClusterIP Service. Runtime behavior comes from local bootstrap config, config-server downloaded files in config-cache, and the Pingora data plane built by light-pingora.

The current gateway already has useful data-plane pieces:

  • HTTP and HTTPS proxying through Pingora.
  • Static upstreams from proxy.yml.
  • Service-aware routing from router.yml.
  • Direct registry, controller-backed discovery, and static service targets.
  • Handler chains for security, header mutation, CORS, rate limits, token handling, MCP, WebSocket, static resources, and config reload.
  • Live config managers and reloaders for route and handler modules.

Gateway API adds a Kubernetes-native control plane. For ingress, users create GatewayClass, Gateway, and route resources such as HTTPRoute. For service mesh, the GAMMA model attaches route resources directly to Kubernetes Service objects instead of using Gateway and GatewayClass.

Product Boundary

Keep the product line split by operational role:

  • light-pingora is the shared data-plane framework.
  • light-gateway remains the microgateway, sidecar, BFF, API, agent, MCP, and LLM gateway product configured through Light runtime, config-server, controller-rs, and local config.
  • light-k8s-gateway is the proposed Kubernetes Gateway API product for north/south ingress. It should reuse light-pingora and lift reusable light-gateway modules where appropriate, but it should own Kubernetes watches, Gateway API status, RBAC, listener translation, TLS Secret handling, and EndpointSlice routing.
  • light-k8s-gateway-controller and light-k8s-gateway-proxy should be separate deployments from the first implementation. The controller owns Kubernetes RBAC and status writes. The proxy owns untrusted client traffic and should not need Kubernetes API permissions.
  • A future light-mesh or light-sidecar product should own transparent east/west Service Mesh behavior if we pursue GAMMA conformance. It should share the Gateway API route compiler and light-pingora data-plane modules, but its deployment model is sidecar or node-local interception, not ingress.

This avoids giving ordinary microgateway deployments broad Kubernetes RBAC and keeps config-server/controller-rs routing separate from portable Gateway API routing intent.

Goals

  • Let operators install light-k8s-gateway as a Gateway API implementation with a controller name such as networknt.com/light-k8s-gateway.
  • Support north/south ingress with GatewayClass, Gateway, HTTPRoute, Kubernetes Service, EndpointSlice, Secret, and ReferenceGrant.
  • Separate Kubernetes reconciliation from request proxying so control-plane RBAC is never granted to the public traffic data plane.
  • Provide a migration path from NGINX or Traefik by running side by side with a distinct GatewayClass, then moving routes class by class or host by host.
  • Reuse the existing Pingora proxy, handler chain, service discovery, metrics, and config reload model instead of creating a separate proxy stack.
  • Use Gateway API policy attachment for Light-specific Kubernetes policy CRDs instead of annotations or out-of-band route policy.
  • Support east/west traffic using Gateway API mesh semantics where HTTPRoute.parentRefs can point at a Service.
  • Keep Light-specific policies available without forcing them into portable Gateway API fields. Gateway API should configure routing; Light config and future policy CRDs should configure Light-specific behavior.
  • Build toward Gateway API conformance tests for both Gateway and Mesh feature sets.

Non-Goals

  • Do not remove existing config-server, direct registry, portal registry, or static route support.
  • Do not require every light-gateway deployment to watch Kubernetes. Gateway API support should be disabled unless explicitly configured.
  • Do not run the Kubernetes controller reconciler inside public data-plane pods with broad Kubernetes RBAC.
  • Do not claim immediate support for every Gateway API route type. Start with HTTPRoute; add GRPCRoute, TLSRoute, TCPRoute, and UDPRoute in later milestones.
  • Do not make transparent east/west interception a hidden side effect of the ingress deployment. Mesh mode needs an explicit data-plane deployment model.
  • Do not treat a non-transparent egress gateway as fully GAMMA-compliant mesh support.

Target API Versions

The north/south MVP targets the Gateway API v1 Standard Channel resources:

  • GatewayClass
  • Gateway
  • HTTPRoute
  • ReferenceGrant

Experimental or later milestones must be labeled explicitly in docs, manifests, and conformance reports. This includes GAMMA mesh behavior and route kinds such as GRPCRoute, TLSRoute, TCPRoute, and UDPRoute when those features rely on non-Standard channels in the installed Gateway API version.

North/South Ingress Model

For ingress replacement, light-k8s-gateway should run as two cooperating pieces:

  • light-k8s-gateway-controller: watches Kubernetes resources, validates attachment and policy, updates status, performs leader election, and produces a compiled routing snapshot.
  • light-k8s-gateway-proxy: consumes signed or mTLS-protected snapshots and serves client traffic through Pingora. It has no Kubernetes watch or status permissions and can scale independently with an HPA.

The split is mandatory from day 1. It prevents a proxy vulnerability in the public data plane from becoming a Kubernetes control-plane compromise. The controller can run as an HA deployment with Kubernetes Lease leader election; only the leader reconciles resources and writes status. Non-leader controller replicas stay warm and can take over quickly.

Snapshot delivery can start as a lightweight internal gRPC stream and evolve toward an xDS-like API if we need richer incremental updates. The proxy should apply the received GatewayApiSnapshot through the same kind of ConfigManager swap used by the current Pingora modules.

Typical installation:

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: light-k8s-gateway
spec:
  controllerName: networknt.com/light-k8s-gateway
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: public
  namespace: gateway-system
spec:
  gatewayClassName: light-k8s-gateway
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All
    - name: https
      protocol: HTTPS
      port: 443
      hostname: api.example.com
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: api-example-com
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: petstore
  namespace: apps
spec:
  parentRefs:
    - name: public
      namespace: gateway-system
      sectionName: https
  hostnames:
    - api.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /pets
      backendRefs:
        - name: petstore
          port: 8080

The controller resolves this into a runtime route table:

Gateway listener
  -> accepted HTTPRoutes
  -> host/path/header/method/query matches
  -> filters supported by light-k8s-gateway
  -> backend Service
  -> EndpointSlice addresses
  -> Pingora ProxyTarget set

The existing proxy.yml and router.yml paths remain useful for legacy and non-Kubernetes deployments. Kubernetes Gateway API routes should not depend on service_id headers or pathPrefixService.yml; they should route from the compiled Gateway API table directly to Kubernetes endpoints.

Required Ingress Patches

Add a Kubernetes Gateway API module:

k8sGatewayApi:
  enabled: ${k8sGatewayApi.enabled:false}
  mode: ${k8sGatewayApi.mode:ingress}
  controllerName: ${k8sGatewayApi.controllerName:networknt.com/light-k8s-gateway}
  gatewayClassName: ${k8sGatewayApi.gatewayClassName:light-k8s-gateway}
  watchNamespaces: ${k8sGatewayApi.watchNamespaces:[]}
  statusAddress: ${k8sGatewayApi.statusAddress:}

Implementation changes:

  • Create apps/light-k8s-gateway-controller and apps/light-k8s-gateway-proxy.
  • Add Gateway API and Kubernetes clients, likely behind a Cargo feature such as k8s-gateway-api, using kube, kube-runtime, k8s-openapi, and generated Gateway API resource types.
  • Watch GatewayClass, Gateway, HTTPRoute, ReferenceGrant, Service, EndpointSlice, Secret, and Namespace.
  • Compile watched objects into a deterministic GatewayApiSnapshot.
  • Push the compiled snapshot to proxy pods over an authenticated internal channel.
  • Store the received snapshot in a proxy-side ConfigManager, similar to the current proxy and router reload model.
  • Add a light-pingora Gateway API route-table module that can select a backend before falling back to existing proxy/router behavior.
  • Update Kubernetes status conditions for GatewayClass, Gateway, listeners, and routes. Status must clearly report unsupported route types, listener conflicts, missing TLS secrets, rejected cross-namespace references, empty backends, and unsupported filters.
  • Add Kubernetes Lease leader election so only one controller replica writes status and publishes snapshots.
  • Add controller RBAC for read watches, Secret reads where allowed, Lease writes, and status updates. Secret read permissions should be namespace-scoped where possible.
  • Give proxy pods no Kubernetes RBAC by default.
  • Add install manifests for separate controller and proxy ServiceAccount, ClusterRole, ClusterRoleBinding, Deployment, Service, and a sample GatewayClass.

The transport also needs a listener model. Today PingoraTransport binds the single server.httpPort and single server.httpsPort from server.yml. That is enough for the first 80/443 ingress path, but full Gateway API support needs multiple listeners with independent protocol, port, hostname, and TLS settings.

Suggested runtime patch:

server:
  listeners:
    - name: http
      protocol: HTTP
      ip: 0.0.0.0
      port: 80
    - name: https-api
      protocol: HTTPS
      ip: 0.0.0.0
      port: 443
      hostname: api.example.com
      tlsCertPath: /var/run/light-k8s-gateway/tls/api/tls.crt
      tlsKeyPath: /var/run/light-k8s-gateway/tls/api/tls.key

Keep server.httpPort, server.enableHttp, server.httpsPort, and server.enableHttps as backward-compatible shorthand.

HTTPRoute Support Plan

Start with the common ingress subset:

  • GatewayClass acceptance for networknt.com/light-k8s-gateway.
  • Gateway listeners for HTTP and terminated HTTPS.
  • HTTPRoute attachment by parentRefs, sectionName, listener hostname, listener namespace policy, and route hostname.
  • HTTPRoute matches for path prefix, exact path, method, headers, and query parameters.
  • backendRefs to Kubernetes Service backends, including weights.
  • ReferenceGrant for cross-namespace backend references.
  • Endpoint resolution from EndpointSlice, with Service DNS as a fallback only when endpoint watching is unavailable.
  • TLS Secret loading for terminated HTTPS.
  • Request header modification and URL rewrite where existing Pingora handlers already provide equivalent behavior.

Later milestones:

  • Request redirect, response header modification, request mirroring, retries, and timeouts.
  • GRPCRoute over HTTP/2.
  • TLSRoute for SNI routing and passthrough.
  • TCPRoute and UDPRoute for L4 ingress if Pingora transport support is added.
  • Backend TLS policy and mTLS to upstream services.

Light Policy Attachment

Kubernetes-native deployments should use the Gateway API Policy Attachment pattern from GEP-713 for Light-specific behavior. Do not use annotations for core behavior, and do not require config-server-owned route policy for the Kubernetes Gateway API path.

Add Light policy CRDs with targetRefs that point at Gateway API resources:

apiVersion: gateway.lightapi.net/v1alpha1
kind: LightAuthPolicy
metadata:
  name: petstore-auth
  namespace: apps
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: petstore
  jwt:
    issuer: https://issuer.example.com
    audience:
      - petstore
apiVersion: gateway.lightapi.net/v1alpha1
kind: LightRateLimitPolicy
metadata:
  name: petstore-ratelimit
  namespace: apps
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: petstore
  limits:
    - name: default
      requests: 1000
      window: 60s

The controller should resolve effective policy for supported target kinds such as Gateway, listener section, HTTPRoute, route rule, and eventually Service for mesh. Policy status should report Accepted, Programmed, and conflict conditions so resource owners can tell whether a policy is active.

Config-server remains valid for non-Kubernetes light-gateway deployments and for migration bridges. For light-k8s-gateway, Kubernetes resources should be the source of routing and policy intent.

TLS Secret Handling

TLS Secret material must not be written to persistent disk or normal config-cache.

Preferred handling:

  • The controller reads referenced TLS Secret objects, validates references and ReferenceGrant requirements, and distributes certificate material to proxies through the authenticated snapshot channel.
  • Proxies hold certificate material in memory and update Pingora TLS state without persisting private keys.
  • If Pingora integration requires file paths for an early milestone, write temporary files only to an emptyDir mounted with medium: Memory, under a path such as /var/run/light-k8s-gateway/tls.

Never copy TLS private keys into config-server, config-cache, persistent volumes, image layers, or logs.

Endpoint Abstraction

light-pingora should not need to know whether endpoints came from Kubernetes, direct-registry.yml, controller-rs discovery, or a static config file. Add a shared endpoint abstraction such as:

UpstreamCluster
  name
  protocol
  tls settings
  load-balancing policy
  EndpointSet
    endpoint address
    port
    health/ready state
    metadata

light-k8s-gateway-controller translates Service and EndpointSlice objects into this shape. Existing Light discovery paths can translate direct registry and portal-registry results into the same shape. The Pingora route-table module then selects an UpstreamCluster without carrying Kubernetes-specific logic.

East/West Mesh Model

Gateway API mesh support uses a different binding model. Routes attach directly to Service resources:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: petstore-policy
  namespace: apps
spec:
  parentRefs:
    - group: core
      kind: Service
      name: petstore
      port: 8080
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /v1
      backendRefs:
        - name: petstore-v2
          port: 8080
          weight: 10
        - name: petstore-v1
          port: 8080
          weight: 90

The runtime semantics are:

  • If no route attaches to a Service, default mesh behavior forwards to the Service backend.
  • If routes attach and the request matches at least one route, the selected route backendRefs determine the destination.
  • If routes attach and no route matches, reject the request.
  • Same-namespace routes are producer routes and affect all clients.
  • Different-namespace routes are consumer routes and affect clients in the route namespace.

The current light-gateway can proxy service-to-service calls explicitly, but it does not transparently intercept traffic to Kubernetes Service frontends. That means a real mesh implementation needs a data-plane attachment model, not only a route compiler.

Recommended mesh milestones:

  • Mesh M0: compile Service-attached HTTPRoute resources and expose the effective route table through logs, module registry, and status. This proves the control-plane model without traffic interception.
  • Mesh M1: support an explicit in-cluster egress gateway mode. Workloads call light-gateway directly or through a configured HTTP proxy. This is useful operationally, but not advertised as transparent GAMMA conformance.
  • Mesh M2: add sidecar mode. Inject a lightweight light-gateway sidecar, or preferably a smaller light-sidecar or light-mesh binary using the same light-pingora route-table module. Redirect outbound HTTP traffic to the sidecar, identify the original Service destination, apply Service-attached routes, then proxy to selected endpoints.
  • Mesh M3: add node-local or ambient mode. Use a DaemonSet plus CNI or eBPF redirection to intercept Service traffic without per-pod sidecars. This has a larger operational surface and should follow sidecar validation.

Sidecar mode is the shortest path because the current light-gateway already has sidecar concepts such as sidecar.egressIngressIndicator, token handling for outbound calls, and service discovery. The production packaging should still be a dedicated sidecar or mesh product if the target is transparent east/west traffic. The missing pieces are transparent redirect, original destination detection, and a Service-oriented route table.

Mesh Data-Plane Requirements

To proxy east/west traffic with GAMMA semantics, add:

  • A mesh route compiler that watches HTTPRoute, Service, EndpointSlice, ReferenceGrant, and namespaces.
  • A Service frontend index keyed by namespace, Service name, port, DNS name, ClusterIP, and possibly original destination socket address.
  • Producer and consumer route merge logic that follows Gateway API mesh rules.
  • Request matching and rejection behavior for Services with attached routes.
  • Backend endpoint selection from the selected route's backendRefs.
  • A sidecar or node-local interception mechanism that can recover the original destination Service before the request is proxied.
  • Policy hooks for Light security, token, and observability handlers.
  • Mesh conformance test wiring with --supported-features=Mesh.

Do not map GAMMA Service routes to Gateway listeners. In mesh mode, the Service is the parent object, and GatewayClass/Gateway are intentionally not part of the route binding.

Coexistence With Existing Light Runtime

Keep these layers distinct:

  • Gateway API resources express portable Kubernetes routing intent.
  • light-pingora route tables execute the selected routing intent.
  • handler.yml and Light module config apply Light-specific behavior.
  • light-gateway continues to serve the current microgateway, sidecar, BFF, API, agent, MCP, and LLM provider use cases.
  • light-k8s-gateway owns Kubernetes Gateway API ingress behavior.
  • portal-registry and direct-registry.yml remain available for non-Kubernetes targets and existing Light service discovery.
  • Config-server remains the source for non-Kubernetes light-gateway policy and migration bridges. Kubernetes-native light-k8s-gateway routing and policy intent should come from Gateway API resources and Light policy CRDs.

For ingress, Kubernetes Service and EndpointSlice should be the primary backend source. For non-Kubernetes or hybrid targets, add an explicit implementation-specific backend policy instead of overloading portable backendRefs.

Status And Conformance

Gateway API users rely on status. The controller must update:

  • GatewayClass.status.conditions.
  • Gateway.status.addresses, listener conditions, and supported features.
  • HTTPRoute.status.parents for every parentRef.
  • Light policy CRD status, including Accepted, Programmed, and conflict conditions.

Only the active leader should update Kubernetes status. Controller replicas use Kubernetes Lease leader election to avoid API-server write races and status flapping.

Minimum conformance gates:

go test ./conformance -run TestConformance -args \
  --gateway-class=light-k8s-gateway \
  --supported-features=Gateway,HTTPRoute

Mesh conformance gate:

go test ./conformance -run TestConformance -args \
  --supported-features=Mesh

When ingress and mesh are both enabled:

go test ./conformance -run TestConformance -args \
  --gateway-class=light-k8s-gateway \
  --supported-features=Mesh,Gateway,HTTPRoute

Observability And Telemetry

light-k8s-gateway must be operable as a primary ingress controller. Provide Prometheus metrics, OpenTelemetry traces, and structured logs from day 1.

Proxy metrics:

  • Request count tagged by Gateway, listener, route namespace, HTTPRoute, backend Service, status code, and status class.
  • Request duration and upstream duration histograms.
  • Active connections and in-flight requests.
  • Upstream connection errors, retries, timeouts, and circuit-breaker opens.
  • Snapshot version, snapshot age, and snapshot apply errors.

Controller metrics:

  • Reconcile count, duration, and error count by resource kind.
  • Kubernetes watch reconnect count and API-server request errors.
  • Status update count and conflict count.
  • Leader-election state.
  • Snapshot generation count, size, and publish errors.

Tracing:

  • Propagate W3C traceparent and existing Light correlation IDs.
  • Create ingress spans tagged with Gateway API resource identity: gateway.namespace, gateway.name, listener.name, route.namespace, route.name, route.rule, backend.service.namespace, and backend.service.name.
  • Record upstream selection, retries, and policy decisions as span events without logging tokens, private keys, or sensitive headers.

Migration From NGINX Or Traefik

Recommended customer migration:

  1. Install light-k8s-gateway with a new GatewayClass named light-k8s-gateway.
  2. Keep NGINX or Traefik running for existing Ingress or Gateway API classes.
  3. Create equivalent Gateway and HTTPRoute resources for one host.
  4. Validate status, route behavior, TLS, logs, metrics, and backend health.
  5. Move DNS or load balancer traffic for that host to light-k8s-gateway.
  6. Repeat host by host.
  7. Remove the old ingress controller only after route parity and operational dashboards are in place.

An optional Ingress-to-HTTPRoute converter can help customers migrate, but it should be a tool, not part of the runtime request path.

Open Questions

  • What is the first supported east/west deployment model: current light-gateway as explicit egress gateway, a dedicated sidecar, or ambient?
  • How much of the current server.yml listener contract should remain in light-runtime versus moving Gateway API listener binding into light-pingora?
  • Should the controller-to-proxy snapshot protocol stay as a small internal gRPC API, or should it adopt an xDS-compatible model early?
  • Which Light policy CRDs are required for the MVP: auth, rate limit, header policy, request size, token, or a generic handler-chain policy?
  • What is the exact UpstreamCluster health model shared by Kubernetes EndpointSlice, controller-rs discovery, and direct registry sources?

Suggested Implementation Order

  1. Create apps/light-k8s-gateway-controller and apps/light-k8s-gateway-proxy with separate ServiceAccounts and RBAC.
  2. Add controller leader election with Kubernetes Lease objects.
  3. Define GatewayApiSnapshot, UpstreamCluster, EndpointSet, and the authenticated controller-to-proxy snapshot stream.
  4. Implement proxy-side snapshot loading through ConfigManager.
  5. Implement GatewayClass, Gateway, HTTPRoute, ReferenceGrant, Service, EndpointSlice, Secret, and Namespace watches.
  6. Implement attachment validation, policy validation, status updates, and snapshot publishing.
  7. Add a light-pingora Gateway API route table and route HTTP traffic to Kubernetes Service endpoints.
  8. Add memory-only TLS Secret handling and terminated HTTPS listener support for the common 80/443 ingress case.
  9. Add initial Light policy CRDs using Gateway API policy attachment.
  10. Add Prometheus metrics, OpenTelemetry tracing, and structured logs for the controller and proxy.
  11. Run HTTPRoute Gateway conformance and close gaps.
  12. Add multi-listener runtime support.
  13. Add mesh route compilation for Service-attached HTTPRoute resources.
  14. Add explicit egress gateway mode for early east/west use.
  15. Add sidecar interception and run mesh conformance.
  16. Evaluate ambient/node-local mode after sidecar behavior is proven.

Light-Gateway IPv6 Support

light-gateway can run in IPv4-only, IPv6-only, and dual-stack networks. The gateway uses light-pingora for the inbound HTTP and HTTPS listener, and uses the same routing model for IPv4 and IPv6 upstream services.

Configuration

The inbound bind address is controlled by server.ip and projected into server.yml:

ip: ${server.ip:0.0.0.0}

The default remains IPv4 wildcard binding:

server.ip: 0.0.0.0

Use IPv6 wildcard binding when the host or container network should accept IPv6 connections:

server.ip: "::"

Use a specific IPv6 address when the gateway should bind only to one interface:

server.ip: "fdd0:0:0:1::10"

server.advertisedAddress is separate. It is the address registered with the controller and shown to peers. Do not set it to 0.0.0.0 or ::; use a stable DNS name or a reachable address for the deployment:

server.advertisedAddress: ai-microgateway.light-gateway

Listener Behavior

light-gateway validates server.ip as an IP address before starting the Pingora listener. It then builds the listener socket with the parsed IP and the configured HTTP or HTTPS port.

Examples:

server.ip: 0.0.0.0, server.httpsPort: 8443 -> 0.0.0.0:8443
server.ip: "::",    server.httpsPort: 8443 -> [::]:8443

This avoids the invalid IPv6 address form that results from concatenating the IP and port as strings.

Upstream Routing

Gateway upstream routes can use DNS names, IPv4 literals, or bracketed IPv6 literals.

For proxy.hosts:

hosts: https://[fdd0:0:0:1::20]:8443

For direct-registry.directUrls:

directUrls:
  com.example.orders-1.0.0: https://[fdd0:0:0:1::21]:8443

Discovery responses may also contain IPv6 addresses. The router and websocket router bracket IPv6 discovery addresses before constructing the upstream authority.

When an upstream is referenced by DNS name, the selected address family depends on DNS resolution and the connector behavior. In dual-stack Docker or Kubernetes networks, make sure the backend listens on the address family that DNS returns first, or use service discovery/configuration that points to a reachable address.

Native Deployment

For native host deployment, keep the bind address aligned with the host network:

server.ip: "::"
server.advertisedAddress: gateway.example.com
server.httpsPort: 8443
server.enableHttps: true

Verify the host firewall and TLS certificate cover the advertised hostname.

Kubernetes Deployment

For Kubernetes, use IPv6 binding only when the cluster and service are intended to expose IPv6 traffic:

server.ip: "::"
server.advertisedAddress: ai-microgateway.light-gateway

The Service, pod network, DNS policy, and any ingress or Gateway API resources must also support IPv6. The gateway bind address alone does not make the cluster dual-stack.

Verification

Inside the same network namespace or from a peer pod/container:

getent ahosts <gateway-service-name>
curl -k -g https://[<gateway-ipv6>]:8443/health
curl -k -v https://<gateway-service-name>:8443/health

For an upstream backend reached through light-gateway, confirm both sides:

getent ahosts <backend-service-name>
curl -k -v https://<gateway-host>/<gateway-route>

If the gateway log shows connection refused to an IPv6 upstream address, the backend service is likely not listening on IPv6 or the network does not route that address family.

How to Call an MCP Server with Curl

To talk to an HTTP-based Model Context Protocol (MCP) server using curl, you must follow the strict JSON-RPC 2.0 lifecycle defined by the spec. This includes initiating a handshake, completing an initialization confirmation, and executing the actual tool call.

Here is the exact multi-step process required to interact with a streamable HTTP or Server-Sent Events (SSE) MCP server.

1. Initialize the Connection

Every MCP interaction requires a handshake. You must send an initialize method to create your session.

curl -s -i -X POST "https://your-mcp-server.example.com/mcp" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}'

Action: Extract the mcp-session-id from the response headers and export it (e.g., export SESSION_ID="...").

2. Confirm Initialization

Send an initialized notification to finalize setup.

curl -s -X POST "https://your-mcp-server.example.com/mcp" \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: $SESSION_ID" \
  -d '{"jsonrpc": "2.0", "method": "initialized", "params": {}}'

3. List and Call Tools

Use tools/list to find available tools, and tools/call to execute them, ensuring arguments are structured correctly.

List Tools

curl -s -X POST "https://your-mcp-server.example.com/mcp" \
  -H "Mcp-Session-Id: $SESSION_ID" \
  -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}'

Call Tool

curl -s -X POST "https://your-mcp-server.example.com/mcp" \
  -H "Mcp-Session-Id: $SESSION_ID" \
  -d '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "...", "arguments": {}}}'

Tips

  • Auth: Add -H "Authorization: Bearer $TOKEN" for protected servers.
  • Streaming: Use curl -N for SSE endpoints.

MCP Tools Access Control

light-gateway can enforce fine-grained access control for MCP tools exposed through the MCP router. The router uses the shared access-control runtime from light-pingora, so MCP tools use the same access-control.yml and rule.yml policy files as HTTP API access control.

For MCP traffic, rules apply only to tools/call requests:

  • req-acc rules run before the downstream HTTP or MCP tool is called.
  • res-fil rules run after the downstream response is converted to an MCP result and before the JSON-RPC response is returned to the agent.

tools/list, initialize, notifications/initialized, and session management requests are handled by the MCP router and are not authorized as individual business tools.

MCP Router And access-control.enabled

The access-control.enabled flag is the top-level switch for MCP tool access control:

enabled: true
accessRuleLogic: any
defaultDeny: true
defaultInclude: false
skipPathPrefixes: []

When access-control.enabled is true, the MCP router evaluates configured req-acc rules before invoking a tool. If the tool call is allowed and the matching endpoint has res-fil rules, the router also applies response row or column filters before returning the MCP result.

skipPathPrefixes bypasses the same two phases for matching MCP tool names or matching endpoint keys. For example, if skipPathPrefixes contains local_mcp, then tool names such as local_mcp_echo are allowed without req-acc evaluation and their results are returned without res-fil filtering, even when the policy endpoint key is something else, such as accounts@call.

Endpoint-key prefixes still work. If skipPathPrefixes contains accounts, then an accounts@call endpoint is also allowed without req-acc evaluation and returned without res-fil filtering.

When access-control.enabled is false, the MCP router bypasses both phases:

  • req-acc rules do not deny MCP tool calls.
  • res-fil rules do not alter MCP tool results.

This bypass applies even when rule.yml is still present and contains matching endpoint rules. The rules can remain loaded for later re-enable or reload, but the disabled access-control switch prevents the MCP router from enforcing authorization or response filtering.

This setting is independent from mcp-router.enabled. Set mcp-router.enabled: false to disable the MCP endpoint itself. Set access-control.enabled: false only when the MCP endpoint should continue to serve tools without access-control enforcement.

Endpoint Rules

Each MCP tool maps to a stable endpoint key. If the tool config contains an explicit endpoint, that value is used. Otherwise, the router derives a key from the tool name and method, such as accounts@call.

tools:
  - name: accounts
    description: List accounts
    targetHost: http://account-api:8080
    path: /accounts
    method: GET
    endpoint: accounts@call
    apiType: http

The same endpoint key is referenced from rule.yml:

endpointRules:
  accounts@call:
    req-acc:
      - allow-account-reader
    res-fil:
      - filter-account-rows
      - filter-account-columns
    permission:
      roles: teller manager
      row:
        role:
          teller:
            - colName: accountType
              operator: "="
              colValue: C
      col:
        role:
          teller: accountNo,accountType,balance

Request Authorization

A req-acc rule decides whether the MCP tool call can proceed. When defaultDeny is true, a tool call with no matching endpoint rule or no req-acc rules is denied.

defaultDeny only controls the fallback behavior when the MCP router cannot find request access rules for a tool endpoint. It does not disable access-control globally and it does not bypass configured rules.

When defaultDeny is true, the MCP router fails closed:

  • If the tool endpoint has no entry in rule.endpointRules, the tool call is denied.
  • If the tool endpoint has an endpointRules entry but no req-acc rule IDs, the tool call is denied.
  • If req-acc rules are configured, the rule result decides whether the tool call is allowed.

When defaultDeny is false, the MCP router permits tool calls that do not have request access rules:

  • If the tool endpoint has no entry in rule.endpointRules, the tool call is allowed.
  • If the tool endpoint has an endpointRules entry but no req-acc rule IDs, the tool call is allowed.
  • If req-acc rules are configured, the rule result still decides whether the tool call is allowed.

Use defaultDeny: false when the gateway should expose protected MCP tools without fine-grained authorization rules for every tool endpoint. This avoids creating no-op req-acc rules only to make unrouted tools callable. Keep defaultDeny: true when every MCP tool must have an explicit access-control policy.

For example, this configuration keeps access-control enabled but allows MCP tool calls that have no matching rule.endpointRules entry:

enabled: true
accessRuleLogic: any
defaultDeny: false
defaultInclude: false
skipPathPrefixes: []
ruleBodies:
  allow-account-reader:
    common: Y
    ruleId: allow-account-reader
    ruleName: Allow account reader
    ruleType: req-acc
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: >
      auditInfo.subject_claims.ClaimsMap.role in ["teller", "manager"]
    actions:
      - actionClassName: com.networknt.rule.RoleBasedAccessControlAction

Response Filtering

A res-fil rule transforms the MCP tool result after the downstream call succeeds. Row filters and column filters operate on the JSON payload carried in the MCP result structuredContent and mirrored text content.

ruleBodies:
  filter-account-rows:
    common: Y
    ruleId: filter-account-rows
    ruleName: Filter account rows
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: "true"
    actions:
      - actionClassName: com.networknt.rule.ResponseRowFilterAction

  filter-account-columns:
    common: Y
    ruleId: filter-account-columns
    ruleName: Filter account columns
    ruleType: res-fil
    conditionLanguage: cel
    conditionSecurityProfile: strict
    expression: "true"
    actions:
      - actionClassName: com.networknt.rule.ResponseColumnFilterAction

defaultInclude affects row filtering when no caller claim matches a configured row-filter entry. Keep it false to fail closed and return no rows. Set it to true only when the desired compatibility behavior is to keep all rows.

Response Filtering And Call Caches

The current MCP router has a tools/list visibility cache but no gateway tools/call response cache. If call-result caching is added later, access control wraps the cache rather than being cached with the result:

  1. Authenticate and run req-acc before using a cache entry.
  2. Cache only the normalized backend MCP result before res-fil.
  3. Run the current caller's res-fil rules on every hit and miss.
  4. Never place a post-filter caller view in a shared call-result cache.

The pre-filter cache is sensitive because it may contain rows or columns that the current caller cannot receive. It must remain tenant/principal scoped by default, use a key covering every downstream request dimension, be bounded and short-lived, and be unavailable to tenant code or diagnostics that expose payloads. Cross-principal reuse requires explicit proof that the backend result is principal-invariant. If the gateway cannot rerun current authorization and filtering on a hit, caching is disabled for that tool.

MCP Tools List Access Control

This document describes a design for filtering MCP tools/list results by the same access-control policy model that protects MCP tools/call.

The goal is to avoid showing an agent tools that the current user cannot call, while keeping tools/call authorization as the final enforcement point.

Background

The MCP router exposes tools from multiple backends through one gateway MCP endpoint. A tool can represent a downstream MCP operation:

{
  "name": "local_mcp_echo",
  "apiType": "mcp",
  "endpoint": "echo@call",
  "serviceId": "com.networknt.local.mcp-1.0.0"
}

or a downstream OpenAPI endpoint:

{
  "name": "demo_offer_decision_api_search_offers",
  "apiType": "openapi",
  "endpoint": "/offers@get",
  "serviceId": "com.networknt.offer.decision-1.0.0"
}

The access-control policy uses the tool endpoint key to enforce req-acc when the tool is called:

{
  "echo@call": {
    "req-acc": ["allow-role-based-access-control.lightapi.net"],
    "permission": {
      "roles": "account-manager",
      "groups": "portal.w"
    }
  },
  "/offers@get": {
    "req-acc": ["allow-scp-claim-group-access-control.lightapi.net"],
    "res-fil": [
      "res-column-filter-jwt-claims.lightapi.net",
      "res-row-filter-jwt-claims.lightapi.net"
    ],
    "permission": {
      "roles": "account-manager teller",
      "groups": "portal.w"
    }
  }
}

With the current runtime behavior, tools/list returns configured tools and tools/call enforces access-control. This is secure for invocation, but it can expose unusable tools to an agent.

Problem

The direct way to filter tools/list is to run each tool's req-acc rules before returning the list. That is simple but has drawbacks:

  • tools/list may run many rule evaluations for every agent discovery request.
  • Some req-acc rules can depend on toolArguments, but tools/list has no call arguments.
  • Evaluating a call-time rule with empty arguments can hide tools that would be callable with valid arguments, or show tools that later fail for a specific argument value.
  • tools/call must still run authorization, so list filtering cannot replace invocation enforcement.

For these reasons, tools/list filtering should be treated as a visibility optimization, not the authoritative authorization decision.

Design

Add an optional MCP tools-list visibility filter that uses access-control configuration to decide which tools are visible to the current principal.

The configuration belongs in access-control.yml because it controls how the access-control runtime affects MCP discovery. The MCP router reads the runtime decision, but the policy switch should live with the rest of the access-control settings:

enabled: true
accessRuleLogic: any
defaultDeny: true
defaultInclude: false
skipPathPrefixes: []
claimMappings: {}
toolsListAccessControl:
  mode: permission
  unknownRuleFallback: hidden
  maxCelEvaluations: 100
  maxCacheEntries: 2000

The filter should support three modes:

ModeBehavior
noneCurrent behavior. tools/list returns all configured tools after query filtering.
permissionRecommended default for protected gateways. Use endpoint rules, permission metadata, and JWT claims to cheaply decide tool visibility.
celOptional strict mode. Evaluate configured req-acc rules for each listed tool with empty toolArguments. This is best-effort and must be documented as argument-insensitive.

tools/call still evaluates req-acc in all modes except when access-control is globally disabled or skipped by skipPathPrefixes.

Permission Mode

permission mode should use the same endpoint key that tools/call uses:

  1. Get the tool endpoint key from tool.endpoint, such as echo@call or /offers@get.
  2. Apply the access-control global gates.
  3. Look up rule.endpointRules[endpoint].
  4. Evaluate the endpoint permission metadata against the authenticated principal claims.
  5. Return only visible tools.

This mode does not execute arbitrary CEL. It recognizes the common permission shape already used by the MCP router policies:

{
  "permission": {
    "roles": "account-manager teller",
    "groups": "portal.w"
  }
}

An endpoint can also provide a dedicated list visibility block. This is the preferred shape when call-time req-acc is complex or argument-dependent:

endpointRules:
  accounts@call:
    req-acc:
      - allow-complex-financial-check
    visibility:
      roles: manager teller
      groups: portal.w

When visibility is present, tools/list uses it instead of deriving visibility from permission and known req-acc rule IDs. tools/call still uses the configured req-acc rules.

The visibility check should normalize both permission values and JWT claim values as string sets. It should accept either space-separated strings or arrays. For example, the following should be treated as equivalent:

{ "roles": "account-manager teller" }
{ "roles": ["account-manager", "teller"] }

The standard dimensions are:

Permission keyJWT claim keys
rolesrole, roles
groupsscp, grp, group, groups
positionspos, position, positions
attributesatt, attribute, attributes
usersuid, user_id, sub

Claim lookup is against the same normalized claims map used by req-acc CEL:

auditInfo.subject_claims.ClaimsMap

For example, the visibility checker resolves roles by reading auditInfo.subject_claims.ClaimsMap.role or auditInfo.subject_claims.ClaimsMap.roles, and resolves groups by reading auditInfo.subject_claims.ClaimsMap.scp, auditInfo.subject_claims.ClaimsMap.grp, auditInfo.subject_claims.ClaimsMap.group, or auditInfo.subject_claims.ClaimsMap.groups.

If a deployment uses non-standard claim names, add an access-control-wide claim mapping:

claimMappings:
  roles:
    - custom_roles
  groups:
    - custom_scope

When a mapping is present for a permission key, the mapped claim names are used instead of the standard aliases for that key. Keys without a mapping continue to use the standard aliases. The mapping also applies to built-in request access and response filters. Existing toolsListAccessControl.claimMappings values are retained as a compatibility fallback when the corresponding top-level mapping is absent.

This covers the sample policy where:

  • local_mcp_echo is visible to account-manager.
  • local_mcp_get_random_number is visible to category-admin.
  • OpenAPI tools protected by allow-scp-claim-group-access-control.lightapi.net are visible when the caller has portal.w in scp.

Rule Awareness

The visibility filter should inspect the endpoint's req-acc rule IDs and use accessRuleLogic to combine known checks:

{
  "req-acc": [
    "allow-role-based-access-control.lightapi.net",
    "allow-scp-claim-group-access-control.lightapi.net"
  ]
}

For known generic rules:

  • allow-role-based-access-control.lightapi.net maps to permission.roles against role claims.
  • allow-scp-claim-group-access-control.lightapi.net maps to permission.groups against group or scope claims.

If accessRuleLogic is any, one known rule match makes the tool visible. If it is all, every known rule must match.

Unknown custom req-acc rules need a configured fallback. The safer default is to hide the tool in permission mode unless explicit visibility metadata is present:

endpointRules:
  accounts@call:
    req-acc:
      - allow-custom-account-access
    visibility:
      groups: portal.w

This avoids accidentally exposing tools protected by custom call-time logic.

Rules that do not authorize access should be marked so list visibility can ignore them:

ruleBodies:
  request-correlation-logger:
    ruleId: request-correlation-logger
    ruleType: req-acc
    accessControlEffect: telemetry

The list visibility checker should ignore rules whose accessControlEffect is telemetry or none. Rules without an explicit effect are treated as authorizing rules.

Default Deny And Missing Rules

The list visibility fallback should mirror tools/call fallback behavior:

Policy statedefaultDeny: truedefaultDeny: false
No endpoint ruleHiddenVisible
Endpoint rule with no req-accHiddenVisible
Endpoint rule with known req-accVisible only when permission matchesVisible only when permission matches
Endpoint rule with unknown req-accHidden unless list-specific metadata allows itHidden unless list-specific metadata allows it

This keeps issue-165 behavior consistent: defaultDeny: false can expose tools without requiring no-op rules, while configured access rules still control tools that have policy.

If an endpoint has explicit visibility metadata, that metadata decides list visibility regardless of defaultDeny. defaultDeny only applies when no endpoint rule or no request-access/list-visibility policy is available.

skipPathPrefixes

The MCP router already treats skipPathPrefixes as matching either the MCP tool name or the endpoint key. tools/list should use the same behavior:

skipPathPrefixes:
  - local_mcp

With this configuration, tools such as local_mcp_echo and local_mcp_get_random_number are visible and callable without access-control evaluation, even when their endpoint keys are echo@call and getRandomNumber@call.

CEL Mode

cel mode can be useful when an operator wants the list to follow the exact configured req-acc expressions and accepts the cost.

In this mode, the router evaluates req-acc for each candidate tool with:

{
  "toolArguments": {}
}

This mode should be documented as argument-insensitive. Rules that require specific toolArguments are not reliable for list visibility. tools/call remains authoritative.

CEL mode must fail closed. If a req-acc rule fails to evaluate during tools/list, the tool is hidden and the gateway logs a debug or warn event with the rule ID, endpoint, and tool name. This makes argument-dependent rules visible to operators without exposing tools whose list-time authorization could not be proven.

CEL mode also needs a scale guard. The router should stop evaluating list-time CEL after maxCelEvaluations candidate tools and hide the remaining unevaluated tools, or reject the tools/list request with a clear configuration error. The preferred default is to hide unevaluated tools and log a warning.

Query Filtering Order

The router already supports tools/list query filtering. The recommended order is:

  1. Start from configured tools.
  2. Apply the query or intent filter.
  3. Apply list visibility.
  4. Return the filtered MCP tools array.

Applying the query first reduces the number of authorization checks without changing the response semantics, because hidden tools are never returned.

The query value used for filtering and caching must be normalized before it is included in a cache key. At minimum, trim whitespace and lowercase the query. If the router later accepts structured query parameters, sort the parameter names and normalize repeated whitespace before hashing.

Caching

The visibility result is cached per gateway process when toolsListAccessControl.mode is not none and maxCacheEntries is greater than zero. The cache key includes:

  • Authenticated principal identity, such as uid, sub, or client_id.
  • A stable hash of the normalized claims map used by visibility checks, or the token signature when available. Do not key only by user ID, because a user's roles or scopes can change between tokens.
  • A stable hash of normalized request headers, because CEL mode can inspect headers as part of req-acc evaluation.
  • Normalized query string.

The cache is a size-bounded LRU cache. The default maximum is 2000 entries per gateway process and can be changed with maxCacheEntries. Setting maxCacheEntries: 0 disables the cache. The MCP router runtime does not carry this cache across reloads, so MCP router, access-control, and rule reloads naturally invalidate cached visibility results.

The cache implementation must protect the gateway from high-cardinality query strings generated by agents. The LRU bound limits memory growth; highly unique queries will evict older entries instead of growing the cache without bound.

Security Notes

tools/list filtering improves agent ergonomics and reduces accidental tool selection. It must not be treated as the security boundary.

The security boundary remains tools/call:

  • req-acc runs before every downstream tool call.
  • res-fil runs after eligible downstream responses.
  • Argument-dependent authorization belongs in tools/call, not tools/list.

The design should therefore prefer fast, conservative list filtering and keep full rule evaluation on invocation.

MCP Tool Metadata Usage

This document describes how light-gateway MCP tool metadata should be used for tool search, progressive disclosure, deterministic routing, policy enforcement, and diagnostics.

The main principle is simple: metadata can help an agent find the right tool, but tools/call remains the execution and authorization boundary.

Background

The MCP router can expose tools backed by downstream MCP servers and tools backed by OpenAPI endpoints through the same gateway MCP endpoint.

A downstream MCP tool can be represented as:

- name: local_mcp_echo
  path: /mcp
  method: call
  apiType: mcp
  endpoint: echo@call
  endpointName: echo
  protocol: http
  productId: gtw
  serviceId: com.networknt.local.mcp-1.0.0
  endpointId: 019ec75c-72c5-702e-8e42-59dcf1e68cc2
  description: Echoes back the input
  inputSchema:
    type: object
    properties:
      message:
        type: string
    required:
      - message
  toolMetadata:
    routing:
      domain: MCP0002
      semanticNamespace: MCP0002
      semanticDescription: Echoes back the input
      semanticKeywords:
        - echo
        - Echoes back the input
      semanticWeight: 1.0
      sensitivityTier: internal
      sourceProtocol: mcp
    safety:
      read_only: false
      idempotent: false
      destructive: false
      humanApprovalRequired: false
    lifecycle:
      version: 1.0.0
      status: active
    read_only: false
    destructive: false

An OpenAPI-backed tool can be represented as:

- name: demo_customer_profile_api_get_customer_preferences
  path: /customers/{customerId}/preferences
  envTag: dev
  method: get
  apiType: openapi
  endpoint: /customers/{customerId}/preferences@get
  protocol: http
  productId: gtw
  serviceId: com.networknt.customer.profile-1.0.0
  endpointId: 019e621b-3a4c-78f4-82f5-16ed24f5ba58
  description: Get customer preferences
  inputSchema:
    type: object
    properties:
      customerId:
        type: string
        description: Customer identifier.
      channel:
        type: string
        default: portal
        description: Requested channel context.
    required:
      - customerId
  toolMetadata:
    routing:
      domain: Customers
      semanticNamespace: API0004
      semanticDescription: Get customer preferences
      semanticKeywords:
        - Customers
        - getCustomerPreferences
        - Get customer preferences
      semanticWeight: 1.0
      sensitivityTier: internal
      sourceProtocol: openapi
      parameters:
        customerId: path
        channel: query
    safety:
      read_only: true
      idempotent: true
      destructive: false
      humanApprovalRequired: false
    runtime:
      cacheTtlSeconds: 60
      costTier: low
      estimatedLatencyMs: 100
    lifecycle:
      version: 1.0.0
      status: active
    read_only: true
    destructive: false

Imported catalog data may store inputSchema and toolMetadata as escaped JSON strings. The router accepts that shape, but hand-authored config should prefer structured YAML or JSON. Structured metadata is easier to validate, diff, index, and review.

Current Runtime Boundary

At runtime, the MCP tool config includes:

FieldPurpose
nameGateway-facing tool name exposed to agents.
endpointNameBackend MCP operation name used when forwarding tools/call to a downstream MCP server.
descriptionHuman and model-facing summary.
protocolDiscovery and direct-registry protocol selector.
serviceIdService identity used for portal-registry or direct-registry lookup.
envTagOptional environment discriminator for service lookup.
targetHostDirect base URL override.
pathHTTP path or MCP endpoint path.
methodHTTP method, or call for backend MCP calls.
endpointStable policy endpoint key, such as echo@call or /offers@get.
apiTypemcp or openapi.
inputSchemaJSON Schema used for model tool parameters and argument validation.
toolMetadataStructured routing, semantic, safety, and governance metadata.

The gateway tools/list response should stay compact. It exposes the fields needed by model tool calling: name, description, and inputSchema.

Richer metadata belongs in the catalog/search layer and gateway runtime config. This avoids flooding the model context with operational fields while still making the data available for ranking, policy, routing, diagnostics, and audit.

The current stateful MCP router also accepts params.query or params.intent on tools/list. This is a case-insensitive substring filter, not a scored semantic or vector search. It matches the tool name, description, endpoint ID, selected routing fields, routing.semanticKeywords, and direct values in the safety and lifecycle objects. The router applies tools-list access control after this query filter and returns every remaining match without ranking them.

routing.semanticWeight does not affect gateway tools/list matching, ordering, or visibility. The stateless 2026-07-28 profile does not accept the legacy query or intent parameters. Rich semantic ranking, including the weight, belongs to portal catalog search and the agent's per-turn selection.

Metadata Responsibilities

Use metadata in three layers:

LayerUses metadata forShould not use metadata for
Portal or catalog searchRanking, assignment, filtering, disclosure, governance preview.Direct backend execution.
Agent runtimeProgressive disclosure, placement-aware availability, and per-turn schema selection.Bypassing the final gateway, runner-lease, workflow, or fixed-service policy for the selected placement.
light-gateway MCP routerDeterministic routing, argument mapping, access control, response filtering, audit, diagnostics.Letting model text decide target URLs or service routing.

The agent can use metadata to decide which tools to offer to the model. The gateway uses config metadata to decide how an accepted tools/call is executed.

Tool Source And Execution Placement

Gateway discovery is only one tool source. Every effective catalog entry must carry a server-owned execution placement and stable internal tool reference, for example:

  • gateway: remote API or MCP tool executed through light-gateway;
  • runner: shell, filesystem, browser, local MCP, or other capability exposed by an active runner runtime;
  • workflow: typed durable workflow start/status/cancel operation;
  • fixed-service: typed high-value action such as branch, publish, or sign.

Do not intersect the whole catalog with gateway tools/list. Apply an independent live-availability intersection for each placement:

gateway tools = assigned gateway catalog entries
  intersect gateway tools/list and toolsListAccessControl

runner tools = assigned runner catalog entries
  intersect execution-profile policy
  intersect lease allowedTools
  intersect approved runtime capability manifest
  intersect live worker/local-MCP enumeration where applicable

effective model tools = authorized union of each placement-specific set

The model-facing tool definition is bound to its internal tool reference, placement, schema digest, and policy snapshot. A returned tool call is dispatched only through that bound placement; the model cannot turn a gateway tool into a local command or vice versa. Model-facing name collisions across placements fail closed or are resolved by deterministic server-owned aliases recorded in the snapshot. Never rely on an unqualified name alone.

A local MCP server uses its sandbox-local tools/list under the runner lease; it is not expected to appear in light-gateway tools/list. The model broker, runner control socket, and credential broker are infrastructure channels and must never be advertised as local tools.

The current long-lived light-agent exposes gateway tools only, so its existing catalog-to-gateway intersection remains correct. Placement-aware union is required before enabling coding, browser, filesystem, or personal-edge tools.

Search And Progressive Disclosure

Agents should not send every configured tool to the model. Instead, they should search the effective agent catalog, select a small set of likely tools, then apply the live availability check for each candidate's execution placement.

Recommended flow:

user prompt
  -> load assigned effective agent catalog
  -> search metadata and schema text
  -> apply safety and policy disclosure filters
  -> select top tools for the turn
  -> partition candidates by server-owned placement
  -> intersect gateway candidates with gateway tools/list
  -> intersect runner candidates with lease/runtime/local capability manifests
  -> union the independently authorized, collision-free tool definitions
  -> send only selected schemas to the model
  -> dispatch each selected tool only through its bound placement

The search index should include:

MetadataSearch use
nameExact and alias matching.
endpointNameBackend operation matching.
descriptionGeneral keyword matching.
routing.semanticDescriptionAgent-oriented capability description.
routing.semanticKeywordsHigh-value domain and operation terms.
routing.domainBusiness-domain filtering, such as Customers or Offers.
routing.semanticNamespaceProduct, API, or catalog namespace filtering.
routing.sourceProtocolProtocol-aware selection between MCP and OpenAPI tools.
routing.sensitivityTierDisclosure and governance filtering.
routing.semanticWeightScore multiplier for preferred or higher-quality tools.
inputSchema.propertiesParameter-intent matching, such as customerId, state, or category.
inputSchema.requiredCompleteness checks before exposing or calling a tool.
safety.read_onlyPrefer safe read tools when the prompt is informational.
safety.idempotentDecide whether retries are safe for identical arguments.
safety.destructiveHide or require approval for destructive tools.
safety.humanApprovalRequiredRoute to approval or workflow instead of direct call.
runtime.costTierPrefer cheaper tools when multiple tools can satisfy the prompt.
runtime.estimatedLatencyMsPrefer faster tools for interactive turns.
lifecycle.statusPrefer active tools and avoid deprecated or retired tools.

The search result should be small. A practical default is 3 to 12 tools per turn. Larger lists increase token use and can lead to the model choosing an irrelevant tool.

Schema indexing should be bounded. For complex request bodies, index the top-level property names and descriptions by default, then include nested properties only when the importer marks them as semantically useful. Deeply nested OpenAPI schemas can otherwise flood the index with low-value keywords and increase false-positive tool matches. semanticKeywords should be the curated override when schema text is noisy.

Ranking And Semantic Weight

Current Gateway Behavior

The gateway MCP router does not calculate a relevance score. Its stateful tools/list query is a normalized substring predicate, and routing.semanticWeight is intentionally ignored. For example, these two tools are equally eligible for a gateway query match even though their weights differ:

- name: get_customer_preferences
  description: Get customer preferences
  toolMetadata:
    routing:
      semanticKeywords: [customer preferences]
      semanticWeight: 2.0

- name: search_customer_preferences
  description: Search customer preferences
  toolMetadata:
    routing:
      semanticKeywords: [customer preferences]
      semanticWeight: 0.5

A stateful request with params.query: customer preferences returns both tools, subject to access-control visibility. It does not guarantee that the 2.0 tool appears first.

Current Light-Agent Behavior

light-agent consumes the effective catalog and uses semanticWeight as a multiplier during per-turn tool selection. The effective catalog projects the nested metadata value as the top-level camel-case field semanticWeight; merely adding the value to gateway runtime config does not make gateway tools/list rank its response.

The implemented local ranking calculation is:

weighted_base =
  (
    0.75 * skill_keyword_score
    + 1.5 * tool_keyword_score
    + routing_score
    + max(skill_priority, 0) / 10
  )
  * max(semanticWeight, 0.1)

portal_score = max(
  first_available(combinedScore, semanticScore, vectorScore),
  0.0
)

final_score =
  weighted_base
  + portal_score
  + lifecycle_adjustment
  + informational_safety_bonus

The weight defaults to 1.0 and has a lower bound of 0.1. A zero or negative configured value therefore reduces a local keyword score but cannot erase it. The multiplier applies only to the locally calculated keyword/routing/priority portion. A score supplied by portal semantic search is added afterward and is not multiplied again by light-agent. Portal vector ranking may already have applied the effective semantic weight when it produced combinedScore; avoiding a second multiplication preserves that server-owned score.

For example, assume the following component scores:

skill_keyword_score       = 1.0
tool_keyword_score        = 2.0
routing_score             = 2.0
skill_priority            = 3
semanticWeight            = 1.5
combinedScore from portal = 0.8 (already weighted by portal, when applicable)
lifecycle                 = active          (+0.25)
informational prompt      = read-only/idempotent tool (+0.50)

weighted_base = (0.75 + 3.0 + 2.0 + 0.3) * 1.5 = 9.075
final_score   = 9.075 + 0.8 + 0.25 + 0.50       = 10.625

Weight changes relative preference; it does not bypass assignment, lifecycle, sensitivity, approval, or other disclosure filters. It also does not make an unrelated tool a local keyword match. When both the weighted base and portal score are zero, the tool is not a scored candidate.

Candidates are ordered by descending final score. Ties prefer lower cost, then lower estimated latency, skill sequence, and finally tool name. The selected catalog names are subsequently intersected with live gateway tools/list, so a high-weight tool that is not currently executable or visible is still removed.

Portal search can use the same metadata for vector or hybrid search:

  • Use semanticDescription, semanticKeywords, description, schema property descriptions, tags, and categories for embeddings.
  • Use routing.domain, semanticNamespace, sourceProtocol, sensitivityTier, read_only, destructive, and assignment state as structured filters.
  • Use endpointId as the stable document ID for evaluation and feedback.
  • Favor lifecycle.status: active over deprecated, and exclude retired tools from normal disclosure.
  • Use runtime.costTier, runtime.estimatedLatencyMs, and rate-limit metadata as tie-breakers when several tools can satisfy the same intent.

Disclosure Filters

Search ranking should run after coarse assignment and governance filters.

Before a tool schema is sent to the model, the agent or catalog API should remove tools that are not appropriate for the current principal and task:

FilterBehavior
Agent assignmentOnly include tools assigned through the effective agent catalog.
EnvironmentMatch hostId, serviceId, and envTag.
Runtime availabilityGateway entries intersect live gateway tools/list; runner entries intersect the active lease, approved runtime manifest, and any live local enumeration. Never use one source to validate another placement.
LifecycleHide retired tools and prefer active tools over deprecated tools.
SensitivityDo not disclose tools above the caller or agent sensitivity allowance.
Destructive flagHide unless an approval path or guarded workflow is configured.
Human approvalRoute to approval or workflow instead of direct model execution.
Read-only preferencePrefer read-only tools unless the user intent requires mutation.
Budget and rate limitPrefer lower-cost tools and avoid tools whose rate budget is exhausted.

Disclosure is not authorization. A hidden tool should not be shown to the model, but a visible tool must still be authorized by tools/call.

Tools List

The gateway tools/list endpoint has two jobs:

  1. Report which configured tools are currently executable through the gateway.
  2. Optionally filter the list by access-control visibility.

It should not become the primary semantic search API. The catalog or agent cache is a better place for richer semantic ranking because it can include skill assignment, tags, categories, prompt instructions, feedback, and non-runtime governance data.

For gateway-placed catalog entries, the recommended pattern is:

catalog search selects candidate tool names
gateway tools/list confirms executable visible tools
model receives only confirmed tool schemas

This keeps the runtime boundary clean:

  • Catalog search can evolve independently.
  • Gateway tools/list stays protocol-compatible and compact.
  • Gateway tools/call remains the final enforcement point.

See MCP Tools List Access Control for list visibility filtering.

Catalog Policy And Gateway Visibility

Catalog policy means the portal-side disclosure decision made before a tool is shown to an agent. It includes agent assignment, skill-to-tool links, environment, sensitivity tier, lifecycle status, approval requirements, and any tenant or persona rules owned by the catalog/control plane.

Gateway visibility means the runtime decision made by light-gateway toolsListAccessControl when tools/list is requested. It checks the current token, claims, gateway policy, and live runtime configuration.

Both are needed:

visible tools =
  assigned gateway-placed catalog tools
  intersect live gateway tools/list
  intersect gateway toolsListAccessControl result

Catalog policy prevents irrelevant or unassigned tools from reaching the model. Gateway toolsListAccessControl prevents the agent from seeing tools that are not visible to the current runtime principal. Neither replaces tools/call authorization.

Execution Routing

After the model chooses a tool, the agent calls:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "demo_customer_profile_api_get_customer_preferences",
    "arguments": {
      "customerId": "CUST-1001",
      "channel": "portal"
    }
  }
}

The gateway then resolves the configured tool by name and executes it deterministically.

For apiType: mcp:

  1. Resolve the backend target from targetHost, or from serviceId, envTag, and protocol.
  2. Establish or reuse the backend MCP session.
  3. Forward a backend tools/call.
  4. Use endpointName as the backend tool name when present.
  5. Return the backend MCP result to the caller after access-control response filtering.

For apiType: openapi:

  1. Resolve the backend target from targetHost, or from serviceId, envTag, and protocol.
  2. Start from configured path and method.
  3. Read toolMetadata.routing.parameters.
  4. Map arguments into path, query, header, cookie, or body.
  5. Invoke the HTTP endpoint.
  6. Convert the HTTP response into an MCP result.
  7. Return the result after access-control response filtering.

Parameter mapping is what lets one model-facing argument object become a correct HTTP request:

toolMetadata:
  routing:
    parameters:
      customerId: path
      channel: query
      idempotency-key: header
      body: body

The model still submits one flat JSON argument object. The gateway owns the split into HTTP destinations. A mapped argument should be consumed exactly once: path, query, header, and cookie arguments should be stripped from fallback body placement, and body arguments should not also become query parameters.

Header and cookie names should come from admin-approved catalog metadata, not from model output. The gateway should validate those names and block hop-by-hop or security-sensitive headers unless an explicit administrative allowlist permits them.

If no mapping is present, the router falls back to method-based behavior:

  • GET and HEAD arguments become query parameters.
  • JSON-body methods send the argument object as the request body.

That fallback is permitted only when the configured path contains no placeholder. A path containing OpenAPI {name} syntax must never reach the method-based fallback.

Path-template tools should not rely on fallback behavior. For a path such as /customers/{customerId}, the OpenAPI import or catalog authoring process should generate:

toolMetadata:
  routing:
    parameters:
      customerId: path

Without that mapping, the gateway cannot safely know whether customerId belongs in the path, query string, header, cookie, or body. Importers can infer this from OpenAPI parameter locations, but the runtime config should carry the resolved mapping explicitly.

The current Rust MCP router rejects a missing or incorrect path mapping when the tool is called. The target contract also fails earlier:

  • OpenAPI/LightAPI import must emit one explicit path mapping for every path placeholder and reject an ambiguous or missing source parameter;
  • catalog/config publication must reject unsupported placeholder syntax, placeholders without matching mappings, mappings whose location is not path, and path mappings with no matching placeholder;
  • gateway configuration validation must repeat these checks before publishing a runtime snapshot or accepting traffic;
  • runtime validation remains defense in depth and returns a stable routing error without contacting the backend.

Reload keeps the last known-good runtime snapshot when a newly supplied tool is invalid and reports the rejected tool/config digest through diagnostics. It must not silently omit the mapping and enable fallback behavior.

Validation tests cover missing, extra, duplicate, malformed, percent-encoded, and incorrectly located placeholders at import/publication and gateway config load. Every rejected case asserts that no backend request is sent; the valid case asserts one percent-encoded path-segment substitution and no duplicate query/body placement.

The supported path placeholder syntax should be OpenAPI-style {name}. The gateway should not infer Spring or Express-style :name segments by default; importers should normalize or reject non-OpenAPI path-template syntax before publishing gateway config.

Retries, Caching, And Rate Limits

Safety and runtime metadata can help agents and gateways decide how aggressively to retry or cache calls.

Recommended fields:

toolMetadata:
  safety:
    read_only: true
    idempotent: true
    destructive: false
    humanApprovalRequired: false
  runtime:
    cacheTtlSeconds: 60
    retry:
      enabled: true
      maxAttempts: 2
      retryOn:
        - timeout
        - 502
        - 503
        - 504
    rateLimit:
      key: customer-profile-read
      costUnits: 1
    costTier: low
    estimatedLatencyMs: 100

idempotent is different from read_only. A read-only tool is normally idempotent, but some write tools can also be idempotent when they use an idempotency key. Automatic retries should require idempotent: true and an explicit retry policy. Destructive tools should not be retried automatically unless the operation is proven idempotent and protected by an idempotency key.

cacheTtlSeconds should only apply to results that are safe to cache. The current Rust MCP router implements the bounded tools/list visibility cache; it does not yet implement a gateway tools/call response cache. Before adding one, preserve this mandatory ordering:

authenticate and run req-acc
  -> compute a raw-result cache key
  -> load the normalized pre-res-fil MCP result, or call the backend on miss
  -> store only the normalized pre-res-fil result when eligible
  -> run the current caller's res-fil rules on every hit and miss
  -> return the caller-specific filtered result

The shared call cache must never store a post-res-fil result. Reusing a caller-specific filtered value would couple cached data to an earlier caller's claims and policy. Conversely, the pre-filter value can contain more data than any caller may see, so the cache itself is a sensitive tenant boundary: encrypt or keep it process-local as policy requires, bound its entries and TTL, exclude it from logs, and never expose cache inspection to tenant code.

The raw-result key includes tool/config digest, resolved backend and environment, method, normalized arguments/body, representation-affecting headers, authenticated tenant/principal by default, and every request dimension that can change the downstream response. Cross-principal reuse is allowed only when policy explicitly proves the backend result is principal-invariant; it does not follow merely from read_only: true. Access control, revocation, and res-fil are always reevaluated on a cache hit. Do not cache denials, filter errors, partial/streaming results, secret-bearing results, or unknown outcomes.

There are two distinct cache types:

CachePurposeRecommended owner
tools/list visibility cacheReuse the filtered list of visible tool names for the same principal, claims, headers, and query.Gateway MCP router.
tools/call raw-result cacheReuse a normalized pre-res-fil result for an identical safe backend request; run caller-specific filtering on every hit.Backend service first; gateway only when explicitly configured.

The tools/list cache is a runtime optimization for discovery. It does not cache business data and does not change tools/call authorization. It should be enabled when toolsListAccessControl is enabled and bounded by a maximum entry count.

Gateway-level tools/call response caching should be opt-in and conservative. Start with backend-owned caching for expensive read APIs. Add gateway response caching only for tools with read_only: true, idempotent: true, a positive cacheTtlSeconds, a pre-filter storage boundary, and a cache key that includes all tenant/principal, argument, backend, environment, request-header, and tool configuration dimensions that can change the raw result. If the gateway cannot prove those conditions or cannot rerun res-fil on a hit, caching stays disabled for that tool.

Before enabling a gateway call cache, integration tests use callers with different claims and row/column filters against the same raw backend result. They prove that req-acc and current res-fil run on every hit, caller outputs remain distinct, revocation or policy reload takes effect without waiting for the raw-result TTL, backend-varying identity dimensions prevent unsafe hits, filter errors are not cached, and pre-filter bytes never appear in logs or cache diagnostics.

Rate-limit and cost metadata should influence ranking and diagnostics. It can also prevent an agent from repeatedly selecting a tool whose backend quota is already exhausted.

Service Resolution

Routing should avoid model-supplied URLs. The selected tool already carries the deployment routing data.

Resolution order:

  1. Use targetHost when explicitly configured.
  2. Use direct-registry when a matching static URL is configured.
  3. Use service discovery through serviceId, envTag, and protocol.

The gateway must validate protocol compatibility when direct-registry is used. For example, a tool configured for protocol: http should not silently route to an incompatible backend entry.

targetHost is administrative configuration, not model input. Automated imports must treat targetHost as untrusted until the owning control plane validates and approves it. Validation should include allowed schemes, allowed hostnames or service identities, optional CIDR allowlists, DNS and redirect handling, and environment ownership. This prevents a compromised catalog import from turning the gateway into an SSRF path to metadata services, loopback addresses, or internal control-plane endpoints.

DNS and CIDR checks must be enforced on the actual resolved address used by the gateway connector, not only on the URL string. This prevents DNS rebinding from turning an approved-looking hostname into a loopback, link-local, private, or metadata-service address at connection time. Redirect targets should go through the same validation.

Access Control

MCP tool metadata should complement, not replace, access-control policy.

The gateway applies the shared access-control runtime around tools/call:

  • req-acc runs before the downstream tool is invoked or a call-result cache entry is used.
  • res-fil runs after the downstream or cached pre-filter result is converted to an MCP result, on every cache hit and miss.
  • A future gateway call cache stores only the normalized pre-res-fil result; a shared cache never stores caller-filtered output.

Use metadata as follows:

MetadataAccess-control use
endpointStable key for endpoint rules.
endpointIdStable audit and governance identifier.
sensitivityTierDisclosure and policy input.
read_onlySafe-tool classification and policy input.
destructiveApproval or denial input.
humanApprovalRequiredWorkflow or approval routing input.
sourceProtocolPolicy and diagnostics dimension.

Do not rely on model instructions for sensitive operations. If destructive: true or humanApprovalRequired: true, enforcement should be in policy or workflow, not only in the prompt.

See MCP Tools Access Control for invocation authorization and response filtering.

Metadata Storage

Use one canonical metadata object and derive indexed columns from it.

Recommended storage:

  • Store toolMetadata and inputSchema as JSON or JSONB in catalog tables.
  • Store flattened fields such as routingDomain, semanticNamespace, sourceProtocol, sensitivityTier, semanticWeight, readOnly, and destructive as indexed projection columns when search needs them.
  • Regenerate flattened projections when the canonical JSON changes.
  • Store endpointId as the stable identity for audit, scoring feedback, and catalog synchronization.

This avoids drift where toolMetadata.routing.domain says one thing and a flattened routingDomain column says another.

The gateway should not read portal catalog tables directly. Light Portal or the control plane owns catalog authoring, normalization, approval, and projection. It publishes a flattened runtime config, such as mcp-router.yml or config-cache content, to gateway instances. The gateway then executes from that approved runtime config and live service discovery state.

Catalog import must normalize compatibility fields before publishing gateway config. If safety.read_only and top-level read_only disagree, or safety.destructive and top-level destructive disagree, the import should fail or rewrite the compatibility fields from the canonical safety object. Agents and gateways should never observe conflicting safety values.

Metadata Contract

The recommended metadata shape is:

toolMetadata:
  routing:
    domain: Customers
    semanticNamespace: API0004
    semanticDescription: Get customer preferences
    semanticKeywords:
      - Customers
      - getCustomerPreferences
      - preferences
    semanticWeight: 1.0
    sensitivityTier: internal
    sourceProtocol: openapi
    parameters:
      customerId: path
      channel: query
  safety:
    read_only: true
    idempotent: true
    destructive: false
    humanApprovalRequired: false
  runtime:
    cacheTtlSeconds: 60
    costTier: low
    estimatedLatencyMs: 100
  lifecycle:
    version: 1.0.0
    status: active
  read_only: true
  destructive: false

The duplicated top-level read_only and destructive fields are compatibility fields. New code should prefer safety.read_only, safety.destructive, and safety.humanApprovalRequired, then fall back to the top-level fields.

Recommended field semantics:

FieldRequiredSemantics
routing.domainRecommendedBusiness capability group.
routing.semanticNamespaceRecommendedCatalog/API namespace for filtering and grouping.
routing.semanticDescriptionRecommendedAgent-facing capability summary.
routing.semanticKeywordsRecommendedSearch keywords and aliases.
routing.semanticWeightOptionalCatalog/light-agent ranking multiplier. Default 1.0, with a 0.1 lower bound in current light-agent selection. It does not affect gateway tools/list.
routing.sensitivityTierRecommendedDisclosure and governance tier.
routing.sourceProtocolRecommendedSource protocol, such as mcp, openapi, http, or lightapi.
routing.parametersRequired for non-trivial OpenAPI toolsArgument location mapping.
safety.read_onlyRecommendedTrue when the tool does not mutate state.
safety.idempotentRecommendedTrue when identical calls can be safely retried.
safety.destructiveRecommendedTrue when the tool can delete, reset, revoke, overwrite, or cause irreversible effects.
safety.humanApprovalRequiredRecommendedTrue when a workflow or approval step must precede execution.
runtime.cacheTtlSecondsOptionalTTL hint for safe raw backend results. It never authorizes caching a post-res-fil caller view; gateway call caching remains disabled until the pre-filter contract is implemented.
runtime.retryOptionalRetry policy, only honored for idempotent calls.
runtime.rateLimitOptionalRate-limit grouping and per-call cost units.
runtime.costTierOptionalRelative execution cost such as low, medium, or high.
runtime.estimatedLatencyMsOptionalExpected latency used for ranking and diagnostics.
lifecycle.versionRecommendedTool contract version visible to agents and catalogs.
lifecycle.statusRecommendedLifecycle state such as active, deprecated, or retired.

Use normalized sensitivity reference values such as public, internal, confidential, and restricted. Older imported values such as Internal-Only should be normalized during catalog import or edited through the App/GenAI/Tool dropdowns.

The current Portal reference tables for this metadata are:

Reference tableMetadata fieldValues
sensitivity_tiertoolMetadata.routing.sensitivityTier and sensitivity_tier projectionpublic, internal, confidential, restricted
source_protocoltoolMetadata.routing.sourceProtocol and source_protocol projectionopenapi, mcp, lightapi, http
lifecycle_statustoolMetadata.lifecycle.status and lifecycle_status projectionactive, deprecated, retired
parameter_locationvalues inside toolMetadata.routing.parameterspath, query, header, cookie, body
cost_tiertoolMetadata.runtime.costTier and cost_tier projectionlow, medium, high

Use openapi when the tool contract is generated from an OpenAPI document. Use http for manually configured HTTP-family tools without an OpenAPI contract, including REST-style endpoints or future HTTP transports such as GraphQL-over-HTTP and gRPC-over-HTTP.

Diagnostics

Operators need to understand why an agent saw or did not see a tool and where a selected call was routed.

Diagnostics should include:

EventUseful fields
Catalog searchquery, selected tool names, scores, score reasons, catalog hash, catalog version.
Disclosure filteringhidden tool names, policy reason, sensitivity tier, destructive flag, approval requirement.
Gateway list checkcatalog tools missing from gateway, extra gateway tools, gateway list error.
Tool calltool name, endpoint, endpointId, serviceId, envTag, sourceProtocol, policy outcome, correlation ID.
Backend routingtarget source, selected URL without secrets, discovery node, direct-registry match.
Runtime policyretry attempt, idempotent flag, cache hit or miss, rate-limit decision, cost tier.
Response filteringendpoint, filter rule IDs, filtered result status, policy outcome.

The agent diagnostics endpoint should compare assigned catalog tools with live gateway tools/list so operators can see catalog/runtime drift.

The gateway should avoid logging tool arguments in full. When arguments are logged for debugging, masking should follow the inputSchema and metadata sensitivity signals.

Distributed tracing should carry selected metadata as span attributes. Useful OpenTelemetry attributes include:

AttributeSource
mcp.tool.nameTool name.
mcp.tool.endpoint_idendpointId.
mcp.tool.endpointendpoint.
mcp.tool.domaintoolMetadata.routing.domain.
mcp.tool.namespacetoolMetadata.routing.semanticNamespace.
mcp.tool.source_protocoltoolMetadata.routing.sourceProtocol.
mcp.tool.read_onlytoolMetadata.safety.read_only.
mcp.tool.idempotenttoolMetadata.safety.idempotent.
mcp.tool.cost_tiertoolMetadata.runtime.costTier.

These attributes let operators group latency, errors, policy denials, and rate limits by domain, namespace, protocol, and tool contract instead of only by URL.

Advanced Metadata Usage

The same metadata contract can support features beyond search and routing.

Dry Run And Mocking

Development and workflow validation can use sandbox metadata:

toolMetadata:
  sandbox:
    enabled: true
    mode: mock
    mockResponse:
      customerId: CUST-1001
      preferences:
        channel: portal

Mocking must be opt-in and environment-scoped. Production gateways should not return mock responses unless a deployment explicitly enables sandbox mode for a tool, environment, or test principal.

UI Rendering Hints

Some tool results are easier to inspect as structured UI components:

toolMetadata:
  ui:
    component: customer-profile-card
    resultShape: customerProfile

UI metadata should be treated as a rendering hint, not as trusted executable frontend code. The frontend should map known component names to local UI components and ignore unknown values.

Catalog search can use related-tool hints to pre-warm or prioritize likely next schemas:

toolMetadata:
  relatedTools:
    - demo_customer_profile_api_get_customer_preferences
    - demo_offer_decision_api_search_offers

Related tools should not bypass assignment, visibility, or their placement-specific live-availability intersection. Gateway-placed entries still require gateway tools/list; runner entries require the lease/runtime/local manifest checks. Related links only affect ranking and prefetch.

Sub-Agent Orchestration

In a multi-agent deployment, a tool may require skills owned by a worker agent:

toolMetadata:
  orchestration:
    requiredSkills:
      - data_analysis
      - python_execution
    preferredAgentId: analytics-worker

The supervisor can use these hints to delegate the user task or to avoid disclosing a tool to an agent that cannot safely execute the surrounding work.

This is orchestration metadata, not a source protocol. Do not use sourceProtocol: agent. Keep sourceProtocol for concrete protocol or contract sources such as mcp, openapi, http, and lightapi.

Do not add orchestration reference tables in the first rollout. If sub-agent delegation becomes a product feature, reuse the Light Portal agent and skill model:

  • Agent capabilities are the skills assigned to each agent.
  • requiredSkills should be selected from the existing skill catalog.
  • preferredAgentId, if present, should refer to an agent in the managed agent registry.
  • The catalog or supervisor should validate that the preferred agent has the required skills.

Evaluation Feedback

Metadata should also support closed-loop improvement.

Track these signals by endpointId and tool name:

  • Search query text or normalized intent.
  • Tool rank and selected rank position.
  • Whether the model called the tool.
  • Whether the call succeeded.
  • Whether the user accepted the result.
  • Whether policy denied the call.
  • Whether retries, cache hits, rate limits, or cost budgets affected the call.
  • Whether schema validation or required arguments failed.

This feedback can tune semanticKeywords, semanticDescription, and semanticWeight without changing the backend API contract.

Implement metadata usage incrementally:

  1. Normalize imported inputSchema and toolMetadata to structured JSON.
  2. Validate administrative routing fields such as targetHost and normalize compatibility safety fields.
  3. Project searchable fields into catalog columns or search documents.
  4. Add keyword search over name, endpointName, description, semanticDescription, semanticKeywords, domain, namespace, and schema property names.
  5. Apply disclosure filters for assignment, environment, lifecycle, sensitivity, destructive tools, and approval-required tools.
  6. Add server-owned tool placement and partition selected candidates into gateway, runner, workflow, and fixed-service sets. Intersect only gateway candidates with live gateway tools/list; require runner candidates to match the lease/runtime/local capability manifests.
  7. Add diagnostics for selected, hidden, missing, conflicting, and placement-incompatible tools.
  8. Move the existing path-placeholder/mapping checks into importer, publication, and gateway startup/reload validation while retaining runtime rejection as defense in depth. In frameworks/light-pingora/src/mcp.rs, refactor the existing openapi_path_placeholders and mapping checks into one shared validator called by both validate_config and request construction so startup and call-time behavior cannot drift.
  9. Add retry, rate-limit, and OpenTelemetry attributes after the core disclosure path is stable. If gateway tools/call caching is later added, implement a bounded pre-res-fil cache and rerun req-acc/res-fil for every caller and hit. In the current handle_tool_call pipeline, the cache may replace backend execution after authorization, but it must feed the existing filter_mcp_response call rather than bypass or follow it.
  10. Add semantic or hybrid search after keyword behavior is proven.
  11. Feed evaluation results back into keywords and semantic weights.

Do not start by changing gateway tools/call. The gateway execution path is already the right boundary. The first improvement should be better catalog search and per-turn tool disclosure.

Semantic vector search is needed for large catalogs, but it should be optional. Keep keyword plus structured filtering as the baseline implementation, then add hybrid search as an enhancement for deployments that have enough tools to justify the extra index and operations cost.

Example End-To-End Flow

User prompt:

Show customer CUST-1001 preferences and find available travel offers.

Catalog search:

  1. Matches customer, preferences, and CUST-1001 against the customer profile tool metadata and schema.
  2. Matches travel and offers against the offer search tool metadata and schema.
  3. Filters out destructive or approval-required tools.
  4. Classifies both selected entries as gateway-placed and intersects their names with gateway tools/list.

Model tool disclosure:

demo_customer_profile_api_get_customer_preferences
demo_offer_decision_api_search_offers

Execution:

  1. The model calls demo_customer_profile_api_get_customer_preferences.
  2. The gateway maps customerId to the path and channel to the query string.
  3. The gateway runs req-acc.
  4. The gateway invokes the downstream customer profile API.
  5. The gateway runs res-fil if configured.
  6. The model receives the MCP result.
  7. The model calls demo_offer_decision_api_search_offers if more data is needed.

The model never receives backend URLs, discovery nodes, or direct-registry details. It only receives the selected tool schemas.

Resolved Guidance

The recommended default decisions are:

TopicDecision
Semantic searchSupport optional semantic or hybrid vector search. Keyword plus structured filters remain the required baseline.
Sensitivity tierSet a default when API details create endpoints. Store allowed values in the sensitivity_tier reference table and expose normalized dropdowns in the App/GenAI/Tool pages.
Destructive toolsRequire workflow-backed or approval-backed execution for destructive tools. Do not expose them as direct model-callable tools unless approval is configured.
Tool availabilityPartition by server-owned execution placement. Gateway entries intersect portal policy and live gateway tools/list; runner entries intersect execution policy, lease allowlist, approved runtime manifest, and live local enumeration. Union only independently authorized, collision-free definitions.
Semantic weightPopulate semanticWeight when the endpoint is created, then allow authorized updates from the App/GenAI/Tool page.
CachingUse gateway caching first for tools/list visibility. Keep tools/call caching backend-owned by default. A future gateway call cache stores only normalized pre-res-fil results, reruns req-acc and caller-specific res-fil on every hit, and remains disabled unless the raw-result key and sensitive cache boundary are proven safe.
Path parametersFail closed at import/publication and gateway startup/reload when a path-template mapping is missing or inconsistent; retain call-time rejection as defense in depth. OpenAPI import generates toolMetadata.routing.parameters, and method fallback applies only to paths without placeholders.

If a future deployment needs path-template inference, add an explicit opt-in field such as routing.parameterInference: pathTemplate. The default should remain explicit mapping because it is safer, easier to audit, and consistent with OpenAPI parameter locations.

Light-Workflow

light-workflow is the workflow execution service for Agentic Workflow documents.

It loads workflow definitions, executes workflow tasks, integrates with light-rule for rule-backed checks, and exposes workflow execution APIs.

Key Dependencies

  • workflow-core
  • light-rule
  • axum
  • sqlx
  • reqwest

Role

light-workflow is the runtime service that turns workflow specifications into long-running execution state. It is used by agentic flows, human-in-the-loop orchestration, and integration-test style automation.

Start Workflow

This page describes the local workflow start path used to test light-workflow from light-portal.

light-workflow does not create workflow definitions and it is not the public entry point for starting a workflow. For local testing, create the workflow definition through the portal workflow service, then start it through the startWorkflow command. The running light-workflow process consumes the workflow start event from the portal database and executes the workflow tasks.

Runtime Path

The local start flow is:

  1. Create or update a workflow definition in light-portal.
  2. Start the workflow with the workflow service startWorkflow command.
  3. workflow-command writes a workflow started event into the event store and outbox tables.
  4. light-workflow polls the same database, loads the definition by wfDefId, creates the process and task records, and executes the workflow.

For this reason, the DATABASE_URL used by light-workflow must point to the same database used by the local portal stack.

Prerequisites

Start the local portal stack first. For the Rust local stack, use the normal portal-config-local deployment command from the portal-config-loc checkout:

./scripts/deploy-local.sh pg rust

Make sure the workflow command and query services are available in that stack. The workflow definition pages in portal-view depend on those services.

Then build light-workflow:

cd /home/steve/workspace/light-fabric/apps/light-workflow
cargo build -p light-workflow --locked

Start light-workflow Locally

Create light-workflow.env in /home/steve/workspace/light-fabric/apps/light-workflow:

DATABASE_URL=postgres://postgres:secret@localhost:5432/configserver
LIGHT_WORKFLOW_HTTP_ADDR=0.0.0.0:8436
RUST_LOG=light_workflow=debug,info
WORKFLOW_LOG_ANSI=false

Start the service with the debug binary:

./run.sh --debug-binary

The script loads light-workflow.env automatically. If you do not use the env file, export the values before running the script:

export DATABASE_URL=postgres://postgres:secret@localhost:5432/configserver
export LIGHT_WORKFLOW_HTTP_ADDR=0.0.0.0:8436
export RUST_LOG=light_workflow=debug,info
export WORKFLOW_LOG_ANSI=false
./run.sh --debug-binary

Do not set the variables on separate shell lines without export. That creates shell variables only for the current shell and run.sh will not receive them.

The easiest local test is to create the definition in the portal UI and start it from the workflow editor test action.

  1. Open light-portal.

  2. Go to the workflow definition page.

  3. Create a workflow definition.

  4. Paste one of the example workflow YAML files from:

    /home/steve/workspace/light-fabric/apps/light-workflow/examples
    
  5. Save the definition.

  6. Open the definition in the workflow editor.

  7. Use the editor test run action with a JSON input object.

For the basic example, use apps/light-workflow/examples/simple-set-assert.yaml and this input:

{
  "applicantId": "APP-001"
}

The editor test action is preferred for local testing because it parses the input text as JSON and sends input as an object.

The table run button opens the generic startWorkflow form. If using that path, make sure the request sends input as a JSON object, not as a string. If the input is submitted as a string, the workflow command may accept the request but the runtime context will not have the expected object fields.

Start with Postman or curl

You can also start the workflow directly through the portal command endpoint. Send the request to the same light-gateway or light-portal host used by the UI. Do not send this request to light-workflow; light-workflow is the executor, not the command API.

The command envelope is:

{
  "host": "lightapi.net",
  "service": "workflow",
  "action": "startWorkflow",
  "version": "0.1.0",
  "data": {
    "hostId": "<host-id>",
    "wfDefId": "<workflow-definition-id>",
    "input": {
      "applicantId": "APP-001"
    }
  }
}

Example curl shape:

curl -k -X POST "https://localhost:8443/portal/command" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <access-token>" \
  -d '{
    "host": "lightapi.net",
    "service": "workflow",
    "action": "startWorkflow",
    "version": "0.1.0",
    "data": {
      "hostId": "<host-id>",
      "wfDefId": "<workflow-definition-id>",
      "input": {
        "applicantId": "APP-001"
      }
    }
  }'

If your local UI uses a session cookie instead of a bearer token, use Postman with the same authenticated session or copy the current local authorization header from the browser request.

Creating the Definition by API

For most local tests, create the definition in the UI. It is easier because the YAML can be pasted directly.

If you create the definition through the command API, send a workflow definition command first and use the returned definition id as wfDefId in the startWorkflow command.

The command shape is:

{
  "host": "lightapi.net",
  "service": "workflow",
  "action": "createWfDefinition",
  "version": "0.1.0",
  "data": {
    "hostId": "<host-id>",
    "namespace": "light-portal",
    "name": "simple-set-assert",
    "version": "1.0.0",
    "definition": "<workflow-yaml-as-json-string>"
  }
}

When calling this from Postman, remember that the YAML definition is a JSON string field. Newlines must be escaped correctly by the JSON editor or sent by a tool that can build the JSON body safely.

Example Workflows

The current examples are in /home/steve/workspace/light-fabric/apps/light-workflow/examples:

FilePurposeInput
simple-set-assert.yamlBasic local smoke test with no external dependency.{ "applicantId": "APP-001" }
http-risk-decision.yamlCalls a risk evaluation HTTP endpoint and branches on the result.{ "applicantId": "APP-001", "loanAmount": 25000, "creditScore": 720 }
human-approval.yamlCreates a human approval style workflow and waits for a later decision.{ "requestId": "REQ-001", "summary": "Approve test request" }
insurance-claim-rest-v1.yamlComplete product demo with direct HTTP API orchestration, native agent tasks, and human tasks.See examples/README.md.
insurance-claim-mcp-v1.yamlComplete product demo with gateway MCP tool orchestration, native agent tasks, and human tasks.See examples/README.md.
insurance-claim-headless-v1.yamlHeadless insurance-claim regression workflow with deterministic agent outputs and no human-task pauses.See examples/README.md.

Start with simple-set-assert.yaml. It is the best smoke test because it does not require another service.

For a complete multi-agent product demo, use the insurance claim suite in apps/light-workflow/examples. The product walkthrough is Insurance Claim Agentic Workflow, and the operational runbook is in apps/light-workflow/examples/README.md.

For http-risk-decision.yaml, start a local mock service for the URL used by the definition. When light-workflow runs natively with run.sh, 127.0.0.1 means the host machine. When light-workflow runs in Docker, 127.0.0.1 means the container itself, so change the workflow endpoint to a Compose service name or host.docker.internal.

For human-approval.yaml, the first run should create a waiting task. Completing that flow requires the worklist or task-completion API path.

Verify Execution

Watch the light-workflow log after sending startWorkflow. A successful run should show that the start event was received, the first task was initialized, and the executor picked up task work.

Useful database checks:

select wf_def_id, namespace, name, version
from wf_definition_t
order by update_ts desc
limit 5;

select process_id, wf_instance_id, status_code, context_data
from process_info_t
order by started_ts desc
limit 5;

select wf_task_id, task_type, status_code, task_output
from task_info_t
order by started_ts desc
limit 10;

select c_offset, event_type, aggregate_id, payload
from outbox_message_t
order by c_offset desc
limit 10;

If outbox_message_t has the workflow started event but no process or task records appear, check that light-workflow is running against the same DATABASE_URL as the portal stack.

Troubleshooting

  • DATABASE_URL is required: Put DATABASE_URL in light-workflow.env, export it before running run.sh, or put the assignment on the same command line as ./run.sh.
  • function make_interval(mins => bigint) does not exist: Rebuild and restart light-workflow. The runtime query must cast the retry value to int before passing it to make_interval.
  • Workflow definition list is empty in the UI: Confirm the workflow query service is running and the local stack is using the jar or binary that contains the workflow definition owner-scope fix. Some local stacks run copied service artifacts, so rebuilding a source checkout is not enough unless the deployed artifact is refreshed.
  • No tasks are created after starting the workflow: Confirm the startWorkflow command wrote a workflow started event to the outbox table, and confirm light-workflow points to that same database.
  • The workflow input is missing fields: Confirm input was submitted as a JSON object. A string that contains JSON text is not the same as a JSON object in the workflow context.

Native Agent Call

Status

Recommended platform boundary.

call: agent is currently a native light-workflow task. It does not invoke a running light-agent container. The workflow engine loads the portal agent definition, selected skills, and skill tools from the database, builds a bounded model prompt, calls the configured model provider directly, validates the JSON output, and continues the workflow.

Containerized light-agent remains the interactive agent runtime. It serves chat clients, keeps session memory, loads its effective catalog, and calls MCP tools through light-gateway.

This page defines how both models should coexist in an enterprise platform.

Problem

The platform has two useful agent execution models:

  • native agent tasks inside light-workflow
  • containerized light-agent services

Both can use the same portal-authored concepts: agent definitions, skills, tools, workflow mappings, and gateway-routed API capabilities. They should not be treated as interchangeable runtime paths.

The main design question is whether a workflow should keep executing call: agent natively or call a containerized light-agent service for every agent step.

Current Behavior

When a workflow contains:

do:
  - review-offer:
      call: agent
      with:
        agent: com.networknt.agent.offer-1.0.0
        skill: offer-decision
        input:
          customerId: "${ .customerId }"
          profile: "${ .profile }"
        outputSchemaRef: offerDecision

light-workflow handles the task itself:

  1. Resolve the agent by agent_def_id or agent API name.
  2. Load active skills assigned to the agent from agent_skill_t.
  3. If a skill is specified, narrow the prompt to that skill.
  4. Load skill tool metadata from skill_tool_t, tool_t, and tool_param_t.
  5. Build a bounded prompt from workflow context, skill instructions, optional task instructions, and the expected output schema.
  6. Call the model provider configured on the portal agent definition.
  7. Parse and validate the model response as JSON.
  8. Return the structured output to the workflow context.

The native task does not:

  • call the light-agent HTTP or WebSocket endpoint,
  • use light-agent session memory,
  • let the model run a dynamic gateway tool loop,
  • execute tool calls from the model response.

Skill tools are included as guidance and future-routing context. In the current runtime phase, API orchestration remains explicit workflow tasks such as call: http, call: mcp, assert, switch, and ask.

Native Agent Tasks

Native agent tasks are best for bounded reasoning where the workflow remains the system of record.

Good examples:

  • classify a request,
  • normalize user-provided input,
  • summarize API results,
  • choose between workflow branches,
  • draft a customer-facing explanation,
  • assess whether human approval is required,
  • produce structured output that must match a schema.

Benefits:

  • Strong auditability: workflow records input, output, status, retry, and failure state.
  • Deterministic orchestration: API calls, approvals, assertions, and retries stay in the workflow definition.
  • Easier governance: output schemas and workflow-owned context constrain the model.
  • Lower operational coupling: the task does not depend on a separate agent service instance being healthy.
  • Better replay and diagnostics: the workflow engine owns the execution state.

Tradeoffs:

  • It is not the full light-agent runtime.
  • It does not use chat session history or Hindsight memory.
  • It can duplicate some prompt/catalog handling from light-agent.
  • Model provider scaling is tied to light-workflow.
  • Dynamic tool selection is intentionally limited.

Containerized Agents

Containerized agents are independently deployed light-agent services.

They are best for interactive or autonomous agent behavior where the agent runtime itself is the product surface.

Good examples:

  • user-facing chat agents,
  • long-lived specialist agents,
  • agents that need session memory,
  • agents that should cache and refresh their effective catalog locally,
  • agents that need a dynamic tools/list and tools/call loop through light-gateway,
  • agents that must scale independently from workflow execution.

Benefits:

  • Real agent runtime behavior: memory, chat sessions, local catalog cache, and gateway tool execution.
  • Independent deployment, scaling, health checks, and versioning.
  • Clear service identity through controller registration.
  • Better fit for interactive clients and long-running conversational work.

Tradeoffs:

  • Harder workflow audit if the agent internally decides which APIs to call.
  • More distributed failure modes: network errors, timeouts, retries, and partial progress.
  • Requires strict request and response contracts.
  • Requires idempotency, correlation IDs, auth scopes, and timeout policy.
  • Can make the workflow less deterministic if the agent is allowed to run an open-ended tool loop.

Recommendation

Keep the mixed approach, but make the boundary explicit.

Use native call: agent for bounded reasoning inside workflow-controlled processes. Use workflow tasks and subworkflows for API orchestration. Use containerized light-agent for interactive chat and specialist runtime agents.

The recommended enterprise pattern is:

main workflow
  -> call: mcp or call: http for deterministic API access
  -> run/start subworkflow for reusable skill-backed API orchestration
  -> call: agent for bounded reasoning over workflow-owned context
  -> ask/assert/switch/retry/audit in workflow

chat client
  -> containerized light-agent
  -> effective catalog from portal-query
  -> tools/list and tools/call through light-gateway
  -> session memory and chat history

Do not route every workflow agent step through a containerized agent by default. That would move too much process control into agent services and make enterprise audit, replay, and approval harder.

Do not remove native call: agent. It is the right primitive for workflow-owned reasoning steps.

Skill To Workflow Pattern

For skills that require API orchestration, prefer mapping the skill to a workflow or subworkflow.

Example:

skill_t: customer-profile-review
  -> skill_workflow_t: customer-profile-enrichment-v1
  -> wf_definition_t: workflow that calls gateway MCP tools

In that pattern:

  • the skill describes when and why to use the capability,
  • the workflow owns the API call sequence,
  • light-gateway executes MCP tool calls,
  • native call: agent can summarize or classify the results,
  • the workflow remains the audit boundary.

This is the preferred model for enterprise API access because it prevents an agent from inventing an unreviewed process path.

Demo Guidance

The current demos should be described precisely:

  • insurance-claim-rest-v1.yaml shows workflow-owned API orchestration with direct HTTP calls plus native agent tasks for bounded reasoning.
  • insurance-claim-mcp-v1.yaml shows the same business flow through light-gateway MCP tools plus native agent tasks for bounded reasoning.
  • insurance-claim-headless-v1.yaml shows the deterministic regression path without human-task pauses.

The demos do not currently prove that light-workflow invokes the containerized light-agent services. That can be added later as an explicit runtime integration if the platform needs it.

Future Containerized-Agent Invocation

If workflow needs to call containerized light-agent services in the future, do not silently change the meaning of native call: agent. Add an explicit mode or task contract so operators can see which runtime path is used.

Possible options:

call: agent
with:
  mode: native
  agent: com.networknt.agent.offer-1.0.0
  skill: offer-decision
call: agent
with:
  mode: service
  agent: com.networknt.agent.offer-1.0.0
  skill: offer-decision
  timeout: PT30S

or a separate task type:

call: agent-service
with:
  serviceId: com.networknt.agent.offer-1.0.0
  envTag: dev
  skill: offer-decision

The service-call contract must require:

  • explicit timeout and retry policy,
  • idempotency key for side-effecting work,
  • correlation and workflow instance headers,
  • output schema validation,
  • clear failure mapping to workflow status,
  • portal/gateway authorization policy,
  • observability across workflow, gateway, controller, and agent logs.

Decision Matrix

NeedPreferred runtime
Deterministic API sequenceWorkflow task or subworkflow
Gateway-routed API accesscall: mcp through light-gateway
Bounded model reasoningNative call: agent
Human approval or form inputask task
Policy assertionassert, switch, or rule task
Interactive chatContainerized light-agent
Session memoryContainerized light-agent
Dynamic tool loopContainerized light-agent
Enterprise audit and replayWorkflow-owned task

Long-Term Direction

The platform should keep both execution models:

  • Native agent tasks for workflow-owned reasoning.
  • Containerized agents for interactive, memory-backed, independently scaled agent services.

The enterprise control rule is simple: workflows own durable process state and auditable API orchestration; agents provide bounded reasoning or interactive specialist behavior within contracts defined by the platform.

Execution Backends And Sandbox Execution

Status

Proposed product design.

light-workflow should support multiple execution backends for tenant-authored, automation-heavy, and developer-local workflows. The workflow engine remains the durable orchestrator and policy authority. Effectful work is dispatched through the leased runner boundary defined in the Light-Workflow Runner design, and the runner uses a capability-described ExecutionBackend selected by the effective policy.

Not every backend is a security sandbox. Cube Sandbox and Docker Sandboxes use microVM boundaries. Rootless OCI containers and ordinary Kubernetes Jobs share a host kernel unless a stronger runtime is configured. Fedora Toolbx is a host-integrated developer environment and explicitly is not a sandbox. The policy model must preserve these differences instead of treating every backend as interchangeable.

Problem

Workflows can be created by tenants and can eventually include tasks that run commands, scripts, containers, model calls, MCP tools, browser automation, or release automation. Those capabilities are useful, but they are also the highest-risk part of the workflow runtime.

The platform needs a way to say:

  • whether a workflow can request effectful execution,
  • where each task is allowed to run,
  • which minimum isolation boundary and host-integration limits apply,
  • whether sandboxed tasks may share a workspace,
  • which command, image, resource, network, filesystem, artifact, and secret policies apply,
  • how task claims and remote execution remain correct across crashes and retries,
  • how release workflows can keep build state without exposing publish or signing credentials to tenant-controlled code.

Architecture And Ownership

Use one authoritative execution path:

workflow start event
  -> light-workflow
       - creates workflow and task state
       - resolves and persists the effective policy snapshot
       - owns branching, retries, cancellation, and audit
  -> controller-rs
       - authenticates runners
       - issues and renews fenced task leases
       - rejects stale task reports
  -> light-workflow-runner
       - validates the lease and effective task policy
       - invokes the selected ExecutionBackend
       - streams bounded logs and reports normalized results
  -> execution backend
       - prepares or resumes the execution environment
       - enforces its approved isolation, resource, network, workspace,
         credential, and lifecycle policy
       - executes the approved command specification

Component ownership is:

  • light-workflow: Workflow state, policy snapshots, task attempts, transition decisions, retry decisions, cancellation state, and durable audit.
  • controller-rs: Runner identity, admission, capabilities, lease ownership, lease renewal, fencing, and quarantine.
  • light-workflow-runner: Effectful task execution, backend selection from the lease, backend API credentials, log streaming, artifact transfer, and result normalization.
  • ExecutionBackend: Capability-described adapter for a microVM sandbox, shared-kernel container, Kubernetes Job, dedicated VM, host-integrated environment, or fixed external action.

light-workflow should not hold backend control-plane credentials in the SaaS topology and should not implement separate direct protocols for Cube, Docker, Kubernetes, or other substrates. A local installation may colocate the runner and backend adapter with light-workflow, but it must preserve the same durable attempt, lease, fencing, policy, result, and audit contracts.

Goals

  • Keep workflow orchestration outside effectful execution environments.
  • Keep tenant-authored code outside the SaaS workflow process.
  • Make the effective policy server-owned, immutable, and auditable.
  • Support multiple backend purposes without weakening minimum isolation.
  • Support per-task and per-workflow execution lifecycles where the backend has those capabilities.
  • Support long-running tasks without duplicate execution caused by stale locks.
  • Clean up execution resources autonomously when a runner cannot reach the control plane.
  • Queue temporary capacity shortages without busy retry loops or consuming a workflow retry attempt.
  • Fail closed when a required backend capability or policy control is absent.
  • Keep raw release and signing credentials away from arbitrary workflow code.
  • Treat agent-generated workspace changes as untrusted output and validate them against a server-owned path policy.
  • Generate verifiable build provenance for release artifacts without exposing attestation credentials to tenant-controlled code.

Non-Goals

  • Do not expose vendor or backend APIs directly through the workflow DSL.
  • Do not let workflow metadata define raw backend network rules or backend credentials.
  • Do not describe Toolbx or an ordinary shared-kernel container as equivalent to a microVM security boundary.
  • Do not store security policy or execution lifecycle state in workflow context.
  • Do not promise exactly-once external side effects. The runtime provides fenced at-least-once execution plus explicit reconciliation.
  • Do not treat a fresh sandbox as sufficient authorization for publishing or signing.

First Schema Surface

Use existing metadata fields first so the design can be introduced without an immediate workflow-core schema break. WorkflowDefinitionMetadata already has document.metadata, and every task has metadata through TaskDefinitionFields.

Workflow metadata requests security requirements through an approved profile. It does not directly select a backend credential, mutable image name, vendor, or raw network policy:

document:
  dsl: "1.0.3"
  namespace: release
  name: light-fabric-polyrepo-release
  version: "1.0.0"
  metadata:
    lightWorkflow:
      runner:
        runnerPool: release
      security:
        schemaVersion: 1
        executionProfile: release-sandbox
        profileVersion: 7
        placement: runner
        isolation:
          minimumBoundary: microvm
          allowedHostExposure: []
          workloadTrust: untrusted
        sandbox:
          sessionScope: workflow
        workspace:
          mode: copy-on-write
        containerEngine:
          access: private-daemon
        network:
          protocols:
            - https
        credentials:
          delivery: proxy-injected

A task may request stricter isolation and an approved command template:

do:
  - publish-github-release:
      run:
        shell:
          command: light-release-publish
          arguments:
            - "${ .artifactSetId }"
            - "${ .version }"
      metadata:
        lightWorkflow:
          runner:
            commandTemplateId: light-fabric-release-publish-v1
          security:
            sandbox:
              sessionScope: task
              reason: release-credential-isolation
            approval:
              required: true
              bindTo:
                - artifactSetDigest
                - releaseTarget
            credentials:
              - github-release-oidc

The command template is the authority. The command and arguments in the workflow must match the approved template after expression resolution. The runner rejects a mismatch rather than executing arbitrary text.

approval.required makes the task ineligible for runner scheduling until light-workflow has persisted a matching approval. It does not instruct a runner to claim the task and wait. The eventual lease references the already validated approval and represents a new fixed-action attempt.

Unknown lightWorkflow.security fields, unsupported schema versions, invalid types, and unapproved profile versions must fail definition validation. They must not be ignored.

Later, a first-class field can normalize into the same internal policy object:

security:
  schemaVersion: 1
  executionProfile: release-sandbox
  profileVersion: 7
  placement: runner
  isolation:
    minimumBoundary: microvm
    allowedHostExposure: []
    workloadTrust: untrusted
  sandbox:
    sessionScope: workflow
  workspace:
    mode: copy-on-write

Policy Dimensions

Placement, isolation boundary, backend selection, routing, session scope, and workspace reuse are separate decisions. They must not be combined into one mode value.

Isolation Boundary

host-integrated

The environment deliberately shares host facilities such as the user's home directory, session services, devices, sockets, or host networking. Fedora Toolbx belongs in this category. It is useful for trusted local development and troubleshooting, but it is not a security boundary for tenant-authored code.

shared-kernel-container

The task runs in an OCI container that shares the host kernel. Rootless Docker or Podman and a default Kubernetes container belong here. This boundary is appropriate for trusted build and packaging tasks when capabilities, mounts, syscalls, resources, and networking are constrained. It must not satisfy a profile requiring a separate kernel.

microvm

The task runs with a separate guest kernel in a lightweight VM. Cube Sandbox and Docker Sandboxes belong here. A microVM can satisfy untrusted-code profiles only when workspace, network, credential, lifecycle, control-plane, and cleanup requirements are also enforced.

dedicated-vm

The task runs on a separately provisioned VM dedicated to an approved tenant, workflow, or runner pool. This can support privileged or long-running workloads, but image provenance, teardown, attestation, and network isolation remain required.

external-service

The task calls a fixed service-owned action such as publishing, signing, or deployment. It is not a general command environment. Authorization derives from the action contract and immutable inputs rather than from shell isolation.

The effective profile sets a minimum boundary and an allowedHostExposure allowlist. An empty allowlist means the backend may expose no host facilities. The backend's approved hostExposure set must be a subset of that allowlist. Boundary names are not the complete security decision. For example, a microVM with a writable host workspace or raw credentials may be unsuitable for a high-risk task.

Boundary matching uses a server-owned compatibility relation, not simple enum sorting. An approved dedicated VM may satisfy a separate-kernel requirement, but external-service is comparable only to a fixed-action requirement, and a backend cannot claim a stronger boundary merely by changing its registration.

workloadTrust is trusted or untrusted. Tenant-authored code, generated code, dynamic agent tools, and content from an untrusted repository default to untrusted; workflow metadata cannot mark them trusted. An untrusted workload requires an approved compatibility record with supportsUntrustedCode=true in addition to the boundary, workspace, network, credential, and lifecycle requirements.

Backend Taxonomy And Intended Use

BackendBoundaryIntended useUntrusted tenant code
Cube SandboxmicrovmRemote or clustered tenant sandbox sessions, snapshots, controlled egressAllowed only with the Cube production baseline
Docker Sandboxes (sbx)microvmAutonomous coding agents and isolated Docker buildsAllowed with clone workspace mode and enforced policy
Rootless Docker or Podman containershared-kernel-containerLightweight trusted CI, tests, packaging, and toolsNot by default
Kubernetes JobDeclared by approved runtime classScalable jobs in a tenant or service clusterOnly when the selected runtime and node policy satisfy the required boundary
Fedora Toolbxhost-integratedTrusted developer tooling and host troubleshootingNever
Dedicated VMdedicated-vmPrivileged, tenant-dedicated, or long-running workAllowed when its approved profile satisfies the task requirements
Publisher, signer, or deployer serviceexternal-serviceFixed irreversible actions over immutable inputsNo arbitrary code surface

Toolbx usually does not need a per-task backend implementation. A trusted local runner may itself run inside Toolbx and register a host-integrated backend with execution session scope none. The policy must record its home, device, D-Bus, socket, and network exposure and prevent it from claiming isolated or secret-bearing tasks.

An ordinary Docker container and Docker Sandboxes are different backends. A container shares the host kernel. Docker Sandboxes place an autonomous agent inside a microVM with a private Docker daemon. No backend may mount the host Docker socket for tenant-authored execution.

Backend Capability Contract

Every registered backend has an operator-approved compatibility record. A representative capability document is:

{
  "backendId": "docker-sbx-local",
  "kind": "microvm",
  "implementation": "docker-sandboxes",
  "version": "approved-version",
  "isolationBoundary": "microvm",
  "supportsUntrustedCode": true,
  "workspaceModes": ["direct", "clone"],
  "hostExposure": [],
  "networkEnforcement": ["deny-by-default", "http-l7"],
  "supportedEgressProtocols": ["http", "https"],
  "credentialDelivery": ["proxy-injected"],
  "containerEngineAccess": "private-daemon",
  "lifecycle": ["inspect", "reconnect", "cancel", "destroy"],
  "sessionScopes": ["task", "workflow"]
}

This is an effective, profile-specific record, not an implementation-wide claim. It is scoped to the backend implementation and version plus the approved template, image, runtime class, node policy, workspace mode, and enforcement configuration that make the capabilities true. Registration and leases bind the digest of that exact record. A configuration or compatibility change creates a new immutable record and digest.

The minimum capability vocabulary includes:

  • isolation boundary and supported trust classes,
  • direct host exposure such as home, devices, D-Bus, SSH agent, localhost, container sockets, and writable workspace mounts,
  • workspace modes: direct, ephemeral, clone, copy-on-write, and workflow reuse,
  • network enforcement layer and supported protocols,
  • credential delivery: proxy-injected, workload identity, attempt-bound local broker, or task-unique read-only tmpfs file; environment-value delivery is prohibited,
  • container-engine access: none, private daemon, or prohibited host daemon,
  • CPU, memory, disk, process, time, output, artifact, and concurrency controls,
  • lifecycle inspection, reconnect, cancellation, snapshots, log cursors, operation lookup, idempotency, and cleanup,
  • tenant isolation, data residency, attestation, and audit support.

Runner self-report is not sufficient. Server-owned compatibility definitions and backend conformance tests determine which capabilities are trusted. A backend may claim only task attempts whose effective requirements are a subset of that approved compatibility record.

The workflow DSL does not name a backend implementation. Policy resolution selects an eligible backend from the registered runner pool and persists the selected backend ID, implementation, version, and capability digest in the effective task policy.

Placement

host

The task runs in the trusted light-workflow process. Only control-plane tasks and explicitly approved native calls can use this placement.

runner

The task is sent through a fenced lease to light-workflow-runner. The runner executes it through an eligible backend permitted by the effective profile.

Execution Session Scope

none

The runner does not create an additional execution environment. This is allowed only when the effective profile explicitly permits the runner environment itself as the execution boundary.

workflow

One backend execution session is reused by approved tasks in one workflow instance. This supports a shared checkout, build output, and dependency cache. The selected backend must advertise workflow-session support. The session must never be reused across workflow instances, tenants, principals, or incompatible policy snapshots.

agent-session

One backend execution session provides a bounded interactive workspace across turns for one authenticated agent session. Reuse requires identical tenant, host, principal, agent definition, workspace base, policy digest, runtime adapter, backend compatibility, network/model/tool policy, and unexpired cleanup state. Conversation history and memory remain origin-domain state and are never recovered from the sandbox.

task

One fresh backend environment is created for one task attempt. This is stricter isolation and is required for untrusted code that must not share state, raw credential fallbacks, and high-impact operations.

Profile labels such as per-agent-call or per-publish map to task scope plus an isolation class and additional policy requirements. They are not separate lifecycle primitives.

Workspace Reuse

Workspace reuse is independently controlled:

  • ephemeral: Fresh workspace for one task attempt.
  • workflow: Reused only within one workflow instance and policy snapshot.
  • agent-session: Reused only for the same authenticated agent session, principal, immutable base, runtime adapter, and policy snapshot.
  • copy-on-write: A task receives an isolated clone of an approved workspace.

Cross-tenant caches and mutable cross-workflow workspaces are out of scope. Shared dependency caches, if added later, require content-addressing, integrity verification, and separate poisoning controls.

Task Routing

Host execution remains the default for control-plane tasks:

ask
assert
set
switch
workflow context merge
task creation and transition
process state persistence
approved native call.agent without tools or file access

Runner execution is required for effectful or tenant-local task families:

run.shell
run.script
run.container
browser automation
tenant-provided code
filesystem mutation outside workflow context
external MCP server processes
command-line tools
release build and package commands
agent tasks with files, tools, or private network access

Calls that can run in more than one location require policy-based placement:

TaskHost placementRunner placement
call.httpApproved SaaS endpoint and host credential boundaryTenant-private endpoint or sandbox egress boundary
call.jsonrpcApproved SaaS endpointTenant-private or backend-local endpoint
call.mcpApproved gateway endpointExternal process or tenant-local MCP server
call.agentBounded model call without tools or filesTools, files, generated code, or tenant-local data
call.ruleDefault for curated local rulesOnly when an approved rule profile requires isolation

Placement depends on endpoint identity, credential source, data boundary, required capabilities, and network policy. Egress reachability alone is not sufficient. Destination validation remains mandatory for host-executed HTTP, JSON-RPC, and MCP calls.

Unsupported task types and task/backend combinations must be rejected before execution. When the task graph can be inspected statically, definition publication should reject the workflow before any instance can partially run. Dynamic destinations and values are validated again for each attempt.

Effective Policy

The runtime computes a workflow policy snapshot at instance creation and a derived effective policy for each task attempt:

{
  "policySnapshotId": "019f0000-0000-7000-8000-000000000001",
  "requestedProfile": "release-sandbox",
  "effectiveProfile": "release-sandbox",
  "profileVersion": 7,
  "policyDigest": "sha256:...",
  "placement": "runner",
  "runnerPool": "release",
  "approvedTaskTypes": ["run.shell", "call.http", "call.mcp"],
  "executionRequirements": {
    "minimumBoundary": "microvm",
    "allowedHostExposure": [],
    "workloadTrust": "untrusted"
  },
  "executionBackend": {
    "backendId": "cube-prod-east",
    "kind": "microvm",
    "implementation": "cubesandbox",
    "version": "approved-version",
    "capabilityDigest": "sha256:..."
  },
  "sandbox": {
    "templateId": "tpl-immutable-id",
    "templateDigest": "sha256:...",
    "sessionScope": "workflow",
    "workspaceMode": "copy-on-write"
  },
  "networkPolicyId": "release-egress-v3",
  "networkPolicyDigest": "sha256:...",
  "trustBundleRef": "trust-bundle://enterprise-egress-v3",
  "trustBundleDigest": "sha256:...",
  "credentialPolicy": "brokered-task-scoped",
  "artifactPolicy": "release-artifacts-v2",
  "provenancePolicy": {
    "format": "slsa-provenance-v1",
    "mode": "signed",
    "policyDigest": "sha256:..."
  },
  "localCleanupPolicyDigest": "sha256:...",
  "resourcePolicy": "release-build-medium-v1"
}

The snapshot is stored in dedicated runtime state, not in workflow context or task output. Audit records reference its immutable ID and digest.

Policy resolution rules are field-specific:

  • Operator profile definitions provide the base allowed backend compatibility records, templates, commands, networks, trust bundles, mounts, workspace-change policies, credentials, provenance, local cleanup, limits, and placements.
  • Service policy intersects the profiles and capabilities available in the deployment.
  • Tenant policy further restricts the allowed set.
  • Workflow metadata requests one allowed profile and version.
  • Task metadata may request stricter isolation or a subset of capabilities; it cannot downgrade operator-derived workload trust.
  • Allowlists are intersected.
  • Explicit denies take precedence.
  • Numeric resource and duration limits use the lowest permitted maximum.
  • task session scope may strengthen workflow scope; a task cannot weaken a required execution environment to none.
  • The selected backend must meet the minimum isolation boundary and every required capability. A host-integrated or shared-kernel backend cannot satisfy a microVM requirement.
  • Backend and template selections must be members of the approved set. They do not have a meaningful "more privileged" ordering within the same boundary.
  • Credential access is the intersection of profile, task, command template, approval, and current credential-broker policy.

Profile versions are immutable. A new operator policy creates a new version. An in-flight workflow continues with its recorded snapshot unless an emergency revocation explicitly invalidates it. Revocation must fence new attempts, cancel affected active attempts where possible, revoke credentials, and record why the snapshot was invalidated.

Profile changes that require approval must remain pending and cannot publish an active workflow definition. Runtime approvals for irreversible tasks are separate objects and must bind the approver, task, command template, artifact digest, target, policy snapshot, and expiry.

Durable Runtime State

Remote backend execution adds distributed state and requires dedicated persistence. Do not put session IDs, leases, policy snapshots, credentials, or backend operation IDs in process_info_t.context_data.

The controller/runner portion of this state is origin-neutral. A workflow task, standalone agent turn, or agent action can use the same scheduling, execution attempt, lease, backend, and cleanup contract. light-workflow and light-agent keep separate domain tables and are the only services allowed to advance their respective subjects. See Light-Agent Execution for agent session and turn ownership.

The initial storage model should include:

workflow_execution_policy_t

  • workflow process and instance IDs,
  • tenant, host, trigger principal, and correlation IDs,
  • requested and effective profile IDs,
  • profile version and policy digest,
  • creation and revocation state.

execution_session_t

  • authenticated origin and subject scope, including optional workflow or agent session correlation,
  • backend ID, kind, implementation, version, and capability digest,
  • backend environment and session IDs,
  • immutable template or image ID and digest where applicable,
  • workflow policy snapshot ID,
  • tenant, workflow, and principal scope,
  • lifecycle state/version/fence, active action/lease owner, task and lease deadlines,
  • optional approval-hold ID/reason, hold expiry, policy/cost binding, pause/checkpoint state, and retained-resource evidence,
  • origin idle/max expiry, policy/grant expiry, effective minimum expiry, backend-native expiry, last runner contact, last inspection time, cleanup deadline, attempt count, and cleanup state.

execution_session_cleanup_request_t

  • request ID, authenticated origin, origin-session/subject correlation, and execution-session ID,
  • close, revoke, expiry, policy-change, quarantine, or operator reason,
  • requested, dispatched, cleaned, retryable, or operator-action state,
  • attempt/fencing watermark, retry schedule, and cleanup evidence reference,
  • unique active request per execution session.

execution_input_t

  • immutable input ID, authenticated origin subject and optional attempt or execution session,
  • kind such as context, workspace base, skill package, trust bundle, or fixed action input,
  • content digest, size, media/package type, storage reference, provenance and scanner bindings, and mount/entrypoint policy,
  • staging, verification, retention, and cleanup state without embedded storage credentials.

execution_attempt_t

  • authenticated origin service, execution subject kind and ID, and monotonically increasing attempt number,
  • optional workflow task or agent turn/action correlation,
  • lease ID, runner ID, and fencing token,
  • backend operation ID and command idempotency key,
  • effective task-policy digest,
  • state, heartbeat, started, deadline, and completed timestamps,
  • normalized result, error classification, and reconciliation state.

runner_scheduling_request_t

  • idempotent scheduling request ID, authenticated origin, execution subject, tenant fairness key, runner pool, and effective requirements digest,
  • enqueue time, queue deadline, priority class, and scheduling state,
  • short-lived capacity reservation ID, runner and backend slot, reservation expiry, and consumption state,
  • cancellation, policy-revocation, and terminal admission reason.

workflow_approval_t

  • approval request ID, tenant, workflow, orchestration task, and state,
  • artifact set and provenance digests, release target and version, command template, policy digest, and prior-outcome reconciliation state,
  • approver identity, decision, reason, creation, decision, and expiry times, and single-use nonce,
  • consuming post-approval execution attempt ID or rejection and expiry transition.

Standalone agent approvals remain in agent-domain storage, but use the same immutable binding and single-use post-approval common-attempt contract. The runner never owns either approval table.

workflow_artifact_t

  • immutable artifact ID, tenant, workflow, task, and attempt IDs,
  • canonical name, size, media type, and trusted digest,
  • storage reference, retention class, provenance statement and envelope digests, attestation signer identity, and approval bindings.

The runner also maintains a minimal durable local cleanup journal before it prepares an environment or dispatches an operation. The journal contains the lease, fencing token, backend environment and operation IDs, absolute and monotonic deadlines, backend-native expiry, and cleanup state. It contains no credential values or task payloads. Runner restart recovery and the local watchdog use this journal; the SaaS database remains authoritative for workflow state.

Security and execution audit should be append-only. Mutable status tables can reference the latest state, but must not replace the history needed to explain claims, retries, cancellation, policy changes, and cleanup.

Origin Result Wakeup

The common attempt row is authoritative. The PostgreSQL transaction that conditionally stores a newly terminal execution_attempt_t also emits a versioned execution_result_ready_v1 notification containing only attempt ID, authenticated origin, subject kind, and correlation ID. It carries no result bytes, tenant content, or authorization.

light-workflow or light-agent uses that notification only to wake a reconciler, reloads and verifies the authoritative attempt, and conditionally accepts it into its own domain transaction. Every origin must also run indexed startup and periodic catch-up scans because notifications can be missed, duplicated, or reordered. A push callback may be another wakeup later, but correctness never depends on notification delivery and controller/runner code never updates origin-domain state directly.

Lease, Attempt, And Fencing Model

Remote backend execution is at-least-once. Exactly-once external effects cannot be guaranteed across the backend and workflow database boundary.

Each attempt receives a short-lived lease and a monotonically increasing fencing token. The runner must renew the lease while the backend operation is active. Every progress, log, artifact, and completion report includes the lease ID, attempt number, and fencing token. The control plane rejects reports from a stale token.

Completion updates must use compare-and-set semantics against the active attempt. A late result from an expired attempt must not overwrite a newer attempt. Workflow transitions occur only after the accepted result and audit records commit.

Backend prepare and execute calls must receive stable idempotency keys when the backend supports them. When the runner loses contact after dispatch, the attempt enters UNKNOWN, not immediately FAILED. A reconciler inspects the backend operation before deciding whether to accept a result, resume waiting, cancel, or create another attempt.

The DSL idempotencyKey is part of the task contract, but it is not by itself a guarantee. A side-effecting command template must declare how the target system honors that key or how the runner queries the external operation before a retry. Tasks without such a contract must not be automatically retried after an unknown outcome.

Execution Session Lifecycle

For workflow-session execution:

  1. light-workflow resolves and persists the workflow policy snapshot.
  2. light-workflow marks the task ready and submits an idempotent scheduling request. It remains PENDING_CAPACITY without an execution attempt when no eligible slot is available.
  3. controller-rs reserves an eligible runner and backend slot. The control plane idempotently creates the task attempt against that reservation and issues its lease with the task policy, attempt number, and fencing token.
  4. The runner validates the lease, its own capabilities, and the approved command template.
  5. The runner idempotently prepares, resumes, or inspects the backend execution session scoped to the workflow policy snapshot.
  6. The runner dispatches the command with a stable backend operation ID and starts lease and operation heartbeats.
  7. Logs are streamed with bounded chunks and resumable sequence numbers.
  8. Declared artifacts are safely copied into controlled storage and hashed outside the sandbox trust boundary.
  9. The runner reports a normalized result with the active fencing token.
  10. light-workflow accepts the result, persists the transition, and schedules cleanup when the session is no longer needed.

The session identity is scoped to:

tenant id and host id
workflow definition id and version
workflow process and instance id
policy snapshot id and digest
trigger principal or approved service identity
runner pool and execution backend

For workflow and agent sessions, effective physical-session expiry is the earliest of the origin session idle/max expiry, execution policy, credential or broker-grant expiry, and backend-native TTL. The runner must not extend one clock merely because another has time remaining.

When an origin closes, revokes, or expires its logical session, the same durable origin transaction creates an idempotent execution_session_cleanup_request_t. controller-rs fences and cancels active attempts, revokes grants, and dispatches cleanup. The runner destroys the backend session and records evidence; retries survive controller and runner restart. A backend-native TTL is the last fail-safe. Leaving a known-abandoned sandbox alive until that independent TTL is a cleanup defect.

An action lease and a reused execution session are different resources. Ending an action lease always removes executable authority, model/credential broker access, and task-scoped grants. It cleans task scope, but it does not by itself delete a compatible workflow or agent-session workspace.

Under an explicit non-secret retention policy, an origin may put the session in IDLE_APPROVAL_HOLD with a durable hold ID, reason, policy digest, holdUntil, retained-resource cost, and verified checkpoint/patch evidence. The runner pauses or checkpoints where supported. The hold expires no later than approval expiry, idle/max lifetime, policy/cost limit, grant boundary, or backend TTL; it cannot be extended by fake action heartbeats. Zero active attempts is not an abandonment signal while the bounded hold is valid. Close, revocation, policy mismatch, hold expiry, or cleanup request still destroys the session.

Do not reuse a sandbox across tenants, unrelated workflow instances, different policy snapshots, or incompatible principals.

A workflow session is single-writer unless the backend profile explicitly supports safe concurrent operations. Parallel workflow branches must use separate task sandboxes or copy-on-write clones unless their workspace access is serialized.

Cancellation fences the attempt first, then requests backend cancellation and environment cleanup. If cleanup cannot be confirmed, the attempt remains in a cleanup-pending state and an orphan reconciler continues inspection. A cancelled or expired attempt can never publish a valid completion afterward.

Disconnected Runner Watchdog

Control-plane reconciliation alone cannot clean resources inside a tenant network that has become unreachable. Every runner therefore has an autonomous watchdog, separate from the task worker, with these rules:

  • Write and sync the local cleanup journal before creating a backend resource.
  • Stop claiming and starting work as soon as the control-plane session is unavailable.
  • A running task may continue only while its server-issued lease remains locally valid. A connectivity grace period cannot extend expiresAt or the task deadline.
  • At lease expiry, task deadline, cancellation observed before disconnect, or maximum environment lifetime, locally fence the attempt, revoke local credential handles, cancel the operation, and destroy or quarantine the environment.
  • Use a monotonic deadline derived when the lease is received, bounded by the authenticated absolute deadline, so wall-clock rollback cannot extend execution.
  • Tag every backend resource with tenant, workflow, attempt, policy digest, owner runner, and expiry. Configure a backend-native TTL or lifecycle rule where available so cleanup still occurs if the runner host itself fails.
  • On startup and periodically, scan the local journal and backend-owned tagged resources, retry expired cleanup with bounded backoff, and preserve minimal evidence for unresolved operations.
  • On reconnect, report cancellation, outcome, and cleanup evidence with the original lease and fencing token. The control plane may accept matching resource cleanup evidence for a fenced attempt, but only the current token can transition task outcome; an expired result remains diagnostic.

Destroying an environment cannot undo an external side effect. A disconnected fixed action or publish operation remains UNKNOWN and must be inspected and reconciled before retry. The control-plane orphan reconciler, local watchdog, and backend-native expiry are complementary layers.

Timeouts, Resource Limits, And Admission

The policy must distinguish:

  • task wall-clock deadline,
  • total workflow deadline,
  • backend API request timeout,
  • sandbox idle timeout,
  • maximum sandbox lifetime,
  • lease heartbeat interval and expiry,
  • cancellation grace period,
  • cleanup and evidence-retention period.

Backend idle timeout is not a task wall-clock timeout. The runner and control plane enforce the task deadline even when backend activity keeps resetting its idle timer.

Each profile must set backend-enforced limits for:

  • CPU and memory,
  • writable disk and inode or file count,
  • process and PID count,
  • open files,
  • network destinations, connections, and optional bandwidth,
  • stdout, stderr, and structured result size,
  • artifact count and total bytes,
  • maximum concurrent tasks and sandboxes per tenant and runner pool.

Admission checks capacity and tenant quotas before a lease is issued. The runtime must provide backpressure instead of creating unbounded pending sandboxes. Cost and quota exhaustion are explicit non-command failure classes.

Capacity Queueing And Backoff

Temporary saturation is a scheduling state, not a command failure. When no eligible runner or backend slot is available, the task remains PENDING_CAPACITY; no task attempt, lease, sandbox, or workflow retry is created. controller-rs owns a bounded, per-tenant fair queue and atomically reserves capacity before issuing a lease.

The authoritative workflow task remains in light-workflow; the controller queue stores only the idempotent scheduling request. When capacity opens, controller-rs returns a short-lived reservation token. Attempt creation and lease issuance bind that token through an idempotent, fenced handshake so a lost response cannot allocate two attempts or two capacity slots.

Runner claims use long polling or server push. Empty claims and transient backend-capacity responses carry retryAfter and use capped exponential backoff with jitter. Capacity release wakes only a bounded number of eligible waiters, and repeated backend admission failures open a short circuit breaker instead of causing a thundering herd.

Hard tenant quota, policy, or cost-limit violations return execution_admission_denied and require a policy, quota, or operator change. Temporary saturation returns execution_capacity_deferred and remains queued until capacity is available, the queue deadline expires, the workflow is cancelled, or policy is revoked. Queue time counts toward the workflow deadline but not the task wall-clock deadline, which begins only after lease acceptance.

Execution Backend Interface

The provider-neutral interface is named ExecutionBackend. It supports capability discovery and the normalized operations needed for crash recovery:

capabilities
validate effective configuration
prepare environment idempotently
inspect environment
resume or reconnect
execute task idempotently
inspect operation
stream logs from cursor
cancel operation
export artifact safely
report measured execution evidence
create and delete checkpoint
clean up environment

Not every operation applies to every backend. A Toolbx-backed trusted runner may have no separate environment to create. An external signer exposes a fixed action rather than a shell or filesystem. Optional operations are advertised through the approved capability record; required missing operations fail policy resolution.

Capabilities include isolation and host exposure, resource and network controls, credential delivery, private container-engine access, snapshots, cancellation, log cursors, operation lookup, idempotency, measured execution evidence, attestation support, native expiry, and cleanup behavior.

Backend-specific response codes are normalized, but request IDs and raw diagnostic references remain in restricted audit data. Every backend adapter must pass a boundary-appropriate conformance suite before it is enabled.

Cube Sandbox Production Baseline

Cube Sandbox defaults are not the Light platform security policy. The Cube adapter and deployment admission must verify the following baseline:

  • CubeAPI is authenticated and authorized. Authorization checks both HTTP path and method.
  • CubeAPI, CubeMaster, Cubelet, WebUI, Redis, and database access are restricted to approved private networks and protected with firewall rules.
  • TLS or mTLS protects control-plane traffic where it crosses a host boundary.
  • Sandbox public traffic is disabled unless an approved task explicitly needs an inbound service, and that service requires its per-sandbox access token.
  • allow_internet_access is false for restricted profiles.
  • L3/L4 allow rules and L7 HTTP/HTTPS rules are generated from the effective profile, not copied from workflow metadata.
  • The effective rendered network configuration is inspected after provider template and request merging.
  • Non-HTTP traffic, DNS, SSH, registry access, and provider-internal traffic are considered explicitly. L7 HTTP rules do not control arbitrary TCP or UDP.
  • Templates contain the required egress CA only when TLS interception is part of the approved profile.
  • Provider, control-plane, template, and SDK versions are pinned to a tested compatibility set.

The adapter must not rely on a domain list while leaving default internet access enabled. It must not allow a workflow-supplied rule to precede or weaken operator policy during provider rule merging.

Docker Sandboxes Backend Baseline

Docker Sandboxes (sbx) is a separate microVM product, not an ordinary Docker container. Each sandbox has its own guest kernel, Docker daemon, filesystem, and network. It is a strong candidate for autonomous coding agents and workflows that need to build or run containers without exposing the host Docker daemon.

The approved Docker Sandboxes backend must enforce:

  • clone workspace mode for untrusted or autonomous tasks; the default direct workspace mount is not an isolation boundary because changes are immediately applied to the host working tree,
  • deny-by-default network policy with only approved HTTP and HTTPS destinations,
  • explicit rejection of tasks requiring raw TCP, UDP, ICMP, SSH, or private network access that the backend cannot provide safely,
  • host-side proxy injection for supported service credentials,
  • no registry, SSH, or custom credential copied into the VM unless the effective task policy explicitly accepts that exposure,
  • the sandbox's private Docker daemon; the host Docker socket is never mounted,
  • immutable backend and template or kit compatibility records,
  • lifecycle inspection, reconnect, cancellation, disk quotas, and explicit removal after the workflow retention period,
  • centrally managed organization policy for managed endpoints, or a locked and audited local policy for approved developer-local runners.

Docker Sandboxes is initially a developer-local or managed-endpoint backend. Before it is used as a headless service backend, the adapter must prove stable machine-to-machine lifecycle control, runner authentication, idempotent operation lookup, audit export, and cleanup through the same conformance suite used by the control plane.

Shared-Kernel Container And Kubernetes Baseline

Rootless Docker or Podman containers and default Kubernetes Jobs share a host kernel. They are useful for high-volume trusted builds, tests, packaging, and approved internal tools, but they do not satisfy a microvm or dedicated-vm requirement.

The baseline requires:

  • rootless execution where supported,
  • no privileged containers and no host container-engine socket,
  • no host PID, IPC, or network namespace,
  • dropped Linux capabilities, no-new-privileges, a restrictive seccomp profile, and the applicable SELinux or AppArmor policy,
  • read-only root filesystem except for declared ephemeral volumes,
  • canonical allowlisted mounts with no user-controlled host paths,
  • hard cgroup CPU, memory, PID, disk, time, log, and concurrency limits,
  • deny-by-default network policy and tenant-scoped service identity,
  • immutable image digests and image provenance verification,
  • complete pod, job, container, volume, and credential cleanup.

A Kubernetes backend records its approved runtime class and node isolation. Kata, gVisor, dedicated nodes, or another hardened runtime may qualify for a stronger server-owned compatibility record, but the word Kubernetes alone does not establish the isolation boundary.

Toolbx And Host-Integrated Baseline

Fedora Toolbx exists to provide a convenient mutable development and host troubleshooting environment. It exposes the user's identity, home directory, network, devices, D-Bus, system journal, SSH agent, and other host facilities. It must be classified as host-integrated, never as a sandbox.

An approved Toolbx runner profile must:

  • accept only trusted operator or developer tasks,
  • advertise all host integrations in its backend capability record,
  • use execution session scope none,
  • reject tenant-authored code, dynamic agent tools, and arbitrary scripts from untrusted sources,
  • reject publish, signing, platform credential, and cross-tenant tasks,
  • rely on the host user's permissions and audit identity rather than claiming a separate security boundary,
  • remain opt-in for local workflows and never be selected as a fallback when a stronger backend is unavailable.

Toolbx can be useful without a dedicated per-task adapter: the trusted light-workflow-runner process can run inside a Toolbx environment and register that environment's approved host-integrated capabilities.

Dedicated VM And External Action Baseline

A dedicated VM backend is suitable for tenant-dedicated, privileged, or long-running workflows when the profile pins the VM image, tenant assignment, network, bootstrap identity, resource limits, attestation, and teardown. A pre-existing mutable VM cannot claim untrusted work solely because it is a VM.

External action backends expose fixed typed operations instead of arbitrary commands. Publishers, signers, and deployers validate immutable inputs, approval bindings, target identity, and idempotency keys. They remain the preferred backend for irreversible actions and high-value credentials.

Release Workflow Example

A Light-Fabric release workflow can use one workflow session for build work:

light-fabric
portal-service
controller-rs
light-example-rs

The execution path remains:

light-workflow
  - owns workflow, policy snapshot, attempts, approvals, and transitions
  -> controller-rs fenced lease
  -> light-workflow-runner
       - owns backend interaction and artifact transfer
  -> workflow-session sandbox
       - checks out repositories
       - runs tests and approved build commands
       - stores workflow-scoped caches and build output
       - exports declared artifacts

Recommended task grouping:

prepare workspace          workflow-session sandbox
checkout repositories      workflow-session sandbox
run unit tests             workflow-session sandbox
build release artifacts    workflow-session sandbox
generate release notes     workflow-session sandbox or bounded host task
publish release            per-task fixed publish action
sign artifacts             external signing service or per-task fixed action

Do not mount a host Docker socket into tenant-authored build sandboxes. Container image builds should use an approved rootless builder or remote build service, with pinned builder and base-image digests.

Build and package tasks may share state within one workflow policy snapshot. Publish and signing tasks must not execute arbitrary scripts from that mutable workspace. They receive only immutable artifact records from controlled storage, verify trusted-side digests, and use an operator-owned action. Runtime approval binds the exact artifact set, target, version, command template, policy snapshot, and expiry.

Agent-produced changes must not flow directly into a release. After a patch is accepted and merged, release artifacts are rebuilt from the reviewed immutable commit under a fresh build attempt and provenance record.

Agent Workspace Change Policy

An agent-modified workspace is untrusted output even when the agent ran in a microVM. Every write-capable agent profile references an immutable workspaceChangePolicyId and digest in the effective policy and lease. The policy defines allowed and denied path patterns, repository roots, file types, maximum changed files and bytes, and whether file creation, deletion, rename, mode changes, submodule changes, or binary files are allowed. Workflow metadata can request a stricter subset but cannot weaken this policy.

The default agent-repair policy denies changes to privilege-bearing surfaces, including:

.git/** and repository hooks
.github/workflows/** and reusable CI actions
.gitlab-ci.yml, Jenkinsfile, azure-pipelines.yml, and equivalents
CODEOWNERS and repository approval policy
workflow definitions and runner or execution-policy configuration
publish, signing, deployment, and release-credential configuration

Repository-specific policy adds equivalent paths used by that project. An exception requires a distinct operator-approved profile and explicit human review; an agent cannot request the exception itself.

In-sandbox filesystem enforcement is defense in depth. The authoritative check happens after export in a trusted runner or control-plane component by diffing against the immutable base commit or tree in a fresh trusted checkout, without using repository-provided hooks or mutable Git configuration. It canonicalizes path separators, case and Unicode according to repository rules; detects symlink, hardlink, rename, mode, submodule, and nested-repository changes; and creates an immutable canonical patch whose digest is validated against the path policy. A violation returns workspace_change_denied, records restricted evidence, and prevents branch, pull-request, artifact, publish, or signing actions from consuming the patch.

The agent environment never receives repository push credentials. Branch or pull-request creation is a separate fixed action that consumes only the immutable accepted patch, its base commit, path-policy digest, and human-review requirements, never the mutable agent workspace.

Trusted Input And Skill-Package Staging

External inputs are not fetched by sandbox code. Before creating the backend environment, trusted runner code resolves the lease's immutable execution_input_t records, downloads them with runner authority, verifies kind, size, digest, signature/provenance and scan bindings where required, and rejects unsafe archive paths, links, devices, ownership, and expansion ratios.

The runner stages only the accepted context, workspace base, trust bundle, and skill-package bytes and mounts them read-only with nodev, nosuid, and noexec unless an approved package entrypoint requires execution. light-agent-worker may revalidate the mounted package manifest, but neither the worker nor generated code receives artifact-store credentials or outbound package-download access. Verification or staging failure prevents sandbox creation. Staged inputs are attempt/session scoped and are removed by the same idempotent cleanup and watchdog path as the environment.

Secret And Credential Handling

The execution environment must never receive broad platform credentials. Credential access is server-owned and task-scoped.

Required rules:

  • Workflow metadata references only logical credential names.
  • The effective task policy and command template must both allow the credential.
  • Credentials are delivered through opaque, short-lived redemption handles; values are never included in workflow context, leases, logs, or audit.
  • Prefer backend-side or egress-proxy credential injection so arbitrary code cannot read the raw value.
  • Prefer workload identity and short-lived OIDC tokens over static release tokens.
  • Raw credential values are forbidden in environment variables, command-line arguments, process titles, shell history, workflow context, and persistent configuration files. Environment variables may contain only non-secret references such as a credential socket, metadata endpoint, or mounted-file path.
  • When the task process must obtain a token, use a workload-authenticated local metadata service or broker bound to the attempt, execution identity, audience, scope, and short expiry. It must be unreachable from other tasks, must not trust an unauthenticated host-wide localhost caller, and must not log or cache returned values.
  • Sandboxed model inference uses a runner-owned broker outside the untrusted payload boundary. Provider keys and reusable proxy bearer tokens are not projected into the worker or generated-code environment.
  • Prefer a runner-created preconnected descriptor, peer-credential-authenticated Unix-domain socket, vsock, or backend-equivalent local channel. A socket pathname alone is not authority: the broker binds peer and attempt and enforces the approved model, data-boundary and policy digests, token/cost budget, rate, cancellation, and expiry.
  • Run the trusted worker/runtime and generated payload under separate identities and process/mount namespaces. Deny ptrace and cross-process /proc access, and prevent broker-descriptor inheritance or reconnection by payload children. A model adapter that requires an extractable provider key is ineligible for an untrusted profile.
  • A read-only, memory-backed tmpfs file with task-unique ownership and mode 0400 is the fallback for tools that cannot use a broker. Mount it only for the consuming process or task environment and unmount and overwrite metadata on completion.
  • The tmpfs raw-value fallback is allowed only in a fresh task sandbox with narrow egress, no untrusted command, no pause or checkpoint, and mandatory termination after the task.
  • Secret-bearing profiles disable core dumps, restrict cross-process /proc inspection, and exclude credential mounts from snapshots, artifacts, and diagnostic bundles.
  • Credential revocation occurs on completion, cancellation, lease expiry, policy revocation, or runner quarantine.
  • Log redaction is defense in depth, not the primary secret boundary. Encoded or transformed secrets cannot be reliably redacted after exposure.

A workflow-session sandbox must not receive raw publish or signing credentials. Snapshots and auto-pause can preserve process memory and filesystem contents; secret-bearing task sandboxes must use kill-on-timeout and must not be resumed.

Network Policy

Every sandbox profile defines deny-by-default egress. A release profile may allow destinations such as:

github.com
api.github.com
ghcr.io
crates.io
index.crates.io
registry.npmjs.org
approved container registries

The profile must also define schemes, ports, methods, paths, DNS behavior, and whether apex and wildcard subdomains are allowed. github.com does not imply *.github.com, and an HTTPS allow rule does not imply SSH access on port 22.

For Cube Sandbox, restricted profiles set allow_internet_access=false, use explicit L3/L4 allow targets, and add L7 rules for HTTP/HTTPS method and path control. The adapter verifies the effective provider policy rather than assuming the requested policy was installed.

Host-executed HTTP, JSON-RPC, and MCP calls keep destination validation, redirect restrictions, response-size limits, and service credential policy. Sandbox placement is not a substitute for SSRF and destination validation.

TLS Inspection Trust Bundles

TLS interception is an explicit profile capability, never an implicit side effect of network routing. Operator policy selects an immutable trustBundleRef and digest; workflow metadata cannot supply a CA or disable certificate verification. The trust bundle contains public CA certificates only, never interception private keys.

Prefer installing the approved bundle in the immutable template or image. When runtime projection is required, mount it read-only and let the approved command template select the appropriate adapter, for example:

  • the OS trust store for native tools,
  • an immutable Java truststore selected with JVM truststore options,
  • NODE_EXTRA_CA_CERTS pointing to the mounted public bundle for Node.js,
  • SSL_CERT_FILE or REQUESTS_CA_BUNDLE pointing to an approved merged bundle for Python and OpenSSL-based tools.

These variables contain non-secret paths, not credential values. The runner verifies the effective trust-store digest from inside the environment before execution and records it in audit and provenance. Rotation creates a new versioned bundle and policy digest. Tasks using certificate pinning, mTLS, or a runtime that cannot honor the bundle must use a separately approved non-intercepting route or fail closed; they must never fall back to disabling TLS verification.

Artifact And Log Boundary

The sandbox filesystem and console are untrusted input. Tasks declare candidate artifact paths, but a glob match alone does not authorize export:

metadata:
  lightWorkflow:
    artifacts:
      - dist/*.tar.gz
      - dist/*.sha256
      - target/release/light-workflow

Artifact transfer must:

  • resolve paths beneath a canonical workspace root,
  • refuse symlinks, hardlinks outside the root, devices, sockets, and path traversal,
  • avoid time-of-check/time-of-use races,
  • enforce per-file, total-byte, and file-count limits,
  • compute the authoritative digest after bytes cross the sandbox trust boundary,
  • write to immutable, tenant-scoped storage,
  • record media type, provenance, template digest, command template, task attempt, policy digest, and retention class,
  • scan or validate artifacts when the artifact policy requires it.

Task output contains references, not raw large artifacts:

{
  "artifacts": [
    {
      "artifactId": "019f0000-0000-7000-8000-000000000010",
      "name": "light-fabric-0.3.0-x86_64-unknown-linux-gnu.tar.gz",
      "sha256": "...",
      "size": 12450000,
      "storeUri": "artifact://...",
      "provenanceRef": "provenance://...",
      "provenanceDigest": "sha256:..."
    }
  ]
}

Logs use bounded, ordered chunks with sequence numbers and resumable cursors. The runner applies output limits before transmission. The artifact store keeps full logs only when policy allows it; workflow context keeps summaries and references. Log access, encryption, retention, and deletion remain tenant scoped.

Build Provenance Attestation

For build and release profiles, trusted-side hashing is followed by automatic provenance generation. The interchange format is an in-toto Statement v1 with the SLSA Provenance v1 predicate (https://slsa.dev/provenance/v1). The ExecutionBackend supplies measured execution evidence, but a trusted runner supervisor or control-plane attestor constructs and authenticates the final statement; tenant-controlled build steps cannot choose or rewrite its fields.

The statement binds at least:

  • each exported artifact name and trusted digest as an attestation subject,
  • the command-template build type, template version, resolved argument digest, and policy-approved external parameters,
  • source repository URI, immutable commit and tree digest, input artifact digests, base images, and best-effort resolved dependencies,
  • workflow, task, attempt, lease, and policy snapshot identities,
  • runner and builder identity and version,
  • backend kind, implementation, capability record, template or image digest, runtime class where applicable, and execution session isolation,
  • resource, network, workspace-change, credential-delivery, and trust-bundle policy digests,
  • start and completion timestamps, outcome, and completeness metadata.

The attestation is stored immutably beside the artifacts and referenced by URI and digest. When signed provenance is required, signing occurs outside the tenant execution environment with a short-lived workload identity or key that tenant code cannot access. Publish and signing actions verify the attestation signature, subject digests, builder identity, policy expectations, and approval bindings before consuming an artifact.

Using the SLSA format does not by itself establish a SLSA Build level. A profile may declare provenanceMode: unsigned for development or signed for release, but the platform must be assessed against the applicable SLSA build-platform and isolation requirements before making a level claim. Host-integrated builds must not inherit a hosted or isolated level merely because they emitted the same JSON shape. Provenance records claims and evidence about the build; it does not by itself prove artifact correctness or that the execution environment was uncompromised.

Audit

Every sandboxed task attempt records:

  • tenant, host, trigger principal, and correlation ID,
  • workflow definition ID and version,
  • workflow process and instance ID,
  • task ID, task name, and attempt number,
  • runner ID, lease ID, and fencing token,
  • requested profile and immutable effective policy digest,
  • backend ID, kind, implementation, version, capability digest, and request ID,
  • execution session ID, immutable template or image digest, and backend operation ID,
  • command template ID, resolved argv digest, working directory, immutable base commit or tree, workspace-change policy and accepted patch digests, and environment name allowlist,
  • credential names and delivery mechanism, never values,
  • requested and effective network, trust-bundle, resource, and local-cleanup policy digests,
  • approval IDs and the artifact and target digests they authorize,
  • artifact metadata, provenance statement and envelope digests, builder and signer identities, and verification result,
  • exit status, duration, resource usage, output sizes, and log reference,
  • cancellation, disconnect, watchdog action, retry, reconciliation, and cleanup events.

For call: agent, also record model provider scope, model name, prompt profile, token budget, output schema ID, validation result, tool policy, and data boundary, plus the canonical changed-file manifest and path-policy result.

Audit records are append-only. Workflow-visible output must not contain backend credentials, control-plane tokens, raw injected secrets, or restricted backend diagnostics.

Failure And Retry Handling

Backend and runner failures map to stable workflow errors:

  • hard admission, quota, or cost-policy failure: execution_admission_denied,
  • temporary capacity shortage: execution_capacity_deferred,
  • capacity queue deadline expired: execution_queue_timeout,
  • policy rejection: execution_policy_denied,
  • unsupported capability: execution_capability_missing,
  • environment startup failure: execution_start_failed,
  • task wall-clock timeout: execution_timeout,
  • command non-zero exit: command_failed,
  • agent patch violates the workspace policy: workspace_change_denied,
  • required provenance cannot be generated or authenticated: provenance_generation_failed,
  • oversized result, log, or artifact: execution_output_too_large,
  • lease expiry: runner_lease_expired,
  • cancellation: execution_cancelled,
  • backend outcome not yet known: execution_outcome_unknown,
  • cleanup not confirmed: execution_cleanup_pending.

Retry classification is explicit:

  • Policy, validation, and unsupported-capability failures are not retried.
  • execution_capacity_deferred stays in the fair scheduling queue and does not consume a command retry attempt.
  • Idempotent startup may retry using the same session idempotency key.
  • A non-zero command exit follows the workflow retry policy only when the command template declares retry safety.
  • Transport loss after dispatch enters UNKNOWN and is reconciled before a new attempt.
  • Timeout requests cancellation, fences the attempt, and inspects the backend before retry evaluation.
  • External side effects require a target-system idempotency or reconciliation contract. Otherwise, an unknown result requires operator intervention.

The workflow process does not transition until it has accepted a result from the current fenced attempt and committed the result, audit, artifact records, and next-task creation atomically in the workflow database.

Cleanup And Retention

Sandbox termination and evidence retention are separate responsibilities.

  • Workflow execution sessions are cleaned after workflow completion, permanent failure, cancellation, maximum lifetime, or policy revocation.
  • Per-task backend environments are cleaned after the task result and required evidence are secured.
  • Backend snapshots and checkpoints are independent resources and must be explicitly deleted according to policy.
  • Failed destroy or delete calls create cleanup-pending records for the orphan reconciler.
  • Backend metadata tags include tenant, workflow instance, policy snapshot, session record, and expiry so orphan discovery does not depend only on the workflow database.
  • The runner watchdog cleans expired local journal entries and tagged backend resources when control-plane contact is unavailable. Native backend expiry remains required where possible in case the runner host also fails.
  • Retained snapshots, logs, and artifacts require encryption, tenant isolation, retention limits, deletion audit, and data-residency policy.
  • Secret-bearing sandboxes cannot be paused, checkpointed, or retained for debugging.

Definition And Runtime Approval

Definition approval and task approval solve different problems.

Definition approval covers profiles that enable command execution, external MCP processes, broad network access, mutable mounts, credential access, or high-cost resource classes. The approval produces an immutable workflow definition and profile version.

Runtime approval covers a specific irreversible action. A publish or signing approval includes:

tenant and workflow instance
task and attempt
command template
artifact set and trusted digest
release target and version
effective policy digest
approver and approval time
expiry and single-use nonce

Changing any bound value invalidates the approval. A new attempt after an unknown outcome requires reconciliation and may require a new approval; it must not silently reuse the prior approval.

Approval Is An Orchestration State

Waiting for a person is owned by the authenticated origin service— light-workflow for workflow tasks or light-agent for standalone agent actions—never by a runner task:

build or stage task completes
  -> artifacts and provenance commit
  -> action lease ends and task credentials/model channel are revoked
  -> task sandbox cleans, or eligible session workspace enters bounded hold
  -> origin persists WAITING_APPROVAL
  -> approval is granted and bindings are revalidated
  -> origin creates a new numbered domain and common execution attempt
  -> controller-rs issues a fresh lease, fencing token, and scoped grants

When approval is known before dispatch, the origin records the bound action intent but creates no common execution attempt until approval. If a running agent/runtime discovers the boundary, it returns a known approval_required terminal result; its lease and grants end and its sandbox is cleaned or explicitly checkpointed under bounded non-secret policy. Approval never reactivates that attempt.

The origin transaction that enters WAITING_APPROVAL persists exactly one session disposition—cleanup or a policy-valid bounded hold. If common session state is later moved to another database, use an idempotent transactional outbox. A session reaper must not infer abandonment from the ended action lease before that disposition is durable.

The runner must not poll for approval, hold or renew a task lease, or keep a secret-bearing environment alive while the workflow waits. A non-secret workflow or agent session may be paused/checkpointed or retained only through the separate bounded hold contract when explicit cost, maximum-lifetime, and retention policy allows it. Important uncommitted work should also be exported as an approved immutable patch/checkpoint so correctness does not depend only on the live sandbox. The preferred release path exports immutable artifacts and provenance, then cleans the build environment.

Approval rejection or expiry transitions the orchestration state without dispatching a runner task. Approval grants authorize only the new fixed-action attempt and are rechecked against its operation, arguments, artifact, provenance, target, policy, command template, expiry, and nonce. The approval is consumed once by the new common attempt, which has a monotonic fencing token. The prior attempt, lease, backend handle, and grants remain immutable and are never returned to the build or agent environment. A held workspace may be reused by the fresh action only after principal/base/runtime/policy/expiry and cleanup-state revalidation; otherwise restore only a verified policy-permitted checkpoint/patch into a fresh environment.

Implementation Plan

Phase 1: Contracts And Persistence

  1. Define versioned security metadata and strict validation.
  2. Define field-specific policy resolution and immutable policy snapshots.
  3. Add dedicated policy, execution session, session-cleanup request, immutable input, common attempt, artifact, approval, and audit persistence.
  4. Define workspace-change, trust-bundle, credential-projection, local-cleanup, capacity-queue, and provenance policy contracts.
  5. Persist tenant, trigger principal, correlation ID, and policy snapshot on workflow start.
  6. Define the identifiers-only transactional result-ready PostgreSQL wakeup plus indexed startup/periodic origin catch-up.
  7. Keep unsupported run.* task types disabled.

Phase 2: Runner Lease And Fencing

  1. Align with the light-workflow-runner registration and lease protocol.
  2. Add attempt numbers, lease renewal, fencing tokens, compare-and-set result acceptance, cancellation, and reconciliation.
  3. Add normalized result and resumable log contracts.
  4. Store terminal common results and emit origin wakeups in one transaction; prove correctness when notifications are missed, duplicated, or reordered.
  5. Add the durable runner cleanup journal, autonomous watchdog, startup scan, and backend-native expiry contract.
  6. Add bounded per-tenant fair capacity queues, atomic slot reservation, and jittered claim backoff.
  7. Prove that stale or duplicate reports cannot transition a workflow or agent domain object.

Phase 3: Minimal Per-Task MicroVM Backend

  1. Define the ExecutionBackend trait, capability vocabulary, compatibility records, and boundary-appropriate conformance tests.
  2. Implement Cube Sandbox capability discovery and production-baseline checks.
  3. Support one approved run.shell command template in a per-task sandbox.
  4. Start with no credentials, no external side effects, deny-all egress, and hard resource limits.
  5. Add cancellation, orphan cleanup, and backend operation reconciliation.

Phase 4: Additional Backends, Artifacts, And Sessions

  1. Add safe artifact extraction, trusted-side hashing, immutable storage, and trusted-side in-toto/SLSA provenance generation.
  2. Add Docker Sandboxes for autonomous developer-local agents, requiring clone workspace mode and an approved policy.
  3. Add rootless OCI container execution for trusted build and packaging profiles.
  4. Allow trusted runners hosted in Toolbx to register only host-integrated, no-sandbox capabilities; no per-task Toolbx adapter is required initially.
  5. Add Kubernetes Jobs only with an approved runtime-class compatibility record.
  6. Add workflow-session reuse with single-writer enforcement where supported.
  7. Add bounded agent-session reuse with effective minimum expiry and durable origin close/revoke/expiry cleanup requests.
  8. Add session state/version/fencing plus idempotent hold/pause/checkpoint, resume, and cleanup operations. IDLE_APPROVAL_HOLD is separate from an action lease, bounded by effective expiry and cost policy, and never carries model or credential authority.
  9. Add trusted runner-side download, verification, safe extraction, staging, read-only mounting, and cleanup for immutable skill packages and inputs.
  10. Add copy-on-write isolation for parallel branches and agent repair tasks.
  11. Add checkpoint lifecycle and deletion without allowing secret-bearing snapshots.
  12. Add server-owned agent workspace-change policies and trusted post-export diff validation before branch or pull-request creation.

Phase 5: Network And Credential Profiles

  1. Add deny-by-default L3/L4 and L7 policy generation and verification.
  2. Add immutable TLS trust-bundle profiles and language-runtime adapters.
  3. Add brokered credential handles, authenticated local metadata endpoints, read-only tmpfs fallback, and backend-side credential injection.
  4. Add protected runner-owned model-broker transports, separate worker/payload identities, descriptor isolation, and broker-side model and budget policy.
  5. Add short-lived workload identity and revocation.
  6. Add runtime approval records bound to immutable inputs.

Phase 6: Release, Publish, And Signing

  1. Add release build/test/package workflows using workflow sessions.
  2. Export immutable artifact sets with provenance.
  3. Add fixed publish actions or a dedicated release service.
  4. Add external signing or a fixed per-task signing action.
  5. Require digest-bound human approval and complete unknown-outcome reconciliation before retry.
  6. Persist approval waiting only in the origin service; end any current action lease before waiting, use only the separate bounded session-hold contract where allowed, and issue a new numbered domain/common fixed-action attempt, lease, fencing token, and grants after approval.

Acceptance And Failure-Injection Tests

The feature is not ready for tenant workloads until tests prove:

  • a command lasting beyond the old task-lock interval is not claimed twice,
  • an expired runner cannot report success after a new attempt starts,
  • a crash after backend dispatch but before database commit is reconciled,
  • a terminal result committed while its origin listener is offline is found by indexed catch-up and accepted once; duplicate or reordered wakeups do not duplicate the domain transition,
  • repeated create and execute requests do not create duplicate operations,
  • a disconnected runner stops new work, locally fences execution at lease expiry, and cleans the environment without control-plane reachability,
  • runner restart replays the cleanup journal, and backend-native expiry cleans resources when the runner host does not restart,
  • temporary saturation remains queued with bounded jittered backoff and does not create attempts, sandboxes, or a claim storm,
  • a host-integrated or shared-kernel-container backend cannot claim a task requiring microvm,
  • a Toolbx runner cannot claim tenant-authored, isolated, or credential-bearing tasks,
  • Docker Sandboxes direct workspace mode is rejected for untrusted tasks and clone mode preserves the host repository boundary,
  • no tenant-authored backend receives a host container-engine socket,
  • a Kubernetes Job cannot claim a stronger boundary than its approved runtime class and node policy provide,
  • cancellation fences results and eventually removes the sandbox,
  • origin session close, revocation, and expiry fence active work and reclaim a reused sandbox without waiting for backend-native TTL, even across controller or runner restart,
  • policy revocation stops new attempts and revokes credentials,
  • denied network destinations remain denied for HTTP, non-HTTP, DNS, and direct IP access,
  • backend control-plane requests require authorization,
  • output, log, artifact, process, disk, and time limits are enforced,
  • symlink and path-traversal artifact exports fail,
  • agent changes to CI/CD, workflow, approval, release, signing, deployment, or repository-policy paths are rejected before PR or branch creation,
  • case, Unicode, symlink, rename, mode, submodule, and nested-repository tricks cannot bypass the workspace-change policy,
  • raw credentials do not appear in process memory snapshots, logs, task output, artifacts, or workflow context for the preferred credential path,
  • raw tokens are never placed in environment variables or argv, and metadata service and tmpfs projections are isolated to the leased task,
  • generated payload code cannot inspect or inherit the worker's model-broker channel, obtain a provider/proxy bearer, impersonate another attempt, choose an unauthorized model, or exceed broker-enforced budget,
  • skill-package digest/signature mismatch, unsafe archive content, or staging failure prevents sandbox creation, and sandbox code cannot access the artifact store,
  • the effective TLS trust-bundle digest is verified without allowing workflow code to add a CA or disable certificate validation,
  • snapshots and failed sandbox creations are found and cleaned by the orphan reconciler,
  • required provenance binds the trusted artifact digest, source and input digests, command template, builder and backend identity, and policy digest; tampering or an unexpected signer blocks publish,
  • no action lease, model channel, action credential, or secret-bearing task environment remains active while a workflow or agent waits for approval; an eligible non-secret session survives only through a distinct bounded hold/checkpoint, and approval creates a fresh domain/common fixed-action attempt, lease, and fencing token without reopening the prior attempt,
  • releasing an action lease does not prematurely clean a valid approval-held session, while hold/session expiry and close/revocation always trigger cleanup; resume cannot extend the fixed maximum or restore unverified state,
  • publish approval fails if the artifact digest, target, policy, command, or expiry changes.

Open Decisions

  • Whether approved execution profiles live only in service configuration or are also portal-managed immutable records.
  • Whether artifact metadata lives in portal tables while bytes live in object storage, or whether another artifact service owns both.
  • Whether all publish and signing operations use a separate release service or whether a limited set of fixed runner actions is supported.
  • Which Cube and Docker Sandboxes versions and capabilities form the first supported compatibility sets.
  • Which hardened Kubernetes runtimes and dedicated-VM attestation mechanisms should be approved for untrusted workloads.
  • Whether Toolbx support should remain an operational runner profile or later gain explicit local-runner lifecycle helpers.
  • Which watchdog deadlines, backend-native TTL mechanisms, and cleanup evidence are required for each backend compatibility record.
  • Which protected runner-local broker transport is supported first for each backend: preconnected descriptor, peer-checked Unix-domain socket, vsock, or backend-native equivalent.
  • Which repository paths are protected by the default agent policy and how repository-specific additions are approved.
  • Which provenance signer, transparency or timestamp mechanism, storage convention, and target SLSA Build level release profiles require.
  • How emergency policy revocation balances immediate termination against preserving forensic evidence.
  • Whether a first-class security field should be added to workflow-core after the metadata-based contract proves stable.

References

Insurance Claim Agentic Workflow

This page describes a product workflow demo for orchestrating multiple agents, skills, APIs, and human tasks with light-workflow.

The scenario is an auto insurance claim from first notice of loss to a settlement recommendation. It is a useful demo because it is familiar, has clear business states, needs several API calls, and includes human decisions that should not be delegated fully to an agent.

Demo Goal

The workflow should show how a deterministic process can coordinate:

  • two or three agents
  • multiple skills per agent
  • REST API calls
  • MCP tool calls through light-gateway
  • human input and approval tasks
  • branching based on policy, risk, and claim severity

The same business flow should be executable in two variants:

  • REST workflow: calls the demo APIs directly with HTTP/OpenAPI tasks.
  • MCP workflow: calls the same capabilities through MCP tools exposed by light-gateway.

The workflow owns the process. Agents work inside bounded tasks and should not invent new process paths outside the workflow definition.

For the agent execution boundary, see Native Agent Call. In the current implementation, call: agent is a native light-workflow task. It does not invoke a containerized light-agent service. API access in this demo is owned by the workflow through direct HTTP tasks or MCP tool calls routed through light-gateway.

Execution Model

This demo uses the enterprise workflow-first model:

  • light-workflow owns the claim process, task state, retries, branching, human tasks, and audit trail.
  • API access is explicit in the workflow as call: http or call: mcp.
  • Native call: agent tasks perform bounded reasoning over workflow-owned context and must return structured output.
  • Skills provide instructions, tool context, and workflow mappings, but they do not give an agent permission to invent unreviewed process paths.
  • Containerized light-agent services are not invoked by this demo workflow. They remain the runtime for chat clients and future service-agent integration.

Demo APIs

The existing demo APIs can be used as stand-ins for insurance services.

APIRole in the claim workflow
demo-customer-profile-apiPolicyholder profile, vehicle list, policy status, contact preference, prior claims.
demo-offer-decision-apiClaim triage, risk decision, settlement or repair recommendation.

If more realism is needed later, the same workflow can add simulated services for document storage, repair estimates, fraud review, or payment authorization.

Agents

Claim Intake Agent

The Claim Intake Agent owns first notice of loss collection and basic validation.

Skills:

  • collect accident facts
  • validate required claim fields
  • look up customer, policy, and vehicle data
  • identify missing information
  • summarize the claim for the next agent

Typical tools or API calls:

  • get customer profile
  • get customer policies
  • get covered vehicles
  • get prior claims

Human tasks:

  • claimant confirms accident details
  • claimant answers missing information questions
  • claimant uploads or confirms photos, police report, and tow status

Coverage And Liability Agent

The Coverage and Liability Agent checks whether the claim can continue and whether a human adjuster must review it.

Skills:

  • coverage eligibility check
  • incident date versus policy period check
  • vehicle coverage check
  • liability and severity classification
  • fraud or special investigation flagging

Typical tools or API calls:

  • get policy status
  • get prior claim history
  • run triage decision
  • run risk decision

Human tasks:

  • adjuster reviews unclear liability
  • adjuster confirms coverage exception handling
  • special investigation team reviews high-risk claims

Settlement Agent

The Settlement Agent prepares the next action and customer-facing explanation.

Skills:

  • repair versus total-loss recommendation
  • deductible explanation
  • settlement recommendation
  • customer message draft
  • next-document request

Typical tools or API calls:

  • get offer decision
  • get customer contact preference
  • create settlement recommendation

Human tasks:

  • adjuster approves high-value payment
  • claimant accepts repair or settlement path
  • claimant requests callback or more review

Claim Context And Handoffs

The workflow engine owns the claim state. Agents should be treated as stateless workers that read the current claim context, perform a bounded task, and return structured output.

Each major step enriches a shared claim context:

  • intake adds normalized accident facts and missing information status
  • customer lookup adds profile, policy, vehicle, and prior-claim data
  • coverage review adds eligibility, deductible, liability, and risk signals
  • triage adds severity, recommended path, and human-review requirements
  • settlement adds the recommendation, explanation, and next actions

Handoffs between agents should happen through this workflow-owned context, not through private agent memory. This keeps the process deterministic, replayable, and auditable.

Workflow Outline

1. Start Claim

Input:

{
  "customerId": "CUST-001",
  "vehicleId": "VEH-001",
  "incidentDate": "2026-05-30",
  "accidentDescription": "Rear-ended at an intersection.",
  "location": "Ottawa, ON",
  "injuryReported": false,
  "vehicleDrivable": false
}

The workflow validates that customerId, vehicleId, incidentDate, and accidentDescription are present.

2. Fetch Customer Context

The workflow calls the profile and policy capabilities to retrieve:

  • customer identity
  • policy list
  • covered vehicles
  • contact preference
  • prior claim count

Assertions:

  • customer exists
  • vehicle belongs to customer
  • at least one active policy exists

3. Ask For Missing Information

If the input is incomplete, the workflow creates a human task for the claimant.

Example questions:

  • Was anyone injured?
  • Was another vehicle involved?
  • Is the vehicle drivable?
  • Was a police report filed?
  • Are photos available?

The workflow should be resumable after the claimant answers.

4. Coverage Check

The workflow passes the gathered claim context to a native Coverage and Liability agent task. That task checks:

  • policy active on incident date
  • covered vehicle
  • applicable coverage type
  • deductible
  • excluded conditions

Branches:

  • no matching policy: route to adjuster review
  • policy inactive: prepare denial draft for human review
  • coverage found: continue to triage

5. Triage Decision

The workflow calls the decision API, either directly with HTTP or through light-gateway MCP, with normalized claim context.

Expected decision output:

{
  "severity": "medium",
  "riskLevel": "low",
  "recommendedPath": "repair",
  "requiresAdjusterReview": false,
  "estimatedLoss": 3200
}

Branches:

  • low risk and low value: continue automatically
  • unclear liability: create adjuster review task
  • high risk: create special investigation task
  • high value: create approval task

6. Settlement Recommendation

The workflow passes the approved claim context to a native Settlement agent task. That task prepares:

  • recommended path: repair, estimate, total-loss review, denial draft, or more information
  • deductible explanation
  • next documents required
  • customer-facing summary

The result should be structured so the UI can render it and the agent can explain it.

7. Human Approval

Approval is required for:

  • high estimated loss
  • denial recommendation
  • special investigation referral
  • liability uncertainty
  • customer dispute

The task should record:

  • approver role
  • approval decision
  • comment
  • timestamp
  • whether the workflow should proceed, revise, or stop

8. Customer Response

The claimant chooses one of:

  • accept repair path
  • request adjuster callback
  • upload more documents
  • dispute the recommendation

This should be modeled as a human ask task rather than an agent-only step.

9. End State

Possible workflow outcomes:

StateMeaning
claim-approvedClaim can proceed to repair or settlement.
needs-adjuster-reviewHuman adjuster must review before next action.
needs-customer-infoClaimant must provide missing information.
referred-to-siuClaim is referred to special investigation.
claim-denied-draftDenial is drafted but still needs human approval.

Failure Handling And Fallbacks

The demo should show graceful degradation when an API call or agent task cannot finish automatically.

Recommended fallback behavior:

FailureWorkflow response
Customer profile returns 404Create a manual customer verification task.
Policy or vehicle lookup is unavailableRetry, then route to adjuster review with the partial claim context.
Decision API is unavailableCreate a manual triage task and include the last successful context.
Agent output fails validationRe-run once with validation feedback, then create a human review task.
Human task times outEscalate to the configured role or mark the claim as waiting for follow-up.

The failure branch should preserve the accumulated claim context and the failed request or response metadata so the human reviewer can continue from the same state instead of restarting the claim.

REST Variant

The REST workflow calls the demo APIs directly.

Use this variant to show:

  • deterministic API orchestration
  • direct HTTP/OpenAPI task execution
  • workflow assertions
  • human waiting tasks
  • repeatable headless tests with fixed inputs

Example task sequence:

start-claim
get-customer-profile
assert-active-policy
ask-missing-info
run-claim-triage
switch-risk-path
ask-adjuster-approval
prepare-settlement-summary
ask-customer-response
complete-claim

MCP Variant

The MCP workflow invokes the same capabilities through MCP tools exposed by light-gateway.

Use this variant to show:

  • tool discovery with tools/list
  • tool execution with tools/call
  • agent skill guidance over the selected tool set
  • gateway as the runtime MCP data plane

Skills should be treated as guidance and curation for the agent, not as the runtime transport. The workflow still calls MCP tools through light-gateway. A skill describes when and how to use tools. For example, the coverage-review skill can instruct the agent to call evaluate_coverage before score_claim_risk, explain which fields must be present, and define what output shape the workflow expects.

Example tool groups:

SkillTools
claim-intakeget_customer_profile, get_policy, get_vehicle, list_prior_claims
coverage-reviewevaluate_coverage, score_claim_risk, classify_liability
settlementrecommend_offer, generate_customer_summary, list_required_documents

Human Task Model

Human work should be explicit and durable.

Recommended task types:

  • claimant information request
  • adjuster approval
  • liability review
  • special investigation review
  • customer settlement response

Recommended fields:

  • prompt
  • mode: choice, text, object, file, approval
  • assignee or candidate role
  • due time
  • validation rules
  • sensitive flag
  • comments
  • decision result

The workflow should pause at the human task and resume after a valid response is recorded.

The pause is durable. light-workflow persists the process and task state while waiting, so the workflow can remain idle for hours or days without consuming active execution resources. When the claimant, adjuster, or investigator completes the task, the workflow resumes from the persisted state and continues with the same claim context.

Minimal First Implementation

Start with a narrow happy path:

  1. Start with customerId, vehicleId, and accident details.
  2. Workflow fetches customer profile through HTTP or MCP.
  3. Workflow asserts active policy and covered vehicle.
  4. Workflow calls the decision API for triage.
  5. Workflow asks an adjuster to approve if estimatedLoss exceeds a threshold.
  6. Native Settlement agent task prepares the recommendation.
  7. Workflow completes with claim-approved or needs-adjuster-review.

This first version is enough to demonstrate multi-agent orchestration without needing every insurance edge case.

Later Enhancements

Add complexity incrementally:

  • document upload and OCR simulation
  • repair shop estimate comparison
  • fraud and special investigation path
  • payment authorization
  • subrogation when another driver is liable
  • scheduled headless regression runs
  • customer notification drafting
  • analytics for cycle time and approval bottlenecks

Demo Success Criteria

The demo is successful if it shows:

  • the same business process running through REST and MCP variants
  • agents using skills to perform bounded work
  • APIs called through both direct HTTP and MCP tool paths
  • at least one human input task
  • at least one human approval task
  • auditable workflow state transitions
  • clear final outcome and explanation

Light Portal Setup

This page describes the portal-side setup required to run the light-workflow product demos from a local light-portal stack.

For the execution model behind native agent tasks, see Native Agent Call. For the insurance product scenario, see Insurance Claim Agentic Workflow.

Prerequisites

Start the local portal stack with the workflow services, gateway, controller, and Postgres available.

For the Rust local stack:

cd /home/steve/workspace/portal-config-loc
./scripts/deploy-local.sh pg rust

The local stack should include:

  • Postgres,
  • workflow-command,
  • workflow-query,
  • light-gateway,
  • controller,
  • config-server,
  • demo-customer-profile-api,
  • demo-offer-decision-api.

light-workflow must use the same database as workflow-command:

DATABASE_URL=postgres://postgres:secret@localhost:5432/configserver

Start Light-Workflow

Build and run light-workflow from the light-fabric checkout:

cd /home/steve/workspace/light-fabric
cargo build -p light-workflow --locked

cd apps/light-workflow
DATABASE_URL=postgres://postgres:secret@localhost:5432/configserver \
LIGHT_WORKFLOW_HTTP_ADDR=0.0.0.0:8436 \
RUST_LOG=light_workflow=debug,info \
WORKFLOW_LOG_ANSI=false \
./run.sh --debug-binary

For repeated runs, put those values in apps/light-workflow/light-workflow.env and run:

./run.sh --debug-binary

Import Agent Catalog Data

Native call: agent tasks load portal agent, skill, and tool metadata from the portal database. Import the demo catalog events before running workflows that contain agent tasks.

cd /home/steve/workspace/event-importer
./importer.sh \
  --filename /home/steve/workspace/light-fabric/apps/light-workflow/examples/agent-catalog-events.json

For a different host or user, pass replacement rules:

./importer.sh \
  --filename /home/steve/workspace/light-fabric/apps/light-workflow/examples/agent-catalog-events.json \
  --replacement '[
    {"field":"hostId","from":"01964b05-552a-7c4b-9184-6857e7f3dc5f","to":"<host-id>"},
    {"field":"user","from":"01964b05-5532-7c79-8cde-191dcbd421b8","to":"<user-id>"},
    {"field":"operationOwner","from":"01964b05-5532-7c79-8cde-191dcbd421b8","to":"<user-id>"},
    {"field":"deliveryOwner","from":"01964b05-5532-7c79-8cde-191dcbd421b8","to":"<user-id>"}
  ]'

The demo catalog uses modelProvider: mock for deterministic local runs. For real model execution, update the portal agent definitions to use the desired provider and apiKeyRef.

Upload API Metadata

For the insurance claim demos, upload or refresh the OpenAPI specs for:

  • demo-customer-profile-api,
  • demo-offer-decision-api.

The portal catalog should contain endpoint and tool projections for the demo APIs before the MCP workflow is run. The MCP workflow expects light-gateway tools/list to expose these tools:

getCustomerProfile
getCustomerPreferences
getCustomerPolicies
getCoveredVehicle
listPriorClaims
triageClaim
recommendSettlement

Verify the tool surface through the gateway:

curl -k -sS -X POST "https://localhost:8443/mcp" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <access-token>" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Create Workflow Definitions

Create workflow definitions in the portal UI or through the workflow command API. For the insurance claim demo, create these definitions:

insurance-claim-rest-v1.yaml
insurance-claim-mcp-v1.yaml
insurance-claim-headless-v1.yaml

The files live in:

/home/steve/workspace/light-fabric/apps/light-workflow/examples

After creation, capture their ids:

psql "postgresql://postgres:secret@localhost:5432/configserver" \
  -c "select host_id, wf_def_id, name from wf_definition_t where active and name in ('insurance-claim-rest-v1', 'insurance-claim-mcp-v1', 'insurance-claim-headless-v1') order by name;"

Roles And Human Tasks

The insurance claim workflow creates durable human tasks. Confirm that the demo host has the roles used by those assignments:

claimant
claims-adjuster
siu-investigator
customer-service

Human tasks remain in the portal database while waiting. The workflow resumes after the task-completion command records a valid response.

Start And Verify

Use the portal UI start action, Postman collection, or curl helper from the examples directory.

cd /home/steve/workspace/light-fabric/apps/light-workflow/examples

ACCESS_TOKEN=<token> \
HOST_ID=<host-id> \
HEADLESS_WF_DEF_ID=<headless-wf-def-id> \
./insurance-claim-demo-curl.sh start-headless

Run the SQL verification helper after each start or task completion:

psql "postgresql://postgres:secret@localhost:5432/configserver" \
  -v host_id=<host-id> \
  -f /home/steve/workspace/light-fabric/apps/light-workflow/examples/insurance-claim-demo-queries.sql

For the full runbook, see:

/home/steve/workspace/light-fabric/apps/light-workflow/examples/README.md

Troubleshooting

SymptomCheck
Workflow starts but no process appearsConfirm light-workflow uses the same DATABASE_URL as workflow-command.
Agent task fails before a human taskConfirm agent-catalog-events.json was imported for the same hostId.
MCP tool is not foundCall gateway tools/list and confirm the tool names match the workflow YAML.
Human task is not visibleCheck task_asst_t, role membership, and task status.
Input fields resolve as ${ .customerId }Confirm startWorkflow sends input as a JSON object, not a JSON string.

Comparison: Light-Fabric vs. AgentGateway

This document provides a high-level comparison between Light-Fabric and AgentGateway to help architects and engineering leaders choose the right foundation for their agentic workflows.

Overview

While both systems aim to facilitate interaction with Large Language Models (LLMs), they operate at different layers of the AI stack and prioritize different architectural outcomes.

FeatureLight-FabricAgentGateway
Primary PhilosophyAgentic Fabric: Unified Governance & LifecycleAgentic Gateway: High-performance Proxy
Core ArchitectureIntegrated Platform (Layer)Standalone Gateway (Service)
Target UserCentral IT / Platform EngineeringApplication Developers / DevOps
Lifecycle ManagementAPIs, Agents, MCPs, and GatewaysPrimarily LLM Request Routing
LanguageNative Rust (Extreme Performance)Rust / Go (Variable)

1. Governance vs. Connectivity

Light-Fabric (Governance)

Light-Fabric is designed as a Single Control Plane. It assumes that in an enterprise environment, "freedom without governance is chaos." It provides:

  • Centralized Registry: Every agent, skill, and tool is registered and governed via the light-portal.
  • Fine-Grained Authorization: Deep policy enforcement at the endpoint level, including row and column-level data masking.
  • Auditability: A unified audit trail for all agentic interactions across the entire organization.

AgentGateway (Connectivity)

AgentGateway typically focuses on the North-South traffic between an application and multiple LLM providers. Its primary strength is:

  • Simplified Routing: Getting a request from Point A to Point B with retries and failover.
  • Provider Abstraction: Normalizing different LLM APIs into a single interface.

2. Integrated Intelligence: Hindsight

One of the defining differences of the Light-Fabric is the deep integration of Hindsight Memory.

  • Light-Fabric: Memory is not an "add-on." The platform provides native biomimetic memory banks (World Facts, Experiences, Mental Models) that are automatically managed and scoped (Global, Shared, Private) as part of the fabric.
  • AgentGateway: Typically treats memory as external state. The application or a separate vector database must manage context before sending the request through the gateway.

3. Skill & Tool Management

Centralized Skills (Fabric)

In Light-Fabric, skills (tools) are first-class citizens. They are registered, versioned, and governed centrally. An agent doesn't just "have" a tool; the Fabric grants the agent access to a skill based on its role and the current context.

Standard Tooling (Gateway)

AgentGateway generally passes tool definitions through to the provider. The management of who can use which tool and how those tools are secured is usually left to the application logic.


4. Orchestration: Hybrid Agentic Workflows

Light-Fabric (Integrated Orchestrator)

Light-Fabric treats orchestration as a foundational service. It implements a Hybrid Model:

  • Deterministic Process: The overall business logic (e.g., insurance claim steps) is fixed and compliant.
  • Autonomous Tasks: Individual steps within the process are delegated to agents.
  • Statefulness: The Fabric manages long-running state across days or weeks, ensuring durability.

AgentGateway (Stateless Proxy)

AgentGateway is primarily a stateless component.

  • External Orchestration: The workflow logic must reside in your application code or an external engine (like Temporal).
  • Proxy Only: It handles the communication but does not "understand" or manage the multi-step business process itself.

5. Security: The Rule Engine

Light-Fabric (Integrated Governance)

Light-Fabric includes an integrated YAML-based Rule Engine (light-rule) designed for fine-grained authorization:

  • Data Filtering: Automatically masks or filters response data (column/row level) based on policies.
  • Policy Enforcement: Checks permissions before an agent executes a tool or accesses a memory unit.
  • Hot-Reloading: Security rules can be updated in real-time without redeploying the platform.

AgentGateway (Basic Middleware)

AgentGateway typically provides basic security features like API key validation or rate limiting.

  • Limited Filtering: While it can intercept traffic, implementing complex, context-aware data masking usually requires writing custom middleware or handling it at the application level.

6. MCP Support: Gateway vs. Ecosystem

Light-Fabric (Integrated Tooling)

Light-Fabric treats Model Context Protocol (MCP) as a primary source for agent tools.

  • Direct Integration: Agents use the mcp-client to directly consume tools from MCP servers.
  • Registry Management: MCP servers are registered in the light-portal, allowing for centralized discovery and governance.
  • Unified Security: The same Fine-Grained Authorization rules apply to MCP tools as they do to native Rust tools.

AgentGateway (Specialized MCP Proxy)

AgentGateway provides a highly specialized MCP Gateway layer.

  • Protocol Translation: It excels at translating between different MCP transports (SSE, Streamable HTTP, etc.).
  • Exposing Servers: Its primary role is to make MCP servers accessible to external applications through a normalized gateway interface.
  • Advanced Networking: Includes features like stream merging and specialized MCP routing.

For a deep dive into the technical differences, see our Detailed MCP Feature Comparison.


Summary: Which to Choose?

Choose Light-Fabric if:

  • You are building an Enterprise AI Strategy that requires unified governance, stateful workflows, and integrated security.
  • You need to manage the entire lifecycle of agents and the business processes they participate in.
  • You require advanced data privacy (masking) and long-term memory (Hindsight) as native platform features.

Choose AgentGateway if:

  • You need a lightweight proxy to handle LLM provider failover and basic request normalization.
  • You prefer to manage agent logic, workflows, memory, and security entirely within your external application stack.
  • You are looking for a simple tool to solve immediate connectivity needs without implementing a comprehensive platform layer.

Detailed Comparison: MCP Gateway Features

This document provides a technical deep dive into the Model Context Protocol (MCP) implementations in Light-Fabric and AgentGateway.

Feature Matrix

FeatureLight-FabricAgentGateway
Primary RoleProvider/Gateway/Portal: Exposes MCP/API Servers.Provider/Gateway: Exposes MCP servers.
OnboardingAuto-Discovery: Automatic tools/list sync.Manual: K8s CRD/Manifest configuration.
Data PrivacyDeep: Row/Column level masking.Basic: Allow/Deny access control.
TransportsSSE, Streamable HTTP, WebSocketSSE, Streamable HTTP, WebSocket
Legacy IntegrationNative: REST/RPC to MCP transformation.External: Manual wrappers required.
AuthorizationManaged: Roles, Groups, Positions, Attributes.Infrastructure: CEL-based policies.
Hot-ReloadingNative: Integrated Control Plane & Registry.Infrastructure: Istio/xDS sync.
AuthenticationJWT (End-to-End Propagation)JWT, Keycloak, OIDC, Passthrough
ObservabilityDistributed Tracing (OTEL) and Integrated Hindsight MemoryDistributed Tracing (OTEL)

1. Architectural Intent

AgentGateway: The Network Proxy Layer

AgentGateway is designed as a high-availability proxy for MCP servers. Its primary focus is the North-South traffic between an application and multiple MCP backends.

  • Multiplexing: Optimized for merging multiple MCP backends into a single upstream connection (mergestream.rs).
  • Protocol Translation: Excels at translating between SSE, Streamable HTTP, and WebSocket transports.
  • Infrastructure Focus: Operates as a Kubernetes-native component managed via manifests and standard networking policies.

Light-Fabric: The Managed Enterprise Platform

Light-Fabric provides a Unified Governance Fabric that treats AI agents and MCP tools as part of the broader enterprise API ecosystem.

  • Unified Gateway: The AI Gateway (Rust/Pingora-based) serves as a single entry point for UI, Agents, and Tools, supporting both MCP and traditional REST/RPC APIs.
  • Centralized Portal: Uses the Light-Portal as a control plane for onboarding (auto-discovery), configuration (hot-reloading), and security management.
  • Governed Intelligence: Integrates the gateway directly with Hindsight Memory and the Fine-Grained Rule Engine, ensuring that every tool call is governed by corporate compliance rules (e.g., row/column masking).
  • End-to-End Security: Maintains a single JWT-based identity from the user's chat interface all the way to the underlying MCP or API endpoint.

2. Security & Authorization

AgentGateway: Infrastructure-Aware RBAC

AgentGateway uses Common Expression Language (CEL) for its authorization policies.

  • Capabilities: High-speed, network-level blocking based on JWT claims and request headers.
  • Limitation: Lacks native support for content-aware data masking or organizational hierarchy logic.

Light-Fabric: Content-Aware Managed Auth

Light-Fabric provides a mature Fine-Grained Authorization layer:

  • Managed ABAC/PBAC: Supports Role, Group, Corporate Position (Hierarchy), and Attribute-based protection.
  • Data Privacy: Supports native Row and Column filtering (data masking), ensuring agents only see data they are authorized to process.
  • End-to-End JWT: The same JWT token is propagated from the UI through the Agent to the AI Gateway and MCP tool.

3. Lifecycle & Tool Onboarding

AgentGateway: Configuration-Driven

Onboarding tools in AgentGateway is an infrastructure task:

  • Manual Mapping: Requires defining Kubernetes Custom Resources (HTTPRoute, Backend) to map MCP servers to the gateway.
  • Scope: Primarily focused on exposing existing MCP servers.

Light-Fabric: Registry-Driven

Light-Fabric provides a "Zero-Effort" onboarding experience via Light-Portal:

  • Auto-Discovery: Registering an MCP API triggers an automatic tools/list call to populate the registry.
  • Protocol Transformation: Automatically transforms existing OpenAPI/REST and RPC services into MCP tools without requiring wrappers.
  • Centralized Governance: All tools (Native, REST, MCP) are managed in a single unified registry.

4. Control Plane & Configuration

AgentGateway: Kubernetes-Native

  • Orchestration: Managed via the Istio/xDS control plane.
  • Updates: Configuration changes are applied via Kubernetes manifests (YAML).

Light-Fabric: Portal-Managed

  • Hot-Reloading: Uses a dedicated Config Server and Control Plane to update gateway and agent configurations in real-time without restarts.
  • Enterprise Management: Business-centric UI for managing tool visibility, agent permissions, and security policies.

5. Conclusion

  • Use AgentGateway if you are an infrastructure provider who needs to expose MCP-based tools to multiple external applications securely and reliably.
  • Use Light-Fabric if you are building intelligent agents that need to use those tools to solve complex business problems within a governed framework.

Why Light-Fabric Already Covers the MCP Gateway — No Second Gateway Required

This document addresses a recommendation (produced by Grok AI) suggesting that an enterprise should deploy the open-source AgentGateway as a dedicated MCP layer alongside an existing API platform. After performing a side-by-side source code analysis of both projects (see vs-agentgateway.md and vs-agent-gateway-mcp.md), we present the findings below.


1. The Recommendation Was Generated Without Knowledge of Light-Fabric

The Grok-produced analysis operates under a critical blind spot: it has no knowledge of Light-Fabric (Rust-based, open-sourced to customers) or its capabilities. The recommendation frames the choice as "keep your existing REST platform + add AgentGateway for MCP," because Grok only knows about publicly documented open-source projects. It does not account for the fact that:

  • Light-Fabric is already in production and serving agentic workloads today.
  • Every feature listed in the recommendation — MCP federation, tool discovery, protocol translation, security, and observability — has already been built, demonstrated, and validated with the project team.
  • The comparison is therefore not between "a REST framework" and "an MCP gateway." It is between two systems that both provide MCP gateway capabilities, where one (Light-Fabric/Light-Gateway) is already deployed and battle-tested in our environment.

2. Source Code Analysis: Light-Fabric Already Does What AgentGateway Does

We conducted a detailed, code-level comparison of both projects. The full results are documented in our High-Level Comparison and Detailed MCP Feature Comparison. The key findings are summarized below.

2.1 MCP Protocol Support

CapabilityLight-FabricAgentGateway
TransportsSSE, Streamable HTTP, WebSocketSSE, Streamable HTTP, WebSocket
Tool DiscoveryAuto-discovery via tools/list syncManual K8s CRD configuration
Protocol TranslationNative REST/RPC → MCP transformationManual wrappers required
Stream HandlingSupportedSupported (mergestream)

Both projects support the same MCP transports. Light-Fabric goes further with automatic tool discovery and native protocol transformation from existing REST/RPC APIs — exactly the "OpenAPI-to-MCP mapping" that the Grok recommendation credits to AgentGateway, except Light-Fabric does it without requiring a separate component.

2.2 Security & Authorization

CapabilityLight-FabricAgentGateway
AuthenticationJWT (end-to-end propagation)JWT, Keycloak, OIDC, Passthrough
AuthorizationRole, Group, Position, Attribute-based (ABAC/PBAC)CEL-based policies
Data PrivacyRow/Column-level maskingAllow/Deny access control
Rule EngineIntegrated YAML-based, hot-reloadableBasic middleware

The Grok recommendation highlights "tool-level RBAC" and "MCP-compliant OAuth 2.1" as AgentGateway strengths. Our code analysis shows that Light-Fabric's authorization model is significantly deeper — it supports corporate-hierarchy-aware policies and content-level data masking that AgentGateway simply does not implement.

2.3 Lifecycle & Operations

CapabilityLight-FabricAgentGateway
OnboardingPortal-driven, auto-discoveryK8s manifest-driven, manual
Hot-ReloadingNative (Config Server + Control Plane)Infrastructure-dependent (Istio/xDS)
ObservabilityOTEL + integrated Hindsight MemoryOTEL + OpenInference
OrchestrationIntegrated hybrid workflows (deterministic + autonomous)None (stateless proxy)

Light-Fabric manages the entire lifecycle — from tool registration through governance to runtime orchestration — while AgentGateway only handles the proxy layer.


3. Two Gateways Is Overkill

The Grok recommendation frames the architecture as a "clean separation of concerns." In practice, deploying both Light-Fabric and AgentGateway creates redundant infrastructure with real costs:

Duplicated Capabilities

Both systems would be performing the same core functions:

  • Receiving MCP requests from agents
  • Translating tool calls to backend HTTP requests
  • Enforcing security policies on tool access
  • Providing observability for agentic traffic

Running two gateways that do the same thing is not "separation of concerns" — it is duplication of concerns. Every MCP request would traverse two proxy layers instead of one, adding latency and operational complexity for zero additional capability.

Operational Burden

  • Two deployment pipelines to maintain on EKS
  • Two sets of security policies to keep in sync
  • Two configuration surfaces (K8s CRDs for AgentGateway vs. Portal for Light-Fabric)
  • Two failure domains to monitor and troubleshoot
  • Two upgrade cycles to coordinate

The "No Code Changes" Claim Is Misleading

The Grok recommendation states AgentGateway requires "no code changes." This is true only if you ignore the work required to:

  • Write and maintain Kubernetes Custom Resources for every MCP backend
  • Build manual wrappers for non-MCP services (Light-Fabric does this natively)
  • Implement application-level logic for everything AgentGateway doesn't cover (stateful workflows, data masking, memory management)

Light-Fabric also requires no code changes to existing backend services — and it provides the governance layer out of the box.


4. Addressing the "Rust Performance" Argument

The recommendation claims AgentGateway has a "performance edge" due to its Rust data plane. This argument does not hold:

  • Light-Fabric's AI Gateway currently runs on the high-performance Java-based light-gateway, and a new Rust-based AI Gateway is also under way, built on the Pingora framework (Cloudflare's production proxy engine). Even the existing Java gateway delivers exceptional throughput, and the Rust gateway will remove the JVM from the critical path entirely.
  • Both systems benefit from Rust's zero-cost abstractions, memory safety, and lack of garbage collection pauses.
  • The performance comparison between the two Rust implementations would be marginal and workload-dependent — not a differentiator.

5. Addressing the "Custom Development" Concern

The recommendation warns against "implementing MCP directly" because it "involves significant custom development." This concern does not apply:

  • Light-Fabric's MCP support is not custom development — it is a fully implemented, production-ready feature of the platform.
  • The MCP client, gateway routing, tool registry, and security integration are all existing, tested components, not a backlog of work to be done.
  • The project team has already seen these features demonstrated end-to-end.

6. Summary

Concern from Grok RecommendationReality
"Light4j is a REST framework, not an AI proxy"Light-Fabric is a full agentic platform with an AI Gateway already in production
"AgentGateway provides MCP federation and tool discovery"Light-Fabric provides the same capabilities with deeper governance
"Rust performance advantage over JVM"Light-Fabric's Java gateway is already very fast, and a Rust (Pingora-based) gateway is coming
"Clean separation of concerns"Two gateways doing the same thing is duplication, not separation
"No code changes required"True for both — but AgentGateway requires extensive K8s manifest management
"Custom MCP implementation is risky"Light-Fabric's MCP support is already built, tested, and in production

Conclusion

The Grok-generated recommendation is well-structured but fundamentally flawed because it was produced without knowledge of Light-Fabric's capabilities. When evaluated against the actual source code and production state of both systems, the case for adding AgentGateway collapses:

  • Light-Fabric already provides every MCP gateway capability that AgentGateway offers.
  • Light-Fabric goes significantly further with integrated governance, data privacy, memory, and orchestration.
  • Adding a second gateway introduces operational complexity and latency with no net-new capability.

The pragmatic, low-risk path is to continue with the platform that is already built, already in production, and already proven to the team.