Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

The document site for light-portal application.

Architecture

Design

Light Portal is an application that connect the providers to the consumers, and it contains many components or applications. Each component will have some API endpoints and a user interface in the portal view single page application.

To allow the users to understand each component in detail in term of design, we have collected all the design documents in this section.

Portal View

Mutliple Environment

This document outlines the necessary changes to configure portal view to work dynamically across different environments (sdx, dev, non-prod, prod) using environment-specific configuration.

1. Environment Variables Setup

Create .env File

Create environment-specific .env files in project root:

# Environment variables
# VITE_BASE_PATH is used as the base URL prefix for API calls.
VITE_BASE_PATH=/bff/admin/
# VITE_PORTAL_URL is the full absolute URL where the frontend static files are served
VITE_PORTAL_URL=https://sdx.lightapi.net/bff

Required Environment Variables

  • VITE_BASE_PATH: Defines the sub-path where your application is deployed.
  • VITE_PORTAL_URL: The API endpoint base URL.

Benefits of .env Configuration

  • Switch environments without code changes
  • Maintain a single codebase for all environments

2. Vite Configuration Changes

File: vite.config.js Location: Project root

Required Change:

import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');

  return {
    plugins: [react()],
    base: env.VITE_BASE_PATH || "/",
    // ... other configurations
  };
});

Why This Change is Necessary?

The Problem Without base Configuration

When your application is deployed to a sub-path rather than the domain root, all asset references break.

Deployment ScenarioRequired base Value
https://example.com/"/" (default)
https://example.com/portal/"/portal/"
https://example.com/app/v2/"/app/v2/"

What base Affects

The base configuration controls how Vite prefixes:

  • Static asset URLs (JavaScript, CSS, images, fonts)
  • Client-side routing paths
  • Public folder references

Example: Without vs With base

Without base Configuration:

  • App hosted at: https://example.com/portal/
  • Vite generates: <script src="/assets/index.js">
  • Browser requests: https://example.com/assets/index.js
  • Result: 404 Not Found ❌

With base: “/portal/”:

  • App hosted at: https://example.com/portal/
  • Vite generates: <script src="/portal/assets/index.js">
  • Browser requests: https://example.com/portal/assets/index.js
  • Result: Success ✅

3. React Router Configuration Changes

File: App.tsx Location: src/App.tsx

Required Change:

import { BrowserRouter } from 'react-router-dom';

function App() {
  const basename = import.meta.env.VITE_BASE_PATH || "/";

  return (
    <BrowserRouter basename={basename}>
      {/* Your app routes and components */}
    </BrowserRouter>
  );
}

export default App;

What basename Does

The basename prop tells React Router the base URL prefix for all routes in your application.

Routing Behavior Comparison

ScenarioWithout basenameWith basename=“/portal”
<Link to="/dashboard">Navigates to /dashboardNavigates to /portal/dashboard
path="/settings" matches/settings/portal/settings
useNavigate("/login")Goes to /loginGoes to /portal/login

Why It’s Required

When your app is hosted at a sub-path (e.g., https://example.com/portal/), React Router needs to know that /portal is the deployment prefix, not part of your route definitions.

Without basename:

  1. You define <Route path="/dashboard" />
  2. User visits /portal/dashboard
  3. React Router sees /portal/dashboard → no match → Route Not Found ❌

With basename="/portal":

  1. React Router strips /portal from the URL
  2. Sees /dashboard → matches your route → Success ✅

4. API Call Configuration

Current Behavior Issue

Without a configured base URL, the browser constructs API request URLs relative to the current page origin.

Example:

  • App running at: https://example.com/portal/dashboard
  • API call: fetch('/api/users')
  • Browser sends request to: https://example.com/api/users

This may work in some cases but breaks when:

  • API is hosted on a different domain/subdomain
  • API has a different base path
  • Cross-environment consistency is needed

Solution: Custom Fetch Wrapper

File: src/utils/fetchClient.js

const BASE_URL = import.meta.env.VITE_API_BASE_URL || "";

/**
 * Custom fetch wrapper with automatic base URL prefixing
 * @param {string} endpoint - API endpoint path (e.g., '/api/users')
 * @param {Object} options - Fetch options (method, headers, body, etc.)
 * @returns {Promise} - Response JSON
 */
async function fetchClient(endpoint, options = {}) {
  const url = `${BASE_URL}${endpoint}`;

  const defaultHeaders = {
    "Content-Type": "application/json",
  };

  const config = {
    ...options,
    headers: {
      ...defaultHeaders,
      ...options.headers,
    },
  };

  const response = await fetch(url, config);

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  return response.json();
}

export default fetchClient;

Usage Example

import fetchClient from './utils/fetchClient';

// GET request
const users = await fetchClient('/api/users');

// POST request
const newUser = await fetchClient('/api/users', {
  method: 'POST',
  body: JSON.stringify({ name: 'John Doe', email: '[email protected]' }),
});

// With custom headers
const data = await fetchClient('/api/protected', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
});

Benefits

  • Consistency: All API calls use the same base URL
  • Environment flexibility: Different API endpoints per environment
  • Maintainability: Single place to update API configuration
  • Error handling: Centralized response validation

5. Build and Deployment Steps

Step 1: Change the .env variables for specific environment (sdx, dev, prod etc.)

VITE_BASE_PATH=/ (base path)
VITE_PORTAL_URL=https://example.com (endpoint URL)

Step 2: Build

npm run build

MCP Registry Design for AI Gateway

This document outlines the registration and management strategy for Model Context Protocol (MCP) tools within the AI Gateway.

Registration Strategy: The Hybrid Model

For a robust and scalable AI Gateway, the recommended approach is a Hybrid Model: Register the MCP Server as the primary entity, but manage and expose the Tools individually.

This approach balances the technical requirements of connectivity with the operational requirements of governance, security, and performance.


1. Register the Server (The “Connection” Layer)

The MCP Server should be treated as the source of truth and the primary unit of connectivity.

  • Centralized Configuration: Authentication (API keys, OAuth), base URLs, transport protocols (SSE or Stdio), and environment variables are defined at the server level.
  • Connectivity Management: A single server acts as a wrapper around related APIs. Registering tools individually would create significant overhead and redundant connections.
  • Lifecycle & Health Monitoring: If an MCP server goes down, all its tools become unavailable. It is more efficient to monitor health and availability at the server level.
  • Dynamic Discovery: The MCP protocol includes a tools/list capability. By registering the server, the gateway can automatically sync and discover new tools when the server is updated, eliminating the need for manual registration of every new function.

2. Expose Tools Individually (The “Governance” Layer)

While the gateway connects to the server, it should expose and manage tools as individual objects. This is crucial for:

  • Granular Permissions (RBAC): Access control can be applied at the tool level. For example, a “Finance” team might be granted access to a get-invoice tool but restricted from a modify-ledger tool, even if both reside on the same ERP server.
  • Context Window Optimization: Large Language Models (LLMs) have limited context windows. Sending all 50 tools from a large server to an LLM wastes tokens and increases the “lost in the middle” effect. The Gateway should allow for the activation of specific subsets of tools for a given AI session or agent.
  • Rate Limiting & Cost Control: High-compute or high-cost tools (e.g., generate-video) can be rate-limited or billed differently compared to lightweight tools (e.g., get-weather).
  • Safety & Compliance: Metadata can be attached to individual tools to flag them as Read-Only, Destructive, or Sensitive, enabling specific security flows (like “Human-in-the-loop” approvals) for risky operations.

The implementation should follow a “Catalog” or “App Store” pattern:

  1. Provider/Server Registration: An admin registers a server (e.g., “The GitHub MCP Server”) with its credentials.
  2. Automated Discovery: The Gateway calls the server’s list_tools method and populates a tool catalog.
  3. Governance & Activation: Admins “enable” specific tools for specific model configurations or user groups.
  4. Routing Layer: When a model requests a tool, the Gateway resolves the request to the owning Server and handles the underlying communication.

Comparison of Approaches

FeatureIndividual RegistrationGroup (Server) RegistrationRecommended: Hybrid
ManagementExtremely difficult (manual entry for every tool)Easy (single connection)Optimal (Auto-sync tools from server)
SecurityGranular (Tool-level RBAC)Coarse (All-or-nothing access)Granular (Policy per tool)
LLM ContextPrecisePotential for bloatingPrecise (Selectable subsets)
MaintenanceHigh (Breaks if tool name changes)LowLow (Unified lifecycle)
ConnectivityRedundant connectionsEfficientEfficient (One connection, many tools)

Data Model & Schema Design

The AI Gateway leverages the existing API registry schema used by light-gateway, with specific enhancements to accommodate the unique requirements of the MCP protocol.

Conceptual Mapping

MCP Conceptlight-gateway TableMapping Strategy
MCP Serverapi_tRepresents the top-level service (e.g., “Postgres MCP Server”).
Server Instanceapi_version_tManages the connectivity parameters and the overall tool manifest.
MCP Toolapi_endpoint_tEach tool is registered as an individual endpoint belonging to an MCP version.
Tool Permissionsapi_endpoint_scope_tHandles RBAC and scope-based access to specific tools.

Core Tables & Enhancements

To support MCP, the following schema adjustments are implemented:

1. API Version (Server Connection)

The api_version_t table is enhanced to store transport-level configurations for stdio or SSE connections.

ALTER TABLE api_version_t ADD COLUMN transport_config TEXT;
-- JSON Example for transport_config: 
-- {"transport": "stdio", "command": "npx", "args": ["-y", "@mcp/server-google"]}

2. API Endpoint (Tool Definition)

The api_endpoint_t table acts as the tool registry. We relax the traditional HTTP method constraints and add fields for MCP tool metadata.

-- Allow 'call' as a valid operation for MCP tools
ALTER TABLE api_endpoint_t DROP CONSTRAINT api_endpoint_t_http_method_check;
ALTER TABLE api_endpoint_t ADD CHECK ( http_method IN ( 'delete', 'get', 'patch', 'post', 'put', 'call' ) );

-- Store the Tool Schema (for LLM validation) and Metadata (for safety flags)
ALTER TABLE api_endpoint_t ADD COLUMN tool_schema TEXT;   -- JSON Schema of the tool inputs
ALTER TABLE api_endpoint_t ADD COLUMN tool_metadata TEXT; -- e.g., {"destructive": true, "read_only": false}

Full Registry Schema Reference

-- API Definition (The MCP Server)
CREATE TABLE api_t (
    host_id                 UUID NOT NULL,
    api_id                  VARCHAR(16) NOT NULL,
    api_name                VARCHAR(128) NOT NULL,
    api_desc                VARCHAR(1024),
    api_status              VARCHAR(32) NOT NULL,
    active                  BOOLEAN NOT NULL DEFAULT TRUE,
    PRIMARY KEY (host_id, api_id)
);

-- API Version (The Connection/Transport)
CREATE TABLE api_version_t (
    host_id                 UUID NOT NULL,
    api_version_id          UUID NOT NULL,
    api_id                  VARCHAR(16) NOT NULL,
    api_version             VARCHAR(16) NOT NULL,
    api_type                VARCHAR(7) NOT NULL,    -- 'mcp', 'openapi', etc.
    transport_config        TEXT,                   -- MCP-specific connection data
    spec                    TEXT,                   -- Full tool manifest (optional)
    active                  BOOLEAN NOT NULL DEFAULT TRUE,
    PRIMARY KEY(host_id, api_version_id),
    FOREIGN KEY(host_id, api_id) REFERENCES api_t(host_id, api_id) ON DELETE CASCADE
);

-- API Endpoint (The Individual Tool)
CREATE TABLE api_endpoint_t (
    host_id              UUID NOT NULL,
    endpoint_id          UUID NOT NULL,
    api_version_id       UUID NOT NULL,
    endpoint             VARCHAR(1024) NOT NULL,  -- Tool Name
    http_method          VARCHAR(10),             -- 'call' for MCP
    endpoint_name        VARCHAR(128) NOT NULL,
    endpoint_desc        TEXT,
    tool_schema          TEXT,                    -- Input parameter validation
    tool_metadata        TEXT,                    -- Safety and cost metadata
    active               BOOLEAN NOT NULL DEFAULT TRUE,
    PRIMARY KEY(host_id, endpoint_id),
    FOREIGN KEY(host_id, api_version_id) REFERENCES api_version_t(host_id, api_version_id) ON DELETE CASCADE
);

Tool Metadata & Synchronization

Populating the api_endpoint_t table involves coordinating data from the MCP Server with operational policies defined within the AI Gateway.

Sources of Metadata

The metadata for each tool is synthesized from three primary sources:

1. Standard MCP Server Response (Automated)

When the Gateway performs a tools/list call, the MCP server provides the baseline technical definition for each tool.

  • Source Fields: name, description, inputSchema.
  • Mapping: These are mapped directly to endpoint, endpoint_desc, and tool_schema respectively.

2. Gateway Operational Enrichment (Manual/Policy)

Since the standard MCP protocol does not include operational flags (like safety or cost), the AI Gateway manages these in the tool_metadata JSON column.

  • Administrative Enrichment: Platform admins use the Gateway UI to tag specific tools. Common tags include:
    • destructive: true: Triggers a warning or confirmation flow.
    • human_approval_required: true: Places the request in a queue for manual sign-off.
    • cost_tier: "high": Used for rate-limiting or internal billing.
  • Heuristic Auto-Tagging: The Gateway can automatically infer metadata based on patterns. For example, any tool starting with get_ or list_ is auto-flagged as read_only: true.

3. Protocol Extensions (Custom)

The MCP specification allows for additional properties in the tool object. If a custom MCP server includes an extra metadata or annotations block, the Gateway’s synchronization logic can be configured to capture and store these directly.


Synchronization Workflow

The following lifecycle ensures the Gateway’s registry remains accurate:

  1. Connection: The Gateway establishes a connection to the server using the transport_config.
  2. Discovery (Sync): The Gateway calls tools/list and performs an “upsert” for all tools found.
    • Existing tools have their tool_schema and endpoint_desc updated.
    • New tools are created with a default active status and baseline tool_metadata.
  3. Review: An administrator reviews the newly discovered tools in the Gateway dashboard.
  4. Governance Policy: The administrator “enables” the tool for specific roles and configures any required safety metadata (e.g., flagging the drop_table tool as destructive).
  5. LLM Execution: When a model calls the tool, the Gateway uses the stored tool_schema for pre-flight validation and the tool_metadata to enforce security policies.

Too Many Pages/Forms

The portal has accumulated many pages, generated forms, custom admin screens, and feature-specific entry points. The sidebar can expose these pages, but it does not help a user understand which pages are required to finish a real business task. The MCP Gateway quick start wizard is a useful experiment, but it also shows the limitation of a rigid linear wizard: real tasks have optional steps, pre-existing data, and multiple valid starting points.

This document proposes a task-oriented navigation layer for portal-view.

Problem

Users currently need to know the portal information architecture before they can complete a task. For example, onboarding an API to MCP Gateway may require some combination of:

  • create or select an API
  • create or select an API version
  • link the API version to a gateway or sidecar instance
  • select MCP tools
  • configure access control
  • revisit instance, API, or role administration later

The same pattern exists across other areas. A task is not a single route; it is a sequence of related pages and forms. The current navigation model makes users pick pages first, then infer the task process themselves.

Current MCP Wizard Observation

The MCP Gateway wizard already has useful building blocks:

  • flowConfig.tsx keeps step metadata in one place.
  • McpServerForm.tsx renders a generic wizard shell.
  • useMcpPrefill.ts can resume from URL context such as apiId, apiVersionId, and instanceApiId.
  • Several steps are marked skippable.

However, the wizard is still too rigid:

  • Step order is linear even when the task is naturally conditional.
  • Initial step selection relies on hard-coded step numbers.
  • Optional work is represented as skip buttons instead of task state.
  • The wizard duplicates or wraps existing forms instead of treating existing pages/forms as first-class task steps.
  • The solution is specific to MCP Gateway and does not help users navigate the rest of the portal.

Design Goals

  • Let users start from a task, not a page name.
  • Keep existing pages and generated forms as the source of truth.
  • Support multiple entry points into the same task.
  • Detect what has already been completed and show only relevant next actions.
  • Support optional, required, blocked, complete, and skipped steps.
  • Preserve role-based visibility and host-specific context.
  • Allow users to leave a task, return later, and continue from context.
  • Make the approach reusable for MCP, API publishing, access control, deployment, config promotion, migration, and admin workflows.

Non-Goals

  • Do not replace every admin page with a wizard.
  • Do not create a separate custom form for each task if an existing generated form already works.
  • Do not use the sidebar as the only navigation surface.
  • Do not force a strict step sequence when the data model allows safe jumping.

Proposed Solution

Add a task-oriented navigation layer above the current pages/forms.

The main pieces are:

  1. Task Center
  2. Task Registry
  3. Task Progress Resolver
  4. Task Navigation Shell
  5. Global Search and Command Palette
  6. Contextual Next Actions

Task Center

The Task Center is a page where users choose what they want to accomplish. It should group work by intent, not by implementation table.

Example task groups:

  • API Marketplace
    • Register a new API
    • Add an API version
    • Publish an API
    • Review API details
  • MCP Gateway
    • Onboard an existing API to MCP Gateway
    • Register a standalone MCP server
    • Configure MCP tools
    • Configure MCP access control
  • Access Control
    • Create role
    • Assign permissions
    • Configure endpoint access
  • Platform Operations
    • Register controller/gateway instance
    • Link API version to instance
    • Promote configuration
  • Portal Administration
    • Manage host users
    • Export/import portal data
    • Convert migration snapshot

Each task card should show:

  • title
  • short description
  • required role
  • common starting object, such as API, instance, host, or client
  • progress status when the current context is known
  • primary action such as Start, Continue, Review, or Fix Missing Step

Task Registry

Introduce a registry that describes tasks and steps declaratively. This is the generalized version of the current MCP flowConfig.tsx, but it should route to existing pages/forms instead of rendering every step inside one wizard.

Example TypeScript shape:

export type TaskDefinition = {
  id: string;
  title: string;
  description: string;
  category: string;
  roles?: string[];
  keywords: string[];
  entryPoints: TaskEntryPoint[];
  steps: TaskStep[];
};

export type TaskStep = {
  id: string;
  title: string;
  description?: string;
  required: boolean;
  dependsOn?: string[];
  route: (ctx: TaskContext) => string;
  formId?: string;
  completeWhen?: TaskCompletionCheck;
  visibleWhen?: TaskVisibilityCheck;
  blockedWhen?: TaskBlockedCheck;
};

The task registry should live close to portal navigation code, for example:

src/tasks/taskRegistry.ts
src/tasks/taskTypes.ts
src/tasks/resolvers/
src/pages/tasks/TaskCenter.tsx
src/pages/tasks/TaskDetail.tsx

Page And Form Metadata

To make search and tasks work well, pages and generated forms need metadata.

For generated forms, the metadata can come from Forms.json plus a small registry override when the form title is not enough.

For custom pages, add a route/page registry:

export type PageDefinition = {
  route: string;
  title: string;
  description?: string;
  category: string;
  roles?: string[];
  keywords: string[];
  entities?: string[];
};

This registry can feed:

  • sidebar sections
  • Task Center
  • command palette
  • page breadcrumbs
  • contextual next actions

The important rule is that page/form metadata should be reused, not copied into each wizard.

Task Progress Resolver

A task should not blindly ask users to complete steps that are already done. Each task can have a resolver that checks the current host and entity context.

For MCP Gateway, the resolver can check:

  • API exists
  • API version exists
  • instance API link exists
  • MCP tool configuration exists
  • access control exists

The UI then marks each step:

  • Complete
  • Required
  • Optional
  • Blocked
  • Skipped
  • Needs review

The resolver should use existing query endpoints where possible. The first implementation can query on page load. Later, it can cache per task/session.

Task Navigation Shell

Instead of a full-screen wizard that owns all steps, use a task shell that can wrap or accompany existing pages.

Recommended behavior:

  • A task detail page shows the checklist and current state.
  • Selecting a step navigates to the existing page/form with task context in the URL or router state.
  • The target page shows a compact “Task” panel or return link.
  • After save, the user can return to the checklist or continue to the next recommended step.

Example URL:

/app/form/createService?task=mcp-onboard-api&returnTo=/app/tasks/mcp-onboard-api

This keeps existing page behavior intact while adding guided navigation.

Global Search And Command Palette

The portal should have a global launcher. It should search tasks, pages, forms, and entities.

Examples:

  • “onboard mcp”
  • “create api”
  • “auth client”
  • “relation type”
  • “instance api”
  • “export snapshot”

Search results should be role-aware and host-aware.

Result types:

  • Task
  • Page
  • Form
  • Entity
  • Recent item

This is the fastest way to help expert users without forcing them through a wizard.

Contextual Next Actions

Detail pages should expose next actions based on the current entity.

Examples:

  • API detail
    • Add version
    • Link version to gateway
    • Configure MCP tools
    • Configure access control
  • Instance detail
    • Link API version
    • Configure MCP tools
    • View gateway servers
  • Auth client detail
    • Assign owner
    • Review sessions
    • Review audit
  • Snapshot export
    • Convert snapshot
    • Import snapshot

These actions should come from the same task registry, not from one-off buttons hard-coded on every page.

MCP Gateway Example

The MCP Gateway quick start can be rebuilt as a task:

Task: Onboard API to MCP Gateway

Steps:
1. Select or create API
2. Select or create API version
3. Choose deployment mode
4. Link API version to gateway or sidecar instance
5. Select MCP tools
6. Configure access control

Step behavior:

  • API selection is required unless apiId is already provided.
  • API version is required unless apiVersionId is already provided.
  • Spec upload is optional and only shown when creating a new API/version.
  • Deployment mode is required when the version is not linked.
  • Gateway selection is required only for centralized deployment.
  • Tool selection is optional if users only want to register the server first.
  • Access control is optional but should be shown as a recommended final step.

This task can support several entry points:

/app/tasks/mcp-onboard-api
/app/tasks/mcp-onboard-api?apiId=...
/app/tasks/mcp-onboard-api?apiId=...&apiVersionId=...
/app/tasks/mcp-onboard-api?instanceApiId=...

The UI should not rely on fixed step numbers. It should compute visible steps from the task context and completion state.

Task State

Start with client-side state:

  • URL query parameters for entity context
  • sessionStorage for in-progress task context
  • existing backend records for real completion state

Later, add persisted task state if needed:

  • user id
  • host id
  • task id
  • context JSON
  • skipped step ids
  • last active step
  • updated timestamp

Persisting task state should not become the source of truth for business data. It should only remember navigation state and user choices. Completion should be derived from actual portal records.

The sidebar should become smaller and more stable. It should expose major areas, not every page/form.

Recommended sidebar sections:

  • Home
  • Tasks
  • Marketplace
  • MCP Gateway
  • Operations
  • Administration

Deep links should still exist, but they should be discoverable through search, contextual actions, and task detail pages.

Implementation Plan

Phase 1: Inventory And Metadata

  • Create page/form metadata registry.
  • Add task registry types.
  • Register the most-used pages and forms.
  • Add global search over registered tasks/pages/forms.

Phase 2: Task Center

  • Add /app/tasks.
  • Add task category cards.
  • Add task detail checklist page.
  • Implement client-side task context with URL parameters and session storage.

Phase 3: MCP Gateway Task

  • Convert the current MCP wizard flow into mcp-onboard-api task definition.
  • Reuse existing MCP components for the pages that still need custom UI.
  • Replace hard-coded step numbers with resolver-driven visible steps.
  • Add return-to-task behavior after saves.

Phase 4: Contextual Actions

  • Add task actions to API detail and instance detail pages.
  • Add task actions to access control and config pages where appropriate.
  • Use the task registry to drive action visibility.

Phase 5: Broader Rollout

  • Add tasks for API publishing, config promotion, host/user management, and snapshot export/import.
  • Reduce sidebar clutter once task/search usage is available.
  • Add persisted task state only if session storage is not enough.

Risks And Mitigations

RiskMitigation
Task registry duplicates sidebar and route definitionsReuse page/form metadata as the source for labels, roles, and keywords
Task state becomes staleDerive completion from backend records, not saved task status
Users lose flexibilityAllow direct page navigation and command-palette search
Implementation grows into another wizard frameworkRoute to existing pages/forms wherever possible
Role filtering becomes inconsistentCentralize role checks in the page/task registry

Recommendation

Keep the MCP Gateway wizard as a prototype, but do not build more isolated wizards in the same style. The long-term solution should be:

  • a task registry
  • a Task Center
  • resolver-driven progress
  • global search
  • contextual next actions
  • reuse of existing pages and generated forms

This gives new users guided paths while still letting experienced users jump directly to the page or form they already know.

Config Update Page

The current portal-view configuration admin area is complete but split across many table pages and generated forms. A customer Settings page shows a denser workflow: list applicable config properties in a tree, edit scalar values inline, and open a modal for list/map values. This document proposes a similar page for portal-view that can update config property overrides at the environment, product, product version, instance, API, app, and app-api levels. In this document, API, app, and app-api mean the instance-linked config override scopes represented by instanceApiId, instanceAppId, and instanceApiId + instanceAppId.

Current Implementation

The customer Settings implementation is centered on these files:

  • Settings.jsx
  • SettingsListView.jsx
  • SettingsListMapModal.jsx
  • InputForm.jsx
  • JsonSchemaForm.jsx

The useful behavior is:

  • one page loads applicable config properties and the current custom values
  • properties are displayed as a tree under configName
  • scalar values are edited inline
  • list and map values open a modal with form, raw JSON, and raw YAML tabs
  • save chooses create or update based on whether the override exists
  • delete removes the override and lets the inherited value show again

The customer implementation currently handles instance, instance API, instance app, and instance app API targets. It chooses the query/write action from instanceId, instanceApiId, and instanceAppId.

portal-view already has separate config override pages:

  • src/pages/config/ConfigEnvironment.tsx
  • src/pages/config/ConfigProduct.tsx
  • src/pages/config/ConfigProductVersion.tsx
  • src/pages/config/ConfigInstance.tsx
  • src/pages/config/ConfigInstanceApi.tsx
  • src/pages/config/ConfigInstanceApp.tsx
  • src/pages/config/ConfigInstanceAppApi.tsx

Those pages use Material React Table, fetch one override aggregate at a time, and navigate to generated react-schema-form routes for create/update. The form definitions live in src/data/Forms.json, and the generic form runner is src/components/Form/Form.tsx.

The existing form approach works for CRUD, but it is inefficient for config editing because the user must pick a config, pick a property, leave the list page, edit one value, and return.

Goals

  • Provide a single task-oriented config editor for the seven override scopes.
  • Show the property catalog and current override values together.
  • Preserve the existing config-command write APIs.
  • Preserve optimistic concurrency by carrying aggregateVersion for existing override rows.
  • Avoid client-side joins across independently paginated result sets.
  • Keep existing table pages and generated forms available as admin fallback routes.
  • Support scalar editing inline and structured list/map editing in a modal.
  • Show inherited/default value and custom override value separately.
  • Make delete/reset mean “remove this override”, not “delete the base property”.
  • Support read-only and hidden states when the user lacks write permission for a scope or target.

Non-Goals

  • Do not replace react-schema-form globally.
  • Do not replace the existing config list pages during the first release.
  • Do not edit File or Cert property values inline in the first release. Those can continue to use existing generated forms.
  • Do not require every config property to have a typed form schema before the page is useful.
  • Do not add a bulk transaction command in the first release. The UI can stage multiple changes and then orchestrate the existing single-row commands.

Add a Config Update page under the configuration task area. The first row is a scope and target selector:

  • Scope: Environment, Product, Product Version, Instance, API, App, App API
  • Target: the selected scope’s identity, such as environment, productId, productVersionId, instanceId, instanceApiId, instanceAppId, or both app/API ids for app-api
  • Optional filters: config phase, config type, property type, resource type, and “show overridden only”
  • Save mode: staged changes by default, with optional single-row Apply for quick edits

Below the selectors, render a tree/table:

  • group rows by configName
  • property leaf rows show propertyName
  • columns: value type, inherited value, override value, effective source, required, resource type, config phase, description, status
  • row status: inherited, overridden, dirty, saving, conflict, error
  • toolbar actions: expand all, collapse all, refresh, reset override, review changes, apply changes
  • row action menu: view history, open fallback form, copy identifiers

Editing behavior:

  • string: inline text editor or larger popover editor for long values
  • boolean: select true/false
  • integer and float: numeric editor with validation before save
  • list and map: open a structured modal
  • unsupported valueType: view-only with a link to the existing form route
  • propertyType File or Cert: open the existing create/update form in a drawer or modal overlay

The page should keep the inherited/default value visible while editing an override. If the override is deleted, the row remains visible and falls back to the inherited value.

The staged-change panel should list every pending create, update, and reset before applying. This matters for coordinated changes such as enabling a flag and setting a related URL. The backend commands can still run one by one, but the user gets a review step and can see partial failures without losing the full set of intended changes.

The row action menu should include View History. It should link to the audit log or history page pre-filtered by configId, propertyId, and the selected scope target. The row already shows updateUser and updateTs; history gives operators the deeper trail they need when debugging production configuration changes.

Structured Value Modal

The modal should start with raw JSON and raw YAML tabs. If a schema is available for the property, add a Form tab.

The customer code loads schema assets with:

schemas/<propertyName>/<propertyName>.json
schemas/<propertyName>/config.js

portal-view does not currently have this property-schema convention, so the first implementation should not depend on local schema assets. Use raw JSON/YAML with syntax and JSON validation first. Typed form support should come from the light-portal schema registry through schema-query, schema-command, and schema_t.

If a row has a schemaId and schemaVersion, the dialog should lazily fetch the published schema body from schema-query when the user opens the structured editor. The main getConfigUpdateProperties response should include schema metadata but not schemaBody, so the paginated table does not move large schema documents unnecessarily.

The schema association should key by configId + propertyId. Human-friendly keys such as configName + propertyName can be shown in the UI, but should not be used as the durable validation key.

List/map values should be saved as compact JSON strings because the command APIs store propertyValue as a string.

Schema Registry Validation

The schema registry should be used for structured map and list values once the registry is hardened enough for production validation. The config update page should treat the registry as optional per property: rows with no schema still use valueType validation and raw JSON/YAML editing.

getConfigUpdateProperties should return lightweight schema metadata:

{
  "schemaId": "security-jwt-claim-mapping",
  "schemaVersion": "1.0.0",
  "schemaType": "json",
  "schemaStatus": "P",
  "hasSchema": true
}

The UI should enable the Form tab only when the schema exists, is published, and is compatible with the property value type. Schema documents should be cached by hostId + schemaId + schemaVersion, with host-specific lookup falling back to a global schema.

Validation must run in both places:

  • frontend validation gives immediate editor feedback and highlights the JSON path that failed
  • backend validation remains authoritative in the config command handlers before a create or update override is accepted

Backend validation should parse propertyValue according to valueType before running JSON Schema validation. The UI should normalize YAML input to compact JSON before sending the command payload, so the command APIs continue to receive string values.

The schema registry needs a config-property binding before this can be enabled. The preferred minimal binding is schemaId + schemaVersion on the base config property definition. If tenant-specific schemas are needed later, schema lookup can resolve the same schema id/version against the selected hostId first and then fall back to the global row.

API Matrix

The write side can reuse the current command APIs.

ScopeCreateUpdateDelete
EnvironmentcreateConfigEnvironmentupdateConfigEnvironmentdeleteConfigEnvironment
ProductcreateConfigProductupdateConfigProductdeleteConfigProduct
Product VersioncreateConfigProductVersionupdateConfigProductVersiondeleteConfigProductVersion
InstancecreateConfigInstanceupdateConfigInstancedeleteConfigInstance
APIcreateConfigInstanceApiupdateConfigInstanceApideleteConfigInstanceApi
AppcreateConfigInstanceAppupdateConfigInstanceAppdeleteConfigInstanceApp
App APIcreateConfigInstanceAppApiupdateConfigInstanceAppApideleteConfigInstanceAppApi

For existing override rows, the update/delete payload must include the current aggregateVersion so the event persistence layer can enforce the monotonic version check. For new override rows, the page sends the scope identity, configId, propertyId, and propertyValue.

Security And RBAC

The page must not assume that a user who can view configuration can write every override scope. The selected scope and target should be checked against the same permission model used by the existing config admin routes and command handlers.

Recommended behavior:

  • hide scopes the user cannot see
  • show read-only rows for scopes the user can read but cannot update
  • disable apply/reset controls when the selected target is not writable
  • show a lock icon or tooltip for read-only rows
  • keep backend command authorization authoritative, even when the UI already filtered the control

Unauthorized command responses should be mapped back to the row that triggered the command. The page should not fail the entire table because one row is not writable.

Read Model

The instance-facing scopes already have applicable-property queries:

  • getApplicableConfigPropertiesForInstance
  • getApplicableConfigPropertiesForInstanceApi
  • getApplicableConfigPropertiesForInstanceApp
  • getApplicableConfigPropertiesForInstanceAppApi

These queries return property metadata and inherited/effective values, including:

  • configId
  • configName
  • configPhase
  • configType
  • propertyId
  • propertyName
  • propertyType
  • propertyValue
  • propertySource
  • propertySourceType
  • valueType
  • resourceType
  • required
  • displayOrder

The same page needs current override metadata from:

  • getConfigInstance
  • getConfigInstanceApi
  • getConfigInstanceApp
  • getConfigInstanceAppApi

The page should not join applicable rows and override rows across separately paginated API calls. That produces brittle pagination, filtering, sorting, and row-count behavior. Instead, Phase 1 should add a merged backend read model that returns one row per configurable property with inherited value, override value, effective value, override metadata, and permission hints.

Environment, product, and product version currently have list/getFresh queries for existing overrides, but they do not have equivalent applicable-property queries:

  • getConfigEnvironment
  • getConfigProduct
  • getConfigProductVersion

The new merged query should cover these scopes before they are exposed in the new page. A temporary client merge is acceptable only for a local prototype with unpaginated data; it should not be shipped as the production page behavior.

Proposed Generic Query

Add a Phase 1 query such as getConfigUpdateProperties in config-query.

Request:

{
  "hostId": "host uuid",
  "scope": "instance",
  "target": {
    "instanceId": "instance uuid"
  },
  "filters": {
    "configPhases": ["R"],
    "propertyTypes": ["Config"],
    "resourceTypes": ["all"]
  },
  "offset": 0,
  "limit": 1000,
  "active": true
}

Response:

{
  "total": 1,
  "properties": [
    {
      "scope": "instance",
      "hostId": "host uuid",
      "configId": "config uuid",
      "configName": "security.yml",
      "configPhase": "R",
      "propertyId": "property uuid",
      "propertyName": "jwt.clockSkew",
      "propertyType": "Config",
      "valueType": "integer",
      "resourceType": "all",
      "required": false,
      "schemaId": "security-jwt-clock-skew",
      "schemaVersion": "1.0.0",
      "schemaType": "json",
      "schemaStatus": "P",
      "defaultValue": "60",
      "defaultSourceType": "config_property",
      "overrideValue": "120",
      "overrideAggregateVersion": 3,
      "effectiveValue": "120",
      "effectiveSourceType": "config_instance",
      "canUpdate": true,
      "canDeleteOverride": true
    }
  ]
}

This query should be read-only. It does not need new write commands.

The query owns inheritance and candidate selection. The frontend owns presentation, editing state, and calls to the existing command APIs.

Frontend Structure

Recommended files:

src/pages/config/update/ConfigUpdatePage.tsx
src/pages/config/update/ConfigUpdateTable.tsx
src/pages/config/update/ConfigValueEditor.tsx
src/pages/config/update/ConfigStructuredValueDialog.tsx
src/pages/config/update/configUpdateScopes.ts
src/pages/config/update/configUpdateApi.ts
src/pages/config/update/configValue.ts
src/pages/config/update/configUpdateDraft.ts

configUpdateScopes.ts should be the single source of truth for scope metadata:

type ConfigUpdateScope = {
  id: 'environment' | 'product' | 'productVersion' | 'instance' | 'api' | 'app' | 'appApi';
  label: string;
  targetKeys: string[];
  applicableQuery?: string;
  overrideQuery: string;
  overrideResponseKey: string;
  createAction: string;
  updateAction: string;
  deleteAction: string;
  getFreshAction?: string;
  defaultResourceTypes?: string[];
  defaultConfigPhases?: string[];
};

The page should avoid hard-coding create/update/delete branching inside cell handlers. The handler asks the selected scope metadata which action and keys to use.

Draft And Apply Flow

The default edit mode should stage changes locally. A dirty row is not saved until the user chooses Apply for that row or Review & Apply from the toolbar.

The draft model should track:

  • operation: create, update, reset
  • previous effective value
  • next override value
  • scope target keys
  • configId
  • propertyId
  • current aggregateVersion
  • validation state

The review dialog should group changes by operation and show enough context for operators to catch mistakes before applying. If multiple commands are applied and one fails, the dialog should show which rows succeeded and which rows need attention. The page should refetch or refresh successful rows and leave failed rows dirty with their error state intact.

Save Flow

  1. User edits a row.
  2. UI validates the value against valueType and the schema registry when a published schema is attached to the property.
  3. UI marks the row dirty and stores a draft operation.
  4. User applies a row or opens Review & Apply.
  5. UI builds payload from selected scope, row configId, row propertyId, and normalized propertyValue.
  6. If an active override row exists, call the update action and include aggregateVersion.
  7. If no active override row exists, call the create action.
  8. On success, update the row with returned aggregate version or refetch that row.
  9. On conflict or error, keep the draft value, restore the displayed committed value, and show the row error.

The local override map should store the full override row, not just the string value. At minimum it needs:

  • propertyValue
  • aggregateVersion
  • active
  • scope identity fields
  • updateUser
  • updateTs

Before update or delete, the UI should support the same getFresh* pattern used by the existing admin pages. If the row has been open for a while, the Apply action can fetch the latest row to get the freshest aggregateVersion. At minimum, a version conflict must offer a “Refresh Row & Try Again” action that reloads that row, compares the current backend value with the user’s draft, and lets the user reapply intentionally.

Validation errors should stay close to the edited cell. For example, an invalid integer should keep the cell in edit/error state with a short message. Backend validation, authorization, and conflict errors should be attached to the row that caused them, not only shown as a global toast.

Reset Flow

Reset means delete the override for the selected target and property.

  1. User selects an overridden row.
  2. UI calls the scope’s delete action with target keys, propertyId, and aggregateVersion.
  3. On success, clear overrideValue and overrideAggregateVersion.
  4. The displayed effective value reverts to the inherited/default value.

Rows with no override should not allow reset.

Like update, reset should support getFresh* before delete or expose the same “Refresh Row & Try Again” conflict path.

Routing

Add a route such as:

/app/config/update

The route should accept task context and target context through query params:

/app/config/update?scope=api&instanceApiId=...&task=mcp-onboard-api

Existing config table pages can link to it when they already have target context. Existing generated forms should remain available from row overflow actions for advanced edits and File/Cert values.

The View History row action should preserve context by opening the audit trail in a drawer, modal, or task-aware route with filters already applied. The filter payload should include the selected scope, target keys, configId, and propertyId.

For fallback forms, prefer opening the existing react-schema-form experience inside a drawer or modal over navigating away from the table. That keeps the user’s current scope, filters, expansion state, selected row, and staged changes intact. Full-page navigation can remain as a secondary fallback for complex forms that cannot safely render in an overlay.

Implementation Plan

Phase 0: schema registry foundation for config validation

  • Harden schema-query, schema-command, and schema_t enough for production JSON Schema lookup.
  • Add a durable config-property-to-schema association with schemaId and schemaVersion.
  • Make schema lookup tenant-aware: host-specific schema first, global schema second.
  • Validate schema bodies on schema create/update.
  • Add backend config value validation for create/update override commands.
  • Add tests for schema CRUD, tenant/global lookup, version pinning, and invalid config property values.

Phase 1: merged read model and instance-facing MVP

  • Add getConfigUpdateProperties or an equivalent merged query in config-query.
  • Return candidate properties, inherited values, current override values, override metadata, schema metadata, and permission hints in one paginated/sortable result.
  • Build the page for Instance, API, App, and App API.
  • Use existing command APIs for create/update/delete.
  • Support scalar inline edits.
  • Support list/map raw JSON/YAML modal.
  • Support staged changes and Review & Apply.
  • Support row-level validation/error/conflict states.
  • Enable the typed Form tab only for properties with a published schema.

Phase 2: higher-level scopes

  • Add Environment, Product, and Product Version selectors.
  • Expose each scope only through the merged read model, not a client-side paginated join.
  • Ensure product version respects product-version config/property mappings where available.

Phase 3: typed structured forms

  • Use the schema registry for list/map config properties.
  • Support custom validators by property key.
  • Add tests for string array, object array, map, and malformed JSON/YAML values.

Phase 4: task integration

  • Link from configuration task panels to /app/config/update.
  • Add contextual next actions from instance, API, app, and app-api pages.
  • Keep generated create/update forms as drawer/modal fallback actions.

Risks And Open Questions

  • Environment inheritance needs a precise target rule. Existing applicable instance queries include environment_property as an inherited source, but the target environment is not selected by the current instance-facing query contract.
  • Product and product-version candidate lists can be too broad if they are loaded from all config properties. Product version should eventually use the product version config mappings.
  • The customer Settings code stores custom values in a map keyed by propertyId; for portal-view, the key should include scope target plus propertyId to avoid collisions when multiple targets are loaded.
  • propertyId is the stable merge key only after the candidate list has been constrained to the selected target. If multiple configs can contain the same property id in unusual imports, use configId + propertyId.
  • The page should avoid silently editing File or Cert values as plain text.
  • Staged apply is not atomic until a bulk command exists. The UI must show partial success and partial failure clearly.
  • Overlaying generated forms in a drawer depends on the form runner handling router state, success/failure navigation, and task context without forcing a full-page transition.
  • The schema registry is not fully implemented and tested yet. Schema-backed validation should not be enabled until tenant-aware lookup, version pinning, and backend command validation are in place.

Recommendation

Build the page as a new task-oriented editor, not as a rewrite of the existing config admin tables. Make the merged getConfigUpdateProperties read model a Phase 1 backend requirement so the frontend does not perform brittle pagination-sensitive joins. Start frontend exposure with the four instance-facing scopes, then add environment, product, and product version once the same merged query handles their inheritance and candidate-selection rules.

Implement the minimal schema registry foundation before enabling schema-backed validation in the config update page. The raw JSON/YAML editor and scalar valueType validation can be built in parallel, but the Form tab and backend schema enforcement should wait for the registry work.

Config Snapshot Output and Comparison

The /app/config/configSnapshot page lists effective configuration snapshots and links to the raw snapshot tables, but it does not show the final values.yml represented by a snapshot. It also requires users to inspect snapshots one at a time. This document adds a deterministic values.yml output for each row and a comparison workflow for two to four snapshots. The same comparison engine supports historical selection from the snapshot page and current-snapshot selection across instances from /app/instance/instanceAdmin.

Decision Summary

  • Add a View values.yml row action. It opens a read-only preview with Copy and Download actions.
  • Generate the YAML on the server from the complete snapshot. Do not assemble it from the paginated property table in the browser.
  • Select historical data by explicit hostId + snapshotId; do not reuse the runtime query that implicitly selects only the snapshot marked current.
  • Add Compare current snapshots to InstanceAdmin. It resolves the current snapshot for each selected instance, then opens the same comparison route with exact snapshot ids.
  • Define one canonical YAML contract for the portal query and both the Java and Rust config servers. The Java services share one codec; the Rust service implements the same contract against the same golden test vectors.
  • Let users compare two, three, or four snapshots in a semantic property matrix. Two snapshots remain the normal and simplest case.
  • Offer a literal side-by-side YAML diff only when exactly two snapshots are selected. Three or four full YAML panes are too narrow to read reliably.
  • Compare effective typed values by default and show source-only changes separately.

Pre-Implementation Baseline

This section records the baseline that motivated the design. The feature has since been implemented; the authoritative implementation and acceptance state is recorded in the two linked implementation plans and their shared 2026-07-14-config-snapshot-live-acceptance.md evidence record.

portal-view/src/pages/snapshot/ConfigSnapshot.tsx renders a server-paginated Material React Table. It calls lightapi.net/config/getConfigSnapshot/0.1.0, defaults the current filter to true, and identifies each row by instanceId + snapshotId. Existing row actions update or delete the header and navigate to the effective property, file, deployment, API, app, app-api, instance, environment, product, and product-version snapshot tables.

portal-view/src/pages/instance/InstanceAdmin.tsx is also server-paginated. Its rows already contain hostId, instanceId, instanceName, serviceId, and envTag, and each row has a Snapshot action that opens the snapshot page for that instance. It does not currently support row selection. Its current field belongs to the instance record and is not a config snapshot id or proof that config_snapshot_t contains a current row.

Snapshot creation calls the PostgreSQL create_snapshot procedure. The procedure copies the raw override levels and materializes one effective row per snapshotId + configPhase + configId + propertyId in config_snapshot_property_t. Each effective row records:

  • config_phase
  • config_id and property_id
  • property_name and property_type
  • property_value and value_type
  • source_level

The Java light-config-server already turns current snapshot rows into YAML. Its snapshot query joins config_snapshot_property_t to config_t, emits the key as configName.propertyName for Config properties, orders by the emitted key, parses values according to value_type, and writes a # source_level comment before each entry. However, that query locates a snapshot indirectly with current = true, serviceId, and environment. It cannot output a specific historical row selected in portal-view.

The Rust config server in portal-service/apps/config-server independently exposes /config-server/configs. It loads current snapshot rows through portal-service/crates/portal-core, then currently formats each property_value directly into YAML without using the returned value_type. It cannot consume a Java shared utility class, so it requires a Rust canonical codec and cross-language parity fixtures if both config-server implementations are supported.

The current PostgreSQL schema does not enforce the resolver’s cardinality assumption. config_snapshot_t has no index beginning with (host_id, instance_id, current) and no partial uniqueness constraint for current rows. Its broader scope index begins with host_id, environment, so it does not directly cover a host/instance/current lookup. The Java persistence path attempts to clear an older current row, but its update is scoped by instance_id rather than the full host_id + instance_id identity. The database can therefore contain duplicate current rows and the resolver must not depend on application convention alone.

The existing getConfigSnapshotProperty action is not an appropriate export API. It is paginated, accepts only snapshotId, returns stored strings rather than typed values, and does not enforce the full YAML serialization contract. Using it from the browser could silently omit properties or produce values whose YAML types differ from runtime configuration.

Goals

  • Let an authorized user view, copy, and download the runtime values.yml for any current or historical configuration snapshot.
  • Make output deterministic so two semantically identical snapshots generate identical YAML and the same digest.
  • Make effective configuration drift visible without requiring users to open every snapshot property page.
  • Let users compare the current effective configuration of two to four same-service instances directly from InstanceAdmin.
  • Support the common two-snapshot comparison and useful three- or four-snapshot comparisons without making the page unreadable.
  • Keep snapshot metadata visible so users know which instance, environment, timestamp, and current state each value belongs to.
  • Preserve value types, nested maps, and list order.
  • Prevent cross-host snapshot access and avoid persisting sensitive output in browser storage.

Non-Goals

  • Do not compare raw override tables in the first release. The comparison is of the effective values.yml materialized in config_snapshot_property_t.
  • Do not include File or Cert snapshot rows in values.yml. The existing Snapshot Files action remains the inspection path for those artifacts.
  • Do not compare more than four snapshots on screen.
  • Do not edit or restore configuration from the comparison page.
  • Do not treat YAML formatting differences as configuration changes.
  • Do not replace the existing snapshot property pages.
  • Do not turn the feature into a general instance-drift comparison for files, certificates, deployments, APIs, apps, or other instance state.
  • Do not compare instances with different serviceId values in the first release.

Repository Scope

  • portal-db for current-snapshot integrity audits, remediation tooling, and the database-enforced current-row invariant
  • light-portal for the Java canonical codec and snapshot persistence contract
  • config-query for the historical snapshot output/comparison query
  • light-config-server for Java runtime values.yml parity
  • portal-service for Rust runtime values.yml parity
  • portal-view for snapshot-page and InstanceAdmin selection plus the shared output/comparison experience
  • deployment/config repositories for packaging and live verification

Why Support More Than Two Snapshots

Two snapshots answer the most common question: “what changed?” Three snapshots also have clear operational uses:

  • before, candidate, and current
  • dev, QA, and production
  • last known good, failed rollout, and repaired rollout

Four columns are still usable in a full-width property matrix and cover a typical staged rollout. Beyond four, value columns become too narrow and users lose the baseline while horizontally scrolling. Larger comparisons are better handled later as a downloadable report or drift dashboard.

The initial limit should therefore be:

ViewSupported snapshotsReason
Effective property matrix2–4Values remain scannable as dynamic table columns.
Side-by-side YAML diff2Raw text needs enough horizontal width and has a natural left/right baseline.
Download one values.yml1Produces the exact artifact for one snapshot.

values.yml Row Action

Add a row action with tooltip View values.yml. Selecting it requests the snapshot by its explicit hostId and snapshotId and opens a large dialog or right-side drawer containing:

  • snapshot timestamp, snapshot id, instance name/id, environment, service id, and whether it is current
  • configuration phase, initially fixed to runtime phase R
  • property count and SHA-256 digest
  • a read-only monospace YAML preview
  • Copy and Download buttons
  • a link back to Snapshot Properties for source-level inspection

The preview must show the entire output, not only the current table page. While loading, disable Copy and Download. If the snapshot contains no runtime Config properties, show an explicit empty-state message rather than an empty dialog that looks like a failed request.

Use a stable, filesystem-safe download name such as:

values-<instanceName>-<snapshotTs>-<snapshotId>.yml

The downloaded media type should be application/yaml;charset=utf-8. The file should end with one newline.

The download helper must remove its temporary anchor and call URL.revokeObjectURL(...) in a finally block after triggering the download. This follows the cleanup pattern already used by portal-view’s downloadJson helper and prevents repeated exports from retaining Blob URLs in the single-page application.

The snapshot procedure captures both runtime and promote phases, but values.yml is the runtime startup artifact. Phase R is therefore the first release contract. A later phase selector can expose P if operators establish a concrete promote-time verification use case.

Canonical YAML Contract

The output and comparison API must use the same typed conversion rules as the config server:

valueTypeTyped representation
stringYAML string
booleanYAML boolean
integerYAML integer
floatYAML number
mapParse stored JSON into a YAML mapping.
listParse stored JSON into a YAML sequence.

Canonicalization rules are:

  1. Include only config_phase = 'R' and property_type = 'Config'.
  2. Form the top-level key as configName.propertyName, matching the current config-server behavior.
  3. Sort top-level entries by that complete emitted key, not by property_name alone.
  4. Sort keys recursively inside map values.
  5. Preserve list order because sequence order can be semantically significant.
  6. Preserve the effective source_level as the comment immediately before its property.
  7. Use fixed block style, two-space indentation, stable quoting rules, UTF-8, and a single final newline.
  8. Reject an invalid stored value for its declared type. Do not quietly coerce it to a string or omit it.
  9. Reject duplicate emitted keys instead of letting one overwrite another.
  10. Require source_level to be non-blank and single-line before inserting it into a YAML comment.

Map key order does not change YAML meaning, but recursive sorting prevents nested map insertion order from creating noisy diffs. Source comments are useful diagnostics, but a source-comment change alone is not an effective value change.

Move or extract the current Java typed YAML conversion in ServiceConfigurationUtil into a shared canonical serializer that both light-config-server and config-query can call. A suitable home is the shared light-portal utility/domain layer already used by both Java services.

Implement the same contract in Rust, preferably beside ConfigEntry and the snapshot query in portal-service/crates/portal-core, and make portal-service/apps/config-server call it instead of formatting raw values. Because Java and Rust cannot share a runtime class, both codecs must consume the same language-neutral input/output test vectors, including expected UTF-8 YAML bytes and digest.

The fixture corpus has an explicit contract version and a machine-readable manifest containing the SHA-256 of the raw rows and expected YAML. Vendored copies live in the Java and Rust test suites so unit tests remain hermetic. A cross-repository CI job checks the copies from explicitly checked-out revisions for byte equality. Tests must not download fixtures from a mutable branch or a latest raw URL because that would make an otherwise unchanged build depend on network availability and moving remote state.

For a current snapshot, add parity tests proving the portal output and the Java and Rust config-server outputs are byte-for-byte identical. This is the strongest check that the preview represents the artifact a runtime service receives regardless of which supported config server is deployed.

Query API

Add a read action:

lightapi.net/config/getConfigSnapshotValues/0.1.0

It should retain the existing portal.r scope and use a batch-shaped request for both output and comparison:

{
  "hostId": "019...",
  "snapshotIds": ["019...", "019..."],
  "configPhase": "R",
  "include": ["entries"]
}

snapshotIds must contain between one and four unique ids. include may contain entries, yaml, or both. The output dialog requests yaml; the comparison matrix requests entries; the two-pane YAML tab loads yaml only when opened. This avoids sending both a large structured representation and a duplicate YAML string when only one is needed.

The response preserves request order:

{
  "configPhase": "R",
  "snapshots": [
    {
      "snapshotId": "019...",
      "snapshotTs": "2026-07-13T14:30:00Z",
      "instanceId": "019...",
      "instanceName": "light-gateway-dev",
      "environment": "dev",
      "serviceId": "com.networknt.light-gateway-1.0.0",
      "current": false,
      "propertyCount": 2,
      "sha256": "sha256:...",
      "entries": [
        {
          "key": "server.enableHttp",
          "value": true,
          "valueType": "boolean",
          "sourceLevel": "instance"
        },
        {
          "key": "server.httpPort",
          "value": 8080,
          "valueType": "integer",
          "sourceLevel": "default"
        }
      ]
    }
  ]
}

When yaml is requested, each snapshot object also contains the canonical yaml string. The digest is calculated from the exact UTF-8 YAML bytes.

The persistence query should join config_snapshot_t, instance_t, config_snapshot_property_t, and config_t, scope every selected id by cs.host_id = ?, and apply the same InstanceAdmin ownership predicate to instance_t for owner-scoped users. admin and host-admin retain host-wide scope. It must not require current = true. Load all requested metadata and properties in bounded batch queries rather than one query per snapshot.

Reject the whole request if any selected snapshot is missing, belongs to another host, has malformed typed data, or exceeds the configured response limit. A partial comparison can look authoritative while silently omitting a column, so partial success is unsafe here.

Snapshot-Page Selection

Enable row selection on ConfigSnapshot.tsx and add a Compare selected toolbar button. It is disabled until at least two snapshots are selected.

The current getConfigSnapshot response does not contain a property count. Extend it additively with propertyCount, defined as the count of phase R, type Config rows for that snapshot, and a response-level comparisonLimits.maxProperties sourced from the same backend configuration used by getConfigSnapshotValues. Load page counts in one aggregate query, not one count query per row.

The selection state retains propertyCount. Disable Compare selected when the sum across selected snapshots exceeds maxProperties and explain the configured limit in the button tooltip. This check is advisory: the API still enforces both property and serialized-byte limits and remains authoritative. If it returns 413 because the byte limit is exceeded or metadata changed, show an actionable error that suggests selecting fewer snapshots or downloading them individually.

The page uses server-side pagination, so selected snapshot metadata must be stored independently of the currently loaded data array. Selection should survive paging and sorting and should be cleared explicitly by the user or when the host changes. Reject a fifth selection with a short explanation.

The page currently defaults current to true, which often exposes only one snapshot per instance. When compare mode is activated with fewer than two visible candidates, show a Show snapshot history action that removes the current filter. Do not silently change a filter merely because one checkbox was selected.

Same-instance history is the default use case. Cross-instance comparison is also valuable for environment drift, so permit it when all selected snapshots have the same serviceId. Show a Cross-instance comparison banner and keep instance and environment metadata pinned above each column. Reject snapshots with different service ids in the first release; comparing unrelated services mostly produces missing-key noise and is better handled as separate exports.

All selected snapshots must belong to the signed-in host and be visible under the caller’s effective instance ownership scope. This is enforced by the server even though the list query is already scoped. A hidden snapshot id and an unknown snapshot id both return the same unavailable response.

InstanceAdmin Current-Snapshot Entry Point

Add controlled row selection and a Compare current snapshots toolbar action to /app/instance/instanceAdmin. This is a second selector for the shared comparison page, not a separate comparison implementation.

Selection rules are:

  • select two to four unique instances
  • preserve selection across server-side pagination and sorting
  • clear selection when the authenticated host changes
  • require a non-empty, identical serviceId across all selected instances
  • use only rows visible under the existing InstanceAdmin read/ownership scope
  • do not infer config snapshot availability from the instance row’s current field

Reject a fifth instance and mixed-service selections before making a resolver request. Show selected instance names, environments, and a Clear action in the toolbar. Do not silently change the page’s active/current filters when compare mode is enabled.

Current-Snapshot Resolver API

Add a read action:

lightapi.net/config/getCurrentConfigSnapshotsByInstances/0.1.0

Request:

{
  "hostId": "019...",
  "instanceIds": ["019...", "019..."],
  "configPhase": "R"
}

instanceIds must contain two to four unique ids. The handler validates the authenticated host and instance-read scope, then resolves all ids in one bounded query against config_snapshot_t and instance_t. It requires cs.current = true, phase R property counts, and one current config snapshot per selected instance. It must preserve request order and must not issue one query per instance.

Response:

{
  "resolvedAt": "2026-07-13T18:30:00Z",
  "comparisonLimits": {
    "maxProperties": 10000,
    "maxResponseBytes": 5242880
  },
  "snapshots": [
    {
      "instanceId": "019...",
      "instanceName": "light-gateway-dev",
      "serviceId": "com.networknt.light-gateway-1.0.0",
      "environment": "dev",
      "snapshotId": "019...",
      "snapshotTs": "2026-07-13T18:00:00Z",
      "propertyCount": 125
    }
  ]
}

The server revalidates that all resolved snapshots have the same non-empty serviceId; the UI check is only an early usability check. Resolution is all-or-nothing. A missing instance, missing current snapshot, multiple current snapshots, mixed service ids, ownership failure, or property-limit violation must not produce a partial set. Error responses contain ids and counts only, never configuration values.

Stable unavailable/cardinality behavior is:

  • 404 CURRENT_CONFIG_SNAPSHOT_UNAVAILABLE when an instance is missing, hidden by owner scope, or has no current snapshot
  • 409 CURRENT_CONFIG_SNAPSHOT_CARDINALITY when an instance has multiple current snapshots
  • 409 CURRENT_CONFIG_SNAPSHOT_SERVICE_MISMATCH when current snapshot and instance service ids disagree

Before enabling the resolver, add a partial unique index matching the intended identity and access path:

CREATE UNIQUE INDEX uq_config_snapshot_current_instance
ON config_snapshot_t (host_id, instance_id)
WHERE current IS TRUE;

This both supports the resolver query and guarantees at most one current snapshot per host/instance. Deployment must first audit every target database for duplicate current rows and for current snapshot rows whose service_id does not match the authoritative instance_t.service_id. Any current-row violation blocks rollout until an operator reviews and applies an idempotent, recorded remediation. Historical service-id mismatches are reported separately and are not rewritten automatically: historical snapshot metadata is evidence of the captured state and needs an explicitly approved migration if the product decides it is corrupt. Verify the final resolver with EXPLAIN (ANALYZE, BUFFERS) on representative data and require an index-backed plan without per-instance queries.

After successful resolution, navigate to:

/app/config/configSnapshotCompare?snapshotIds=<id1>,<id2>[,<id3>,<id4>]&source=current-instances

The comparison request continues to use explicit snapshot ids. This freezes the result to the snapshots that were current when the user started the comparison and keeps refresh/deep links reproducible. If one becomes non-current before or after the values request, show its normal current badge as false and offer Refresh current snapshots, which resolves the instance ids from the loaded snapshot metadata again. Never silently replace a comparison column while the user is inspecting it.

Comparison Page

Add a route such as:

/app/config/configSnapshotCompare?snapshotIds=<id1>,<id2>[,<id3>,<id4>]

Keeping the ids in the URL makes refresh and task-aware navigation reliable. The host continues to come from authenticated user context and is not trusted from the URL.

The page header contains ordered snapshot cards. Each card shows:

  • a short label (A, B, C, or D)
  • instance and environment
  • snapshot timestamp and id
  • description, snapshot type, and current badge
  • property count and digest

The snapshot list continues to show the stored userId as the authoritative audit identity. The first release does not resolve it through a user-profile service: display names can change, and profile availability must not affect snapshot inspection. A later enhancement may show a human-readable name alongside the raw userId, but must not replace or obscure the identifier.

The user can choose any selected snapshot as the baseline and reorder the cards. The initial baseline is the oldest selected snapshot, using snapshotTs and then snapshotId as a deterministic tie-breaker.

Effective Property Matrix

The default view is a table built from the union of every emitted key:

Configuration keySnapshot ASnapshot BSnapshot CStatus
server.enableHttptruefalsefalseValue changed
server.httpPort808080808080Same
oauth.tokenKeyUrlMissingPresentPresentAdded after baseline

Each value cell renders scalars compactly and maps/lists in an expandable formatted block. It also shows valueType and sourceLevel as secondary text. Use typed deep equality after canonicalizing map keys; never compare JSON or YAML strings. Equal-looking values with different types remain changes: for example, integer 1 differs from float 1.0, and string "true" differs from boolean true.

Status rules are:

  • Same: the key exists in all snapshots with the same type and value.
  • Value changed: at least two selected snapshots have different typed values or value types.
  • Missing: the key is absent from at least one selected snapshot.
  • Source changed: all effective values match but sourceLevel differs.

For three or four snapshots, the row status describes the selected set and each non-baseline cell additionally receives a same/changed/missing marker relative to the baseline.

Provide filters for All, Changed, Missing, Source changed, and Same, plus a key search. Default to Changed + Missing so the first view focuses on drift. Keep unchanged rows available because users also need to prove that a critical property did not change.

Two-Snapshot YAML Diff

When exactly two snapshots are selected, add a YAML diff tab with synchronized left and right panes. Both panes use canonical YAML, so line differences are meaningful. Support wrapping, next/previous difference, copy, and download.

Use the official CodeMirror MergeView API from @codemirror/merge as the preferred implementation because the application already uses CodeMirror 6 and the YAML language extension. Configure both editors as read-only, set bounded scanLimit and timeout diff options for large or highly divergent files, lazy-load the merge module with the YAML tab, and call destroy() when the tab unmounts. Phase 0 still verifies accessibility, license, bundle impact, and compatibility before freezing the dependency.

Do not show this tab for three or four snapshots. The property matrix is the side-by-side representation for those counts.

Security and Privacy

Snapshot values can contain credentials and other sensitive configuration.

  • Enforce both authorization layers: gateway endpoint policy requires portal.r and one of admin, config-admin, or config-viewer; the handler then validates authenticated host access and applies InstanceAdmin owner scope for every requested snapshot or instance. admin and host-admin are the owner-scope bypass roles at the persistence layer.
  • Return Cache-Control: no-store through the portal query path.
  • Do not write YAML, typed values, or comparison responses to application logs.
  • Do not store selected values or YAML in localStorage, sessionStorage, task context, or analytics events.
  • Store only snapshot ids in the URL.
  • Make Copy and Download explicit user actions.
  • Preserve normal portal session expiry and CSRF behavior.

If fine-grained config-read permissions are introduced later, this endpoint must use the same effective permission as the runtime snapshot property view, not a weaker generic page permission.

Performance and Limits

  • Cap the request at four snapshots.
  • Apply the proposed 10,000-property cap to the total entries across the request, not independently to each snapshot.
  • Batch metadata and property loading.
  • Return only requested representations through include.
  • Enable HTTP compression for JSON/YAML responses.
  • Virtualize or paginate the rendered comparison rows in the browser, but build the comparison from the complete server response.
  • Abort an in-flight request when the user changes selection or leaves the page.
  • Enforce a configurable property-count and serialized-response-size limit and return an explicit error instead of truncating.
  • Build the sorted key union, typed deep comparisons, and row classifications in a module Web Worker. Use request generations, ignore stale replies, and terminate the worker when the request changes or the page unmounts.
  • Keep React rendering, progress/error state, and the virtualized matrix on the main thread. useTransition may lower the priority of committing a completed result or changing filters, but it does not replace the worker for the comparison calculation itself.

The server must never paginate the data used to calculate a comparison. A rendered table may paginate after the full key union is known.

Implementation Areas

light-portal

  • Add a host-scoped persistence method that loads one to four snapshots by explicit id, including metadata and effective Config rows.
  • Add the method to PortalDbProvider and PortalDbProviderImpl.
  • Extract the canonical typed YAML serializer into a shared utility/domain class.
  • Add a host-scoped batch resolver that maps two to four instance ids to exactly one current snapshot each and returns runtime property counts in request order.
  • Scope current-snapshot reset/update statements by both host_id and instance_id so the write path matches the database identity.

portal-db

  • Add pre-deployment audits for duplicate current rows and current/historical snapshot-to-instance service_id mismatches.
  • Add an operator-reviewed, idempotent remediation path for blocking current rows; do not silently choose a duplicate winner or rewrite history.
  • Add the partial unique current-snapshot index to baseline DDL and an additive patch, with a deployment choice between concurrent creation and a maintenance window based on table size and patch-runner transaction constraints.
  • Add schema and query-plan gates proving duplicate current rows are rejected and the resolver lookup uses the new index.
  • Register both additive query actions in the endpoint/access-control catalog, mirroring the established config-query request-access rule and config roles.

light-config-server

  • Update the Java config-server path to use the shared Java serializer.
  • Run the shared golden test vectors through the Java runtime path.

portal-service

  • Add the Rust implementation of the canonical typed YAML contract, preferably in crates/portal-core beside the snapshot row model.
  • Update apps/config-server to use the Rust codec instead of interpolating raw property_value strings.
  • Run the same golden test vectors through the Rust runtime path.

config-query

  • Add GetConfigSnapshotValues and register lightapi.net/config/getConfigSnapshotValues/0.1.0 in spec.yaml.
  • Validate snapshot count, duplicates, phase, include, and host ownership.
  • Return ordered snapshot objects and stable error responses.
  • Add GetCurrentConfigSnapshotsByInstances, reuse the comparison limit model, enforce host/instance-read scope, and return only resolver metadata.

portal-view

  • Add a values.yml row action, preview component, copy helper, and YAML download helper.
  • Add cross-page row-selection state with a four-snapshot cap.
  • Add the Compare selected toolbar action and Show snapshot history helper.
  • Add ConfigSnapshotCompare.tsx, its route, task/page registry metadata, and contextual help registration.
  • Add reusable typed-value and source-change comparison helpers.
  • Add cross-page InstanceAdmin selection, same-service validation, the current-snapshot resolver call, and navigation into the shared comparison route.

No new table or column is required because effective typed values and snapshot metadata are already materialized. The dependent cross-instance workflow does require the partial unique current-snapshot index above; the per-snapshot output/comparison workflow can ship independently of that index.

Testing

Backend

  • An explicit historical snapshotId is returned even when current = false.
  • A snapshot outside hostId is rejected.
  • One to four ids are accepted in request order; zero, five, duplicates, and unknown ids are rejected.
  • Only runtime Config rows are emitted.
  • Top-level and nested map keys are stable and sorted while list order is preserved.
  • String, boolean, integer, float, map, and list values retain their types.
  • Integer 1 and float 1.0, and string "true" and boolean true, remain distinct typed values.
  • Invalid typed values and duplicate emitted keys fail loudly.
  • Source comments are emitted deterministically.
  • The current snapshot output is byte-for-byte equal to both supported config-server implementations’ values.yml output.
  • Batch loading does not execute one property query per selected snapshot.
  • Snapshot list counts include only phase R Config rows and are loaded without an N+1 query.
  • The instance resolver returns one ordered current snapshot per selected instance and rejects missing, duplicate-current, mixed-service, cross-host, and unauthorized selections without partial output.
  • The database rejects a second current snapshot for the same host/instance while allowing current rows for different host/instance pairs.
  • The resolver’s representative EXPLAIN (ANALYZE, BUFFERS) plan uses the partial current-snapshot index and the resolver remains free of N+1 queries.
  • Pre-deployment audits classify duplicate current rows and current versus historical service-id mismatches without mutating data by default.

Frontend

  • The row action requests the clicked snapshot id, including a historical row.
  • Preview loading, empty, error, Copy, and Download states work.
  • The download has the expected filename, MIME type, and bytes.
  • Selection survives server-side paging and rejects a fifth snapshot.
  • Selection proactively blocks a total over the server-provided property cap, while a later authoritative 413 still produces actionable guidance.
  • Show snapshot history removes only the current filter.
  • Different-service selections are rejected; same-service cross-instance selections show a warning.
  • Two, three, and four snapshot matrices use the complete key union.
  • Value, missing, and source-only changes are classified correctly.
  • Map key order does not create a false change and list order does.
  • Equal-looking cross-type values are marked changed and display their types.
  • Worst-case permitted comparison computation runs in the worker, ignores stale replies, and terminates on selection change or unmount.
  • The YAML diff tab appears only for exactly two snapshots.
  • The merge view is read-only, uses bounded diff work, and is destroyed on unmount.
  • Every download revokes its Blob URL and removes its temporary anchor on success or failure.
  • A comparison URL restores selection after refresh without storing values.
  • InstanceAdmin selection survives paging, rejects mixed services and a fifth instance, and never treats the instance current flag as snapshot state.
  • Current-instance resolution navigates with exact snapshot ids and an source=current-instances marker.
  • A snapshot that becomes non-current remains visible as the resolved artifact; refresh requires an explicit user action.

Acceptance Criteria

  • Every row on /app/config/configSnapshot can output a complete, sorted, type-correct runtime values.yml for that exact snapshot.
  • Output for a current snapshot matches what both supported config-server implementations serve for the same service and environment.
  • Users can select two to four snapshots and compare all effective keys in one view.
  • Oversized selections are rejected proactively when counts prove they exceed the property cap, without weakening the API’s count and byte-limit checks.
  • Users can select two to four same-service instances in InstanceAdmin and compare the snapshots that were current when comparison started.
  • The database enforces at most one current config snapshot per host_id + instance_id, and resolver rollout is blocked by unresolved current-row integrity violations.
  • Two selected snapshots can also be inspected as a canonical side-by-side YAML diff.
  • The comparison clearly distinguishes value changes, missing keys, and source-only changes.
  • Requests cannot read snapshots from another host or outside the caller’s effective instance ownership scope, and no sensitive value is persisted in browser storage.

Rollout

  1. Freeze the canonical contract and cross-language golden test vectors.
  2. Add the shared Java serializer, equivalent Rust serializer, explicit snapshot query, parity tests, and the getConfigSnapshotValues action.
  3. Add the per-row values.yml preview, Copy, and Download actions.
  4. Add selection and the two-snapshot property matrix/YAML diff.
  5. Enable the already-designed third and fourth matrix columns after the same comparison tests pass with dynamic columns.
  6. Audit current-snapshot integrity in each target database, apply reviewed remediation for blocking current rows, deploy the partial unique index, and verify the resolver query plan.
  7. Add InstanceAdmin current-snapshot resolution and selection after the shared comparison route, backend values API, and database gates have passed.
  8. Apply portal-db/postgres/patch_20260714_02_config_snapshot_compare_endpoints.sql to register both actions in the portal endpoint/access-control catalog, run normal syncConfigInstanceApi for config API LPS1120/1.0.0 on each affected gateway, create and promote a new gateway configuration snapshot, and restart or reload the gateway so its effective endpoint rules contain both actions.
  9. Deploy the portal bundle to the actual served/mounted dist and verify its asset hash before acceptance.

The API and selection cap support four snapshots from the beginning, even if the UI rollout enables two first. This avoids an API redesign while still allowing the simpler two-snapshot experience to be validated independently.

Rust Controller Logging

The controller service dashboard already has a logger page for Java runtimes. That page is built around Logback concepts: named loggers, per-logger levels, historical log content, and live streaming through controller-mediated MCP tools. Rust products need a similar operator workflow, but the underlying logging model is different. Rust services use tracing targets and one runtime logging.filter expression instead of mutable Logback logger objects.

This document proposes a Rust-aware logger page for gateway, agent, API, deployer, and workflow runtimes:

  • gtw: light-gateway
  • agt: light-agent
  • api: Rust API services built on light-axum or light-runtime
  • dpl: light-deployer
  • wf: light-workflow

The goal is to keep the existing controller page entry point while switching the page behavior based on runtime capabilities.

Current State

portal-view has a unified controller logger page at /app/controller/logger. The page receives a runtime instance from the control pane dashboard and uses controller MCP tools:

  • get_loggers
  • set_loggers
  • get_log_content
  • start_logs
  • stop_logs

Those contracts work for Java services where the runtime can inspect and update Logback logger levels.

Rust services already expose live logging filter control through the light-runtime MCP handler:

  • get_logging_filter
  • set_logging_filter
  • reload_modules with modules: ["runtime/logging"]

The tested config server baseline is:

logging.filter: info,light_pingora::security=debug

This keeps the process at info by default and enables debug only for the gateway security target.

Goals

  • Provide one operator page for Rust log filter control, time-based history, and live streaming.
  • Reuse the controller-mediated MCP path instead of adding direct browser access to runtime instances.
  • Preserve the existing Java logger page behavior.
  • Use Rust tracing vocabulary in the UI: target, level, filter expression, and source.
  • Let operators build common filters without memorizing module paths.
  • Keep an advanced filter input for exact EnvFilter expressions.
  • Support time-range log lookup from the running process.
  • Support live log streaming through notifications/log.
  • Make reset behavior explicit: live filter changes are temporary unless the instance configuration is updated separately.

Non-Goals

  • Do not replace Java Logback logger management.
  • Do not make the browser connect directly to pods, services, or container runtimes.
  • Do not keep historical logs in portal-view or controller memory.
  • Do not store full authorization headers, tokens, cookies, request bodies, or other secrets in log files, log responses, or live stream payloads.

Runtime Detection

The logger page should select the Rust experience when either condition is true:

  • the selected runtime instance advertises product type gtw, agt, api, dpl, or wf
  • the runtime MCP tools/list or controller tool discovery includes get_logging_filter

If detection is uncertain, portal-view can attempt get_logging_filter and fall back to the Java logger page if the response says logging control is not available.

The page should show a capability banner when a selected runtime supports only some features:

CapabilityRequired runtime support
Filter controlget_logging_filter, set_logging_filter, reload_modules
Historyget_log_content backed by a JSON log file or platform log provider
Live streamstart_logs, stop_logs, notifications/log

Page Layout

Use the current logger page route and high-level structure, but render Rust content when the selected instance is Rust.

Header:

  • service label
  • runtime instance ID
  • service ID
  • product type
  • address and port
  • connection status
  • logging capability status

Tabs:

  • Filter
  • History
  • Live Stream

The Java page can keep Config, History, and Live Stream; the Rust page uses Filter instead of Config because the operator edits one tracing filter expression, not a list of Logback logger objects.

Filter Tab

The filter tab controls the active runtime logging.filter.

Controls:

  • current effective filter
  • filter source, such as values.yml:logging.filter, env:RUST_LOG, or mcp:set_logging_filter
  • default level selector
  • target rows for common Rust modules
  • advanced filter text area
  • Apply Live
  • Reset From Config

Levels:

  • error
  • warn
  • info
  • debug
  • trace
  • off

Recommended default level is info.

Example generated filter:

info,light_pingora::security=debug

Apply flow:

operator changes target rows
  -> portal-view builds EnvFilter expression
  -> controller calls runtime set_logging_filter
  -> runtime validates and applies the filter immediately
  -> portal-view refreshes get_logging_filter

Reset flow:

operator clicks Reset From Config
  -> controller calls reload_modules with runtime/logging
  -> runtime reloads logging.filter from current resolved values
  -> portal-view refreshes get_logging_filter

Baseline changes are handled outside this page. If an operator wants the filter to survive restart or reset, they should update the selected instance configuration, for example:

logging.filter: info,light_pingora::security=debug

Target Presets

The advanced filter must accept any valid Rust tracing target. The module picker should be backed by reference data so new targets can be added without a portal-view deployment.

Portal-view should load the dropdown from:

/r/data?name=logging_target

Recommended reference table mapping:

Reference fieldLogging target use
ref_table_t.table_namelogging_target
ref_value_t.value_codeexact Rust tracing target, such as light_pingora::security
value_locale_t.value_descdropdown label and short operator-facing description
ref_value_t.display_orderstable dropdown order
ref_value_t.activeretire a target without deleting the row

The simplest page can load all active targets from /r/data?name=logging_target and group them client-side by product. If product-specific filtering is needed later, add a reference relation such as logging-target-product that links each target to common, gtw, agt, api, dpl, or wf. Operators can still type a custom target if the target is not present in the reference table.

Suggested seed data:

Common targets:

TargetUse
light_runtimebootstrap, config loading, reload, controller registration
light_clientoutbound HTTP and OAuth client support
portal_registrycontrol-plane websocket registration
reqwestoutbound HTTP client internals
hyper_utilconnection and pooling internals
rustlsTLS handshakes and certificates
tungstenitewebsocket handshake and frames

Gateway targets:

TargetUse
light_gatewaygateway application and proxy glue
light_pingorashared Pingora framework code
light_pingora::securityJWT validation and JWK loading
light_pingora::unified_securityunified auth routing
light_pingora::mcpMCP router and backend MCP calls
light_pingora::handlerhandler duration diagnostics
light_pingora::pii_tokenizationtokenization runtime warnings
pingora_corePingora server and protocol lifecycle
pingora_proxyPingora proxy request handling

Agent targets:

TargetUse
light_agentagent HTTP server and session handling
model_providermodel-provider calls and fallback routing
mcp_clientoutbound MCP client requests

API targets:

TargetUse
light_axumHTTP transport and axum integration
light_runtimeshared runtime modules
service crate targetAPI-specific handlers, using the crate name with hyphens converted to underscores

Deployer targets:

TargetUse
light_deployerdeployment workflow and git/Kubernetes operations
light_runtimeshared runtime modules

Workflow targets:

TargetUse
light_workflowworkflow engine, consumers, and task executor
workflow_coreworkflow model and shared core logic
light_rulerule execution
model_providermodel-provider calls
mcp_clientMCP tool calls

The UI can also learn targets from returned history and live rows. Any target seen in logs can become a temporary suggestion for that browser session, but the authoritative dropdown source is the logging_target reference table.

History Tab

The history tab fetches logs from the running application for a time range.

Controls:

  • presets: last 5, 10, 30, and 60 minutes
  • required start time
  • optional end time
  • minimum level
  • optional target filter
  • text search
  • result limit

Request:

{
  "runtimeInstanceId": "019...",
  "startTime": "2026-06-17T21:30:00Z",
  "endTime": "2026-06-17T21:45:00Z",
  "loggerLevel": "debug",
  "loggerName": "light_pingora::security",
  "limit": 1000
}

For compatibility, loggerName maps to the Rust target and loggerLevel maps to the minimum tracing level. The controller can keep the existing get_log_content tool name.

Recommended normalized row shape:

{
  "timestamp": "2026-06-17T21:37:43.147463Z",
  "level": "DEBUG",
  "logger": "light_pingora::security",
  "target": "light_pingora::security",
  "message": "JWT validation failed after JWKS refresh: InvalidSignature",
  "fields": {
    "error": "InvalidSignature"
  }
}

The response can preserve the current grouped shape for compatibility:

{
  "content": {
    "light_pingora::security": {
      "logs": [
        {
          "timestamp": "2026-06-17T21:37:43.147463Z",
          "level": "DEBUG",
          "message": "JWT validation failed after JWKS refresh: InvalidSignature"
        }
      ]
    }
  }
}

Portal-view should flatten the grouped response into rows, as the current Java page already does.

History source selection:

  1. If a JSON log file is configured, parse that file first. This should be the preferred source because the same file can be collected by Splunk or another logging system.
  2. If no log file is configured, use Kubernetes pod logs or container logs when the controller/runtime environment can access them.
  3. If neither source is available, return an explicit unsupported response.

The browser must not read Kubernetes or container logs directly. The controller or runtime-side tool should own that platform access and return the normalized row shape above.

When reading a JSON log file, the reader should filter by timestamp, level, target, and text search. If the file format is line-oriented JSON, each line should contain at least timestamp, level, target, and message.

Live Stream Tab

The live stream tab starts and stops log streaming for the selected runtime instance.

Controls:

  • full filter expression
  • start
  • stop
  • clear
  • auto-scroll toggle
  • bounded client buffer
  • stream status

Request:

{
  "runtimeInstanceId": "019...",
  "filter": "info,light_pingora::security=debug"
}

start_logs should accept the full Rust filter expression because this is the syntax Rust operators already use. For backward compatibility, the controller can still accept level and loggerName, then translate them into a filter expression.

The stream filter controls which events are sent to that stream subscription. It must not change the process-wide logging.filter; process-wide changes still go through set_logging_filter. Because tracing filters can suppress events before stream filtering sees them, the UI should warn when the stream filter is more verbose than the current active runtime filter.

Notification:

{
  "method": "notifications/log",
  "params": {
    "runtimeInstanceId": "019...",
    "timestamp": "2026-06-17T21:37:43.147463Z",
    "level": "DEBUG",
    "logger": "light_pingora::security",
    "target": "light_pingora::security",
    "message": "JWT validation failed after JWKS refresh: InvalidSignature"
  }
}

The portal-view live buffer should remain bounded. The current 1000-row FIFO buffer is a good default.

Each browser/controller session must have its own stream subscription. Starting a stream from one operator must not replace another operator’s stream for the same runtime instance.

Runtime Implementation

Add shared Rust logging support to light-runtime, not separately in every product.

Recommended components:

  • LoggingControl: existing active EnvFilter control.
  • JsonLogWriter: optional line-oriented JSON file writer for services that need historical lookup or Splunk ingestion.
  • LogFileReader: reads and filters configured JSON log files.
  • PlatformLogProvider: controller-side or runtime-side abstraction for Kubernetes pod logs and container logs when no log file is configured.
  • LogStreamHub: per-client subscriptions for live streaming.
  • LogRecord: normalized timestamp, level, target, message, fields, and optional span/correlation fields.

Recommended runtime MCP tools:

ToolPurpose
get_logging_filterReturn current Rust filter and source.
set_logging_filterValidate and apply a live filter expression.
get_log_contentReturn log rows from JSON file or platform log provider by time range, level, and target.
start_logsStart live log notifications for one controller client with a full filter expression.
stop_logsStop live log notifications for one controller client.
reload_modulesReset runtime/logging from resolved config values.

The JSON log file should be configurable:

logging.file.enabled: true
logging.file.path: /var/log/light-gateway/app.log
logging.file.format: json
logging.file.maxBytes: 104857600
logging.file.maxFiles: 10
logging.stream.maxSubscribers: 20

Defaults should be conservative. If no JSON log file and no platform log provider are available, get_log_content should return a clear unsupported response instead of an empty success that looks like there were no logs.

Controller Changes

The controller should expose Rust logging tools through the same callTool path used by the existing logger page.

Add or pass through these tool names:

  • get_logging_filter
  • set_logging_filter
  • reload_modules
  • get_log_content
  • start_logs
  • stop_logs

For Rust runtimes, get_loggers and set_loggers are not the primary control surface. The UI should use get_logging_filter and set_logging_filter instead. The controller may keep get_loggers and set_loggers for Java compatibility.

The controller should route notifications/log back to the portal-view websocket with the originating runtimeInstanceId so the page can ignore logs from other selected services.

For history, the controller should resolve sources in this order:

  1. configured JSON log file
  2. Kubernetes or container log provider
  3. unsupported response with a clear reason

Portal-View Implementation

Recommended structure:

  • keep /app/controller/logger as the route
  • keep the existing Logger component as the shell
  • split Java and Rust behavior into child panels:
    • JavaLoggerPanel
    • RustLoggerPanel
  • reuse the current history and live table rendering where possible
  • add a Rust filter builder for logging.filter

Rust filter builder state:

type RustFilterDraft = {
  defaultLevel: "error" | "warn" | "info" | "debug" | "trace" | "off";
  targets: Array<{ target: string; level: string }>;
  advanced: string;
  mode: "builder" | "advanced";
};

In builder mode, portal-view generates the expression:

<defaultLevel>,<target>=<level>,<target>=<level>

In advanced mode, portal-view sends the text exactly as entered and lets the runtime validate it.

The page should show a warning when the current source is mcp:set_logging_filter, because that indicates a live override that can be lost on restart or reset by reloading runtime/logging.

Baseline Configuration

Live debug changes should call set_logging_filter; they should not update config server by default.

To persist a baseline filter, the operator should use the instance configuration page and update:

logging.filter: info,light_pingora::security=debug

After saving the instance configuration, the config update flow can call:

{
  "name": "reload_modules",
  "arguments": {
    "modules": ["runtime/logging"]
  }
}

This makes the saved config the active baseline. Alternatively, the operator can return to the logger page and use Reset From Config to reload only runtime/logging.

The Rust logger page can link to the selected instance configuration, but it should not write baseline config itself.

Security And Safety

  • Gate filter changes and log access behind the same controller permissions as the Java logger page.
  • Treat logs as sensitive operational data.
  • Do not render raw ANSI escape sequences as HTML.
  • Truncate very large messages and expose an expand action.
  • Mask obvious token and secret fields in JSON log output, history responses, and live stream payloads.
  • Rate-limit live streams per runtime instance and per controller client.
  • Show a warning before enabling broad trace filters.

Rollout Plan

  1. Add controller pass-through for get_logging_filter and set_logging_filter.
  2. Add RustLoggerPanel in portal-view with filter control only.
  3. Add JSON file logging and a get_log_content reader for Rust services.
  4. Add Kubernetes/container log fallback when no log file is configured.
  5. Add Rust start_logs and stop_logs backed by per-client stream subscriptions.
  6. Seed the logging_target reference data and load dropdown options from /r/data?name=logging_target.
  7. Enable product-specific target presets for gtw, agt, api, dpl, and wf.

Resolved Decisions

  • Historical logs are not kept in memory. Use a configured JSON log file first; if there is no file, fall back to Kubernetes or container logs when they are available.
  • start_logs accepts a full filter expression. Compatibility fields such as level and loggerName can be translated by the controller.
  • The module dropdown is backed by the logging_target reference table exposed through /r/data?name=logging_target.
  • The logger page does not save a baseline. Baseline changes belong in instance configuration.

Human Task UI

ask is the workflow task type that pauses execution for human input. The runtime can now create task_asst_t and worklist_t rows when an ask task waits, so portal-view needs a generic human-task interface that lets an assigned user open the task, provide the requested answer, and resume the workflow.

This document proposes the portal UI and service contracts for that interface.

Current State

The workflow engine persists waiting ask tasks in task_info_t. The ask configuration is stored in task_info_t.task_output.ask, and the workflow runtime context remains on process_info_t.context_data.

The assignment layer is separate:

  • worklist_t represents a user/category worklist.
  • task_asst_t represents a concrete task assignment.
  • Role assignment is resolved by the workflow runtime into one task assignment per active user in the role.

The Worklist page can show assigned tasks, but it does not yet provide a generic input screen for the user to approve or enter data.

Goals

  • Provide one generic page for all human-input workflow tasks.
  • Render the input controls from the ask task definition, not from a workflow-specific page.
  • Let users open tasks from the Worklist page.
  • Keep assignment, claim, completion, and authorization checks on the service side.
  • Support role-assigned tasks where multiple users may receive the same work.
  • Resume the workflow through the existing completeTask command.
  • Keep the UI useful for approval tasks first while leaving room for richer object-input tasks.

Non-Goals

  • Do not create a custom approval page for each workflow.
  • Do not make every workflow task human-actionable; only ask tasks use this interface.
  • Do not expose raw database rows directly to the page.
  • Do not overload the engine locked field for human claims; that field is already used by the workflow executor as a worker lease.
  • Do not replace the existing Worklist administration page in the first phase.

User Flow

The primary flow is:

Worklist
  -> open assigned task
  -> Human Task detail
  -> render prompt and input controls from ask metadata
  -> submit answer
  -> completeTask command
  -> workflow executor resumes the process

For a simple approval workflow, the user sees the prompt and two action buttons derived from the ask options. For structured input, the same page renders a schema-driven form.

The runtime flow for a role-assigned ask task is:

  1. The workflow executor creates one task_asst_t row per assignee and keeps the parent task_info_t row waiting for input.
  2. The assigned user opens the row from Worklist.
  3. getHumanTask loads the assignment, task state, workflow metadata, and process context into one stable page payload.
  4. The user submits an answer through completeTask.
  5. completeTask validates assignment ownership, locks the parent task row, records the result, and deactivates or cancels sibling assignments in the same transaction.
  6. The workflow executor observes the completed ask task and resumes the process with the submitted answer.

Route Design

Add a human task detail route:

/app/workflow/HumanTask

The route should accept taskAsstId and task context through query parameters or router state:

/app/workflow/HumanTask?taskAsstId=...&taskId=...

The Worklist page should route to this detail page for actionable task rows. Worklist administration actions such as create, update, and delete worklists should remain separate from human task completion.

If a dedicated inbox page is needed later, add:

/app/workflow/HumanTasks

That page can list only actionable assigned tasks, while the existing Worklist page can remain the administrative view of worklist definitions.

Data Model Decisions

task_info_t remains the canonical workflow engine task state. Its locked column must stay reserved for executor leasing. Human task claims must not set task_info_t.locked = 'Y', because that would make the executor treat the row as worker-owned runtime work.

Useful existing task_info_t fields for the human task page are:

  • status_code: parent task state. Waiting ask tasks should be open for input; completed ask tasks should be read-only.
  • deadline_ts: optional due or expiry timestamp to show in the UI.
  • locking_user and locking_role: possible global claim metadata if claim is implemented, but not a replacement for assignment-level authorization.
  • task_output: source of the ask metadata.
  • result_code: submitted answer envelope after completion.

task_asst_t remains the assignment layer. The current active flag and unassigned_reason can hide completed assignments from Worklist, but they are too loose to represent claim, release, expiry, and reporting states cleanly. Add an explicit assignment status early in development so the query and command contracts are built on the final assignment state model:

ALTER TABLE task_asst_t
  ADD COLUMN status_code VARCHAR(16) NOT NULL DEFAULT 'ASSIGNED',
  ADD COLUMN claimed_by VARCHAR(126),
  ADD COLUMN claimed_ts TIMESTAMP WITH TIME ZONE,
  ADD COLUMN claim_expires_ts TIMESTAMP WITH TIME ZONE;

Recommended assignment statuses:

StatusMeaning
ASSIGNEDVisible and actionable for the assignee.
CLAIMEDClaimed by one assignee and locked from sibling submissions.
COMPLETEDCompleted by this assignee.
RELEASEDPreviously claimed and returned to the pool.
CANCELLEDNo longer actionable because the parent task ended elsewhere.
EXPIREDNo longer actionable because the task or claim timed out.

Keep active as a fast visibility/backward-compatibility flag. Use status_code for business state and audit/reporting semantics.

Query Contract

The UI should not assemble a human task by calling several generic table queries. Add a normalized query action such as getHumanTask.

Request:

{
  "hostId": "...",
  "taskAsstId": "..."
}

Response:

{
  "hostId": "...",
  "taskAsstId": "...",
  "taskId": "...",
  "processId": "...",
  "wfInstanceId": "...",
  "wfTaskId": "requestApproval",
  "assignedTs": "...",
  "assigneeId": "...",
  "assignmentStatusCode": "ASSIGNED",
  "claimedBy": null,
  "claimedTs": null,
  "deadlineTs": "2026-05-23T14:30:00Z",
  "categoryCode": "approval",
  "reasonCode": "human-approval",
  "taskStatusCode": "W",
  "workflow": {
    "wfDefId": "...",
    "namespace": "light-portal",
    "name": "human-approval",
    "version": "1.0.0"
  },
  "ask": {
    "prompt": "Review the workflow request and choose a decision.",
    "mode": "approval",
    "options": [
      {
        "label": "Approve",
        "value": "APPROVED",
        "description": "Continue the request."
      },
      {
        "label": "Reject",
        "value": "REJECTED",
        "description": "Stop the request."
      }
    ],
    "required": true,
    "allowComment": true,
    "contextKeys": ["requestId", "summary"]
  },
  "contextSummary": {
    "requestId": "REQ-001",
    "summary": "..."
  },
  "context": {
    "requestId": "REQ-001",
    "summary": "..."
  }
}

The service should read from task_asst_t, task_info_t, process_info_t, and wf_definition_t, then return a stable task-detail view. The UI should treat this response as the source of truth.

The query should return a curated contextSummary when the ask metadata defines contextKeys. It may also include the raw context object for administrator troubleshooting or for workflows that have not yet declared a curated context shape. The default user view should prefer contextSummary.

For a list page, add getHumanTaskList later. It should return only active assignments for the current user unless the caller has an administrative permission.

Input Rendering

The page renders controls from ask.mode, ask.options, and ask.schema.

Ask modeControl
approvalPrimary action buttons from options, with an optional comment field.
confirmYes/No control.
choiceRadio group or select from options.
multiChoiceCheckbox group from options.
textText area.
objectSchema-driven form from ask.schema.
fileFuture upload control.

If ask.mode is missing, default to text. If approval has no options, the UI may render default APPROVED and REJECTED actions.

Comments should be configurable per ask task. The recommended metadata is:

{
  "allowComment": true,
  "commentRequired": false
}

approval and confirm should default to allowing comments. Other modes can opt in when the workflow author wants users to explain the submitted value.

Answer Shape

Use a consistent answer envelope for completeTask.

{
  "value": "APPROVED",
  "comment": "Looks good.",
  "submittedAt": "2026-05-22T14:30:00Z"
}

For object input, value is the submitted object:

{
  "value": {
    "approvedLimit": 5000,
    "expirationDate": "2026-06-30"
  },
  "comment": "Approved with a reduced limit.",
  "submittedAt": "2026-05-22T14:30:00Z"
}

The workflow receives this object as the ask task output. A workflow that needs only the selected value can export .output.value; a workflow that wants the full audit envelope can export .output.

Completion Command

The detail page submits through completeTask:

{
  "host": "lightapi.net",
  "service": "workflow",
  "action": "completeTask",
  "version": "0.1.0",
  "data": {
    "hostId": "...",
    "taskId": "...",
    "taskAsstId": "...",
    "statusCode": "C",
    "completedTs": "2026-05-22T14:30:00Z",
    "response": {
      "value": "APPROVED",
      "comment": "Looks good.",
      "submittedAt": "2026-05-22T14:30:00Z"
    }
  }
}

The command should verify that:

  • the assignment exists and is active
  • the assignment status allows submission
  • the current user is the assignee or has an administrative permission
  • the task is an ask task
  • the task is still waiting for input
  • the submitted answer matches ask.mode, ask.options, and ask.schema

The browser may send taskAsstId, taskId, and the answer, but it must not be trusted to identify the completing user. The command service should derive the user id and roles from the authenticated token. A client-supplied completedUser value should be ignored for normal human-task completion.

Completion must be atomic. In one database transaction:

  1. Load the task_asst_t row and verify it belongs to the current user, unless the caller has an explicit administrative override permission.
  2. Lock the parent task_info_t row, for example with SELECT ... FOR UPDATE.
  3. Reject the command if the parent task is already completed or no longer waiting for input.
  4. Validate the answer against the ask metadata and, for object mode, the JSON schema.
  5. Update task_info_t with status C, completed_ts, completed_user, and the answer envelope in result_code.
  6. Mark the selected assignment COMPLETED and inactive.
  7. Mark sibling active assignments for the same task_id as CANCELLED, inactive, and unassigned_reason = 'completed_by_other_user'.

If another user completes the same parent task first, return a stale-task conflict response, preferably HTTP 409, and leave the duplicate submission unapplied.

Claim And Concurrency

Role assignment can create several active assignments for the same task_id. The first user-input page should use optimistic completion with a server-side final check: only the first valid completion succeeds, and later submissions receive a stale-task conflict response. This proves the core flow before adding the operational complexity of explicit claim/release commands.

For a better user experience, add an optional claimHumanTask command.

Recommended claim behavior:

  • claim records the current user on the human-task assignment path with task_asst_t.status_code = 'CLAIMED', claimed_by, and claimed_ts
  • claim does not set task_info_t.locked = 'Y'
  • claim expires after a short timeout or can be released
  • completion still performs the final status check

The engine locked column should remain reserved for executor leasing. Human claims should use either assignment-specific fields added later or locking_user/locking_role without changing the executor lease flag.

When claim is enabled for role-assigned tasks, sibling assignment rows should be visible as claimed or unavailable instead of letting users submit stale answers. Live refresh should use the existing portal notification channel if one is available. If workflow tasks need their own lightweight channel later, prefer server-sent events before adding a separate websocket service.

Assignment Cleanup

When a human task is completed:

  • the selected assignment should no longer appear as actionable
  • sibling active assignments for the same task_id should also disappear
  • the task completion result should remain on task_info_t

With the current table shape, the minimal implementation can deactivate active task_asst_t rows for the task and set unassigned_reason to completed or completed_by_other_user. A later schema iteration can add explicit assignment status fields if the UI needs richer assignment history.

With the recommended status column, cleanup should use structured states:

  • selected assignment: status_code = 'COMPLETED', active = false, unassigned_reason = 'completed'
  • sibling assignments: status_code = 'CANCELLED', active = false, unassigned_reason = 'completed_by_other_user'

Authorization

Normal users should only query and complete assignments where task_asst_t.assignee_id matches their user id. Administrative users may view all assignments for the host if the workflow task endpoints allow it.

The browser should send taskAsstId, but the service should not trust the browser to identify the assignee. It should resolve the current user from the authenticated token and compare it to the assignment row.

Administrative completion on behalf of another user should require a distinct permission, not just the ability to query workflow tasks. The command should record both the authenticated actor and the effective completed user if override support is added.

Recommended authorization model:

  • normal completion requires the endpoint write scope and task_asst_t.assignee_id = authenticated user id
  • administrative override requires workflow.task.override
  • a host or portal administrator, such as the configured portal.admin, may be treated as satisfying the override permission if that is the established portal authorization convention

Use a broad scope such as workflow.write for access to the write endpoint if the service defines workflow-specific scopes. If the current service only has a portal-level write scope, keep the OAuth scope broad and enforce workflow.task.override as the fine-grained application permission.

Page Layout

The detail page should be compact and task-oriented:

  • header with workflow name, task name, status, and due date if deadlineTs is present
  • assignment summary with assignee and category
  • prompt panel
  • context panel with selected workflow/process fields
  • input area rendered from ask metadata
  • sticky submit actions for long forms
  • error or stale-task state

The context panel should show enough data for the user to decide, but it should not dump the full context_data object by default. The first phase can show common fields and provide a collapsible raw context view for administrators.

Timeout handling should be visible as read-only metadata as soon as task_info_t.deadline_ts is available. The UI can show due date or expiry status without implying that automatic runtime timeout processing has already been implemented.

Error States

The page should handle these states explicitly:

  • assignment not found
  • assignment no longer active
  • task already completed
  • task is not an ask task
  • ask metadata missing or invalid
  • validation failed
  • submit conflict because another user completed the task first
  • workflow resume failed after completion

The submit conflict case should take the user back to the worklist after showing that the task is no longer available.

Implementation Phases

Phase 1:

  • Add the task_asst_t.status_code migration before building the query and command handlers.
  • Add getHumanTask.
  • Add /app/workflow/HumanTask.
  • Link actionable Worklist task rows to the detail page.
  • Render approval, choice, multiChoice, confirm, and text.
  • Submit through completeTask.
  • Validate assignment ownership and ask metadata in the command layer.
  • Complete the parent task and assignment cleanup in one transaction.
  • Return stale-task conflicts for duplicate submissions.
  • Hide completed and sibling-cancelled assignments from the worklist.

Phase 2:

  • Add schema-driven object input.
  • Add JSON schema validation on the command side.
  • Add curated context metadata such as ask.contextKeys.

Phase 3:

  • Add optional claimHumanTask and releaseHumanTask.
  • Add claim expiry handling.
  • Add a dedicated human task inbox.
  • Add assignment history and richer audit display.
  • Add live Worklist refresh for claim/completion events through the existing portal notification channel, with server-sent events as the fallback.
  • Add file input if workflow use cases require it.

Resolved Questions

  • task_asst_t should gain explicit assignment status fields. active remains useful for filtering but should not be the only state model.
  • The human task query should return curated context when workflow metadata defines it, with raw context available for administrative troubleshooting.
  • Comments should be configurable. Approval and confirm modes should default to allowing comments.
  • Timeout metadata should be visible as read-only UI state before automatic timeout processing is implemented.
  • Administrative override should use the fine-grained permission workflow.task.override, with portal.admin as the broad administrator path if the portal authorization layer already uses it.
  • Claim/release should remain Phase 3. Phase 1 should rely on optimistic completion and atomic duplicate-submit rejection.
  • Live Worklist refresh should use the existing portal notification channel first. If there is no reusable channel, use server-sent events before adding a dedicated websocket service.

User Filter

As more portal users manage their own APIs, clients, instances, schedules, and configuration records, giving every operator a broad admin role becomes too coarse. A broad admin can see and modify records created by other admins on the same host. This document proposes an incremental owner-scoped filtering model for portal-view.

The first step is a UI-side filter based on the user recorded on each row, such as update_user. This is not a complete security boundary. The same rule must eventually be enforced in the query and command services with fine-grained authorization from the rule engine. The UI implementation is still useful because it improves day-to-day user experience and gives us a concrete policy shape to move into the service layer.

Problem

Portal admin pages were originally designed for a small set of trusted operators. Many tables expose all host-scoped records once the user can access the admin page.

That model creates problems as adoption grows:

  • application owners need to manage their own APIs, clients, and instances
  • broad admin roles expose unrelated records from other teams
  • users can accidentally edit or delete records owned by another user or team
  • creating one role per page, such as api-admin or instance-admin, still does not solve row ownership
  • service-layer fine-grained authorization is not available everywhere yet

The immediate need is to let users use admin-like pages while limiting the rows they see and act on.

Current Experiment

Schedule.tsx is the first experimental page. The idea is:

  • users can access the schedule admin surface
  • normal users only see schedules where updateUser matches their user id
  • global admins or schedule admins can still see all schedules
  • the updateUser column can be hidden for normal users
  • create/update/delete actions are available only on the visible set

One implementation detail matters: ownership filters must be added before the request payload serializes the filters array.

const apiFilters = [];

if (ownedOnly && userId) {
  apiFilters.push({ id: "updateUser", value: userId });
}

const cmdData = {
  filters: JSON.stringify(apiFilters),
};

Adding the filter after cmdData.filters is built will not send it to the backend.

Design Goals

  • Allow regular users to manage records they created or updated.
  • Avoid giving every self-service user broad all-record admin visibility.
  • Keep the admin table implementation familiar and incremental.
  • Centralize the owner filter logic instead of duplicating it page by page.
  • Make the UI rule match the future service-layer rule as closely as possible.
  • Preserve host scoping and existing role-based page visibility.
  • Avoid presenting UI-side filtering as a security boundary.

Non-Goals

  • Do not claim UI filtering is sufficient authorization.
  • Do not replace service-layer rule-engine enforcement.
  • Do not solve full team ownership in the first UI-only pass.
  • Do not migrate every admin page in one large change.
  • Do not overload update_user as the permanent ownership model if a better owner field exists or can be added.

Ownership Model

There are several possible ownership signals. They should be treated in this order of preference.

FieldMeaningRecommendation
owner_user_idexplicit individual ownerbest long-term user ownership field
owner_position_idexplicit position or org-unit ownerbest long-term team/hierarchy ownership field
create_useroriginal creatorgood fallback if available
update_userlast updateruseful interim fallback, but not true ownership
domain-specific owner, such as operation_ownerbusiness owneruseful when the field is reliable and normalized

update_user is acceptable for the first UI experiment because many tables already have it. However, it has an important semantic problem: ownership moves to whoever last updated the row. If Alice creates an API and Bob updates it, Bob becomes the owner under an update_user rule.

The long-term model should add explicit owner fields where needed:

owner_user_id
owner_position_id

owner_group_id is intentionally deferred. Groups are still useful for flat team membership, but position ownership fits the portal authorization model better when access should follow the organization hierarchy. owner_org_id is also deferred because normal portal records are already scoped by host_id, and host_t links back to org_t through the host domain. Add organization-level ownership only if a future cross-host/global ownership use case requires it.

Do not add created_by and updated_by as authorization fields in Phase 4. The existing update_user and update_ts columns remain the last-updater audit trail. If creator audit becomes important, add create_user and create_ts as audit fields later, not as substitutes for stable ownership.

Until explicit owner columns exist, each page should declare which field is used for interim UI owner filtering.

Role Model

Use one page per entity type, but separate page visibility from row scope.

RoleMeaningPage accessRow scope
userbaseline signed-in portal useronly approved self-service admin pagesowned records only
adminglobal portal administrator, effectively super adminall admin pagesall records
<entity>-adminadministrator for one entity type, such as schedule-adminthat entity’s admin pageall records for that entity
platform-admindeployment platform administrator if this role is keptplatform/deployment platform pages onlynot a global all-record role

Do not give every user account access to every admin page. Only pages that are safe for self-service ownership should be exposed to user, and each of those pages must apply the owner filter and action guards.

The admin role can be repurposed as the global all-record role once the sidebar stops using it as a broad menu marker. Role checks must use exact role tokens. A role such as schedule-admin must not match admin through substring checks.

Access Modes

The UI should support three access modes.

Owner-Scoped Admin

This is the default self-service mode. The user can open admin pages, but rows are filtered to records they own.

Example:

roles: user
scope: owned
filter: updateUser = current user id

All-Scope Admin

This is for operators who can see and manage every record on the current host.

Example roles:

admin
schedule-admin

The default all-scope role is admin. Page-specific roles such as schedule-admin can opt a user into all-record visibility for one area. Do not use platform-admin as a global all-scope role because the portal already has a Platform Admin page for deployment platform management.

Read-Only or Support View

Some users may need to see records without modifying them. This can be added later with separate flags:

canReadAll = true
canWriteOwned = true
canWriteAll = false

Proposed UI Architecture

Add a small ownership-scope helper used by admin pages.

Example shape:

type OwnershipScopeOptions = {
  roles?: string | null;
  userId?: string | null;
  ownerField: string;
  allScopeRoles?: string[];
};

type OwnershipScope = {
  ownedOnly: boolean;
  ownerFilter: { id: string; value: string } | null;
  canWriteAll: boolean;
};

Example usage:

import {
  applyOwnershipFilter,
  defaultAllScopeRoles,
  ownershipScope,
} from "../utils/ownershipScope";

const ownership = ownershipScope({
  roles,
  userId,
  ownerField: "updateUser",
  allScopeRoles: [...defaultAllScopeRoles, "schedule-admin"],
});

const apiFilters = applyOwnershipFilter(columnFiltersWithoutActive, ownership);

This helper should live near other portal navigation/task utilities or in a small access utility module, for example:

src/utils/ownershipScope.ts

or:

src/tasks/accessScope.ts

The helper should not call the backend. It only computes the UI filter and UI capabilities from the current user state.

The sidebar should not use admin as a marker on every admin menu link. That made the whole Administration group disappear for normal users and prevented owner-scoped self-service pages from being reachable.

Recommended behavior:

  • admin users see every Administration link.
  • non-admin users see only Administration links explicitly marked with user or a matching entity role, such as role: "user schedule-admin".
  • only add user to a link after that page has owner-scoped filtering and action guards.
  • remove role: "admin" from individual menu links.
  • use exact role-token matching instead of string includes, so schedule-admin does not accidentally grant admin.

At the Phase 3 rollout point, the following Administration links are safe to expose to user because the pages apply the shared owner-scope helper and action guards:

  • API Admin
  • API Detail
  • OAuth Auth Client and Client Token
  • App Admin
  • Instance Admin, Runtime Instance, and instance relationship pages
  • Schedule Admin
  • Workflow Definition

Configuration, platform admin, user/role admin, workflow process/task/audit pages, and lower-volume metadata pages should remain admin-only until they have the same owner-scope treatment or a separate support/read-only policy.

Admin Page Behavior

For an owner-scoped user:

  • add the owner filter before the query payload is serialized
  • hide the owner column if it does not add useful information
  • show a small scope label such as “My records”
  • keep create actions available
  • allow update/delete only for rows matching the ownership rule
  • preserve normal table sorting, pagination, and global filter behavior

For an all-scope admin:

  • do not add the owner filter
  • show a scope label such as “All host records”
  • show the owner/update columns
  • allow existing admin actions

For a user without enough context:

  • if userId is missing, do not run an owner-scoped query
  • show a clear message that user context is required
  • avoid falling back to all-record visibility

Action-Level Guard

List filtering is not enough for a good UI. Row actions should also check the same scope.

Example:

const canUpdateRow =
  ownership.canWriteAll ||
  row.original.updateUser === userId;

For rows the user cannot modify:

  • hide destructive actions, or
  • disable them with a tooltip explaining the scope

Even after service-layer authorization is implemented, the UI should keep these guards so users understand why an action is unavailable.

Phase 4 Ownership Columns

For high-value entity tables, add canonical owner columns directly on the entity row:

owner_user_id UUID NULL
owner_position_id VARCHAR(128) NULL

Recommended constraints where the table has host_id:

FOREIGN KEY (host_id, owner_user_id)
  REFERENCES user_host_t(host_id, user_id)

FOREIGN KEY (host_id, owner_position_id)
  REFERENCES position_t(host_id, position_id)

Both owner columns should be nullable during migration. New records should get owner_user_id from the authenticated user on the service side by default. Do not trust a browser-submitted owner user id unless the caller has permission to assign ownership.

owner_position_id should be optional on create. The UI can show a host position dropdown populated from the user’s allowed positions. If the user has exactly one effective position and the page is configured for position ownership, the UI can default to that position. If the user has multiple positions, require an explicit choice when position ownership is desired.

For portal forms, the optional position owner field should be exposed as ownerPositionId and backed by the existing position label dynaselect query. The form action uses the position/getPositionLabel endpoint, which is backed by the queryPositionLabel persistence method and returns the id/label pairs needed by the select control.

Do not expose ownerUserId as a normal create/update form field. The command path must derive owner_user_id from the authenticated user in the event context. If an owner-transfer use case is needed later, implement it as a separate command with explicit authorization and audit behavior.

Normal update forms may update owner_position_id when the page allows the caller to choose or clear the owning position. update_user changes on every update and remains audit metadata. owner_user_id should not change on normal update; it changes only through an explicit owner-transfer action restricted to the current owner, admin, or the relevant entity-admin role.

Existing rows should be migrated conservatively:

  • if update_user can be resolved to a user in the host, it can be used as an initial owner_user_id
  • leave owner_position_id null unless there is a reliable source for the owning position
  • rows with no owner columns populated should be treated as unassigned legacy rows, visible only to all-scope admins until an owner is assigned

Service-Layer Target

The UI filter is an interim step. The durable solution belongs in the query and command services.

The service layer should eventually:

  • derive user id, roles, host id, and scopes from JWT claims
  • ignore client-supplied owner filters as an authorization source
  • inject owner predicates into query handlers based on the authenticated user
  • reject update/delete commands when the user does not own the row and lacks all-scope permission
  • use rule-engine policies for exceptions and domain-specific ownership

Once service-side owner enforcement is implemented, the UI should no longer be the source of authorization predicates. The service should inject the ownership predicate from authenticated user context and rule-engine decisions.

The UI should still keep owner-aware behavior for usability:

  • show “My records” or “Admin View” scope labels
  • hide or show owner columns based on the user’s scope
  • disable update/delete actions that the current user cannot take
  • optionally send a simple view hint such as scope=owned or scope=all

The service must treat any UI-supplied scope or owner filter as a hint only. It must ignore, override, or reject filters that would expand the caller’s authorized scope.

For owner-scoped users, the service-side predicate should be an OR condition:

owner_user_id = current_user_id
OR owner_position_id IN current_user_effective_positions

For all-scope admins, such as admin or the relevant entity-admin role, the service should omit this owner predicate and return all rows within the normal host scope.

The UI and backend should share the same policy concepts:

host scope
entity type
owner field
owned-only permission
all-record permission
read vs write capability

Position hierarchy must be resolved by the service layer or rule engine. A JWT claim such as pos=ai-engineer only grants exact-position access unless the service expands it to effective positions from position_t and user_position_t. If hierarchy is enabled, the effective position set should include inherited positions according to the existing position inheritance rules.

Rows with owner_position_id IS NULL are not position-owned. A user can still see the row if owner_user_id matches their user id. Rows where both owner_user_id and owner_position_id are null are unassigned legacy rows and should not be visible to normal owner-scoped users by default.

Rule Engine Direction

The rule engine can express policies such as:

user can read API when api.owner_user_id == user.user_id
user can update API when api.owner_user_id == user.user_id
admin can read all APIs on host
admin can update all APIs on host
api-admin can read all APIs on host
api-admin can update all APIs on host
support can read all APIs but cannot update

For tables that do not yet have explicit ownership fields, the policy can temporarily map ownership to update_user.

Rollout Plan

Phase 1: Fix Schedule Experiment

  • Fix filter ordering so updateUser is included in the request.
  • Use roles plus user id to decide owner-scoped vs all-scope mode.
  • Add action-level guards for update/delete.
  • Keep the current route behavior unchanged.

Phase 2: Add Reusable UI Helper

  • Create a shared ownership-scope helper.
  • Add unit-level coverage if the repo has a practical test pattern.
  • Document default all-scope roles.
  • Keep owner field configurable per page.

Phase 3: Apply To High-Value Admin Pages

Start with pages where users commonly manage their own records:

  • API admin
  • API detail/version admin
  • OAuth clients
  • client apps
  • instances
  • instance API links
  • schedules
  • workflow definitions

Then expand to lower-volume metadata pages.

Current implementation status:

  • src/utils/ownershipScope.ts centralizes exact role matching, owner-scope calculation, owner filter injection, and owner-column hiding.
  • Sidebar access now exposes only scoped links to user or matching entity-admin roles, while exact admin continues to see all Administration links.
  • API pages use admin and api-admin for all-record scope, with user limited by updateUser.
  • OAuth client pages use admin and oauth-client-admin for all-record scope, with user limited by updateUser.
  • Client app pages use admin and app-admin for all-record scope, with user limited by updateUser.
  • Instance pages use admin and instance-admin for all-record scope, with user limited by updateUser.
  • Schedule pages use admin and schedule-admin for all-record scope, with user limited by updateUser.
  • Workflow Definition uses admin and workflow-admin for all-record scope, with user limited by updateUser.
  • Task/page search registries use exact role-token checks so schedule-admin or another entity-admin role does not accidentally match global admin, while exact admin still has global visibility.

Deferred from this phase:

  • Workflow Process, Task, Worklist, Work, Audit, and Trace remain admin-only until their ownership rules are defined and implemented.
  • Configuration and platform pages remain admin-only because their ownership model is not yet defined.
  • User and role administration remain admin-only because exposing them to self-service users would require a separate delegated-administration model.

Phase 4: Add Explicit Ownership Fields

Where update_user is too weak, add proper owner fields through the database and services.

Candidate fields:

owner_user_id
owner_position_id

Apply these first to the high-value tables that already have owner-scoped admin pages. Keep the fields nullable during migration, default owner_user_id from the authenticated user on create, and make owner transfer explicit.

Current implementation status:

  • portal-db adds nullable owner_user_id and owner_position_id columns to the high-value portal tables used by the owner-scoped admin pages.
  • The migration backfills owner_user_id from update_user only when update_user is already a UUID. Non-UUID audit values remain unassigned instead of blocking the migration.
  • A database insert trigger defaults owner_user_id from update_user for new rows when the command path writes the authenticated user id into update_user.
  • Query projections for the scoped UI pages now return ownerUserId and ownerPositionId, and UUID filtering recognizes ownerUserId.
  • portal-view now uses ownerUserId for ownership checks on action controls. The UI no longer sends an owner filter for service-enforced pages because service-side scope must include both direct user ownership and position ownership.
  • Owner-aware create/update forms expose optional ownerPositionId with a host-scoped position dynaselect backed by queryPositionLabel.
  • Command schemas allow optional ownerPositionId for the owner-aware create and update commands. They do not accept ownerUserId; owner_user_id comes from the authenticated event user.
  • light-portal persistence writes owner_user_id from the event user on create and writes owner_position_id from ownerPositionId on create/update.
  • Schedule query is the first service-enforced owner-scope path. Non all-scope users are filtered by owner_user_id = current_user_id OR owner_position_id IN effective positions based on authenticated audit context.

Remaining rollout work:

  • Add explicit owner-transfer commands instead of changing ownership through normal update forms.

Phase 5: Enforce In Services

  • Add query-side owner predicates.
  • Add command-side ownership checks.
  • Move policy decisions into rule-engine configuration.
  • Keep the UI filters as usability hints, not authorization.

Current implementation status:

  • Query-side owner predicates are implemented for Schedule, API, API Version, App, OAuth Client, Client Token, Instance, Instance API, Instance API Path Prefix, Instance App, Instance App API, Runtime Instance, and Workflow Definition.
  • Query handlers derive scope from the authenticated audit attachment. Users with the global admin role or the entity-specific all-scope role bypass the owner predicate; other users are scoped by user id or effective positions.
  • The UI keeps owner-aware action guards, but it does not send the owner filter as a request filter for service-enforced pages. That keeps position-owned rows visible when the service grants access by owner_position_id.
  • The db-provider keeps backward-compatible query methods and adds owner-aware overloads so query services can roll forward independently.

Remaining service rollout work:

  • Add command-side ownership checks before update/delete actions.
  • Add explicit owner-transfer commands and audit events.
  • Move the all-scope role and position hierarchy decisions from Java guards into rule-engine policy once the service-side rule context is ready.

Future Improvement: Entity Access Grants

Do not introduce a generic ownership table in Phase 4. It adds query joins, pagination complexity, and weaker referential integrity before we have a clear sharing use case.

A generic table can be added later for secondary grants, sharing, and delegated administration. It should supplement the canonical owner columns rather than replace them.

Possible future shape:

entity_access_t
  host_id
  entity_type
  entity_id
  principal_type   -- user, position, group, role
  principal_id
  access_level     -- owner, maintainer, viewer

Use this only when we need use cases such as:

  • share one API with another position or group
  • give support read-only access to a selected set of records
  • delegate maintenance without transferring the canonical owner
  • manage record-specific exceptions from an Access Admin page

Risks And Mitigations

RiskMitigation
UI filter is bypassedTreat it as interim only; enforce in services next
update_user changes ownership unexpectedlyPrefer explicit owner fields; use update_user only as fallback
users lose access to records updated by operatorssupport owner transfer or explicit owner fields
inconsistent page behaviorcentralize scope helper and rollout page by page
broad admins still need all recordsdefine all-scope roles separately from self-service admin
query filters can be removed by browser toolsbackend must inject authorization predicates from JWT claims

Recommendation

Use owner-scoped filtering as the first UI step, but centralize it immediately. Do not copy the Schedule.tsx logic into every page by hand.

The recommended path is:

  1. fix the schedule filter ordering
  2. introduce a reusable ownership-scope helper
  3. apply it to the most common self-service admin pages
  4. add explicit owner fields where update_user is not good enough
  5. enforce the same rules in query and command services through the rule engine

This gives users a safer admin experience now while creating a clear migration path to real fine-grained authorization.

Contextual Help Links

portal-view has many pages, generated forms, task flows, and admin tables. Even with the task-oriented navigation work, users still need page-specific and form-specific help when they are making a decision or filling a field. This document proposes a contextual help-link model for pages and forms.

Problem

Users often need help at the exact point where they are working:

  • what this page is for
  • when to use this form
  • what required fields mean
  • which optional fields matter
  • what permissions or ownership rules apply
  • what happens after submit
  • how this page fits into a larger task

Today, help is usually outside the UI context. Users must know where to look, which document applies, and which page or form name maps to the screen in front of them.

Design Goals

  • Add a clear help entry point to every major page and generated form.
  • Keep help content close to the product documentation source of truth.
  • Avoid bloating the portal-view application bundle with documentation.
  • Allow documentation-only updates without rebuilding portal-view.
  • Make help links declarative so page, form, and task metadata can drive them.
  • Keep link identifiers stable even if routes or component names change.
  • Support future documentation search, related topics, and task-specific help.
  • Preserve the ability to run the app locally with a configurable docs base URL.

Non-Goals

  • Do not build a full documentation authoring system inside portal-view.
  • Do not duplicate long user guides in component source files.
  • Do not block a page or form rollout because full documentation is missing.
  • Do not use contextual help as a replacement for better labels, validation, or field-level error messages.

Documentation Location Decision

The help content should live in light-portal-doc. portal-view should store only metadata that points to the relevant help page.

Recommended split:

light-portal-doc
  src/help/portal-view/
    pages/
    forms/
    tasks/
    concepts/

portal-view
  page registry, task registry, and form metadata with help ids or help paths

Why light-portal-doc

Pros:

  • Keeps user-facing documentation in the documentation repo.
  • Allows documentation changes without rebuilding or redeploying portal-view.
  • Avoids increasing the app bundle with markdown content.
  • Supports documentation search, navigation, publishing, and review workflows.
  • Allows the same help content to be linked from support tickets, onboarding, release notes, and external docs.
  • Fits the existing pattern where portal-view design docs already live in light-portal-doc.

Cons:

  • Requires stable published URLs.
  • Requires a configurable docs base URL for local and deployed environments.
  • Can drift from UI behavior unless we add link validation and ownership rules.

Why Not portal-view/docs

Pros:

  • Easy to review UI and docs in one PR.
  • Help content can be tightly coupled to the component version.
  • Local development does not need a separate docs deployment.

Cons:

  • Documentation-only changes require app rebuilds and deployments.
  • Large markdown content can bloat the frontend repo and build context.
  • It is harder to provide a proper documentation navigation/search experience.
  • It encourages implementation notes and user help to mix in the same repo.

Recommendation: use light-portal-doc for content and keep portal-view limited to stable link metadata.

Help Content Structure

Create a user-facing help tree separate from design docs:

src/help/portal-view/
  pages/
    api-admin.md
    api-detail.md
    instance-admin.md
    schedule-admin.md
  forms/
    create-api.md
    update-api.md
    create-client.md
    update-instance.md
  tasks/
    mcp-onboard-api.md
    register-standalone-mcp-server.md
  concepts/
    ownership-and-positions.md
    hosts-and-user-hosts.md
    api-versioning.md

Use page-level help for screen orientation and form-level help for submission semantics. Use concept help for reusable explanations that should not be copied into many page/form documents.

URL Strategy

Help URLs should be stable and human-readable.

Recommended public URL shape:

/help/portal-view/pages/api-admin
/help/portal-view/forms/create-api
/help/portal-view/tasks/mcp-onboard-api
/help/portal-view/concepts/ownership-and-positions

Do not make the public URL depend on React route internals or component names. If a route changes from /app/api to another route later, the help URL should not need to change.

portal-view should build the absolute link from a runtime config value:

PORTAL_DOC_BASE_URL=https://doc.lightapi.net

or for Vite:

VITE_PORTAL_DOC_BASE_URL=https://doc.lightapi.net

Local development can point to a local docs server:

VITE_PORTAL_DOC_BASE_URL=http://localhost:3000

Metadata Contract

Use a stable help id or help path in the app metadata. A help path is more direct and easier to validate.

Page registry example:

{
  id: "api-admin",
  title: "API Admin",
  route: "/app/apis",
  helpPath: "/help/portal-view/pages/api-admin",
}

Task registry example:

{
  id: "mcp-onboard-api",
  title: "Onboard API to MCP Gateway",
  helpPath: "/help/portal-view/tasks/mcp-onboard-api",
}

Form metadata example:

{
  "formId": "createApi",
  "helpPath": "/help/portal-view/forms/create-api",
  "actions": []
}

If we need indirection later, we can change to helpId and resolve it through a small registry:

{
  helpId: "forms.create-api"
}

Start with helpPath because it is simple, transparent, and works well with static documentation.

Portal UI Behavior

Each page and generated form should have a small help action in a predictable location.

Recommended behavior:

  • open help in a new browser tab
  • use an external-link icon or help icon with an accessible label
  • keep the help action near the page title or form title
  • if a form is opened inside a task shell, prefer form-specific help first and show task help as a secondary link
  • if no specific help exists yet, fall back to the nearest page or concept help

Example resolution order for a form opened from a task:

  1. form helpPath
  2. current task helpPath
  3. current page helpPath
  4. generic portal help landing page

Do not render a broken link. If a help path is missing, hide the action or show the fallback help link.

Generated Forms

Generated forms should support a top-level helpPath field in Forms.json. The renderer can read it and show a help action in the form header.

For example:

{
  "formId": "createSchedule",
  "helpPath": "/help/portal-view/forms/create-schedule",
  "schema": {},
  "form": []
}

Field-level help can be added later, but it should not be the first step. Many field descriptions can stay in the JSON schema title/description. Use field-level help only for fields where a short description is not enough, such as security, ownership, deployment, or advanced configuration fields.

Possible future field shape:

{
  "key": "ownerPositionId",
  "helpPath": "/help/portal-view/concepts/ownership-and-positions"
}

Task-Aware Help

The task-oriented navigation layer should support task help separately from page or form help. A user working on the same form may need different context depending on the task.

Example:

  • createApi opened from “Register a new API” links to create API form help.
  • createApi opened from “Onboard API to MCP Gateway” can also link to MCP onboarding task help.

The UI should pass task context through existing task URL parameters and layout state, then render both links when useful:

Help: Create API
Related: Onboard API to MCP Gateway

Authoring Guidelines

Each page help document should include:

  • what the page is used for
  • who can access it
  • what records are visible
  • common actions
  • links to related forms and tasks

Each form help document should include:

  • when to use the form
  • what happens after submit
  • required fields
  • important optional fields
  • ownership and permission behavior
  • validation or troubleshooting notes

Keep help content user-facing. Do not put implementation details, class names, or database internals in the main help body unless they are truly needed for an operator.

Validation

To prevent link drift, add a lightweight validation step once the first help docs exist.

Validation should check:

  • every helpPath in portal-view points to a markdown source in light-portal-doc
  • every high-value page has page help
  • every high-value form has form help
  • no help path uses a route-specific or component-specific unstable name

This can start as a script in light-portal-doc or a shared CI check that accepts both repo paths.

Rollout Plan

Phase 1: Documentation Structure

  • Create src/help/portal-view/pages.
  • Create src/help/portal-view/forms.
  • Create src/help/portal-view/tasks.
  • Create src/help/portal-view/concepts.
  • Add placeholder help pages for the high-value admin pages and forms.

Phase 2: App Metadata

  • Add optional helpPath to pageRegistry.ts.
  • Add optional helpPath to taskRegistry.ts.
  • Add optional top-level helpPath to generated form metadata.
  • Add a docs base URL runtime config.

Phase 3: UI Components

  • Add a reusable help-link component.
  • Render page help near page titles.
  • Render form help in the generated form header.
  • Render task help in the task navigation shell.
  • Add fallback behavior when a specific help link is missing.

Phase 4: Coverage And Validation

  • Add help paths for all self-service owner-scoped admin pages.
  • Add help paths for all high-value create/update forms.
  • Add a validation script for help path coverage and broken links.
  • Add missing docs over time as pages move into the task-oriented model.

Initial Scope

Start with the pages and forms most likely to be used by self-service users:

  • API Admin and API Detail
  • create/update API
  • create/update API Version
  • App Admin
  • create/update App
  • OAuth Client and Client Token
  • create/update Client
  • create Client Token
  • Instance Admin and relationship pages
  • create/update Instance
  • create Instance API
  • create/update Instance API Path Prefix
  • create Instance App
  • create Instance App API
  • Schedule Admin
  • create/update Schedule
  • Workflow Definition
  • create/update Workflow Definition

Then expand to admin-only pages after their ownership and access model is clear.

MVP Decisions

Use these decisions for the first implementation.

Do not hide the help action when a specific page, form, or task help path is missing. Fall back to the generic portal-view help landing page:

/help/portal-view/index

This keeps the UI consistent. A missing specific help page should degrade to general help instead of making the help affordance disappear.

Help Presentation

Open help in a new browser tab for the MVP. Do not build an embedded markdown viewer, side drawer, or iframe-based documentation panel in the first version.

This keeps portal-view small and avoids adding documentation rendering, iframe, routing, and panel-state complexity to the app. A side panel can be revisited later if users need in-page help while editing long forms.

JSON Schema Descriptions

Do not auto-generate full form help pages from JSON schema descriptions. Schema titles and descriptions are best used for inline labels, helper text, or field-level tooltips.

Form-level help should explain why the form exists, when to use it, what happens after submit, and how the form fits into a larger workflow. It should not simply repeat field types and required flags.

Documentation Versioning

Use latest documentation URLs for the MVP. Do not introduce release-versioned help URLs in the first implementation.

The portal will likely support both cloud SaaS deployments and enterprise on-premise deployments. SaaS users normally interact with the latest deployed portal, but enterprise customers may run older portal versions for a longer period. Versioned docs are therefore a good future requirement, but they should not block the first help-link rollout.

Keep helpPath values relative and version-neutral:

/help/portal-view/forms/create-api

Then versioning can be introduced later by changing only the configured docs base URL:

PORTAL_DOC_BASE_URL=https://doc.lightapi.net/v2.0

This keeps the app metadata stable while allowing SaaS to use latest docs and on-premise builds to point at version-specific documentation.

Future Enhancements

In-Page Help Drawer

Add an optional in-page help drawer after the helpPath metadata is stable and the first new-tab implementation has proven useful.

The drawer should be opt-in, not the default for every form. Long or complex configuration forms can declare:

{
  "helpPath": "/help/portal-view/forms/update-instance",
  "inPageHelp": true
}

When enabled, the UI can render a right-side drawer that displays the help document through an iframe or a lightweight markdown renderer. This avoids constant tab switching for complex forms while keeping the MVP simple.

Field-Level Help Paths

Add field-level help paths sparingly for complex fields and architectural concepts. Standard fields should continue to use JSON schema titles, descriptions, helper text, or tooltips.

Example future field metadata:

{
  "key": "ownerPositionId",
  "helpPath": "/help/portal-view/concepts/ownership-and-positions"
}

The UI can render a small help icon next to the field label when a field-level helpPath exists. Good candidates include ownership, security, OAuth token exchange, deployment target, transport configuration, and workflow definition fields.

Versioned Documentation

Add release-versioned documentation when multiple portal versions must be supported at the same time, especially for on-premise enterprise deployments.

The relative helpPath values should remain unchanged. The deployment or build configuration should select the versioned docs base URL:

SaaS/latest:
PORTAL_DOC_BASE_URL=https://doc.lightapi.net

On-premise v2.0:
PORTAL_DOC_BASE_URL=https://doc.lightapi.net/v2.0

This gives cloud deployments a simple latest-docs experience and gives enterprise deployments a path to version-matched help without changing portal-view metadata.

Recommendation

Store user-facing help content in light-portal-doc and add declarative helpPath metadata in portal-view. This keeps documentation maintainable and publishable while allowing every page, form, and task to provide context-aware help from the UI.

Event Processing Notifications

Portal commands are event driven. After a command is submitted, one or more CloudEvents are written to event_store_t and outbox_message_t. The hybrid-query event consumer later processes the outbox rows and updates the projection tables used by portal-view.

The notification page in the user profile is intended to show the user the recent processing result for those events. Today the table and read path exist, but notification_t is not populated consistently, so the page cannot provide meaningful status.

Current State

The command path already writes events through the common command handler:

  1. The command handler validates and enriches the request.
  2. It builds one or more CloudEvents.
  3. It inserts those events into event_store_t and outbox_message_t.
  4. The command returns before the query-side projection has necessarily run.

The query side can run through either event-processing pipeline, selected by configuration:

  • Pg-notify pipeline: DbEventConsumerStartupHook polls outbox_message_t, uses the table’s gapless c_offset, groups rows by transaction_id, and writes failed transactions to the database dead_letter_queue.
  • Kafka pipeline: a connector publishes rows from outbox_message_t to Kafka. PortalEventConsumerStartupHook consumes those records, groups records by the command-side transaction_id, and produces failed transactions to the Kafka DLQ topic when DLQ is enabled.

Both pipelines eventually call PortalDbProvider.handleEvent(conn, event). handleEvent dispatches the event to the projection method for that event type. Because both pipelines process the same outbox-backed events, they should share the same user-facing notification status model.

The notification pieces are partially present:

  • notification_t exists in portal-db.
  • NotificationDataPersistenceImpl can query notification_t.
  • NotificationServiceImpl can insert a notification row.
  • user-query exposes getNotification.
  • portal-view has a notification table page.

The current gap is that notification rows are not created at the central event processing boundary.

There is also a separate UI error in MailMenu: it calls getPrivateMessage, whose handler currently returns an empty response. That explains the browser error Unexpected end of JSON input, but it is separate from the notification status design.

Goals

  • Show the current user the latest event processing results in the profile notification page.
  • Record both successful and failed projection processing.
  • Preserve event processing correctness even if notification insertion fails.
  • Keep notification creation centralized instead of adding calls to every projection method.
  • Support commands that emit multiple events.
  • Make the read API filter by host and user by default.
  • Keep enough diagnostic data to debug failed projections.
  • Keep notification writes idempotent so event replay is safe.

Non-Goals

  • Do not replace event_store_t, outbox_message_t, or dead_letter_queue.
  • Do not use notifications as the source of truth for projection state.
  • Do not build a real-time push channel in the first phase.
  • Do not add notification logic manually to every projection method.
  • Do not expose other users’ processing history to non-admin users.

Use notification_t as an operational projection-status table. The command side creates PENDING rows at the central event publication boundary, and the hybrid-query event consumer updates those rows with the processing result.

The primary processing-result write point should be the centralized outbox consumer path, around the call to PortalDbProvider.handleEvent(conn, event).

Recommended lifecycle:

command handler
  -> event_store_t
  -> outbox_message_t
  -> notification_t PENDING row
  -> response to caller

hybrid-query consumer
  -> read outbox_message_t
  -> handleEvent(conn, event)
  -> projection table write
  -> notification_t status row

For command-side publication, insert or update one notification row for each CloudEvent with status PENDING in the same transaction that writes event_store_t and outbox_message_t. Leave event_partition and event_offset null for this first insert, because the consumer has not observed the event position yet.

For successful projection processing, update the notification row for the CloudEvent to status SUCCEEDED and populate event_partition and event_offset from the active processor’s outbox position.

For failed projection processing, insert or update one notification row for each failed CloudEvent with status FAILED or DLQ, and store the exception message. Populate event_partition and event_offset when the processor has that information.

Status Model

Use one explicit status field. Do not keep is_processed; this feature is being implemented for the first time, and a boolean cannot distinguish pending, success, retry, DLQ, and skipped outcomes.

Recommended statuses:

StatusMeaning
PENDINGEvent accepted into event_store_t and outbox_message_t, but the active event consumer has not recorded a processing result yet.
SUCCEEDEDEvent was applied to projection tables and the projection transaction committed.
FAILEDEvent processing failed before the failed transaction was durably written to the configured DLQ, or the DLQ write itself failed.
DLQEvent transaction failed in fallback mode and was durably written to the configured DLQ.
SKIPPEDEvent was read by the active event consumer but intentionally ignored, such as an unhandled event type.

The UI should show the status labels, not the underlying event pipeline. The same status meanings apply to both pg-notify and Kafka processing.

Schema

The existing table is close, but it is too small for operational status and has nonce as INTEGER while event tables use BIGINT.

Recommended table shape:

CREATE TABLE notification_t (
    id                  UUID NOT NULL,
    host_id             UUID NOT NULL,
    user_id             UUID NOT NULL,
    nonce               BIGINT NOT NULL,
    event_class         VARCHAR(255) NOT NULL,
    event_json          TEXT NOT NULL,
    event_ts            TIMESTAMP WITH TIME ZONE NULL,
    process_ts          TIMESTAMP WITH TIME ZONE NOT NULL,
    status              VARCHAR(16) NOT NULL,
    error               VARCHAR(2048) NULL,
    aggregate_id        VARCHAR(255) NULL,
    aggregate_type      VARCHAR(255) NULL,
    aggregate_version   BIGINT NULL,
    event_partition     INTEGER NULL,
    event_offset        BIGINT NULL,
    transaction_id      UUID NULL,
    read_ts             TIMESTAMP WITH TIME ZONE NULL,
    PRIMARY KEY (host_id, id),
    FOREIGN KEY (host_id) REFERENCES host_t(host_id) ON DELETE CASCADE
);

user_id is intentionally not a foreign key to user_t. PENDING rows are inserted on the command side before projection tables are updated, so enforcing that projection FK would break commands such as user creation before the projection catches up.

Recommended indexes:

CREATE INDEX idx_notification_user_process_ts
    ON notification_t (host_id, user_id, process_ts DESC);

CREATE INDEX idx_notification_status_process_ts
    ON notification_t (host_id, status, process_ts DESC);

CREATE INDEX idx_notification_transaction
    ON notification_t (host_id, transaction_id);

CREATE INDEX idx_notification_event_position
    ON notification_t (host_id, event_partition, event_offset);

CREATE INDEX idx_notification_unread_failure
    ON notification_t (host_id, user_id, process_ts DESC)
    WHERE read_ts IS NULL AND status IN ('FAILED', 'DLQ');

event_partition and event_offset are intentionally generic processing position fields. They are useful for operator diagnostics, but the UI should not label them as pg-notify or Kafka details. In the pg-notify processor, event_partition is the configured logical consumer partition and event_offset is outbox_message_t.c_offset. In the Kafka processor, event_partition and event_offset are the consumed Kafka record partition and offset.

Both columns are nullable. PENDING rows should leave them empty at initial insert time. They are filled later by the pg-notify or Kafka processor when the processing result changes the row to SUCCEEDED, FAILED, DLQ, or SKIPPED.

transaction_id remains a UUID because it is generated by the command side and used by both event processors.

Do not store pipeline name, source topic/channel name, or DLQ destination in notification_t. Those are implementation details of the configured event pipeline. Operators can use service configuration and logs when they need pipeline-specific diagnostics.

For existing installations, ship this as a patch:

ALTER TABLE notification_t ALTER COLUMN nonce TYPE BIGINT;
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS status VARCHAR(16);
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS event_ts TIMESTAMP WITH TIME ZONE;
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS aggregate_id VARCHAR(255);
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS aggregate_type VARCHAR(255);
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS aggregate_version BIGINT;
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS event_partition INTEGER;
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS event_offset BIGINT;
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS transaction_id UUID;
ALTER TABLE notification_t ADD COLUMN IF NOT EXISTS read_ts TIMESTAMP WITH TIME ZONE;

ALTER TABLE notification_t DROP CONSTRAINT IF EXISTS notification_t_user_id_fkey;
ALTER TABLE notification_t DROP COLUMN IF EXISTS is_processed;
ALTER TABLE notification_t ALTER COLUMN status SET NOT NULL;

CREATE INDEX IF NOT EXISTS idx_notification_unread_failure
    ON notification_t (host_id, user_id, process_ts DESC)
    WHERE read_ts IS NULL AND status IN ('FAILED', 'DLQ');

Write Path

Notification writes need an explicit transaction policy. A single rule cannot cover every status:

  • Failure and DLQ notifications must be durable even when projection writes are rolled back.
  • Success notifications must not claim success until the projection write has committed.
  • Notification write failures should not break projection processing.

Use a REQUIRES_NEW style helper for notification writes that must survive a projection rollback. In plain JDBC, this means opening a separate connection with its own commit/rollback boundary.

For success rows, there are two safe options:

  1. Commit the projection transaction first, then write SUCCEEDED in a separate notification transaction.
  2. Write SUCCEEDED inside the projection transaction, but wrap it in a savepoint and treat notification insert failure as non-fatal.

The first option is the recommended default because notification failures cannot roll back projection updates. The tradeoff is a small window where projection has committed but the success notification is missing. That is acceptable because event_store_t remains the source of truth and success notifications are user feedback, not projection correctness.

Recommended service methods:

void recordPending(Map<String, Object> event, UUID transactionId);

void recordSuccess(Map<String, Object> event, EventMetadata metadata);

void recordFailure(Map<String, Object> event, EventMetadata metadata, String error, String status);

recordPending should participate in the command-side transaction that writes event_store_t and outbox_message_t. It may store transaction_id, because that value is generated by the command side, but it must leave event_partition and event_offset null. recordSuccess and recordFailure should use the event-processing transaction policy described below.

EventMetadata should carry only pipeline-neutral data that is not inside the CloudEvent map:

  • eventPartition: the active processor’s partition value. For pg-notify this is the configured logical consumer partition; for Kafka this is the consumed Kafka record partition.
  • eventOffset: the active processor’s offset value. For pg-notify this is outbox_message_t.c_offset; for Kafka this is the consumed Kafka record offset.
  • transactionId: the command-side transaction UUID used by both processors.

Both consumers should build this metadata before calling handleEvent, so the failure path still has offset and transaction context after the projection transaction is rolled back.

Use an idempotent upsert:

INSERT INTO notification_t (...)
VALUES (...)
ON CONFLICT (host_id, id) DO UPDATE SET
    process_ts = EXCLUDED.process_ts,
    status = EXCLUDED.status,
    error = EXCLUDED.error,
    event_partition = EXCLUDED.event_partition,
    event_offset = EXCLUDED.event_offset,
    transaction_id = EXCLUDED.transaction_id;

This makes replay and fallback processing safe.

Success Handling

In the normal batch path:

begin projection transaction
for each event from the active pipeline:
  parse CloudEvent
  handleEvent(conn, event)
commit projection transaction

for each successfully committed event:
  recordSuccess(event, metadata) in separate notification transaction

Do not write SUCCEEDED before the projection transaction commits unless it is part of the same transaction. If it is written in the same transaction, a projection rollback must roll back the success row too.

The implementation can keep an in-memory list of successfully applied events while processing the batch. After commit, loop through that list and upsert the success notifications. If a success notification write fails, log it and continue; do not retry the projection.

Failure Handling

Failure rows must be written outside the failed projection transaction.

In fallback mode, processing is retried per transaction. For a failed transaction:

begin projection transaction
savepoint projection_attempt
  process transaction events
on exception:
  rollback to projection_attempt or rollback projection transaction
  write failed events to database DLQ or Kafka DLQ topic
  recordFailure(event, metadata, error, "DLQ") in separate notification transaction

For pg-notify, the DLQ destination is the database dead_letter_queue table. For Kafka, the DLQ destination is the configured Kafka DLQ topic. The failure notification can be committed with the database DLQ transaction for pg-notify, or in a separate notification transaction immediately after the Kafka DLQ produce request is accepted. The key requirement is that it must not be part of the projection work that is being rolled back.

If the database connection enters an unrecoverable error state, close it and open a fresh connection for the DLQ and failure notification writes.

If the payload cannot be parsed as a CloudEvent, the consumer may not know the CloudEvent id or event type. In that case, the DLQ remains the primary failure record. If the consumer metadata still has host, user, partition, offset, and transaction id, the consumer can create a diagnostic notification with a generated id, but this should be treated as a best-effort operational row.

Pending Handling

PENDING is part of phase one. Add pending rows at the central command-side publication boundary that writes event_store_t and outbox_message_t.

The pending notification should be written in the same command-side transaction as the event-store and outbox rows. If the command rolls back, the pending notification must roll back too. Do not add pending writes to individual command handlers.

At this stage, the notification row should contain command-known fields only: CloudEvent id, host id, user id, nonce, event class, event JSON, event timestamp, and transaction id. The processor-owned event_partition and event_offset fields remain null until event processing updates the row.

Read API

Keep getNotification as the main query endpoint, but tighten its contract.

Recommended request fields:

{
  "hostId": "uuid",
  "userId": "uuid",
  "offset": 0,
  "limit": 25,
  "status": "SUCCEEDED",
  "eventClass": "ClientCreatedEvent",
  "nonce": "123",
  "fromTs": "2026-05-08T00:00:00Z",
  "toTs": "2026-05-08T23:59:59Z",
  "error": "duplicate key"
}

Recommended response:

{
  "total": 1,
  "notifications": [
    {
      "id": "uuid",
      "hostId": "uuid",
      "userId": "uuid",
      "nonce": 123,
      "eventClass": "ClientCreatedEvent",
      "status": "SUCCEEDED",
      "processTs": "2026-05-08T16:12:00Z",
      "aggregateId": "host|client",
      "aggregateType": "Client",
      "aggregateVersion": 2,
      "transactionId": "uuid",
      "eventPartition": 0,
      "eventOffset": 1001,
      "error": null,
      "eventJson": "{...}"
    }
  ]
}

eventPartition and eventOffset are intentionally displayed as generic position fields regardless of which event pipeline is configured. The main list can hide them by default and show them in the detail view.

userId is a filter on getNotification, not a separate endpoint contract. The profile notification page should always send the logged-in user’s userId. The admin notification page can omit userId to request host-wide results, or pass a specific userId to narrow the host-wide view to one user.

Authorization rules:

  • Normal users can query only their own token user_id within the selected hostId. If the request omits userId, the backend should apply the token user_id; if the request supplies another userId, the backend should reject it or override it with the token user_id.
  • Admin users can query all users for the host by omitting userId, or filter to a specific user by providing userId.
  • The backend should enforce this using token claims, not only UI filters.

Portal View

The profile notification page should become a processing-status view.

Recommended columns:

  • Time
  • Status
  • Event
  • Aggregate
  • Nonce
  • Error
  • Details

Recommended default filters:

  • hostId from the selected host.
  • userId from the logged-in user.
  • No default status filter.
  • Most recent first.
  • Last 25 rows.

The UI should display concise summaries and keep full eventJson behind an expandable detail row or dialog.

Show all events associated with the user, including successful, failed, and derived events. Derived events should be visible as their own rows instead of being collapsed under the original command transaction.

The current processFlag filter should be replaced by status. No is_processed compatibility mapping is needed because this feature has not yet started populating notification_t.

The header MailMenu should not call getPrivateMessage unless that handler is restored. For notification status, add a small notification badge endpoint or reuse getNotification with limit = 5.

The header badge should count only unread failure notifications, such as FAILED and DLQ, and display the count in red when the count is greater than zero.

In the list, FAILED and DLQ status badges should also use red styling.

Phase 2 adds two narrow user-query RPCs:

  • getUnreadNotificationCount: returns unread FAILED and DLQ notifications for the current hostId and userId.
  • markFailureNotificationsRead: sets read_ts on unread FAILED and DLQ notifications for the current hostId and userId.

The header uses the count endpoint for its badge and marks failures read when the user opens the notification menu. The notification page also marks failures read when it is opened.

Admin Notification Page

Phase 3 should add a separate admin notification page instead of overloading the profile notification page. The recommended location is:

  • Route: /app/event/notifications
  • Menu: Administration -> Event Admin -> Notifications

This page should reuse the same notification table and getNotification read API, but with admin defaults:

  • hostId from the selected host.
  • No default userId filter, so admins see host-wide results.
  • Default status filter for FAILED and DLQ, with an option to show all statuses.
  • Filters for userId, eventClass, status, transactionId, aggregateId, processing position, time range, and error text.
  • No unread badge behavior and no call to markFailureNotificationsRead.

The page should clearly identify itself as an admin view, such as “Admin View: Host Notifications”. Host-wide access must still be enforced by the backend using token roles.

Operational Cleanup

Notifications are operational history. They should not grow forever.

Recommended retention:

  • Keep successful notifications for 30 to 90 days.
  • Keep failed and DLQ notifications longer, such as 180 days.
  • Allow host-level configuration later if needed.

Cleanup should be implemented as a generic operational cleanup process, not as notification-specific UI or command-handler logic. The first cleanup target is notification_t, but the same framework should also support other operational tables such as message_t for private messages.

Recommended implementation:

  • Add an OperationalCleanupStartupHook on the query side.
  • Run cleanup on a fixed interval, such as daily, with config-driven enablement, interval, batch size, and per-target retention days.
  • Use a single cleanup coordinator that owns multiple cleanup targets. Each target defines its table, timestamp column, status/type conditions if needed, retention duration, and batch delete SQL.
  • Use a database lock, such as a PostgreSQL advisory lock or a dedicated cleanup lock row, so only one service instance performs cleanup at a time.
  • Delete in bounded batches to avoid long table locks and large transactions.
  • Use a separate database connection and transaction for cleanup work.
  • Log cleanup failures and continue service startup; cleanup failure must not block query APIs or event processing.

Do not use schedule_t directly for this cleanup. That scheduler is business workflow infrastructure that emits events into event_store_t and outbox_message_t. Operational cleanup is local maintenance and should stay out of the event-processing path.

Example notification cleanup:

WITH doomed AS (
    SELECT host_id, id
    FROM notification_t
    WHERE (status IN ('SUCCEEDED', 'SKIPPED') AND process_ts < ?)
       OR (status IN ('FAILED', 'DLQ') AND process_ts < ?)
    ORDER BY process_ts
    LIMIT ?
)
DELETE FROM notification_t n
USING doomed d
WHERE n.host_id = d.host_id
  AND n.id = d.id;

Private-message cleanup can be another target using message_t.send_time:

WITH doomed AS (
    SELECT host_id, from_id, nonce
    FROM message_t
    WHERE send_time < ?
    ORDER BY send_time
    LIMIT ?
)
DELETE FROM message_t m
USING doomed d
WHERE m.host_id = d.host_id
  AND m.from_id = d.from_id
  AND m.nonce = d.nonce;

Recommended default cleanup targets:

TargetTableRetention
Successful notification historynotification_t where status IN ('SUCCEEDED', 'SKIPPED')90 days
Failed notification historynotification_t where status IN ('FAILED', 'DLQ')180 days
Private messagesmessage_t180 days

Do not delete recent PENDING notifications. Old PENDING rows should be treated as an operational signal first because they may indicate that the event consumer is stopped or lagging. If a hard cap is needed later, make it a separate, longer retention policy.

Snapshot and Promotion

notification_t should be treated as an operational table, not a promoted projection table.

It should be excluded from global snapshot export and conversion alongside event_store_t, outbox_message_t, dead_letter_queue, log_counter, and consumer_offsets.

Rollout Plan

Phase 1: Make Notifications Useful

  • Add status and diagnostic columns to notification_t.
  • Add pipeline-neutral event_partition, event_offset, and transaction_id metadata.
  • Change NotificationService to support separate notification transactions.
  • Insert PENDING rows at the central command-side outbox publication boundary.
  • Insert SUCCEEDED rows after successful handleEvent.
  • Insert DLQ rows in fallback failure handling.
  • Update getNotification to support status and correct timestamp fields.
  • Update portal-view to use status, default to the current user, and show all user-associated events including derived events.

Phase 2: Improve User Feedback

  • Add an unread marker with read_ts.
  • Add a small header badge query for unread FAILED and DLQ notifications and render the badge in red.
  • Mark unread failure notifications as read when the user opens the header menu or the notification page.

Phase 3: Operations

  • Add a generic operational cleanup startup hook with retention targets for notification_t and message_t.
  • Make cleanup configurable by enablement, interval, batch size, and per-target retention days.
  • Add a database lock so only one service instance runs cleanup at a time.
  • Add an admin notification page under Event Admin that uses getNotification without a userId filter for host-wide failures.
  • Add dashboards or alerts for repeated DLQ statuses.

Risks and Mitigations

RiskMitigation
Notification write failure breaks event processingWrite notifications in a separate transaction after projection commit, or use savepoints for same-transaction success rows.
Failure notifications are rolled back with projection failuresWrite FAILED and DLQ rows outside the failed projection transaction.
False success rows after projection rollbackWrite SUCCEEDED only after projection commit, or keep same-transaction success rows rollback-safe.
Duplicate rows on replayUse ON CONFLICT (host_id, id) DO UPDATE.
Users see other users’ eventsEnforce token-based authorization in getNotification.
Operational tables grow without boundAdd generic operational cleanup targets and supporting indexes.
Cleanup runs concurrently on multiple instancesUse a database lock so only one instance runs cleanup at a time.
Cleanup failure blocks query service startupLog cleanup failures and continue startup; cleanup is maintenance, not correctness-critical.
Status meaning stays ambiguousUse status as the only outcome field for both pg-notify and Kafka processing.

API Marketplace Catalog

Context

The portal already has a Marketplace navigation group and an api-marketplace page registry entry. The current API administration page is table-oriented and is useful for owners, but it is not a consumer catalog. A Marketplace API catalog should let users discover APIs by business category, capability, protocol, lifecycle status, and governance metadata.

API create and update forms already use the standardized taxonomy fields:

  • categoryIds for selected category identifiers.
  • tagIds for selected tag identifiers.
  • getCategoryLabelByType with entityType = "api" for category options.
  • getTagLabelByType with entityType = "api" for tag options.

The service query layer also returns categoryIds, categories, tagIds, and tags for API rows. The catalog should use those fields for display and filtering instead of reintroducing the legacy apiTags string field.

Goals

  • Add a Marketplace menu entry for an API catalog.
  • Use database-backed categories and tags, not hard-coded UI lists.
  • Keep categories and tags reusable across future catalog pages.
  • Keep API create/update forms as the source of truth for taxonomy assignment.
  • Give consumers a browse-first experience instead of an admin table.
  • Support deep links from a catalog listing to API detail, versions, endpoints, runtime bindings, and owner actions.
  • Preserve host scope and ownership rules already used by API administration.

Non-Goals

  • Do not replace API administration pages with the catalog.
  • Do not store display names in API rows when they can be resolved from category_t, tag_t, entity_category_t, and entity_tag_t.
  • Do not use the old apiTags field for catalog filtering.
  • Do not make taxonomy values static frontend constants.
  • Do not expose private tenant APIs through a public catalog without an explicit visibility and authorization decision.

Current Building Blocks

AreaCurrent shapeCatalog use
Portal navigationMarketplace group already exists in the sidebarAdd an API Catalog child item under Marketplace
Page registryapi-marketplace points to /app/marketplace, while the app route still needs a real catalog pageKeep a registry entry for search, task links, and help links
API admin pageService.tsx calls service/getApi and displays categories and tagsReuse its query contract but present catalog cards/list views
API detail pageApiDetail.tsx shows API versions and action linksCatalog detail can deep-link to this page
FormscreateApi and updateApi submit categoryIds and tagIdsCatalog reads the same assignments
Category labelscategory/getCategoryLabelByType returns id and labelUse for category tabs, filters, and chips
Tag labelstag/getTagLabelByType returns id, label, value, group code, group label, group sort order, and tag sort orderUse for grouped tag filters and grouped multi-select controls
Databasecategory_t, tag_t, entity_category_t, and entity_tag_t are entity-type scopedUse entity_type = 'api' for API catalog taxonomy

User Experience

The first screen under Marketplace should be the usable catalog, not a landing page. The recommended route is:

/app/marketplace/api

The sidebar can keep the existing Marketplace group, but its children should move from API-type-only links to intent-based entries:

  • API Catalog
  • API Clients
  • Schema Catalog
  • YAML Rule
  • Schema Form

The API Catalog page should provide:

  • Search across API id, name, description, business group, line of business, capability, platform, git repository, categories, and tags.
  • Category tabs or a category rail based on getCategoryLabelByType.
  • Grouped tag filters based on getTagLabelByType.
  • Filter chips for active category and tag selections.
  • A compact card or list row per API with name, description, status, categories, tags, owner, business group, and latest version summary.
  • Actions to view details, review versions, create a new version, update the API metadata, and open related runtime or access-control pages when the user has permission.

The catalog should support an Uncategorized bucket for active APIs without category assignments. This avoids hiding incomplete data and gives admins an easy cleanup target.

Categories And Tags

Categories should be stable browse buckets. Tags should be flexible facets. Both are stored with entityType = "api" so the same tag names can be reused for other entity types without forcing cross-catalog semantics.

Recommended initial API categories:

Category valueLabelPurpose
public-apiPublic APIExternal developer-facing APIs
partner-apiPartner APIAPIs shared with business partners
internal-apiInternal APIOrganization-internal service APIs
platform-servicePlatform ServiceShared platform or infrastructure APIs
data-apiData APIData access, analytics, reporting, and query APIs
ai-automation-apiAI / Automation APIAgent, workflow, automation, or AI-facing APIs
security-compliance-apiSecurity / Compliance APIIdentity, audit, policy, compliance, and control APIs
developer-tooling-apiDeveloper Tooling APIBuild, test, deployment, and developer-experience APIs
legacy-modernization-apiLegacy / Modernization APILegacy integration and modernization APIs

The stored category_name must stay lower-case and URL-friendly. The display labels above are UI labels derived from those values.

Recommended initial API tag groups:

Group codeGroup labelExample tag values
protocolProtocolopenapi, graphql, hybrid, mcp, rest, event-driven
lifecycleLifecycledraft, review, implemented, deprecated, beta, ga
securitySecurityoauth2, jwt, mtls, pii, hipaa, pci, read-only
runtimeRuntimegateway, sidecar, kubernetes, serverless, multi-region
domainDomaincustomer, order, payment, inventory, tax, billing
consumerConsumerpublic, partner, internal, agent-facing, mobile, web
operationsOperationshigh-traffic, low-latency, batch, streaming, critical
integrationIntegrationdatabase, kafka, s3, third-party, mainframe, saas

Stored tag names must stay lower-case and URL-friendly. If a display label needs capitalization, the UI should format it or the label endpoint should provide a separate display field later.

Tags without tag_group_code or tag_group_label should be shown under a General filter group in the catalog UI. Configured groups should appear first by group_sort_order; the General group should appear after configured groups, matching the current label query behavior where null group sort values sort last.

Data Flow

Catalog filter option loading:

portal-view
  -> category/getCategoryLabelByType(hostId, entityType = "api")
  -> tag/getTagLabelByType(hostId, entityType = "api")

Catalog result loading:

portal-view
  -> service/getApi(hostId, offset, limit, active, filters, globalFilter, sorting)
  -> api rows with categoryIds, categories, tagIds, tags

The catalog should prefer server-side pagination and filtering. Client-side filtering is acceptable only for a small first pass because it breaks as soon as the API count exceeds one fetched page.

Query Contract

The existing getApi contract already supports filters, globalFilter, sorting, offset, limit, hostId, and active. To make the catalog work well at scale, add first-class filter support for taxonomy fields:

{
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "offset": 0,
  "limit": 20,
  "active": true,
  "categoryIds": ["..."],
  "tagIds": ["..."],
  "tagMatch": "all",
  "globalFilter": "payment"
}

Recommended semantics:

  • categoryIds uses OR semantics by default. An API in any selected category is returned.
  • tagIds should support tagMatch = "all" and tagMatch = "any".
  • Category and tag filters should use EXISTS against entity_category_t and entity_tag_t with entity_type = 'api' and active = TRUE.
  • Display arrays should continue to be returned as categories and tags.
  • Form update payloads should continue to submit identifiers only through categoryIds and tagIds.

Page Design

The API Catalog page can be implemented as a dedicated page rather than trying to stretch the current API admin table.

Proposed files:

src/pages/marketplace/ApiCatalog.tsx
src/pages/marketplace/components/ApiCatalogFilters.tsx
src/pages/marketplace/components/ApiCatalogCard.tsx
src/pages/marketplace/hooks/useApiCatalog.ts

Page state:

  • search text
  • selected category ids
  • selected tag ids
  • tag match mode
  • active status
  • pagination
  • sorting
  • view mode, either compact list or card grid

Catalog state should be URL-driven from Phase 1. Search text, selected categories, selected tags, tag match mode, active status, sorting, and pagination should be encoded in the query string so users can refresh the page, use browser navigation, and share filtered catalog URLs. Example:

/app/marketplace/api?q=payment&category=public-api&tag=oauth2&tag=mtls&tagMatch=all&page=1

The page should still reuse existing infrastructure:

  • fetchClient for portal query calls.
  • useUserState for host and user context.
  • buildTaskAwareRoute for deep links.
  • ownership utilities for update/delete action visibility.
  • TaskActionPanel for publisher/admin next actions.
  • pageRegistry and contextual help metadata.

Routing And Navigation

Add or update these portal-view entries:

LocationChange
Sidebar.tsxAdd API Catalog under Marketplace with route /app/marketplace/api
App.tsxRoute /app/marketplace/api to ApiCatalog
pageRegistry.tsAdd or update API Catalog metadata, keywords, and help path
taskRegistry.tsUpdate publish/review steps to point to /app/marketplace/api
Help docsAdd a user-facing help page after the UI settles

The existing /app/marketplace route can redirect to /app/marketplace/api or remain a broader Marketplace landing page later. For the first API catalog implementation, redirecting keeps the behavior simple.

Backend Changes

The backend already persists API category and tag relationships. The main backend change is query filtering:

  1. Extend service-query spec for optional categoryIds, tagIds, and tagMatch.
  2. Update GetApi to pass those optional fields to the DB provider.
  3. Update PortalDbProvider#getApi and ApiServicePersistenceImpl#getApi.
  4. Add SQL predicates over entity_category_t and entity_tag_t.
  5. Verify or add compound indexes for taxonomy filtering.
  6. Add tests for category-only, tag-any, tag-all, combined taxonomy filters, and APIs with no taxonomy assignments.

The existing join-table indexes are useful for entity lookups and label resolution, but catalog filtering also needs indexes that start with filter fields. Before implementing Phase 2, verify the query plan and add indexes if needed:

CREATE INDEX idx_entity_tag_filter
ON entity_tag_t (entity_type, tag_id, entity_id)
WHERE active = TRUE;

CREATE INDEX idx_entity_category_filter
ON entity_category_t (entity_type, category_id, entity_id)
WHERE active = TRUE;

For tagMatch = "all", prefer a single grouped subquery over generating one EXISTS predicate per selected tag when the selected tag set can grow. A common shape is to filter entity_tag_t by selected tag ids, group by entity_id, and require COUNT(DISTINCT tag_id) = selectedTagCount.

The query response should continue to include both identifiers and labels:

{
  "apiId": "0001",
  "apiName": "Petstore",
  "categoryIds": ["..."],
  "categories": ["public-api"],
  "tagIds": ["..."],
  "tags": ["openapi", "oauth2"]
}

Implementation Phases

Phase 1: Catalog Page

  • Add the API Catalog route and Marketplace menu entry.
  • Load category and tag options from the existing label endpoints.
  • Load APIs with service/getApi.
  • Render search, category filter, grouped tag filter, and API list/card results.
  • Store catalog filters, search text, sorting, and pagination in the URL query string.
  • Use current query response labels for display.
  • Deep-link to existing API detail and update forms.

Phase 2: Server-Side Taxonomy Filters

  • Add categoryIds, tagIds, and tagMatch to service-query.
  • Implement SQL filtering in ApiServicePersistenceImpl.
  • Keep current table filtering support for admin use.
  • Add DB provider and handler tests.

Phase 3: Catalog Polish

  • Add API detail summary panels with versions, endpoint count, runtime exposure, and access-control hints.
  • Add help docs and task links.
  • Add optional counts per category and tag if the catalog needs faceted counts.

Open Questions

  • Should Marketplace API Catalog show only active APIs by default? The recommendation is yes, with an admin-visible inactive filter.
  • Should unauthenticated users ever see catalog data? The recommendation is no until a separate public visibility model is designed.
  • Should category selection be single-select or multi-select? The recommendation is multi-select OR semantics for flexibility.
  • Should tags use all-match or any-match by default? The recommendation is all for precision, with a visible toggle if users need broader searches.
  • Should OpenAPI tags imported from specs automatically create API catalog tags? The recommendation is no for the first pass. Spec tags are often endpoint-level groupings and should not automatically become curated catalog taxonomy.

AI Agent Registration In Task Center

Status

Initial Task Center implementation is available. The first version uses the existing API-version and agent-definition commands with backend validation guardrails. A dedicated composite registration command remains a later automation enhancement.

Context

Light Portal treats an AI agent as an API. The API record provides the stable catalog identity, ownership, display name, marketplace metadata, and lifecycle. The API version record provides the deployable version identity. The agent definition record is an agent-specific profile extension for the same API version.

The current data model already reflects this relationship:

  • api_t owns the logical API and display name.
  • api_version_t owns the API version identity.
  • agent_definition_t.agent_def_id stores the same UUID as api_version_t.api_version_id.
  • agent_definition_t stores model and runtime profile fields such as model_provider, model_name, api_key_ref, temperature, and max_tokens.
  • Agent query paths join agent_definition_t to api_version_t and api_t to expose the effective agent metadata.

The registration UX should make this model explicit. Operators should not have to understand the table split. They should see one task: register an AI agent.

Goals

  • Add a focused Task Center flow named Register AI Agent.
  • Register the agent first as an API and API version.
  • Create the agent definition profile using the same ID as the API version.
  • Keep event sourcing and replay clean by using domain events instead of direct table writes.
  • Avoid duplicating mutable display name fields between api_t and agent_definition_t.
  • Allow skills, tools, memory, access control, and deployment links to be added after the base agent is registered.

Non-Goals

  • Do not create a second standalone agent registry independent from APIs.
  • Do not make AgentDefinitionCreatedEvent create api_version_t.
  • Do not make ApiVersionCreatedEvent directly write agent_definition_t unless the event schema is intentionally expanded later.
  • Do not require all skill and tool assignments during the initial registration.
  • Do not replace the existing Manage GenAI Assets task. That task remains the broader maintenance flow.

Identity Model

The agent identity is the API version identity.

api_t
  host_id
  api_id
  api_name              # canonical agent display name

api_version_t
  host_id
  api_version_id        # canonical agent definition id
  api_id
  api_version
  api_type = "agt"      # or accepted legacy value "agent"

agent_definition_t
  host_id
  agent_def_id          # same value as api_version_t.api_version_id
  model_provider
  model_name
  api_key_ref
  temperature
  max_tokens

agent_definition_t should remain a profile extension. It should not duplicate the agent name. Reads can continue to expose agentName, but the value should come from api_t.api_name.

API Type

Use agt as the canonical API type for AI agents if the reference data uses the short code model. During migration, command handlers and queries can accept both agt and agent to avoid breaking existing test data or early records.

The Portal UI should display this as Agent and submit the canonical value. Database columns use snake case such as api_type; event payloads and command requests use camel case such as apiType. The mapper must preserve this translation and normalize agent type values consistently.

When registering an agent against an existing API, the backend must validate the existing API-version family. A logical API should not mix unrelated version types. If the selected api_t already has active versions, they must all be agent versions before an agt version can be added. The reverse should also be enforced: once an API has an active agent version, non-agent API versions should not be added under the same api_id.

Event Model

The Task Center flow should produce two domain events for the required base registration:

  1. ApiVersionCreatedEvent
  2. AgentDefinitionCreatedEvent

The two-event design is preferred because these are two separate domain facts:

  • an API version exists and can participate in the API catalog;
  • that API version has an agent runtime profile.

This should not be modeled as two direct table writes from one handler. The event processor should continue to populate projection tables during normal processing and replay.

Event Order

ApiVersionCreatedEvent must be persisted and projected before AgentDefinitionCreatedEvent, because agent_definition_t has a foreign key to api_version_t.

Register AI Agent
  -> ApiCreatedEvent, if the logical API does not already exist
  -> ApiVersionCreatedEvent
  -> AgentDefinitionCreatedEvent
  -> optional AgentSkillCreatedEvent events
  -> optional access-control events

The minimum required sequence for an existing API is:

ApiVersionCreatedEvent
AgentDefinitionCreatedEvent

Aggregate IDs

ApiVersionCreatedEvent keeps the API version aggregate identity:

{
  "aggregateType": "ApiVersion",
  "subject": "<apiVersionId>",
  "data": {
    "hostId": "<hostId>",
    "apiId": "<apiId>",
    "apiVersionId": "<apiVersionId>",
    "apiVersion": "1.0.0",
    "apiType": "agt"
  }
}

AgentDefinitionCreatedEvent uses the same UUID for its aggregate identity:

{
  "aggregateType": "AgentDefinition",
  "subject": "<apiVersionId>",
  "data": {
    "hostId": "<hostId>",
    "agentDefId": "<apiVersionId>",
    "apiVersionId": "<apiVersionId>",
    "modelProvider": "openai",
    "modelName": "gpt-4.1",
    "apiKeyRef": "secret://openai/default",
    "temperature": 0.7,
    "maxTokens": 4096
  }
}

The event utility should continue to accept either agentDefId or apiVersionId for AgentDefinition aggregate ID calculation, but the canonical payload should include both during migration and treat them as equal.

Task Center Flow

Add a Task Center definition:

id: register-ai-agent
title: Register AI Agent
category: API Marketplace or Portal Administration
roles: user, admin
keywords: agent, ai, genai, model, skill, tool

The task should guide the operator through a narrow registration path. It is different from Manage GenAI Assets, which is a broad maintenance task for agents, skills, tools, memory, and session history.

Steps

StepRequiredRoutePurpose
Create or select APIYes/app/form/createApi or API selectorEstablish the logical API record and canonical agent name.
Create agent API versionYes/app/form/createApiVersion?apiType=agtCreate api_version_t with agent API type and return apiVersionId.
Configure agent profileYes/app/form/createAgentDefinition or /app/genai/AgentDefinitionCreate agent_definition_t with agentDefId = apiVersionId.
Assign skillsNo/app/genai/AgentSkillAttach curated skills to the agent.
Review toolsNo/app/genai/Tool or /app/genai/SkillToolConfirm agent-invokable tools through skill-tool assignments.
Configure accessNo/app/access/rolePermissionRestrict who can invoke or manage the agent.
Link runtime instanceNo/app/instance/InstanceApiAttach the agent API version to a deployed runtime or gateway if needed.

Task Context

The task context should carry IDs from one step to the next:

{
  "hostId": "<hostId>",
  "apiId": "<apiId>",
  "apiVersionId": "<apiVersionId>",
  "agentDefId": "<apiVersionId>",
  "serviceId": "<serviceId>",
  "providerId": "<modelProvider>",
  "apiType": "agt"
}

When the API version step completes, apiVersionId should be copied to agentDefId automatically before launching the agent definition step.

Incomplete Registration Handling

If the UI calls createApiVersion and then fails before createAgentDefinition is processed, the system can contain an agent API version without an agent definition. This is an incomplete registration, not a valid runnable agent.

The UI and query layer should treat these rows explicitly:

  • Agent list views should be able to detect agent API versions missing matching agent_definition_t rows by left joining api_version_t to agent_definition_t.
  • The row should be shown as Incomplete or Profile missing, not as a ready agent.
  • The primary action should be Complete profile, prefilled with agentDefId = apiVersionId.
  • A secondary action can delete or deactivate the orphaned API version if the operator abandons the registration.
  • Runtime catalog reads should not expose incomplete agents as executable.

This requirement makes the UI-orchestrated implementation safe enough for the first Task Center version. The long-term backend command should still create the version and profile in one ordered command to reduce orphan creation.

Frontend Design

Phase 1: Task Registry Only

The first implementation can add a Task Center entry that reuses existing pages and forms:

  • createApi
  • createApiVersion
  • createAgentDefinition
  • AgentSkill
  • SkillTool
  • rolePermission
  • InstanceApi

This is low risk and aligns with the current task-oriented navigation model.

The createApiVersion form should support prefilled apiType=agt from the task route. The form completion handler should save returned apiVersionId into the task context.

The createAgentDefinition form should accept apiVersionId or agentDefId from task context and submit both values, with agentDefId equal to apiVersionId.

Phase 2: Dedicated Registration Wizard

After the flow is validated, add a dedicated wizard route such as:

/app/genai/register-agent

The wizard can reduce clicks by combining API version and agent profile fields on one page while still submitting separate commands or a composite command.

Recommended sections:

  • API identity: API name, API ID, status, owner.
  • Version identity: version, service ID, environment tag, target host.
  • Model profile: provider, model, API key reference, temperature, max tokens.
  • Optional skills: selected skill IDs.
  • Optional deployment: instance or gateway link.

Secret Reference Selection

apiKeyRef is a secret reference, not a secret value. The UI should not ask operators to paste raw provider keys into the agent definition form.

The preferred control is a selector populated from the configured secret catalog, config-server reference data, or vault integration available to the current host. The selected value should be stored as a reference such as:

secret://openai/default

If manual entry is temporarily supported, it should be an advanced path with validation. The command should reject values that look like raw API keys and should accept only approved reference schemes.

Secure Default Access

The access-control step is optional for registration completeness, but runtime execution must be secure by default. A newly registered agent should not be publicly invokable just because the API version and profile exist.

Default behavior:

  • management is limited to the creator, owner, or admin roles according to the existing ownership model;
  • runtime invocation is denied until an explicit role, scope, policy, or runtime assignment grants access;
  • skill and tool assignment does not override access control;
  • if no access policy exists, the gateway or agent runtime should treat the effective execution policy as deny-all.

Backend Command Options

Option 1: UI-Orchestrated Existing Commands

The Task Center flow calls existing commands in sequence:

  1. createApi, if a new API is needed.
  2. createApiVersion.
  3. createAgentDefinition.
  4. Optional createAgentSkill events.

This is the recommended initial implementation. It avoids changing command handler infrastructure and uses existing event types.

This option must include incomplete-registration handling. Without that, a browser failure or second-command validation error can leave an agent API version without an agent definition. That state is repairable, but the UI must surface it clearly and runtime catalog reads must ignore it.

Option 2: Composite Register Command

Add a composite command such as:

lightapi.net/genai/registerAiAgent/0.1.0

The command would validate the combined request and emit ordered events:

  1. ApiVersionCreatedEvent.
  2. AgentDefinitionCreatedEvent.
  3. Optional AgentSkillCreatedEvent events.

This improves user experience for automation and API consumers, but it requires the command layer to support a multi-event result in one request. The command must not bypass event processing or write projection tables directly.

The initial composite command should require an existing apiId. Keeping API creation as a separate command keeps the backend contract smaller and preserves the existing API ownership workflow. A later full registration command can add ApiCreatedEvent if automation needs to create the logical API and agent version in one request.

Recommendation

Start with Option 1 only if incomplete registrations are visible and repairable. Prioritize Option 2 before exposing a one-click production registration wizard, because it gives the backend one validation boundary for the API version and agent profile.

Validation Rules

Command handlers should enforce these rules server-side:

  • Agent API versions must use apiType = agt or an accepted compatible value.
  • New writes should use canonical agt. Legacy agent should be accepted only for migration, import, or replay compatibility.
  • A logical API should not mix active agent and non-agent API versions.
  • agentDefId must equal apiVersionId when both are present.
  • The referenced API version must exist before creating the agent definition.
  • The referenced API version must belong to the same hostId.
  • The referenced API version must have agent API type.
  • modelProvider and modelName are required for creation.
  • apiKeyRef, when present, must be a secret reference and not a raw provider key.
  • temperature, when provided, must be in the supported provider range.
  • maxTokens, when provided, must be positive.
  • Optional skill IDs must reference active skills in the same host scope.

The UI should guide the user, but the command and persistence layers should remain authoritative.

Query And Display

Agent list and detail views should display a joined projection:

FieldSource
agentDefIdagent_definition_t.agent_def_id
apiVersionIdsame value as agentDefId
agentNameapi_t.api_name
apiIdapi_version_t.api_id
apiVersionapi_version_t.api_version
apiTypeapi_version_t.api_type
serviceIdapi_version_t.service_id
envTagapi_version_t.env_tag
targetHostapi_version_t.target_host
modelProvideragent_definition_t.model_provider
modelNameagent_definition_t.model_name
apiKeyRefagent_definition_t.api_key_ref

The Agent Definition page should make the API identity read-only once selected. Mutable profile fields should remain editable through AgentDefinitionUpdatedEvent.

Delete And Update Semantics

Updating the API name should update the visible agent name because the display name comes from api_t.api_name.

Updating the API version should not implicitly update model settings. Model profile changes should use AgentDefinitionUpdatedEvent.

Deleting or deactivating the API version should cascade or hide the agent definition through the existing API-version relationship. Explicit AgentDefinitionDeletedEvent remains useful when the operator wants to disable the agent profile while keeping the API version.

Migration Notes

  • Existing rows that use api_type = agent can remain readable while the UI moves toward canonical agt.
  • Projection builders can normalize legacy agent events to agt in api_version_t after the migration window. Event streams remain immutable, but new command writes should use only agt.
  • Existing task contexts may carry either apiVersionId or agentDefId. Task utilities should normalize both values to the same ID.
  • Documentation and form labels should say Agent API version id where the ID is exposed.
  • Import/export and event replay should preserve event order for agent registration bundles.

Implementation Plan

  1. Add Register AI Agent to portal-view/src/tasks/taskRegistry.ts.
  2. Add help content under src/help/portal-view/tasks/register-ai-agent.md.
  3. Ensure createApiVersion can be launched with apiType=agt.
  4. Ensure form completion stores apiVersionId into task context.
  5. Ensure createAgentDefinition can prefill agentDefId from apiVersionId.
  6. Add server-side validation that agentDefId == apiVersionId.
  7. Add compatibility handling for agt and agent API type values.
  8. Add incomplete-registration detection and repair actions for agent API versions that do not have a matching agent definition.
  9. Add secure-by-default invocation checks for agents with no explicit access policy.
  10. Add integration tests for the two-event registration sequence.
  11. Add a composite registerAiAgent command for API-version plus profile creation.

Resolved Recommendations

  • Persist agt as the canonical API type after migration. Keep agent readable for replay, import, and old data, but reject new command writes using agent after the migration window.
  • Put Register AI Agent under API Marketplace initially because the agent is registered as an API and should be discoverable through the API catalog. If a dedicated GenAI Assets category is added later, the task can move there without changing the backend model.
  • Keep skill assignment optional. An agent can be useful as an LLM-only worker, and required skill assignment would block simple conversational agents.
  • The first composite command should require an existing API and emit ApiVersionCreatedEvent plus AgentDefinitionCreatedEvent. Keep ApiCreatedEvent separate until automation needs a full create-everything command.

Decision Summary

Register AI agents through a Task Center flow that starts from API and API version registration. Create api_version_t first, then create agent_definition_t with agentDefId equal to apiVersionId. Use two domain events for the two required facts, keep projection writes behind event processing, reject mixed API-type families, treat incomplete version-only registrations as repairable but non-runnable, default runtime invocation to deny-all, and make the broader skill/tool/access setup optional follow-up steps.

OAuth Kafka

Token Exchange

This document outlines the design decisions and implementation details for supporting multiple token exchange flows in the oauth-kafka module.

Comparison of Detection Methods

When implementing token exchange (RFC 8693), the server must determine which identity provider (IdP) issued the subject_token to verify it correctly and map claims.

MethodExplanationProsConsRecommended For
JWT Peek (iss)Server decodes the token header/body without verification to read the iss claim.Zero client configuration; Uses standard parameters.Token is parsed twice; Sensitive to malformed tokens.Public OIDC providers (Azure, Okta, Google).
Custom URNsClient sends a specific requested_token_type (e.g. urn:networknt:msal).Explicit and unambiguous; Follows standard extensibility.Clients must know the specific URNs for each flow.Mixed heterogeneous token types (SAML vs JWT).
subject_issuerClient passes an extra subject_issuer parameter in the request.Clean API; Works with “opaque” (non-JWT) tokens.Non-standard parameter; Redundant for self-describing JWTs.Opaque tokens or overlapping issuers.
Client ContextServer maps the client_id of the caller to a specific flow.Highly secure; Enforces strict per-client policy.High management overhead; Inflexible for multi-source clients.Rigid, security-conscious B2B integrations.

Implementation Strategy

Our implementation in ProviderIdTokenPostHandler uses Option 4: Client Context as the primary strategy:

  1. Database-Driven Configuration: A new column token_ex_type has been added to the auth_client_t table to specify the supported exchange type for each client.
    ALTER TABLE auth_client_t ADD COLUMN token_ex_type VARCHAR(64);
    
  2. Supported Exchange Types:
    • msal: Microsoft Authentication Library based exchange.
    • ccac: Client Credentials to Authorization Code exchange.
  3. Flow Determination: Instead of relying on client-supplied parameters like requested_token_type, the server retrieves the token_ex_type from the client context in the database to decide which handler to use. This ensures that only authorized exchange types are performed for each specific client.

Recommendation

For the light-portal ecosystem:

  • Option 4: Client Context is the selected method. It provides the highest level of security by ensuring that token exchange flows are explicitly configured and restricted on a per-client basis in the database.
  • token_ex_type should be populated for any client that requires token exchange functionality. Clients without this configuration will not be allowed to perform token exchange.

Future Considerations

  • Implement automated issuer discovery if the number of external providers grows.
  • Support “opaque” token exchange by integrating with introspection endpoints of external IdPs.
  • Extend the auth_client_t configuration to support multiple allowed exchange types per client if needed.

Light OAuth

Light OAuth IPv6 Support

Problem

The Rust light-oauth service binds its HTTPS listener to 0.0.0.0. That is correct for IPv4, but it does not accept connections sent to an IPv6 address.

In a dual-stack container network, Docker DNS can return the IPv6 address for light-oauth before the IPv4 address. A client that does not retry the next address can fail even though the service is healthy on IPv4. One observed failure is the gateway proxying /oauth2/{providerId}/code to https://light-oauth:6881 and receiving ECONNREFUSED on the IPv6 address.

Goals

  • Allow light-oauth to bind IPv4, IPv6, or a specific interface from config.
  • Keep the current default behavior as IPv4 wildcard binding.
  • Avoid breaking existing deployments whose server.yml does not contain the new property.
  • Build the listener address with SocketAddr so IPv6 addresses are parsed correctly.

Non-Goals

  • Do not enable IPv6 for every deployment by default.
  • Do not change TLS, OIDC, token, or database behavior.
  • Do not change gateway upstream retry behavior in this change.

Configuration

light-oauth adds a server bind IP property:

ip: ${server_ip:0.0.0.0}

The default value remains:

server_ip: "0.0.0.0"

To listen on IPv6 wildcard:

server_ip: "::"

To listen on a specific IPv4 or IPv6 address:

server_ip: "172.16.1.3"
server_ip: "fdd0:0:0:1::3"

Implementation

The Rust config model includes an ip field with a default of 0.0.0.0. The default keeps old external server.yml files working.

The listener address is built as:

#![allow(unused)]
fn main() {
let ip = config.ip.parse::<IpAddr>()?;
let addr = SocketAddr::new(ip, config.port);
}

This avoids string formatting problems with IPv6 addresses. For example, :: plus port 6881 must become [::]:6881, not :::6881.

Deployment Guidance

Use IPv6 binding only when the runtime network is intentionally dual-stack and other services can reach the IPv6 address. In a container environment, confirm that:

  • the container network has IPv6 enabled;
  • the service has an IPv6 address;
  • clients resolve or connect to the same address family;
  • health checks cover the chosen address family.

For local or single-stack deployments, keep the default IPv4 binding.

Verification

For IPv4 default:

curl -k https://light-oauth:6881/oauth2/<providerId>/keys

For IPv6 wildcard binding in a dual-stack network, verify from another container:

curl -k -g https://[<light-oauth-ipv6>]:6881/oauth2/<providerId>/keys

If the client uses service DNS, verify that the first returned address family is reachable:

getent ahosts light-oauth
curl -k -v https://light-oauth:6881/oauth2/<providerId>/keys

Light Controller

Light Controller IPv6 Support

Problem

The Rust controller-rs service is the control-plane endpoint for runtime registration, discovery, MCP admin traffic, and portal event streams. In dual-stack deployments, clients can resolve the controller hostname to either IPv4 or IPv6. The controller listener and its TLS configuration must therefore support IPv6 without changing the existing IPv4 deployment defaults.

The important distinction is between socket addresses and service metadata:

  • the controller listener uses a socket address such as 0.0.0.0:8438 or [::]:8438;
  • registered runtime instances publish a host address string plus a separate port, such as fdd0:0:0:1::3 and 8443.

Current Behavior

controller-rs already stores its bind address as a Rust SocketAddr. The default remains IPv4 wildcard binding:

0.0.0.0:8438

The listener address can be overridden with CONTROLLER_ADDR. For IPv6, the value must use bracketed socket-address syntax:

CONTROLLER_ADDR='[::]:8438'

This value is used directly by both server modes:

  • HTTPS/WSS mode uses axum_server::bind(settings.listen_addr).
  • HTTP/WS mode uses tokio::net::TcpListener::bind(settings.listen_addr).

There is no string concatenation of IP and port in the controller listener path, so the common IPv6 failure form :::8438 is avoided.

Goals

  • Support IPv4, IPv6, and dual-stack controller listener binding.
  • Keep the current default as IPv4 wildcard binding.
  • Keep the existing CONTROLLER_ADDR configuration contract.
  • Accept runtime registration metadata that uses IPv4 literals, IPv6 literals, or DNS hostnames.
  • Reject unspecified runtime registration addresses such as 0.0.0.0 and ::, because those are bind addresses, not reachable service addresses.

Non-Goals

  • Do not enable IPv6 by default.
  • Do not change controller WebSocket paths or JSON-RPC contracts.
  • Do not change the runtime registration metadata shape.
  • Do not add client-side IPv4 fallback in this change.

Listener Configuration

Default IPv4 listener:

CONTROLLER_ADDR='0.0.0.0:8438'

IPv6 wildcard listener:

CONTROLLER_ADDR='[::]:8438'

Specific IPv6 interface:

CONTROLLER_ADDR='[fdd0:0:0:1::10]:8438'

Specific IPv4 interface:

CONTROLLER_ADDR='172.16.1.10:8438'

The brackets are required only because CONTROLLER_ADDR is a full socket address. They separate the IPv6 literal from the port.

TLS Configuration

The controller starts with TLS enabled by default. IPv6 listener support does not remove the normal TLS hostname requirements.

For production, provide a certificate whose Subject Alternative Name covers the DNS name or IP address clients use to reach the controller:

CONTROLLER_TLS_CERT_PATH=/config/server.pem
CONTROLLER_TLS_KEY_PATH=/config/server.key
CONTROLLER_TLS_TRUST_CERT_PATH=/config/ca.pem

For generated local self-signed certificates, include any IPv6 literal that clients will use:

CONTROLLER_TLS_SERVER_NAME=localhost
CONTROLLER_TLS_ALT_NAMES='localhost,127.0.0.1,::1'

If clients connect by DNS name, prefer adding that DNS name to the certificate SAN and keep clients using the name instead of a raw IP literal.

Runtime Registration Metadata

Runtime services connect outbound to Light Controller and send service/register metadata. The registration address is not a socket address; the address and port are separate fields.

IPv6 registration metadata should use the raw IPv6 literal:

{
  "serviceId": "com.networknt.light-gateway-1.0.0",
  "protocol": "https",
  "address": "fdd0:0:0:1::3",
  "port": 8443
}

Do not use brackets in the address field:

{
  "address": "[fdd0:0:0:1::3]"
}

Brackets are only used when constructing URL authorities or socket addresses. The controller validates registration addresses as IP literals or DNS hostnames and rejects unspecified bind addresses such as 0.0.0.0 and ::.

Discovery Behavior

Discovery returns the registered address and port separately. Downstream clients, such as light-gateway, are responsible for building a reachable upstream authority. For IPv6 literals, clients must bracket the address when they construct a URL or host:port authority:

address = fdd0:0:0:1::3
port    = 8443
target  = [fdd0:0:0:1::3]:8443

Deployment Guidance

Only configure the controller with CONTROLLER_ADDR='[::]:8438' when the host, container network, Kubernetes Service, and ingress path are intended to accept IPv6 traffic.

In a dual-stack environment, verify all of these:

  • the controller process is listening on IPv6;
  • DNS returns the expected address family;
  • TLS SANs cover the hostname or IP clients use;
  • runtime services can open outbound WebSocket connections to the controller;
  • registered service metadata publishes reachable addresses, not wildcard bind addresses.

Verification

From a peer in the same network:

getent ahosts <controller-host>
curl -k -g https://[<controller-ipv6>]:8438/health
curl -k -v https://<controller-host>:8438/health

For WebSocket clients, verify the same address family through the real endpoint:

wss://<controller-host>:8438/ws/microservice
wss://<controller-host>:8438/ws/discovery
wss://<controller-host>:8438/ctrl/mcp

If a TLS client connects by IPv6 literal and fails certificate validation, check the certificate SANs before changing the controller listener.

Configurable Controller Transport and Codec Profiles

Status

This document defines a layered controller communication architecture in which application semantics, serialization, framing, transport, and gateway routing are separate decisions. It covers both the Rust runtime control channel and the browser MCP path through light-gateway.

  • Status: proposed future transport architecture; the active production milestone is JSON/WebSocket Control Plane V1. WebTransport remains blocked, and the N5 codec decision did not authorize a production rkyv canary
  • Protocol baseline tested: 2026-07-16
  • Implementation gate: WebTransport implementation remains blocked until a future Phase 0 rerun satisfies the browser, native-library, and current-draft contracts
  • Controller: controller-rs
  • Rust runtime client: light-fabric/crates/portal-registry
  • Browser client: portal-view
  • Gateway/BFF: light-fabric/apps/light-gateway
  • Existing compatibility paths: MCP-style JSON-RPC over WebSocket and runtime JSON-RPC over WebSocket
  • Experimental transports and codecs: WebTransport and rkyv
  • Out of scope: metrics ingestion, distributed controller state, browser rkyv, and automatic translation between different transports or codecs

WebTransport remains disabled. WebSocket and JSON remain the defaults until a future feasibility run satisfies protocol compatibility, browser authentication, gateway routing, deployment support, and performance gates.

Completed implementation status

The WebSocket-only near-term sequence N0 through N5 is complete:

  • N0 froze the comparable JSON/WebSocket workload and baseline;
  • N1 extracted codec-neutral session inputs and outputs;
  • N2 added explicit WebSocket profile negotiation and handshake authentication;
  • N3 completed the opt-in rkyv-over-WebSocket vertical slice;
  • N4 completed bounded decoding, fuzzing, and resilience gates; and
  • N5 found no qualifying material codec benefit and selected KEEP_JSON_WEBSOCKET_DEFAULT_NO_CANARY.

N6 is not authorized. The dormant rkyv implementation remains disabled and is not an available production profile. Reopening it requires a new comparable N5 result that satisfies the existing material-benefit threshold. Reopening WebTransport separately requires a new Phase 0 result that satisfies the browser, native Rust, gateway, and deployment contracts.

Active JSON/WebSocket Control Plane V1 Contract

This section is normative for the stabilization milestone. The remainder of this document describes both the current baseline and possible future profiles; where a future-profile section conflicts with this section, this V1 contract wins for production configuration.

Enabled surfaces and profiles

HopV1 profile
Browser to light-gatewayMCP/JSON-RPC 2.0 over /ctrl/mcp WebSocket
light-gateway to controller-rsPayload-opaque /ctrl/mcp WebSocket tunnel
Light-Fabric runtime to controller-rsLegacy JSON over /ws/microservice WebSocket

CONTROLLER_WEBSOCKET_BINARY_PROFILES is empty, runtime controlCandidates do not select rkyv, and no WebTransport listener, capability endpoint, browser ticket, QUIC route, or HTTP/3 inference is enabled. /ctrl/mcp is the single browser control and notification socket; the obsolete /ws/mcp and /ws/portal-events split is not a supported V1 topology.

Cumulative authorization boundaries

The gateway makes one connection decision before discovery, connection permit acquisition, or upstream connection. It constructs the canonical policy key /ctrl/mcp@connect from the matched /ctrl/mcp route and admits only an exact admin, host-admin, or instance-admin role token. Missing, invalid, or unavailable policy denies when defaultDeny is true. Service-to-service routes remain outside this human-role decision.

The gateway registers and enables the shared access-control runtime, injects it into WebSocketRouterRuntime, and does not also run the generic HTTP access-control handler in the normal chat chain. That chain remains exception -> stateless -> security -> websocket.

The gateway remains payload-opaque after upgrade. The controller independently verifies the forwarded Bearer JWT, requires portal.r for MCP connection/read operations, and requires portal.w for mutations. Gateway roles never replace controller scopes. The exhaustive controller tool policy is frozen as data in implementation/light-controller/stabilization/s0/fixtures/controller-tool-policy-v1.json. Any catalog change must update that manifest and its conformance cases in the same pull request.

The Rust OAuth issuer advertises portal.r and portal.w, validates requested scopes as complete tokens rather than substrings, and grants portal.w only to registered clients/users that are entitled to it. Tokens with write authority contain both portal.r and portal.w.

All runtime mutation requires CONTROLLER_MCP_MUTATIONS_ENABLED=true plus portal.w. Shutdown additionally requires CONTROLLER_MCP_SHUTDOWN_ENABLED=true; chaos mutation/fault injection additionally requires CONTROLLER_MCP_CHAOS_ENABLED=true. All three settings default to false. Reading chaos configuration remains a read operation but is hidden unless the chaos feature is enabled.

Browser handshake and Origin

The browser supplies its CSRF token through the existing WebSocket subprotocol mechanism. The gateway validates exactly one matching CSRF token, removes it from the upstream protocol offer, strips browser Cookie/Authorization and untrusted identity headers, and injects only trusted routing context and the gateway-produced Bearer token.

websocket-router.originAllowlist[/ctrl/mcp] is non-empty. The gateway compares the complete normalized scheme, host, and effective port. Missing, opaque/null, malformed, suffix, substring, or unlisted Origins deny before upstream connection. The controller listener is not exposed to the browser network.

MCP lifecycle, messages, and errors

The external session moves through connecting, awaiting-initialize-result, sending-initialized, and ready. Before ready, only initialize, the subsequent notifications/initialized, and WebSocket control frames are valid. The V1 MCP protocol version is 2025-11-25.

Requests and responses use JSON-RPC 2.0. IDs are strings or numbers and are never reused while pending. Notifications omit id. Parse error, invalid-request, method-not-found, invalid-params, internal, timeout, unavailable, resource-limit, and structured tool-error outcomes are bounded and must not expose credentials or full sensitive arguments. An unsupported or unavailable runtime capability never becomes generic success.

Notification ownership

Host lifecycle notices may reach every admitted MCP connection for that host. Log and other runtime streams are bounded per-connection subscriptions keyed by runtime instance and notification class. start_logs adds only the caller’s subscription; stop_logs removes only that caller’s subscription. If the runtime stream is shared, controller reference counting prevents one caller from stopping another. Disconnect, token expiry, runtime reconnect, or explicit stop revokes the affected subscription.

Notification delivery is at-most-once and has no durable cursor in V1. A disconnect may lose notifications. After reconnect, portal-view must complete MCP initialization, rerun the authoritative hybrid query, refresh runtime capabilities, reconcile the live overlay, and only then recreate the user’s requested stream subscriptions. It must not replay mutations or assume an old start_logs subscription survived the connection.

Runtime list and host binding

lightapi.net/instance/getRuntimeInstance/0.1.0 is the authoritative set of UI runtime rows. Its gateway req-acc rule admits the same three administrative roles. The handler reads the independently verified host claim and requires an exact match with requested data.hostId; missing, malformed, mismatched, or client-only host context fails closed even for admin. Controller live state may update only IDs already returned by the hybrid query and never creates a new row.

Non-administrative user/position ownership is deferred until a durable runtime-to-configured-instance association and authoritative human owner source exist. V1 does not widen /ctrl/mcp or this control-page query to those users. V1 also has no cross-host wildcard, portal.global_r bypass, or super-admin exception. A future global view requires a separate design for issuer grants, audit, query fan-out, pagination, failure isolation, and per-host enforcement; it must not weaken the exact verified-host rule in place.

Limits and policy convergence

The controller enforces a 1 MiB MCP JSON message/frame limit plus bounded connections, pending commands, queues, subscriptions, initialization, and request deadlines. The payload-opaque gateway enforces transport/header, connection, rate, idle, upstream-connect, and read/write limits; it does not claim application-message parsing.

An authenticated MCP connection is limited to one active dispatch, 32 requests per second with a burst of 64, and JSON nesting depth 128. Controller-wide MCP admission is limited to 1,024 requests per second with a burst of 2,048. Exceeding a request bound returns a resource-limit outcome; repeated abuse closes the connection. These limits apply before expensive tool dispatch and are independent of the runtime command permits.

A valid policy reload applies atomically to new upgrades. A malformed reload retains the last known-good revision. Existing opaque tunnels are not force-closed by reload; their effective lifetime is the lesser of the verified JWT lifetime and websocket-router.maxConnectionDurationMs=900000. Immediate incident revocation disables the external route or restarts the gateway. Critical off-boarding must use that immediate operational path; routine policy reload alone is not an immediate revocation mechanism for an existing tunnel.

The immutable V1 examples, authorization corpora, limit contract, tool-policy manifest, and baseline gate live under implementation/light-controller/stabilization/s0. They are contract inputs, not runtime dependencies shared between TypeScript and Rust.

Problem

Rust services currently connect to controller-rs through /ws/microservice. The channel carries JSON-RPC messages for:

  • service/register and its acknowledgement;
  • service/update_metadata;
  • discovery lookup and subscriptions;
  • controller-to-runtime commands;
  • runtime-to-controller command responses;
  • runtime notifications such as notifications/log;
  • liveness messages.

The browser separately connects to /ctrl/mcp on light-gateway. The gateway’s WebSocket router authenticates and resolves the target during the HTTP/1 upgrade, then Pingora tunnels the upgraded connection to controller-rs without parsing MCP messages.

Both paths currently bind several independent choices together:

  • MCP and runtime-control application semantics;
  • JSON-RPC serialization;
  • WebSocket message framing;
  • TCP/TLS transport;
  • gateway routing and authentication.

That coupling makes it difficult to determine whether a measured improvement comes from QUIC multiplexing, a binary codec, or both. It also makes a future browser WebTransport path appear to require rkyv, although the browser should continue to use MCP JSON.

The current runtime channel is reliable and portable, but all messages in one direction share one ordered WebSocket byte stream and JSON parsing. A high-volume runtime notification can therefore delay a command response traveling in the same service-to-controller direction.

The commonly cited example of an inbound log blocking an outbound controller command is not precise. TCP is full-duplex: runtime logs travel toward the controller while the command request travels toward the runtime. The risk that must be measured is same-direction queueing, especially command responses sharing the runtime’s outbound path with log notifications.

The design must not assume that WebTransport or rkyv is faster for this workload. Registration also includes JWT validation and persistence, and many command results contain arbitrary JSON. Benchmarks must vary transport and codec independently so the source of any improvement is measurable.

Current Boundaries

The current end-to-end surfaces are:

Surface or hopCurrent protocolPurpose
Browser to light-gateway /ctrl/mcpMCP-style JSON-RPC over WebSocketBrowser administrative calls and notifications
light-gateway to controller-rs /ctrl/mcpPayload-opaque WebSocket tunnelRoute the authenticated browser session to the controller
Runtime to controller-rs /ws/microserviceJSON-RPC over WebSocketRegistration, discovery, commands, responses, and notifications
Discovery client to controller-rs /ws/discoveryJSON-RPC over WebSocketStandalone discovery subscriptions

This proposal keeps every existing endpoint and payload contract. It adds an experimental WebTransport binding for runtime control and defines a future MCP JSON binding through a WebTransport-aware gateway router. /ws/discovery remains WebSocket-only in the initial implementation.

MCP remains the external command surface and JSON-RPC message contract. rkyv is a serialization codec for the private Rust runtime-control profile; it is not a replacement encoding for standard MCP clients.

The private Rust runtime-control profile includes registration, registry and discovery operations carried on that controller connection, commands, responses, and runtime notifications. It does not change general application service-to-service APIs. Standalone discovery clients and non-Rust controller clients remain on their explicitly supported JSON profiles unless a later design adds another interoperable codec.

The existing ServiceMetadata.protocol field describes the runtime’s advertised application protocol, such as https. It must not be reused to record the controller connection transport or wire codec.

Layered Model

The implementation separates five layers:

MCP handlers or runtime-control handlers
  -> logical requests, responses, notifications, and lifecycle events
  -> negotiated wire profile and codec
  -> message framing and logical channel mapping
  -> WebSocket or WebTransport session adapter
  -> optional light-gateway route resolver and transport proxy

The terms used by this document are:

  • Application protocol: MCP or the private runtime-control protocol.
  • Codec: JSON-RPC/JSON or the versioned rkyv wire schema.
  • Wire profile: the complete application message contract, codec, framing, validation rules, limits, and version. A profile does not select a transport.
  • Transport: WebSocket or WebTransport.
  • Router: a gateway component that authenticates the incoming session, selects an upstream, and proxies the selected transport.

Transport configuration selects how a session is carried. Protocol negotiation selects how its bytes are interpreted. A server accepts only explicitly allowed profile and transport pairs; configurability does not imply support for every Cartesian product.

Supported Profile Matrix

SurfaceWire profileCodecWebSocketWebTransportInitial decision
Browser MCPlight-controller-mcp-json-v1JSON-RPCExisting, requiredExperimental after gateway and auth workPreserve MCP interoperability
Runtime controllight-controller-runtime-json-v1JSON-RPCExisting, requiredExperimentalRequired to measure transport independently
Runtime controllight-controller-runtime-rkyv-v1rkyvExperimental binary framesExperimentalRust-only optimized candidate
Browser MCPAny rkyv profilerkyvNot supportedNot supportedBrowser binary MCP is out of scope
Standalone discoveryExisting JSON-RPCJSON-RPCExisting, requiredNot initially supportedNo change in the first release

The current WebSocket paths predate profile negotiation. Absence of a profile token on those paths means the existing JSON behavior. New binary behavior requires an explicit negotiated token and must never be inferred from the first payload bytes.

Goals

  • Make transport and codec selection independent while preserving allowlisted combinations.
  • Add a multiplexed Rust-to-Rust control transport without removing the current WebSocket contract.
  • Define MCP JSON over WebTransport without requiring browser rkyv.
  • Reuse gateway route resolution, discovery, authorization, and limits without treating the current HTTP/1 upgrade tunnel as a WebTransport implementation.
  • Keep controller business behavior identical across transports and codecs.
  • Isolate controller commands and their responses from asynchronous runtime notifications.
  • Use a validated, explicitly versioned rkyv format for stable Rust wire types.
  • Support rolling upgrades where old Rust clients and all Java clients continue to use WebSocket.
  • Configure accepted transports per controller surface so browser MCP, Rust runtime control, and standalone discovery can evolve independently.
  • Let portal-view discover the gateway’s current end-to-end MCP candidates at runtime instead of baking deployment readiness into the UI bundle.
  • Bound memory, stream concurrency, message size, and validation work before accepting untrusted input.
  • Provide explicit rollout, fallback, observability, and rollback behavior.
  • Measure JSON over WebTransport and rkyv over WebSocket so codec and transport effects are isolated.
  • Require measured improvement before changing any default.

Non-Goals

  • Do not replace /ctrl/mcp or its JSON-RPC payload contract.
  • Do not describe a private rkyv encoding as standard MCP.
  • Do not implement browser-side rkyv or require a WebAssembly decoder.
  • Do not translate WebSocket to WebTransport or JSON to rkyv in the first gateway implementation.
  • Do not require every allowed codec to use every transport.
  • Do not require Java or other non-Rust runtimes to implement rkyv.
  • Do not send general metrics or traces to controller-rs in the first release.
  • Do not claim zero allocation or zero head-of-line blocking.
  • Do not solve multi-replica command routing in this change.
  • Do not automatically switch an established session between transports.
  • Do not infer WebTransport availability from the HTTP version used to load the page or an unrelated UI resource.
  • Do not use unchecked rkyv access for network data.
  • Do not enable WebTransport by default in production deployments.

Options Considered

OptionAdvantagesCosts and risksDecision
Keep WebSocket and JSONNo deployment or compatibility changeRetains one ordered stream per direction and JSON overheadRemains the baseline and fallback
JSON over WebTransportIsolates transport effects and supports browser MCP without a new codecRetains JSON parsing and requires UDP/HTTP3 deploymentRequired experimental profile
Binary WebSocket frames with rkyvIsolates serialization changes; works through current infrastructureDoes not provide independent streams; still Rust-specificRequired benchmark comparison
Multiple WebSocket connectionsSeparates control and high-volume events using mature infrastructureMore connection lifecycle and authentication stateViable fallback if multiplexing is the only requirement
Raw QUIC with rkyvStable QUIC RFCs; simpler for native Rust peersNo WebTransport session semantics or future browser compatibilityRequired design-spike comparison
WebTransport with rkyvIndependent streams, datagram capability, and HTTP/3 session modelActive draft, immature implementations, UDP deployment workRust runtime opt-in experiment
Protobuf over WebSocket or QUICStrong polyglot tooling and defined schema evolution rulesParsing and generated-code cost; not zero-copyPreferred alternative if polyglot use expands
Dedicated telemetry pipelineKeeps control-plane load isolated; uses OTLP, Prometheus, or another telemetry protocolSeparate service and operational pathPreferred for metrics and traces

WebTransport is justified only if independent streams, HTTP/3 integration, or browser support provide value beyond raw QUIC or multiple WebSockets. The runtime implementation spike must compare those alternatives rather than treating WebTransport plus rkyv as one indivisible decision.

Decision

Implement the layered profile model with these constraints:

  1. Keep /ws/microservice unchanged, enabled by default, and available throughout migration. It may be disabled for the runtime surface only after that surface’s client-fleet and network compatibility gates pass.
  2. Keep /ctrl/mcp as MCP-style JSON-RPC regardless of transport.
  3. Add https://<controller-authority>/wt/microservice over HTTP/3 and QUIC and support both runtime JSON and runtime rkyv profiles on it.
  4. Permit the runtime rkyv profile over WebSocket binary frames for isolation testing, without changing the legacy JSON default.
  5. Add browser MCP over WebTransport only after the gateway router and browser ticket-authentication flow pass their release gates.
  6. Negotiate one immutable wire profile before decoding non-legacy payloads.
  7. Use reliable streams for all initial WebTransport behavior; datagrams remain disabled.
  8. Preserve the current ControllerCommand correlation, timeout, response, and connection-ID-guarded cleanup semantics inside controller-rs. The bounded admission controls in this design intentionally replace unbounded pending-state admission; preserving semantics does not mean preserving the absence of those limits.
  9. Keep arbitrary tool arguments and results as bounded UTF-8 JSON bytes where the existing runtime contract is dynamic.
  10. Keep the gateway payload-opaque in the initial WebTransport implementation; cross-transport and cross-codec translation require a separate decision.
  11. Advance beyond experimental status only after compatibility, security, deployment, and benchmark gates pass.

High-Level Architecture

browser MCP path
  portal-view
    -> MCP JSON codec
    -> selected WebSocket or WebTransport adapter
    -> light-gateway matching transport router
    -> same transport and MCP JSON profile
    -> controller-rs /ctrl/mcp
    -> CommandRouter

runtime control path
  light-fabric portal-registry
    -> logical runtime-control messages
    -> selected JSON or rkyv codec
    -> selected WebSocket or WebTransport adapter
    -> controller-rs shared registration and command services

controller command path
  CommandRouter
    -> ControllerCommand channel
    -> selected live runtime session driver

The transport drivers must call the same controller service functions for:

  • JWT identity checks;
  • registration persistence;
  • instance insertion and removal;
  • portal and discovery notifications;
  • metadata updates;
  • pending-command completion and timeout handling;
  • disconnect cleanup.

Transport- or codec-specific code must not duplicate those business rules.

Gateway and BFF Routing

The current light-gateway WebSocket router is a payload-opaque HTTP/1 upgrade proxy. It resolves a service from headers, query parameters, or path-prefix configuration, applies authorization and connection limits, selects a discovered upstream, and lets Pingora tunnel the upgraded connection. It does not terminate MCP or translate JSON-RPC messages.

The WebTransport router should preserve that payload-opaque property, but it cannot reuse the HTTP/1 tunnel implementation. It needs an HTTP/3/QUIC listener and must act as a WebTransport server toward the browser and as a WebTransport client toward controller-rs.

The reusable gateway boundary is a transport-neutral route resolver containing:

  • path-prefix, header, and query-based service selection;
  • discovery and direct-registry target selection;
  • authentication and access-control decisions;
  • connection and admission limits;
  • routing-header cleanup, metrics, and reload behavior.

The WebSocket and WebTransport routers use that resolver but own separate listener, handshake, timeout, and proxy state. The initial WebTransport router:

  1. accepts an authenticated WebTransport CONNECT request;
  2. selects the controller using the shared route resolver;
  3. opens an upstream WebTransport session using the same wire profile;
  4. maps the downstream MCP or runtime-control stream to its upstream peer;
  5. maps each additional reliable stream with the same direction and role;
  6. propagates backpressure, stream resets, draining, and session close reasons;
  7. rejects datagrams because version 1 does not define a datagram mapping.

Path-based routing, BFF authentication, and profile negotiation are encrypted inside HTTP/3, so a generic UDP forwarder cannot replace this router. A pure QUIC layer-4 path is acceptable only for deployments with a fixed upstream that do not need BFF policy or path-based service selection.

For the first implementation, transport must match across the gateway:

WebSocket client -> WebSocket router -> WebSocket controller endpoint
WebTransport client -> WebTransport router -> WebTransport controller endpoint

Supporting WebSocket-to-WebTransport, WebTransport-to-WebSocket, JSON-to-rkyv, or rkyv-to-JSON would turn the gateway into an application relay. That requires message decoding, identity and cancellation mapping, independent buffering on both sides, and a separate design and benchmark.

The current Pingora integration has no HTTP/3 or QUIC listener. The experimental WebTransport service therefore runs alongside the existing Pingora TCP service and shares only routing, policy, configuration, and observability components. It must not delay or destabilize the existing HTTPS/WebSocket listener when the feature is disabled or fails to initialize.

Browser Transport Capability Discovery

The browser cannot discover the controller transport directly. It can detect its local WebTransport API, but it cannot infer the selected controller, the gateway-to-controller transport, enterprise UDP policy, or current route readiness. light-gateway therefore exposes an authenticated HTTPS capability resource on the controller BFF authority. That authority is same-origin by default and is resolved as defined in Browser BFF Authority:

GET /ctrl/mcp/capabilities

An example response is:

{
  "revision": "gw-42:controllers-17",
  "generatedAt": "2026-07-15T14:00:00Z",
  "expiresAt": "2026-07-15T14:00:30Z",
  "wireProfile": "light-controller-mcp-json-v1",
  "policy": "preferWebTransport",
  "candidates": [
    {
      "transport": "webtransport",
      "endpoint": "/ctrl/mcp",
      "ticketEndpoint": "/ctrl/mcp/ticket",
      "ready": true
    },
    {
      "transport": "websocket",
      "endpoint": "/ctrl/mcp",
      "negotiation": "legacy",
      "ready": true
    }
  ],
  "maxAgeSeconds": 30
}

revision is an opaque aggregate revision that changes whenever gateway policy, listener or router readiness, the eligible target set, or any target’s controller capability revision changes. generatedAt and expiresAt use UTC. The gateway also returns ETag: "<revision>". Version 1 always returns a complete 200 representation after HTTP-cache expiry rather than a bodyless 304, because the body carries an absolute expiry. Conditional revalidation requires a later contract for refreshing that absolute expiry without retaining a stale body.

The response is gateway-derived deployment state, not a copy of browser feature detection or raw controller configuration. A candidate is ready only when:

  • the corresponding gateway listener and router are enabled and healthy;
  • authentication, ticket issuance when applicable, and the allowed wire profile are configured;
  • the shared route resolver has a healthy controller target that accepts the same transport and profile; and
  • a recent same-transport synthetic probe has verified the gateway-to-controller leg.

Controller support is not inferred from route naming, a generic health response, or deployment-wide configuration. Every controller instance exposes an authenticated internal capability resource over its existing HTTPS listener:

GET /internal/controller/capabilities

It returns instanceId, an opaque revision, generation and expiry timestamps, listener readiness, and the enabled transport/profile set for each of mcp, runtime, and standaloneDiscovery. It also reports draining state. The revision changes whenever startup configuration, listener readiness, or draining state changes; a process restart creates a new instanceId. For example:

{
  "schemaVersion": 1,
  "instanceId": "controller-7f8c",
  "revision": "17",
  "generatedAt": "2026-07-15T14:00:00Z",
  "expiresAt": "2026-07-15T14:00:15Z",
  "draining": false,
  "listeners": {
    "websocket": { "ready": true },
    "webtransport": { "ready": true }
  },
  "surfaces": {
    "mcp": [
      {
        "transport": "websocket",
        "wireProfiles": ["light-controller-mcp-json-v1"]
      },
      {
        "transport": "webtransport",
        "wireProfiles": ["light-controller-mcp-json-v1"]
      }
    ],
    "runtime": [
      {
        "transport": "websocket",
        "wireProfiles": ["light-controller-runtime-json-v1"]
      },
      {
        "transport": "webtransport",
        "wireProfiles": [
          "light-controller-runtime-json-v1",
          "light-controller-runtime-rkyv-v1"
        ]
      }
    ],
    "standaloneDiscovery": [
      {
        "transport": "websocket",
        "wireProfiles": ["light-controller-runtime-json-v1"]
      }
    ]
  }
}

Unknown schema versions, expired responses, duplicate combinations, a transport whose listener is not ready, or a draining instance are ineligible for new sessions. The resource is available only to the gateway through the deployment’s approved service credential or mTLS identity; browser credentials and browser connection tickets are not accepted.

For each candidate, the gateway intersects that instance declaration with its own policy and route configuration, then runs a bounded transport-native probe against a dedicated gateway-authenticated probe resource on the same controller listener. The probe requests one surface and wire profile, completes transport and profile negotiation, returns the controller capability revision, and closes. It does not register a runtime, create an MCP session, enqueue a command, or mutate controller state. Probe credentials, intervals, timeouts, concurrency, and logs are bounded and independently configurable.

Because the first gateway implementation does not relay across transports, it must never advertise a WebSocket candidate backed only by a WebTransport controller endpoint, or the reverse. During a rolling controller upgrade, the gateway builds a candidate-specific eligible target pool from per-instance capability and probe results. It advertises the candidate only when that pool is non-empty and pins each accepted session to a target from that pool for the session lifetime. An instance that becomes stale, unready, or draining is removed from new-session selection and changes the aggregate capability revision; an existing session follows the draining rules rather than being silently moved to another target.

The response uses Cache-Control: private, max-age=30 or a shorter configured value and Vary: Origin, Cookie where those fields affect authorization or candidate policy. portal-view refreshes it after a failed candidate attempt or cache expiry. A normal refresh or an authenticated runtime configuration signal may reveal a new aggregate revision; when it does, the client discards candidates from the old revision. Capability discovery reduces expected failures but does not replace the actual handshake; a network path can change after the response is generated.

Capability-fetch failure does not authorize a blind transport attempt. In auto mode:

  • 401 or 403 is an authentication or authorization failure; do not try a transport and wait for the login state to change;
  • a malformed or semantically invalid response is Internal; reject it and do not infer a candidate;
  • timeout, network failure, 429, or 5xx may use a still-fresh previously validated capability response, but otherwise no session is attempted and the client retries discovery with the existing jittered exponential backoff; and
  • 404 or 501 means the gateway does not implement capability discovery. The auto-mode UI fails closed; an explicitly forced websocket build override is the compatibility escape for an old gateway.

Deploy the capability resource before deploying an auto-mode UI that depends on it. These rules prevent a transient gateway failure from silently weakening a requireWebTransport policy or guessing that legacy WebSocket is acceptable.

Browser BFF Authority

All browser controller resources use one controller BFF authority and ingress path prefix. portal-view resolves it with the same shared helper for capability fetch, ticket issuance, WebSocket, and WebTransport:

  1. use the configured VITE_API_BASE_URL, including its path prefix, when it is non-empty; otherwise
  2. use window.location.origin and the deployment’s normal ingress prefix.

Capability and ticket requests remain HTTPS requests with credentials: include and the existing CSRF rules. The WebSocket URL changes only the scheme to ws or wss; the WebTransport URL remains https. Redirects to a different authority are rejected for ticket issuance and WebTransport establishment.

Same-origin deployment is the version 1 production recommendation. A deployment that intentionally uses a different API authority must explicitly provide all of the following before browser WebTransport is ready:

  • credentialed CORS for capability and ticket HTTPS requests with an explicit portal Origin, never *;
  • compatible secure cookie Domain and SameSite attributes for the authenticated BFF requests;
  • the existing CSRF validation on ticket issuance;
  • an Origin allowlist for WebSocket and WebTransport establishment; and
  • browser and integration tests covering the exact portal and API authorities.

If any cross-origin prerequisite is absent, capability readiness is false and the UI fails closed instead of constructing URLs from mixed authorities.

The HTTP version used for the page navigation or an ordinary API request is not a selection signal. A page loaded over HTTP/2 can open a separate HTTP/3 WebTransport session, while an HTTP/3 page load does not prove that WebTransport CONNECT, ticket authentication, or the gateway-to-controller QUIC leg works. nextHopProtocol may be recorded as bounded diagnostic context only.

The gateway may advertise HTTP/3 for ordinary HTTPS traffic, and the browser may select it automatically. portal-view does not force the HTTP version of normal fetches. Only the WebTransport session requires the HTTP/3/QUIC behavior defined by this design through requireUnreliable: true and the resulting reliability check.

WebTransport Protocol Maturity

At the time of this design update, WebTransport over HTTP/3 is still an active IETF Internet-Draft. Draft versions can use different HTTP/3 settings and wire codepoints. The selected Rust library must be verified against the exact draft implemented by both peers and any intermediary.

Generic HTTP/3 support is not sufficient. The current WebTransport draft also requires extended CONNECT, WebTransport settings, HTTP/3 datagrams, QUIC datagrams, and stream-reset support. End-to-end interoperability must be tested through every supported load balancer or proxy.

The required Phase 0 interoperability target is draft-ietf-webtrans-http3-16. The 2026-07-15 spike did not find a supportable tuple for that target: the tested Rust crates use legacy WebTransport-over-HTTP/3 codepoints, and the tested Chrome did not implement the required CONNECT header, selected-protocol, and reliability API contract. Draft 16 is therefore a target, not a verified baseline. A future implementation must pin and record the exact WebTransport draft, Rust library version, browser version, and intermediary behavior in the release notes and compatibility matrix. A change to any of them requires rerunning the interoperability gate.

Endpoint and Wire Profile Negotiation

The WebTransport resources are:

https://<controller-authority>/wt/microservice  # runtime control
https://<gateway-authority>/ctrl/mcp             # browser MCP through BFF

The corresponding controller-side MCP resource remains /ctrl/mcp. The path identifies the application surface; it does not select JSON versus rkyv.

Clients offer one or more wire profiles such as:

light-controller-mcp-json-v1
light-controller-runtime-json-v1
light-controller-runtime-rkyv-v1

For WebTransport, the client sends profiles through the draft’s WT-Available-Protocols header and the server confirms one with WT-Protocol. Browser JavaScript supplies this offer with WebTransportOptions.protocols; it must not attempt to set WT-Available-Protocols directly in WebTransportOptions.headers. After ready resolves, the browser verifies that the selected transport.protocol is the single expected MCP profile.

For new WebSocket profiles, the client and server use Sec-WebSocket-Protocol. Existing JSON WebSocket clients that do not offer a wire profile retain their current behavior. The existing browser CSRF value in this header is authentication metadata, not a wire profile. The gateway uses the following deterministic rules:

  1. Parse all offered tokens instead of selecting the first token.
  2. Extract and validate exactly one csrf.<value> token when the browser path requires it.
  3. Remove the CSRF token before forwarding a negotiated profile offer upstream.
  4. Forward only recognized, endpoint-allowed wire-profile tokens.
  5. Return only the profile selected by the upstream controller for a negotiated session; never echo a CSRF token as the selected wire profile.
  6. Preserve the current CSRF-only behavior for legacy browser clients that do not offer a wire profile.

A server rejects a new negotiated session when no common allowed profile exists or when the selected profile is not permitted on that endpoint and transport. An explicit negotiated candidate never treats an absent selected token as a successful negotiation. Legacy JSON is represented by an explicit client candidate with negotiation: legacy; it sends no wire-profile token and is the only candidate allowed to accept the existing no-profile response.

The negotiated token selects the complete application wire profile, including:

  • application semantics and codec;
  • frame header layout;
  • numeric message kinds;
  • archived type definitions;
  • rkyv version and format-control features;
  • validation and size limits;
  • logical channel roles and error semantics.

Transport-specific details such as WebSocket frames versus WebTransport streams remain the responsibility of the session adapter. A profile token must not encode websocket or webtransport; the same profile may be allowed on either transport by server policy.

Supporting a new incompatible wire shape requires a new profile token. During a rolling upgrade, the controller should support the current and immediately previous wire-profile versions.

Runtime Authentication and Registration

Every non-legacy negotiated runtime session carries the service JWT in its transport handshake:

Authorization: Bearer <service-jwt>

For WebTransport this is the CONNECT request. For WebSocket it is the HTTP upgrade request. The controller performs signature, issuer, audience, expiry, and other token checks before accepting application messages. The accepted claims are retained only for the pending session and are bound to the registration that follows.

The current WebSocket path continues to carry the JWT in service/register.params.jwt; that contract is unchanged. The runtime JSON profile retains this field on WebTransport so its JSON bytes and behavior remain comparable with the legacy path. When both handshake and registration JWTs are present, they must identify the same service and environment; a mismatch closes the session.

The runtime rkyv profile carries no JWT in ClientHelloV1 and therefore always requires handshake authentication on WebSocket and WebTransport.

The negotiated registration sequence is:

  1. The client sends the WebSocket upgrade or WebTransport CONNECT request with the service JWT and offered wire profiles.
  2. The controller validates the JWT and selects an endpoint-allowed wire profile before completing the handshake.
  3. The controller rejects the handshake if authentication fails or no allowed profile can be selected; otherwise it accepts the transport session.
  4. The client opens the session-control stream within the independent registration deadline.
  5. The controller reads and validates ClientHelloV1.
  6. It matches service_id and environment to the authenticated JWT claims using the same identity rules as the WebSocket path.
  7. It validates address syntax, port, tags, and all other registration fields. Version 1 does not perform an active reachability probe during registration.
  8. It persists registration and inserts the live instance.
  9. It sends ServerHelloV1 containing runtime_instance_id and connection settings.
  10. It permits command and event streams only after the acknowledgement is sent.

An invalid JWT, identity mismatch, or invalid registration closes the session. The client must not fall back to WebSocket for these failures because fallback would mask an authentication or configuration error.

Authorization headers, JWTs, and registration payloads must be redacted from normal logs and metrics labels.

Browser and BFF Authentication

The current browser WebSocket path depends on behavior that does not carry over to WebTransport:

  • the browser automatically includes the accessToken cookie in the WebSocket upgrade;
  • portal-view offers csrf.<token> through Sec-WebSocket-Protocol;
  • the BFF validates the cookie and CSRF value and injects the authenticated bearer token into the upstream WebSocket handshake.

Browser WebTransport requests use Fetch credentials mode omit; cookies and HTTP authentication are not sent automatically. The WebTransport profile token also has real protocol-negotiation semantics and must not be overloaded with a CSRF secret.

The browser path therefore uses a short-lived, single-use connection ticket. Version 1 preserves the controller’s current Bearer-token validation contract; it does not introduce a second gateway identity envelope:

  1. An authenticated browser sends POST /ctrl/mcp/ticket through the existing HTTPS BFF path with its normal cookies and X-CSRF-TOKEN protection.
  2. The BFF validates or refreshes the current login exactly as it does for other protected HTTPS requests.
  3. The BFF creates an opaque, cryptographically random ticket bound to the authenticated subject, roles, client, allowed Origin, /ctrl/mcp, selected wire profile, token expiry, and a nonce.
  4. The BFF stores the ticket in a bounded replay cache together with the current validated or refreshed access token, its expiry, and the authenticated principal. The record has a configurable lifetime no greater than 60 seconds and is consumed atomically on first use. The access token is never placed in the browser-visible ticket. If the replay cache cannot prove a successful write with the required TTL, ticket issuance fails closed.
  5. The browser opens WebTransport and supplies Authorization: WebTransport <ticket> in the CONNECT request headers.
  6. The gateway validates the Origin allowlist, consumes the ticket, restores the authenticated principal, applies normal access control, and establishes the upstream WebTransport session.
  7. The gateway injects the retained access token as Authorization: Bearer <access-token> in the upstream controller handshake, exactly as the current WebSocket BFF path does. The controller independently validates issuer, audience, expiry, required portal.r scope, subject, and client identity. The browser ticket is never forwarded to the controller.
  8. Gateway role-based policy uses the restored principal. It must not assert roles to the controller until a separately reviewed controller claim-mapping contract exists.

The replay cache is security-sensitive credential storage. It must restrict read access to the ticket consumer, encrypt or equivalently protect records at rest and in transit, redact all values, and delete the complete record on consumption or expiry. A cache implementation that cannot provide those properties is not eligible for browser WebTransport.

Ticket consumption also fails closed when the replay cache is unavailable, degraded, returns an ambiguous result, or cannot perform atomic consume. It must not fall back to a process-local cache or accept a self-contained ticket without replay protection.

All gateway nodes must synchronize UTC through the deployment’s approved time service and expose clock-synchronization health. The cache TTL is authoritative, and each record also carries an absolute ticket expiry and access-token expiry; the consumer enforces the earliest deadline. A positive expiry grace period is not allowed because it could extend the 60-second hard maximum. If measured clock error exceeds the configured readiness threshold, ticket issuance and consumption fail closed until synchronization is healthy.

Target browser support for WebTransportOptions.headers, protocols, requireUnreliable, the selected protocol, and the resulting reliability value is a release gate. The browser constructs the session with the ticket header, protocols: ["light-controller-mcp-json-v1"], and requireUnreliable: true. After connection it requires reliability == "supports-unreliable"; a reliable-only connection is not accepted as the HTTP/3/QUIC profile described by this document. Do not put the ticket in a URL query parameter as a compatibility fallback because URLs are commonly retained in proxy, access, and diagnostic logs. If the target browser cannot meet this contract, browser WebTransport remains disabled or falls back according to its configured mode.

POST /ctrl/mcp/ticket terminates in the authenticated HTTPS BFF handler before route-prefix processing. Only a WebTransport CONNECT request for /ctrl/mcp enters the WebTransport router. Method-aware tests must prove that ticket POSTs cannot be proxied, upgraded, or rejected by the session router.

In a multi-replica gateway deployment, ticket issuance and consumption require a shared replay cache or a proven affinity mechanism covering both requests. A self-contained signed ticket without replay protection is insufficient because it can be reused during its validity window.

The accepted WebTransport session must not outlive either the access-token expiry or a configurable maximum session duration. The gateway arms that deadline when it consumes the ticket, closes the session at the deadline, and requires a new ticket before reconnecting. Immediate authorization revocation is guaranteed only when the deployment provides a concrete revocation signal or introspection mechanism consumed by the gateway. Without one, version 1 claims expiry enforcement, not immediate revocation. Ticket values, user access tokens, CSRF values, and upstream authorization headers are always redacted.

Logical Channels and Transport Mapping

Version 1 uses reliable delivery only. Applications operate on logical channels; the transport adapter decides whether those channels share a connection or use independent streams.

Logical channelWebSocket mappingWebTransport mappingContents
MCP sessionOne ordered WebSocket connectionOne long-lived bidirectional streamMCP JSON-RPC requests, responses, and notifications
Runtime session controlMessages on the runtime WebSocketOne long-lived bidirectional streamRegistration, metadata, discovery, ping/pong, and session errors
Runtime commandMessages correlated by request ID on the runtime WebSocketOne bidirectional stream per commandOne command request and one command response
Runtime eventsMessages on the runtime WebSocketOne long-lived unidirectional streamExisting asynchronous notifications, including notifications/log

WebSocket preserves message boundaries but provides one ordered data path per direction. WebTransport streams are ordered byte streams and do not preserve application message boundaries. Their adapters must apply the framing defined by the negotiated wire profile.

The rkyv over WebSocket profile isolates codec cost for measurement; it does not provide independent command and event streams. Avoid claiming WebTransport multiplexing benefits for that combination.

MCP Session Channel

The browser and controller exchange the existing MCP JSON-RPC messages without semantic translation. WebSocket carries one JSON-RPC message per text frame. WebTransport carries the same messages on one long-lived bidirectional stream using the JSON framing below. Server notifications share that stream.

The first MCP request remains initialize according to the current controller contract. A second MCP stream in the same WebTransport session is a protocol violation in version 1.

Runtime Session Control Channel

For WebTransport, the runtime opens this stream first. Registration must be its first frame. The stream remains open for the session and carries low-volume ordered control messages. Only one runtime session-control stream is allowed.

For WebSocket, these logical messages continue to share the existing runtime connection. Registration and acknowledgement ordering remain unchanged.

Runtime Command Channels

On WebTransport, the controller opens a new bidirectional stream for each command. It writes one command request, closes its send direction, reads one command response, and then releases the stream. JSON and rkyv profiles use the same stream lifecycle with different payload framing.

On WebSocket, commands and responses remain messages on the existing connection. The request ID is mandatory on every transport. It preserves audit correlation, timeout handling, and late-response detection.

Command concurrency remains bounded by controller configuration and negotiated limits. A command that cannot obtain transport capacity before its dispatch deadline fails with a transport error; it must not wait indefinitely.

Runtime Event Channel

On WebTransport, the runtime opens one unidirectional event stream after registration. It carries framed asynchronous notifications separately from command responses, so log traffic cannot sit in front of a command response in the same application stream. On WebSocket, event messages remain on the shared ordered connection.

The event path is reliable and flow-controlled. It is not fire-and-forget. The runtime must use a bounded outbound event queue:

  • control and lifecycle notifications are never silently dropped;
  • log records may be dropped at the source when their queue is full;
  • every dropped-log interval emits a counter and a sequence gap visible to the controller;
  • a persistently blocked event stream is reset and the session is reconnected.

The controller continues to translate accepted runtime notifications to the existing external MCP notification shape.

JSON Wire Profile Framing

Existing JSON WebSocket behavior is unchanged: one UTF-8 JSON-RPC value is sent in each text frame and binary frames are rejected for legacy sessions.

On WebTransport, each JSON message is encoded as:

4-byte unsigned big-endian payload length
exactly that many UTF-8 JSON bytes

The length excludes the four-byte prefix. The receiver rejects a value above the endpoint-specific message limit before allocating, reads exactly the declared length with a deadline, validates UTF-8, and parses exactly one JSON value. EOF before the complete payload is a truncated-message error. Zero-length messages and trailing non-whitespace bytes are invalid.

This framing applies to both light-controller-mcp-json-v1 and light-controller-runtime-json-v1 over WebTransport. It is part of those wire profiles, not a generic property of WebTransport.

rkyv Binary Frame Format

Every light-controller-runtime-rkyv-v1 message begins with a fixed 16-byte header encoded without rkyv:

OffsetSizeFieldEncoding
04MagicASCII LCRK
41Wire-profile major1
51Flags0 in version 1
62Message kindUnsigned little-endian integer
84Payload lengthUnsigned little-endian integer
124ReservedMust be zero

The payload immediately follows the header and contains exactly one archived root object. The payload length excludes the header. WebSocket/TLS and WebTransport/QUIC already provide cryptographic integrity, so the application frame does not add a checksum.

The receiver must:

  1. Read the complete header with a deadline.
  2. Validate magic, version, flags, message kind, and reserved bytes.
  3. Reject a length above the limit before allocating the payload buffer.
  4. Allocate an appropriately aligned buffer and read exactly the declared payload length.
  5. Validate the archived root with bytecheck through a safe rkyv API.
  6. Apply semantic limits such as string length, tag count, and allowed command names.
  7. Copy only the fields that must survive the receive buffer or cross an asynchronous boundary.

On WebSocket, one binary message contains exactly one header and payload. On WebTransport, EOF before the complete payload is a truncated-frame error. Extra bytes are the start of the next frame only on long-lived control or event streams.

Message Kind Registry

Version 1 reserves these message kinds:

KindRoot typeAllowed logical channel
1ClientHelloV1Session control
2ServerHelloV1Session control
3MetadataUpdateV1Session control
4DiscoveryRequestV1Session control
5DiscoveryResponseV1Session control
6DiscoveryChangedV1Session control
7PingV1Session control
8PongV1Session control
9SessionErrorV1Session control
10ServerDrainingV1Session control
100CommandRequestV1Command
101CommandResponseV1Command
200RuntimeNotificationV1Runtime events

Unknown kinds are rejected before archived access. Adding a new kind does not change an existing archived root type. Existing version 1 root types are immutable after release.

runtime-rkyv-v1 Wire Types

The type names below describe shared wire types for light-controller-runtime-rkyv-v1, not the existing application structs in either repository. Field order is part of the archived schema and is frozen when that profile is released.

Registration Types

ClientHelloV1 contains:

FieldWire typeSemantic rule
service_idUTF-8 stringNon-empty; maximum 256 bytes; must match the authenticated service claim
env_tagOptional UTF-8 stringMaximum 128 bytes; must agree with authenticated environment claims when present
service_versionUTF-8 stringNon-empty; maximum 128 bytes
application_protocolUTF-8 stringRuntime’s advertised protocol, such as https; maximum 32 bytes
addressUTF-8 stringSyntactically valid IP literal or DNS hostname; maximum 253 bytes; no registration-time reachability probe
portu16Must be greater than zero
tagsVector of WireTagV1Maximum 64 entries; keys sorted and unique

The JWT is not present in ClientHelloV1; it comes from the authenticated WebSocket upgrade or WebTransport CONNECT request.

ServerHelloV1 contains:

FieldWire typeSemantic rule
runtime_instance_id16-byte UUIDAuthoritative ID returned by registration persistence
connection_id16-byte UUIDIdentifies this live transport session
heartbeat_interval_msu32Non-zero negotiated heartbeat interval
max_control_payload_bytesu32Server limit for session-control frames
max_command_streamsu32Maximum concurrent controller command streams

WireTagV1 contains UTF-8 key and value strings. Each is limited to 256 bytes. Duplicate keys are invalid.

Metadata and Discovery Types

MetadataUpdateV1 contains optional service_version, application_protocol, port, and complete replacement tags fields. The same limits and validation rules as registration apply. None means no change. Some(empty tags) clears all tags; empty strings and port zero are invalid.

DiscoveryRequestV1 contains:

FieldWire typeSemantic rule
request_idUTF-8 stringNon-empty; maximum 128 bytes
operationu81 lookup, 2 subscribe, 3 unsubscribe
service_idUTF-8 stringNon-empty; maximum 256 bytes
env_tagOptional UTF-8 stringMaximum 128 bytes
application_protocolOptional UTF-8 stringMaximum 32 bytes

DiscoveryResponseV1 contains the request ID, an optional DiscoverySnapshotV1, and an optional WireErrorV1. Exactly one of snapshot or error must be present.

DiscoveryChangedV1 contains one DiscoverySnapshotV1. A discovery snapshot contains the requested service ID, optional environment and application protocol filters, and a bounded vector of DiscoveryNodeV1 values. Each node contains the current runtime instance ID, service identity, environment, version, application protocol, address, port, tags, connection timestamps, and connected state needed by the existing discovery contract.

The initial maximum number of nodes in one snapshot is 10,000. A larger result is a resource-limit error rather than a partially serialized snapshot. The frame-size limit also applies, and the receiver enforces whichever limit is reached first.

Liveness and Error Types

PingV1 and PongV1 contain the same u64 nonce and signed 64-bit Unix millisecond timestamp. A pong with an unexpected nonce does not satisfy the heartbeat.

SessionErrorV1 and command failures use WireErrorV1:

FieldWire typeSemantic rule
codei32Stable application error code
messageUTF-8 stringSafe operator-facing summary; maximum 1,024 bytes
data_jsonOptional byte vectorOne valid JSON value within the frame-specific limit

Error messages must not contain JWTs, authorization headers, or unredacted sensitive payloads.

ServerDrainingV1 contains a signed 64-bit Unix millisecond drain deadline and a safe reason string limited to 1,024 bytes. A client stops issuing new control requests, lets active command streams finish until that deadline, and reconnects after the server closes the session.

Command and Notification Types

CommandRequestV1 contains a request ID limited to 128 bytes, a tool name limited to 256 bytes, and arguments_json. CommandResponseV1 contains the same request ID, completion timestamp, and exactly one of result_json or WireErrorV1.

RuntimeNotificationV1 contains a method name limited to 256 bytes, params_json, and monotonically increasing u64 sequence number for the current event stream. Sequence numbers restart at zero on a new registered session. A gap tells the controller that the runtime dropped one or more notifications before transmission.

All *_json fields are UTF-8 byte vectors containing exactly one valid JSON value. Their content is validated only after the enclosing archive and byte limits pass.

Version 1 deliberately uses numeric operation and message-kind fields rather than a shared archived envelope enum. New independent kinds can be registered without changing the layout of existing roots. Unknown numeric values are errors; they are never mapped to a default operation.

rkyv Wire Profile

The shared wire crate owns the only rkyv dependency used for this protocol. Both controller and runtime consume the same published or otherwise pinned crate version.

Version 1 fixes these format choices:

rkyv major version: 0.8
endianness: little_endian
alignment: aligned
relative pointer width: pointer_width_32
validation: bytecheck enabled
UUID integration: uuid-1 enabled

Cargo feature unification must not silently change this profile. CI checks cargo tree -e features for the controller and runtime release graphs and rejects conflicting endianness, alignment, or pointer-width features.

Payloads are read directly into an aligned buffer. Network input always uses validated access such as rkyv::access; unchecked access and unchecked deserialization are prohibited.

Wire types must:

  • use fixed-width integers instead of usize or isize;
  • represent UUIDs consistently through the shared crate;
  • represent timestamps as signed 64-bit Unix milliseconds;
  • use sorted key-value vectors instead of unordered maps when deterministic bytes are required;
  • avoid recursive or attacker-controlled deeply nested structures;
  • avoid references to application structs whose layout can change independently;
  • define semantic maximums for strings, lists, tags, and JSON byte fields.

Validation proves that bytes form a structurally valid archive. It does not prove that a service ID, address, port, tool name, or authorization decision is valid. Normal application validation remains mandatory.

Archived references borrow the receive buffer. They must not be stored in controller state, moved into a spawned task, or held across an .await that can outlive the buffer. Values needed by persistence, event publication, or the existing JSON/MCP surfaces are converted into owned application types.

This boundary means the design is not zero-allocation end to end. It avoids parsing and allocation only where handlers can consume archived values directly.

Dynamic JSON Payloads

The existing command layer includes dynamic MCP arguments and results. Version 1 preserves them as bounded UTF-8 JSON byte fields inside otherwise typed wire messages:

CommandRequestV1
  request_id
  tool_name
  arguments_json

CommandResponseV1
  request_id
  completed_at
  result_json or structured error

RuntimeNotificationV1
  method
  params_json
  sequence

The receiver validates UTF-8 and parses JSON only when the existing handler requires serde_json::Value. This preserves current command extensibility and makes the performance limitation explicit. A future wire-profile version may add typed payload kinds for measured hot paths without changing version 1.

Schema Ownership and Evolution

Create a dependency-light shared crate, referred to here as controller-wire, containing:

  • wire-profile identifiers and compatibility metadata;
  • the JSON length-prefix encoder and decoder;
  • the 16-byte rkyv frame header encoder and decoder;
  • runtime protocol and message-kind constants;
  • immutable v1 archived root types;
  • semantic validation helpers;
  • golden JSON and rkyv encoded fixtures;
  • compatibility tests.

The crate must not depend on controller-rs, portal-registry, Tokio, Axum, or a particular WebTransport implementation.

The existing duplicate registration structs in controller-rs and portal-registry must not become independent rkyv schemas. Each side converts between its application types and the shared wire types.

Versioning rules are:

  • never add, remove, reorder, or change fields in a released archived root;
  • never add a variant to a released archived enum;
  • add a new message kind for a new independent payload;
  • add a new wire-profile major version for an incompatible replacement;
  • keep golden bytes for every released root type;
  • test old-client/new-server and new-client/old-server negotiation;
  • keep at least the current and previous wire-profile versions during rolling upgrades.

Transport-Neutral Application Boundary

controller-rs already stores an mpsc::Sender<ControllerCommand> in each live instance. Preserve this boundary. Application services exchange owned logical messages and lifecycle events without importing Axum WebSocket, Tungstenite, QUIC, WebTransport, or rkyv types.

The useful abstractions are deliberately small:

  • an application session for registration, commands, responses, notifications, authentication state, and cleanup;
  • a wire-profile adapter that encodes and decodes owned logical messages;
  • a transport session that opens or accepts logical channels and moves bounded encoded messages;
  • transport capabilities describing independent streams and datagrams.

A broad application-wide Connection trait is not required. The application must not assume every transport supports independent streams, and the transport must not inspect MCP methods or runtime command payloads.

On the Rust client, replace the WebSocket-specific outbound mpsc::Sender<tungstenite::Message> boundary with a logical outbound enum. The selected wire-profile adapter converts that enum to bounded bytes. The selected transport adapter then maps those bytes to WebSocket messages or WebTransport streams. Codec selection must not occur inside the transport driver.

On the controller, refactor the current WebSocket handlers so all transports and codecs call shared functions for registration, inbound messages, pending command completion, notification publication, and disconnect cleanup.

The gateway is intentionally below this application boundary. Its initial WebSocket and WebTransport routers forward payload bytes and do not instantiate the MCP or runtime codecs.

All supported profile and transport adapters must produce the same observable application behavior for:

  • registration acknowledgement;
  • runtime_instance_id assignment;
  • metadata and discovery updates;
  • command results and timeouts;
  • notifications;
  • liveness timestamps;
  • disconnect events and pending-command failure.

Expected Implementation Surfaces

The initial implementation is cross-repository work.

In controller-rs, the main change surfaces are:

  • Cargo.toml for the selected HTTP/3/WebTransport, JSON framing, and wire dependencies;
  • src/config.rs for listeners, allowed profile pairs, and limits;
  • src/lib.rs and src/tls.rs for coordinated TCP and UDP listeners;
  • an authenticated internal capability handler and transport-native probe resources for per-instance transport/profile readiness;
  • src/routes/microservice.rs to extract shared session behavior from the WebSocket driver;
  • src/routes/mcp.rs to extract shared MCP behavior for WebSocket and WebTransport session adapters;
  • src/auth.rs to separate token verification from post-CONNECT registration identity matching;
  • src/state.rs, src/types.rs, and src/command_router.rs to preserve the existing command channel and connection-ID cleanup rules;
  • integration tests for profile, transport, and fallback parity.

In light-fabric, the main change surfaces are:

  • crates/portal-registry/Cargo.toml;
  • crates/portal-registry/src/client.rs for transport-neutral logical queues plus independent codec and transport selection;
  • crates/portal-registry/src/protocol.rs for conversion to the shared wire crate rather than a second archived schema;
  • crates/light-runtime/src/config.rs and runtime.rs for client selection, URL derivation, TLS assets, and fallback settings;
  • frameworks/light-pingora/src/websocket.rs to extract reusable route resolution without changing existing WebSocket behavior;
  • a WebTransport router module and UDP/HTTP3 service with transport-specific limits and stream relay;
  • apps/light-gateway/src/main.rs for coordinated TCP and UDP startup, readiness, reload, and shutdown;
  • BFF ticket issuance, bounded replay-cache storage, Origin validation, and restoration of the authenticated principal;
  • runtime handlers and tests for command, discovery, notification, heartbeat, reconnect, and draining parity.

In portal-view, the main change surfaces are:

  • the controller context for transport preference and serial fallback;
  • an authenticated capability client for /ctrl/mcp/capabilities, with the optional VITE_CONTROLLER_TRANSPORT override;
  • one controller-BFF authority resolver shared by capability, ticket, WebSocket, and WebTransport URLs;
  • an MCP session interface shared by WebSocket and WebTransport clients;
  • the HTTPS ticket request and WebTransport CONNECT header;
  • WebTransport JSON framing and notification handling;
  • browser capability, authentication, reconnect, and cleanup tests.

Deployment repositories must add UDP exposure for each enabled WebTransport listener and router. light-4j requires compatibility tests but no rkyv or WebTransport implementation for the initial release.

Initial Limits

All limits are configurable, but the server must start with finite defaults.

LimitInitial default
Registration/control frame payload1 MiB
Command request payload1 MiB
Command response payload16 MiB
Runtime notification payload1 MiB
MCP JSON message16 MiB, explicitly enforced on both transports
Queued outbound commands per runtime session64
In-flight commands per runtime session64
In-flight commands across the controller4096
Concurrent command streams per WebTransport runtime session64
Active WebTransport runtime sessions1000
Session-control streams1
Runtime event streams1
MCP streams per WebTransport session1
Registration deadline5 seconds, controlled independently from command timeouts
Heartbeat interval30 seconds
Missed heartbeat allowance1 interval
Browser connection-ticket lifetime30 seconds; hard maximum 60 seconds
Browser connection-ticket uses1, consumed atomically
Gateway ticket clock-skew readiness threshold2 seconds; no expiry grace
Maximum validation depth64 for any type that can contain nested data
WebTransport datagramsDisabled

Additional per-IP, per-user, per-Origin, and per-service session and ticket limits must be set from load-test results before internet-facing deployment. A length or count limit is checked before allocation or iteration wherever possible.

The command queue capacity, in-flight-command limit, and concurrent-stream limit are separate controls. The existing COMMAND_CHANNEL_CAPACITY describes a bounded queue; it must not be reused as proof that pending commands or open streams are bounded. Admission reserves an in-flight slot before inserting a pending response entry or opening a command stream. When either the per-session or global limit is reached, dispatch fails immediately with Resource limit and does not enqueue or insert pending state.

Both WebSocket and WebTransport MCP handlers explicitly enforce the 16 MiB MCP application-message limit. The implementation configures the WebSocket frame/message limit rather than relying on a framework default, and the WebTransport length prefix is rejected before allocation when it exceeds the same limit.

Liveness, Errors, and Cleanup

Transport-level TCP or QUIC activity is not sufficient to prove that the application loop is healthy. The runtime sends the profile-specific ping message on the session-control channel and expects its matching pong within the heartbeat deadline. For rkyv these are PingV1 and PongV1; the JSON profile uses the existing JSON-RPC liveness messages. Any valid application message also updates the instance’s last-seen time.

Browser MCP sessions retain the existing MCP ping behavior and are also subject to the gateway’s idle and maximum-connection-duration limits.

Errors are classified as:

  • Unavailable: UDP blocked, timeout, no route, or listener unavailable;
  • Unsupported: the transport, wire profile, or allowed profile/transport pair is not supported;
  • Establishment failed: the browser reported an opaque WebTransport failure before the session became ready and did not expose a more specific cause;
  • Unauthorized: JWT is missing, invalid, or expired;
  • Invalid registration: authenticated claims and registration disagree;
  • Protocol violation: invalid frame, logical channel, ordering, or message kind;
  • Resource limit: frame, queue, session, or stream limit exceeded;
  • Internal: persistence or controller failure.

A malformed WebTransport command stream is reset without immediately killing a healthy session when isolation is safe. Authentication failures, invalid registration, duplicate required channels, repeated malformed frames, or session-wide resource abuse close the entire session.

The browser error surface cannot reliably map every failed WebTransport CONNECT to an HTTP status or server-side category. responseHeaders are available only after the session is established, and a pre-establishment ready rejection may deliberately hide whether the endpoint was unreachable or unwilling to accept the session. Browser fallback therefore uses only client-observable facts:

  • capability-fetch and ticket-issuance HTTP responses retain their explicit Unauthorized, Unsupported, Resource limit, or Internal categories;
  • a synchronous absence of the required constructor or constructor option is Unsupported;
  • a rejection before ready resolves, after successful capability and ticket requests, is Establishment failed; the browser must not relabel it as Unauthorized, Unavailable, or Internal;
  • preferWebTransport may serially fall back to an advertised WebSocket candidate for Establishment failed, while requireWebTransport never does; and
  • after ready resolves, profile negotiation and application errors are structured and follow the no-fallback rules.

The gateway and controller retain the authoritative server-side cause in bounded telemetry even when the browser sees only Establishment failed. Both transports enforce the same authentication and authorization policy, so browser fallback is not an authorization bypass. Operators who cannot accept an opaque pre-establishment downgrade must select requireWebTransport.

Disconnect cleanup must remain connection-ID guarded so a late task from an old transport cannot remove a newly registered replacement connection.

Transport and Profile Selection

Transport and codec remain independent, but fallback ordering must be unambiguous. Clients therefore use an ordered list of explicit candidate pairs:

portalRegistry:
  portalUrl: https://controller:8438
  controlCandidates:
    - transport: webtransport
      wireProfile: light-controller-runtime-rkyv-v1
      negotiation: required
    - transport: websocket
      wireProfile: light-controller-runtime-rkyv-v1
      negotiation: required
    - transport: websocket
      wireProfile: light-controller-runtime-json-v1
      negotiation: legacy

The production default contains only the legacy-compatible pair:

controlCandidates:
  - transport: websocket
    wireProfile: light-controller-runtime-json-v1
    negotiation: legacy

negotiation: required sends the profile offer and requires the peer to select that profile. negotiation: legacy is valid only for an existing JSON WebSocket contract; it sends no wire-profile token and accepts the existing no-profile handshake. This makes new-client-to-old-controller compatibility explicit without allowing an absent selection to downgrade a binary or WebTransport candidate.

Convenience modes such as websocket, preferWebTransport, and requireWebTransport may be retained, but configuration loading must expand them into and expose the ordered candidate pairs. The same rule applies to a preferRkyv convenience setting. Operators must be able to see the effective order and must not depend on an undocumented Cartesian-product ordering.

The existing portalUrl remains the base controller URL, including any ingress path prefix. The client derives /wt/microservice and /ws/microservice without discarding that prefix.

portal-view has one fixed wire profile, light-controller-mcp-json-v1. Its default local mode is auto: fetch /ctrl/mcp/capabilities, intersect the ready candidates with browser support, and follow the gateway policy. The gateway policies are:

Gateway policyBehavior
websocketAdvertise and use the existing legacy WebSocket path; initial production default
preferWebTransportTry an advertised WebTransport candidate first and then the advertised legacy WebSocket candidate for an allowed fallback error
requireWebTransportAdvertise WebTransport as required and fail the controller connection when it is unavailable

The optional build-time override VITE_CONTROLLER_TRANSPORT=auto|websocket|webtransport is intended for local development, emergency compatibility, and controlled tests. auto is the default. websocket forces the legacy WebSocket candidate; webtransport requires WebTransport and does not silently fall back. The build variable is not the source of deployment readiness: the same UI bundle should work when gateway routes or controller transport sets change without a rebuild. A future runtime-injected UI configuration may provide the same override without changing the capability contract.

In auto mode, portal-view performs these steps after authentication and when the first controller consumer requests a connection:

  1. Fetch the authenticated capability resource. If there is no valid response or still-fresh cached response, apply the capability-fetch failure rules, schedule jittered backoff, and return without attempting either transport.
  2. Check for the required local WebTransport constructor and option support.
  3. If policy and candidates permit it, request a ticket and attempt WebTransport with a bounded connection timeout.
  4. Require light-controller-mcp-json-v1 as the selected protocol and supports-unreliable as the reliability mode before MCP initialization.
  5. Fall back to the advertised legacy WebSocket candidate only for observable Unavailable or Unsupported failures, or for the explicitly opaque Establishment failed category under preferWebTransport.
  6. Do not fall back for authentication, authorization, protocol, resource-limit, or internal errors.

The actual attempt, not user-agent sniffing, page nextHopProtocol, or a cached previous success, determines whether the current enterprise network supports the WebTransport path.

The gateway initially uses matchIngress upstream selection: WebSocket ingress uses a WebSocket upstream and WebTransport ingress uses a WebTransport upstream. No automatic cross-transport relay is allowed.

Fallback rules are:

  • attempts are serial, never parallel;
  • fallback is allowed only before runtime registration acknowledgement or MCP initialization succeeds;
  • browser preferWebTransport may fall back for an opaque pre-ready Establishment failed; requireWebTransport may not;
  • do not fall back for Unauthorized, invalid registration, protocol violation, resource-limit, or internal errors;
  • browser ticket authentication or Origin failure is Unauthorized, not an indication that WebTransport is unsupported;
  • log and count the normalized fallback reason without logging secrets;
  • after an established session disconnects, retry its selected transport first with the existing jittered exponential backoff;
  • do not keep two live registrations for the same client attempt;
  • never try a transport/profile pair that is absent from the configured candidate list;
  • requireWebTransport and required-profile policies prevent a network attacker or deployment error from silently forcing a downgrade.

Java light-4j clients do not receive the new setting and continue to use WebSocket.

Controller Configuration

Proposed server settings are:

CONTROLLER_MCP_TRANSPORTS=websocket
CONTROLLER_RUNTIME_TRANSPORTS=websocket
CONTROLLER_STANDALONE_DISCOVERY_TRANSPORTS=websocket
CONTROLLER_WEBTRANSPORT_ADDR=<CONTROLLER_ADDR>
CONTROLLER_WEBTRANSPORT_MAX_SESSIONS=1000
CONTROLLER_WEBTRANSPORT_MAX_COMMAND_STREAMS=64
CONTROLLER_COMMAND_QUEUE_CAPACITY=64
CONTROLLER_MAX_IN_FLIGHT_COMMANDS_PER_SESSION=64
CONTROLLER_MAX_IN_FLIGHT_COMMANDS_GLOBAL=4096
CONTROLLER_REGISTRATION_TIMEOUT_MS=5000
CONTROLLER_MCP_MAX_MESSAGE_BYTES=16777216
CONTROLLER_WEBTRANSPORT_RUNTIME_PROFILES=light-controller-runtime-json-v1,light-controller-runtime-rkyv-v1
CONTROLLER_WEBTRANSPORT_MCP_PROFILES=light-controller-mcp-json-v1
CONTROLLER_WEBSOCKET_BINARY_PROFILES=

The three transport settings are comma-separated enabled sets; server-side order has no preference semantics, duplicate values are invalid, and diagnostics emit their normalized effective values. Runtime registry and discovery messages use CONTROLLER_RUNTIME_TRANSPORTS; CONTROLLER_STANDALONE_DISCOVERY_TRANSPORTS controls only /ws/discovery and any future binding of that standalone surface. These settings apply only to persistent controller communication surfaces; disabling WebSocket for one of them does not disable the controller’s normal HTTPS health or administrative endpoints. Initial production settings contain only websocket. A dual migration configuration can use:

CONTROLLER_MCP_TRANSPORTS=websocket,webtransport
CONTROLLER_RUNTIME_TRANSPORTS=webtransport,websocket
CONTROLLER_STANDALONE_DISCOVERY_TRANSPORTS=websocket

This permits the browser to fall back on enterprise networks while Rust runtime clients prefer WebTransport. A surface may be configured as WebTransport-only after every client and network path for that surface is proven. In particular, CONTROLLER_MCP_TRANSPORTS=webtransport removes browser WebSocket fallback under the initial same-transport gateway design. Java and other clients that do not implement WebTransport require a WebSocket-enabled surface.

CONTROLLER_WEBTRANSPORT_ADDR is a UDP socket address. It may use the same numeric host and port as the existing TCP listener because TCP and UDP have separate port spaces.

In version 1, controller listener addresses, enabled transport sets, and allowed profile sets are startup-only. Changing them requires a controller restart and produces a new controller capability revision. Client preference remains owned by the ordered controlCandidates list; browser preference remains owned by gateway mcpTransportPolicy. Server enabled sets do not create a third preference source.

The WebTransport listener reuses the configured controller certificate and private key but creates a QUIC-compatible TLS 1.3 configuration with HTTP/3 ALPN. Adding h3 to the existing Axum TCP TLS configuration is not sufficient.

The WebTransport listener is enabled when any surface enabled set contains webtransport. Startup is fail-fast when that listener, its TLS identity, or required protocol extensions cannot be initialized. When no surface enables WebTransport, failure to initialize experimental WebTransport code must not affect the existing HTTPS/WebSocket listener.

Allowed-profile lists are deny-by-default. An empty MCP list disables direct MCP WebTransport on the controller even when the runtime WebTransport listener is enabled. CONTROLLER_WEBSOCKET_BINARY_PROFILES remains empty in production until rkyv over WebSocket testing is explicitly approved.

Gateway Configuration

Add a webtransport-router.yml module with route-resolution fields compatible with websocket-router.yml and WebTransport-specific session and stream limits. For example:

defaultProtocol: https
defaultEnvTag: dev
pathPrefixService:
  /ctrl/mcp:
    serviceId: com.networknt.controller-1.0.0
    protocol: https
    envTag: dev
allowedProfiles:
  /ctrl/mcp:
    - light-controller-mcp-json-v1
mcpTransportPolicy: websocket
capabilityMaxAgeSeconds: 30
controllerCapabilityPath: /internal/controller/capabilities
controllerCapabilityStaleMs: 15000
controllerProbeIntervalMs: 5000
controllerProbeTimeoutMs: 2000
maxActiveSessions: 1000
maxStreamsPerSession: 1
ticketTtlMs: 30000
ticketClockSkewReadyMs: 2000
allowedOrigins:
  - https://portal.example.com

mcpTransportPolicy and capabilityMaxAgeSeconds are shared BFF policy settings even if configuration composition presents them beside the WebTransport router. They are not owned by only one router. The WebSocket and WebTransport routers publish bounded readiness state to one capability aggregator, which combines it with route and controller health.

The configuration shape is illustrative until implementation review, but these contracts are required:

  • POST /ctrl/mcp/ticket is handled by the HTTPS BFF before this route table, while WebTransport CONNECT for /ctrl/mcp enters this router;
  • GET /ctrl/mcp/capabilities is handled by the authenticated BFF and reports only end-to-end candidates supported by the configured policy, healthy listeners, route resolver, and controller targets;
  • controller capability responses and transport-native probe results must both be current within their configured stale thresholds before a target is eligible;
  • route resolution code is shared with the WebSocket router rather than copied;
  • activation follows the gateway handler/module model rather than an unrelated second enable flag;
  • WebTransport-specific limits and allowed profiles are explicit;
  • an empty or absent Origin allowlist rejects browser WebTransport;
  • replay-cache unavailability, ambiguous atomic-consume results, or clock error beyond ticketClockSkewReadyMs makes ticket readiness false and fails ticket issuance and consumption closed;
  • invalid startup config fails startup, while an invalid reload retains the last valid runtime;
  • WebSocket and WebTransport route tables may differ, but drift is visible in diagnostics and configuration tests.

Gateway route, policy, limit, and capability configuration is reloadable. A valid reload applies to new sessions and immediately removes disabled or unready candidates from capability responses, producing a new aggregate revision. Existing sessions remain pinned and continue until normal close, token or maximum-duration expiry, target drain, or an explicit security revocation. A routine policy reload does not reinterpret or migrate an established session. When a route or listener must be removed, the gateway marks it draining, stops new admission, waits for the configured drain deadline, and then closes any remaining sessions with a categorized reason. An invalid reload keeps the last valid configuration and revision.

Deployment Requirements

Every supported controller deployment must explicitly expose every transport enabled by its per-surface sets. A dual configuration exposes:

ports:
  - "8438:8438/tcp"
  - "8438:8438/udp"

Kubernetes Services, firewall rules, security groups, ingress or Gateway API resources, and load balancers must also permit UDP on the controller port. The TCP listener remains available for normal HTTPS health and administrative traffic even when all persistent controller surfaces are WebTransport-only.

A browser deployment additionally exposes the gateway’s public HTTPS port over both TCP and UDP, normally 443/tcp and 443/udp. The gateway-to-controller network path must separately permit QUIC to the controller UDP port. Proving the browser-to-gateway leg does not prove the gateway-to-controller leg.

An AWS Application Load Balancer is not the assumed WebTransport path. Use a QUIC-aware Network Load Balancer or another proven end-to-end topology. Generic HTTP/3 termination in Nginx or HAProxy is acceptable only after an integration test proves the required WebTransport draft, extended CONNECT, datagrams, stream resets, connection IDs, and draining behavior.

Existing HTTPS health endpoints prove TCP listeners only. Add WebTransport readiness signals or synthetic probes for both the gateway and controller so operators can distinguish:

  • controller healthy, WebTransport disabled;
  • controller healthy, WebTransport ready;
  • controller healthy, WebTransport failed;
  • gateway healthy, WebTransport router disabled;
  • gateway healthy, browser ticket and upstream WebTransport route ready;
  • gateway healthy, one WebTransport leg failed;
  • complete controller failure.

Multi-Replica Constraint

Current live instances, command senders, and pending requests are held in one controller-rs process. PostgreSQL records audit and projection data; it is not a live command router.

Until a separate distributed-session design is implemented:

  • run one active controller replica for command routing; or
  • provide an external routing mechanism that can direct every command to the process owning the target runtime session.

Load-balancer affinity for a QUIC connection keeps that connection on one controller pod, but it does not make an MCP request arriving at another pod able to use the session. QUIC connection migration also does not migrate application state between controller pods.

This proposal must not be presented as enabling active-active controller replicas.

Graceful Draining

Gateway or controller shutdown follows this order:

  1. Mark the affected WebTransport readiness false.
  2. Stop issuing browser tickets and accepting new WebTransport sessions.
  3. Stop opening new upstream gateway sessions and new runtime command channels.
  4. Send the profile-specific runtime draining message; for rkyv this is ServerDrainingV1.
  5. Allow in-flight MCP requests and runtime commands to complete for a bounded grace period.
  6. Fail remaining pending work with a transport error.
  7. Close upstream and downstream WebTransport sessions with explicit reasons, then close WebSocket sessions under the existing policy.
  8. Let clients reconnect with jittered backoff and obtain a new browser ticket where required.

Connection draining is not transparent failover. A reconnected runtime must perform registration again and receive the authoritative connection ID.

Security Requirements

  • Use TLS 1.3 and normal certificate and hostname verification.
  • Apply the existing JWT issuer, audience, expiry, service identity, and environment checks.
  • Require a non-empty Origin allowlist for browser WebTransport and compare the complete normalized origin, not a suffix or substring.
  • Authenticate the public browser capability resource and expose only public endpoints, bounded policy names, profile tokens, and readiness; do not expose upstream controller addresses, instance IDs, or probe details.
  • Authenticate the internal controller capability and probe resources with a dedicated least-privilege gateway service identity or mTLS identity, restrict them to the gateway network path, reject browser tickets and user credentials, rate-limit them, and redact probe credentials. The internal instanceId and revision must never be copied into the public capability response.
  • Protect ticket issuance with the existing cookie authentication and CSRF checks; bind tickets to one route, origin, principal, profile, expiry, and use.
  • Consume tickets atomically from a bounded replay cache and never accept them from URL query parameters.
  • Fail ticket issuance and consumption closed when the replay cache is unavailable, degraded, or cannot prove atomic single-use behavior; never use a local-cache or unprotected signed-ticket fallback.
  • Require synchronized gateway clocks, enforce the earliest cache, ticket, and access-token deadline, and do not extend the ticket hard maximum with clock tolerance.
  • Treat replay-cache records as credential storage when they retain an upstream access token: restrict access, protect data at rest and in transit, and delete the complete record on consumption or expiry.
  • Do not assume browser WebTransport carries cookies or HTTP authentication.
  • Require the gateway to inject the retained Bearer token upstream and the controller to validate it independently; restored gateway roles are not controller claims.
  • Enforce access-token expiry and maximum session duration with a gateway timer; claim immediate revocation only when a concrete signal or introspection mechanism is configured and tested.
  • Authenticate before accepting application streams or allocating large application buffers beyond the strict handshake allowance.
  • Enable QUIC address validation or Retry for untrusted network exposure when supported by the selected implementation.
  • Limit unauthenticated connections, authenticated sessions, streams, frame sizes, queue sizes, tickets, and bytes per time window.
  • Validate JSON length, UTF-8, and one-value framing before dispatch.
  • Validate every rkyv archived root with bytecheck and then apply semantic validation.
  • Never invoke rkyv unchecked access on network bytes.
  • Reject endpoint, transport, and wire-profile combinations that are not explicitly allowlisted.
  • Treat downgrade from a required transport or profile as an error, not an automatic fallback.
  • Redact tickets, JWTs, cookies, CSRF values, authorization headers, and sensitive command payloads.
  • Treat unknown wire-profile versions and message kinds as errors, not as a reason to guess a Rust type.
  • Fuzz JSON framing, binary frame parsing, and archived validation with arbitrary bytes.
  • Close sessions that repeatedly violate limits instead of continuing to allocate and log indefinitely.

Backpressure and Fairness

Independent QUIC streams remove retransmission ordering between streams, but they share connection congestion control and implementation scheduling. WebTransport does not automatically make a command high priority.

The implementation must:

  • reserve bounded capacity for command streams;
  • prevent the event reader from monopolizing the controller task executor;
  • use bounded browser, gateway, controller, and runtime queues;
  • apply backpressure across both legs of a proxied WebTransport stream rather than buffering an unbounded slow side;
  • cap streams waiting for an upstream mapping and fail closed when the upstream session cannot accept more;
  • expose queue saturation and stream-acquisition latency;
  • avoid spawning an unbounded task for every incoming stream;
  • time out stalled reads and writes;
  • test fairness under log load and packet loss.

If the selected library exposes stream scheduling, the policy may prefer command streams over runtime events. Correctness must not depend on nonstandard priority behavior.

Datagrams and Telemetry

Version 1 does not use WebTransport datagrams.

QUIC datagrams are unreliable and unordered. They have no explicit flow control, cannot be fragmented across QUIC packets, and may be delayed or dropped by the sender or receiver under congestion. Their use requires an application contract for maximum size, batching, sequence numbers, timestamps, loss accounting, and overload behavior.

Metrics and traces already have dedicated ecosystem protocols. Sending them to controller-rs would expand the controller into a telemetry ingestion service and could couple telemetry load to command availability. Any future datagram use requires a separate design comparing at least OTLP, Prometheus-compatible delivery, and a dedicated collector.

If datagram metrics are ever approved, cumulative snapshots are preferred over loss-sensitive deltas, with periodic reliable checkpoints.

Observability

Add metrics with bounded labels for:

  • active sessions by component, hop, transport, and negotiated wire profile;
  • connection attempts, accepted sessions, and categorized failures;
  • fallback count and reason;
  • capability responses and refreshes by bounded policy and candidate set, plus candidate attempts rejected because readiness changed before handshake;
  • capability-fetch failures and discovery backoff by bounded error category;
  • registration and authentication latency;
  • browser ticket issuance, consumption, expiry, replay rejection, and Origin rejection without ticket or Origin values as labels;
  • replay-cache health, atomic-consume failures, and gateway clock-synchronization readiness without cache keys or ticket values as labels;
  • current in-flight commands by session and globally, plus rejections by queue, in-flight, stream, and session limit;
  • gateway downstream-to-upstream session and stream mapping failures;
  • command stream acquisition, command latency, timeout, and reset count;
  • JSON and rkyv frame validation, semantic validation, and size-limit failures;
  • active and blocked streams;
  • event queue depth and dropped log records;
  • bytes sent and received by logical channel and gateway hop;
  • QUIC RTT, loss, and migration when exposed by the library;
  • drain duration and forced session closes.

Structured logs should include connection ID, runtime instance ID after registration, gateway hop, transport, wire profile, logical channel, and normalized error category. Do not use ticket IDs, service JWTs, user tokens, full payloads, Origin values, or unbounded peer values as metric labels.

The runtime instance’s application protocol metadata remains unchanged. Expose the controller transport through diagnostics or a separate optional controlTransport field only after checking event, database, and UI consumers.

Rollout Plan

Phase 0: Feasibility and Baseline Gate

  • Measure allocations, CPU, memory, throughput, and command latency on the existing WebSocket path.
  • Include idle connections, registration storms, command traffic, and concurrent log notifications.
  • Record payload sizes and identify how much time is actually spent in JSON.
  • Pin one Rust WebTransport implementation and prove controller and Rust-client interoperability against the exact supported draft.
  • Prove payload-opaque, same-transport WebTransport stream relay across both gateway legs, including reset, backpressure, close, and draining.
  • Prove that the gateway capability resource reflects end-to-end route and per-instance controller readiness, revisions, candidate-specific target pools, and transport-native probes, and never treats a page’s HTTP version as availability.
  • Prove browser ticket issuance, atomic consumption, protected credential storage, upstream Bearer injection, Origin enforcement, profile selection, requireUnreliable, and expiry-driven session close in every target browser.
  • Prove the target browser’s observable failure behavior: explicit capability and ticket HTTP failures retain their categories, pre-ready WebTransport rejection becomes Establishment failed, preference mode may fall back, and required mode may not.
  • Prove the shared controller-BFF authority resolver in the supported same-origin deployment and in every explicitly supported cross-origin deployment.
  • Prove the required UDP, HTTP/3, certificate, and WebTransport behavior through each supported development and production load-balancer topology.
  • Select the shared replay cache and prove atomic fail-closed behavior, synchronized-clock readiness, and hard-expiry enforcement; decide the owner and release process for controller-wire and the benchmark workload.

Phase 0 is an approval gate. Phase 1 must not begin until these artifacts are reviewed, the pinned compatibility tuple is recorded, and no unresolved item requires changing the authentication trust model, gateway topology, or wire profile boundaries. A failed spike keeps WebTransport disabled. A shared-session refactor may still proceed when it is separately justified as WebSocket hardening or preparation for binary WebSocket/raw QUIC evaluation, but it must not introduce or assume WebTransport behavior.

The 2026-07-15 Phase 0 implementation produced that failed-spike result. Native direct and same-transport relay mechanics, current certificate reuse, bounded reconnect, and Redis atomic consume passed. Chrome 150 did not send the supplied Authorization header. The server observed the supplied profile offer, and stream echo/reset/close passed, but Chrome did not expose the selected protocol, response headers, or required reliability result. The pinned wtransport 0.7.1 native client also discarded response headers, and its protocol generation did not implement draft 16. Production WebTransport remains disabled and Phase 1 was not approved on a WebTransport justification. The executable evidence and closure decision are in implementation/light-controller/phase0.

The separately justified WebSocket-only Phase 1 extraction completed on 2026-07-15. It introduced bounded command admission, explicit WebSocket message limits, transport-neutral controller session modules, a logical-message PortalRegistryClient, a private WebSocket adapter, and shared legacy JSON fixtures. It did not enable WebTransport or change the Phase 0 decision. The closure record and executable gates are in implementation/light-controller/phase1.

The transport-independent shared wire-profile foundation completed on 2026-07-15. light-fabric/crates/controller-wire now exclusively owns the version 1 profile tokens, framing, immutable archived roots, bounded validation, semantic limits, golden bytes, dependency-purity policy, and pinned rkyv feature policy. Both portal-registry and controller-rs compile and test owned conversion boundaries against the same fixtures. This completes implementation-plan Phase 2, not the broader design Phase 2 below: no listener, negotiation, binary WebSocket profile, or WebTransport behavior is enabled. The closure record and executable gates are in implementation/light-controller/phase2.

Phase 1: Extract Shared Session Behavior

  • Refactor controller-rs runtime and MCP message handling into transport- and codec-neutral service functions.
  • Replace the Rust client’s tungstenite-specific outbound channel with logical messages.
  • Separate wire-profile adapters from WebSocket and WebTransport session adapters.
  • Keep behavior and wire output unchanged.
  • Run the complete existing WebSocket test suite.

Gateway route-resolver extraction remains part of the later gateway phase; it was not required for the controller/client shared-session boundary.

Phase 2: Runtime Profile Matrix

The shared crate and conformance portion is complete. Every transport-enabling item in this design phase remains blocked by the Phase 0 no-go.

  • Add the shared controller-wire crate and golden fixtures.
  • Add the UDP/HTTP3 listener, enabled when any controller surface enabled set contains webtransport.
  • Implement runtime JSON over WebTransport first.
  • Implement runtime rkyv over WebSocket binary frames and WebTransport.
  • Verify that JSON and rkyv use the same logical WebTransport channel model.
  • Add ordered transport/profile candidates to the Rust client.
  • Keep all production configurations in websocket mode.

Phase 3: Browser MCP and Gateway Router

  • Add the gateway UDP/HTTP3 service and payload-opaque WebTransport router.
  • Add the authenticated MCP capability resource and short-lived readiness synthesis across both gateway legs.
  • Add ticket issuance, shared replay-cache behavior, Origin enforcement, and authenticated-principal restoration.
  • Add MCP JSON framing and WebTransport support to portal-view and controller-rs.
  • Add portal-view auto-selection and its build-time force override without changing MCP/JSON-RPC 2.0 application semantics.
  • Require WebTransport header and protocol-negotiation support in every target browser.
  • Keep browser production configuration in websocket mode.

Phase 4: Controlled Opt-In

  • Enable approved candidate pairs for selected Rust services in a deployment with verified UDP routing.
  • Enable preferWebTransport for a small browser cohort only after both gateway legs and ticket authentication are proven.
  • Observe fallback, ticket, validation, queue, memory, and command-latency metrics by profile and hop.
  • Exercise rolling upgrades, draining, UDP blocking, and rollback.

Phase 5: Default Decision

Make no default change unless:

  • behavior parity and compatibility tests pass;
  • fuzzing finds no panic or unsafe-access path;
  • supported deployment topologies pass end-to-end tests;
  • target browsers support the required WebTransport headers, protocols, requireUnreliable, selected-protocol, and reliability checks;
  • ticket replay and multi-replica gateway tests pass;
  • the measured benefit is material for the target workload;
  • per-session memory and operational complexity are acceptable;
  • rollback to WebSocket has been exercised.

If these gates fail, retain WebSocket or choose the simpler binary-WebSocket or raw-QUIC alternative.

Verification Plan

Protocol and Unit Tests

  • Allowed and rejected endpoint/transport/profile combinations.
  • Per-surface transport enabled sets enable only the configured MCP, runtime, and standalone-discovery endpoints and derive WebTransport listener activation correctly.
  • Legacy WebSocket clients without a profile retain existing JSON behavior.
  • WebSocket and WebTransport profile negotiation select the same wire contract.
  • WebSocket token permutations prove that csrf.* is consumed only as authentication metadata, is never selected as a negotiated wire profile, and does not depend on client token ordering.
  • Required negotiation rejects an absent selected token; only an explicit negotiation: legacy JSON WebSocket candidate accepts the no-profile path.
  • Ordered candidate expansion and downgrade-policy tests.
  • Capability-response construction includes only policy-allowed, healthy, same-transport end-to-end candidates and applies revision, ETag, generation, expiry, private-cache, and Vary rules.
  • Internal controller capabilities reject unauthorized callers, validate schema and expiry, change revision for readiness and draining changes, and describe only enabled transport/profile pairs.
  • Transport-native probes authenticate, negotiate the requested surface and profile, return the matching controller revision, obey bounds, and never mutate registration, MCP, command, or discovery state.
  • Capability-fetch 401/403, 404/501, 429, timeout, malformed response, and 5xx cases follow the specified no-guessing and backoff behavior.
  • portal-view auto, forced-WebSocket, and required-WebTransport overrides produce the expected ordered attempts without changing the wire profile.
  • The controller-BFF authority resolver preserves configured ingress prefixes, uses one authority for all four browser controller operations, rejects cross-authority redirects, and fails closed when cross-origin prerequisites are absent.
  • Server enabled-set permutations produce the same result regardless of input order; duplicate or unknown transports are rejected. Client candidate order remains observable and authoritative.
  • JSON length-prefix round trips, partial prefixes, invalid UTF-8, oversized lengths, empty payloads, trailing bytes, and multiple values.
  • Golden bytes for every version 1 root type.
  • Header round trips and rejection of invalid magic, flags, lengths, kinds, and reserved bytes.
  • Cross-target fixture reads for all supported release targets.
  • Schema compatibility checks using old committed fixtures.
  • Semantic limits for strings, tags, lists, JSON, and timestamps.
  • No unchecked rkyv access in production modules.
  • Fuzz arbitrary headers and payloads without panic, excessive allocation, or unbounded validation work.

Integration Tests

  • WebSocket registration and commands remain unchanged.
  • Runtime JSON over WebTransport registration, metadata, discovery, commands, responses, notifications, heartbeat, and cleanup match JSON over WebSocket.
  • Runtime rkyv over WebSocket and WebTransport produces the same application behavior as runtime JSON.
  • Browser MCP JSON requests, responses, notifications, cancellation, reconnect, and close behavior match across WebSocket and WebTransport.
  • Capability refresh, stale readiness, controller rolling upgrades, and a route becoming unavailable between discovery and handshake produce categorized retry or fallback behavior.
  • Mixed controller revisions create candidate-specific target pools; sessions are pinned to eligible targets, draining or stale targets receive no new sessions, and aggregate revision changes invalidate old candidates.
  • The gateway WebTransport router maps reliable streams, reset, backpressure, draining, and close without parsing MCP JSON.
  • Cross-transport and cross-codec gateway routes are rejected.
  • Ticket expiry, replay, wrong route, wrong Origin, wrong profile, token expiry, and concurrent consumption are rejected.
  • Replay-cache write failure, outage, degradation, ambiguous consume, and loss of atomicity fail ticket issuance or consumption closed without a local fallback.
  • Gateway clock error below, at, and above the readiness threshold never extends the ticket hard maximum and enforces the earliest applicable deadline.
  • Multi-replica ticket issuance and consumption uses the configured shared cache or proven affinity behavior.
  • Ticket consumption injects the retained Bearer token upstream, the controller independently validates it, credential cache records are deleted, and the session closes no later than token expiry or maximum duration.
  • Browser WebTransport rejects a wrong selected profile and a reliable-only session; requireUnreliable and the target-browser API contract are tested.
  • Controller restart and client reconnect.
  • Registration timeout and partial frames.
  • Invalid, expired, wrong-audience, wrong-service, and wrong-environment JWTs.
  • Oversized frames, too many streams, stalled readers, and blocked writers.
  • Queue, per-session in-flight, global in-flight, stream, and session limits are exercised independently, including admission races and cleanup after timeout.
  • Command timeout, late response, stream reset, and session close races.
  • Serial candidate fallback when UDP is blocked or a profile is unsupported.
  • An opaque browser pre-ready rejection falls back only in preferWebTransport; requireWebTransport fails without a WebSocket attempt.
  • Explicit capability or ticket authentication, authorization, resource, and internal failures never enter the opaque-establishment fallback path.
  • HTTP/2 page plus successful WebTransport, and HTTP/3 page plus failed WebTransport CONNECT or upstream QUIC, prove that page nextHopProtocol does not select the controller transport.
  • No fallback for observable authentication, registration, protocol, resource, or internal failures before establishment, or for their structured forms after establishment.
  • Old Rust client to new controller, new Rust client to old controller, and Java client to new controller.
  • Controller drain with in-flight commands.
  • Gateway policy reload affects new admission and capability revision without reinterpreting established sessions; listener removal drains and closes at the configured deadline; invalid reload retains the last valid revision.

Deployment Tests

  • Direct TCP and UDP access on the same numeric port.
  • Docker Compose with explicit TCP and UDP mappings.
  • Every supported Kubernetes and load-balancer topology.
  • Browser-to-gateway and gateway-to-controller QUIC tested independently and together.
  • Enterprise-network profiles that block UDP or WebTransport CONNECT fall back to an advertised WebSocket candidate without requiring a UI rebuild.
  • Target browser matrix for CONNECT headers, profile negotiation, certificate validation, stream behavior, and network fallback.
  • QUIC connection-ID routing and migration where claimed.
  • TCP health remains available when experimental WebTransport is disabled.
  • Synthetic probes distinguish gateway ingress, gateway upstream, and controller UDP or HTTP/3 failure.

Benchmark Profiles

Compare at least:

  1. runtime JSON-RPC over the legacy single-channel WebSocket;
  2. runtime JSON-RPC over WebTransport using one ordered bidirectional stream;
  3. runtime JSON-RPC over WebTransport using the proposed multiplexed topology;
  4. runtime rkyv over one binary WebSocket channel;
  5. runtime rkyv over WebTransport using one ordered bidirectional stream;
  6. runtime rkyv over WebTransport using the proposed multiplexed topology;
  7. runtime rkyv over raw QUIC with the closest equivalent single-stream and multiplexed topologies;
  8. browser MCP JSON over WebSocket through light-gateway;
  9. browser MCP JSON over WebTransport through light-gateway.

Treat codec, transport, framing, and stream topology as separate benchmark variables:

  • JSON versus rkyv on the same transport and topology measures codec cost.
  • Single-stream versus multiplexed WebTransport with the same codec measures topology and head-of-line effects.
  • Single-channel WebSocket versus single-stream WebTransport is the closest transport comparison, but native framing still differs and must be reported; it must not be described as a pure transport-only measurement.
  • Legacy WebSocket versus multiplexed WebTransport measures the complete proposed operational change, not one isolated variable.

All paired cases use identical logical messages, concurrency, payload mixes, warmup, and connection counts. Results report the selected wire profile, transport, topology, WebTransport draft/library tuple, and browser reliability mode so a reliable-only implementation cannot be mislabeled as the QUIC case.

Run profiles for:

  • many idle registered sessions;
  • registration bursts;
  • small commands and responses;
  • large dynamic JSON results;
  • command responses concurrent with runtime log notifications;
  • packet loss, reordering, and constrained bandwidth.

Record CPU and memory separately for browser, gateway, controller, and runtime, plus allocations, bytes on the wire for each hop, registration rate, ticket latency, command p50/p95/p99 latency, event drops, and reconnect time. Publish the hardware, message mix, connection count, browser version, and network conditions with results.

Risks and Mitigations

RiskMitigation
WebTransport draft or library changesPin and publish compatibility; keep feature experimental and WebSocket available
UDP blocked in customer networksExplicit client modes, serial preference-mode fallback for opaque pre-establishment failure, synthetic readiness, and no fallback in required mode
Browser WebTransport omits cookiesUse CSRF-protected HTTPS ticket issuance and an authenticated CONNECT header
Target browser lacks CONNECT header supportKeep browser WebTransport disabled; do not use query-string tickets
Browser establishes reliable-only WebTransport instead of HTTP/3/QUICSet requireUnreliable, verify reliability, and reject or fall back before MCP initialization
Ticket theft or replayShort lifetime, route/origin/profile binding, atomic one-use replay cache, redacted logs
Ticket cache exposes retained access tokensRestrict and protect cache records, redact values, consume atomically, and delete the complete record on use or expiry
Replay cache is unavailable or returns ambiguous stateFail ticket issuance and consumption closed; never degrade to a local cache or replayable ticket
Gateway clock skew changes ticket validityRequire clock-synchronization readiness, enforce cache and absolute expiry, and allow no positive grace beyond the hard maximum
Gateway principal and controller identity divergeInject the retained Bearer token upstream and require independent controller validation; do not invent role claims
Gateway QUIC service destabilizes TCP proxySeparate listener/runtime, independent readiness, resource limits, failure isolation
One gateway WebTransport leg is healthy and the other failsPer-leg probes, categorized metrics, and no session acceptance until upstream establishment succeeds
Capability response is stale or overstates end-to-end readinessShort private cache lifetime, same-transport synthetic probes, refresh after failure, and actual handshake as authority
Browser hides the specific WebTransport establishment causePreserve explicit capability and ticket HTTP errors; use Establishment failed for opaque pre-ready rejection, retain the detailed cause in server telemetry, and require WebTransport where downgrade is unacceptable
Capability request itself failsUse only a still-fresh validated response; otherwise attempt no transport and retry discovery with jittered backoff
Page HTTP version is mistaken for controller reachabilityNever select from nextHopProtocol; use gateway candidates, browser feature detection, and the real session attempt
WebTransport-only MCP removes enterprise fallbackUse dual per-surface transport enabled sets until UDP and WebTransport CONNECT are proven for the complete client fleet
Wire-profile or transport downgradeOrdered explicit client candidates, unordered server enabled sets, required-mode policy, reject unlisted pairs
rkyv schema driftShared wire crate, immutable roots, protocol negotiation, golden fixtures
Malformed archive or resource exhaustionSafe validation, preallocation limits, semantic bounds, fuzzing, session quotas
No meaningful performance gainBenchmark against simpler alternatives before changing defaults
Dynamic JSON still dominatesMeasure it; add typed payloads only in a new version for proven hot paths
Event traffic affects commandsSeparate event and command streams, bounded queues, fairness tests
Multi-replica request reaches wrong podKeep single active replica or design a distributed session router separately
Load balancer supports HTTP/3 but not WebTransportRequire end-to-end draft-specific integration tests
WebSocket and WebTransport route config driftsShared resolver, diagnostics, and configuration parity tests
CSRF token is selected as a WebSocket wire profileParse tokens by role, strip CSRF before upstream negotiation, and never use first-token selection
Fallback creates duplicate registrationsSerial attempts, registration acknowledgement boundary, connection-ID-guarded cleanup
Operational complexity exceeds benefitPreserve WebSocket default and define a complete rollback path

Phase 0 Decisions and Reopen Questions

The failed feasibility run locks the following decisions without authorizing production implementation:

  • WebTransport remains disabled and WebSocket/JSON remains the production path.
  • Planned gateway replacement uses drain when possible and bounded client reconnect after interruption. There is no zero-downtime QUIC preservation claim.
  • The replay-store candidate is a shared Redis-compatible service using hash-only keys, protected values, hard TTL, SET NX PX, atomic GETDEL, TLS, ACLs, and fail-closed outage behavior. There is no local production fallback.
  • Internal capability and probe resources use a dedicated gateway mTLS identity.
  • controller-wire remains a dependency-light, independently versioned crate owned by light-fabric and consumed by controller-rs.
  • WebSocket and WebTransport route files remain separate initially with a shared resolver and enforced parity diagnostics.
  • The nine-case benchmark manifest and required workload/metric set are locked by the Phase 0 evidence package.

Reopen WebTransport feasibility only when a candidate Rust stack implements the selected current draft and exposes request/response negotiation in both roles, and a target shipping browser passes CONNECT headers, profile negotiation, requireUnreliable, reliability, reset, close, and stream tests end to end. Deployment environments, enterprise UDP/CONNECT behavior, production load targets, and any distributed controller session router remain later questions because the mandatory local tuple failed first.

References

Agent Skill And API Endpoint Discovery

Problem

The GenAI chat flow has two separate concepts that are easy to confuse:

  • The light-gateway MCP endpoint is the runtime server that lists and executes tools. An agent should call the gateway for tools/list and tools/call. A listed tool may be backed by a downstream MCP server or by a gateway-routed HTTP/OpenAPI endpoint.
  • Portal-query is the catalog service for skills, tools, and agent assignments. The agent should read this catalog through the genai-query API, cache it locally, and search it during chat.
  • The controller registry remains a runtime control-plane service for registration, discovery, and cache-management commands. It should not own the portal skill/tool catalog and should not execute downstream MCP or REST calls.

During chat, light-agent should use its local catalog cache to find relevant skills, then call tools/list on the gateway to verify executable tools. Tool execution still goes through the gateway. If the catalog cache is empty or stale, the agent should refresh it from portal-query. If portal-query is temporarily unavailable, the agent should still be able to use the gateway tool list directly.

The missing piece is a portal-managed catalog that explains which API endpoints exist, which endpoint projections are invokable by agents, which skills they belong to, and which agents are allowed or expected to use those skills. Without that catalog, the agent can list executable gateway tools, but it has no domain guidance beyond each tool description.

Goals

  • Keep the gateway as the runtime source of truth for MCP tool execution.
  • Keep direct gateway tools/list and tools/call working even when no skills have been authored.
  • Treat API endpoints as the generic capability unit. MCP tools, OpenAPI operations, JSON-RPC methods, and future protocol operations should all become endpoint-level capabilities before they are exposed to agents.
  • Populate a portal endpoint and tool catalog from API version parsing, LightAPI descriptions, gateway-discovered MCP tools, manually pasted MCP tools/list payloads, and gateway-routed REST tools.
  • Let portal users create skills that contain instructions and curated tool selections.
  • Let portal users assign skills to agent definitions.
  • Use the genai-query API and spec as the portal-query access surface for skills, tools, and agent assignments.
  • Let the agent cache the effective catalog locally and reload it when controller cache-management invalidation is triggered.
  • Make skills useful for progressive disclosure without requiring every MCP tool to be wrapped before it can be called.
  • Store semantic routing metadata for endpoint capabilities so the agent or portal-query can perform macro-filtering, keyword search, vector ranking, context viability checks, and safety filtering.

Non-Goals

  • Do not move MCP request routing or downstream REST calls into the controller.
  • Do not implement skill/search in controller-rs. Controller-rs can invalidate the agent cache, but portal-query owns catalog reads.
  • Do not use config-server as the first delivery path for the skills/tools catalog. The agent can fetch from portal-query and cache locally.
  • Do not require every gateway tool to have a skill before it is executable.
  • Do not replace the existing MCP Gateway registry design. This design extends it with agent-facing skill curation.
  • Do not implement embeddings in the first phase. Keyword search is enough for the initial local catalog search.
  • Do not limit the catalog to MCP tools. The UI may use “tool” when referring to LLM tool-calling, but the persistent capability model should be endpoint first.
  • Do not use skill assignments as the only authorization control. Gateway policy and downstream authorization still apply at execution time.

Concepts

ConceptResponsibilityExample
API EndpointCanonical endpoint-level capability stored by API version. It may come from OpenAPI, MCP tools/list, LightAPI, JSON-RPC, or another protocol./v1/accounts@get, getRandomNumber@call
ToolAgent-facing projection of an endpoint as an executable LLM function. The runtime call is made by name through the gateway.getAccounts calling GET /v1/accounts
SkillDomain guidance plus a curated set of tools. It helps an agent decide what to expose and how to reason.“Account Management” using account read and create tools
AgentRuntime worker that receives a user prompt, discovers skills and tools, calls the LLM, then executes requested tools through the gateway.account-agent
GatewayMCP server and router. It owns runtime tools/list and tools/call behavior.light-gateway /mcp
Portal QueryCatalog API service for reading skills, tools, tool params, skill-tool mappings, and agent-skill assignments.genai-query API
Controller RegistryRuntime control-plane service for service metadata, discovery, and cache invalidation.cache-management MCP tool
PortalAuthoring UI and persistence layer for tools, skills, and agent assignments.Tool Catalog, Skill Editor, Agent Skill Assignment

Target Architecture

The target flow keeps runtime execution and control-plane metadata separate.

Portal UI
  -> writes api_endpoint_t, tool_t, tool_param_t, skill_t, skill_tool_t, skill_workflow_t, agent_skill_t

light-gateway /mcp
  -> lists executable tools from mcp-router.tools and downstream MCP servers
  -> executes tools/call against downstream MCP or REST services

light-workflow
  -> owns deterministic multi-step workflow execution, task state, and audit events

portal-query genai-query API
  -> serves skill/tool/agent-skill catalog reads from portal data

controller-rs portal registry
  -> registers agents and sends cache-management invalidation commands

light-agent
  -> loads assigned skills and mapped tools from portal-query
  -> caches the effective catalog locally
  -> searches cached skills during chat
  -> lists executable tools from light-gateway
  -> calls selected tools through light-gateway

For the account-agent example:

  1. The gateway exposes account tools such as getAccounts and getAccountByNo.
  2. Portal stores the canonical endpoint rows in api_endpoint_t.
  3. Portal publishes selected endpoint rows into tool_t as agent-invokable capabilities.
  4. An operator creates an “Account Management” skill in skill_t.
  5. Portal links that skill to the account tools through skill_tool_t.
  6. Portal assigns the skill to the account agent through agent_skill_t.
  7. At startup or cache reload, the agent reads the assigned catalog through genai-query and caches it locally.
  8. At chat time, the agent searches its local catalog cache.
  9. The agent combines matched skill instructions with the gateway tool definitions.
  10. Any tool call still goes to light-gateway tools/call.

Source Of Truth

The gateway is the runtime source of truth for executable tools. If a tool is not available from the gateway, the agent should not be able to execute it just because it exists in the portal database.

api_endpoint_t is the canonical portal endpoint catalog. It stores the endpoint identity, protocol method, path, logical tool schema, endpoint description, and raw tool metadata for one API version.

tool_t is the agent-facing projection of an endpoint. It stores the tool name, agent description, implementation type, optional endpoint reference, response schema, active flag, semantic routing fields, and semantic embedding. The full metadata object should still be preserved in api_endpoint_t.tool_metadata for import/export and agent cache payloads.

The portal database is the control-plane catalog. It stores:

  • operator-friendly descriptions,
  • skill instructions,
  • agent assignments,
  • governance metadata,
  • cached or imported tool schemas.

Tool sync should be idempotent. The recommended unique identity is:

host_id + api_version_id + endpoint

Gateway exposure is a separate deployment selection. The catalog should sync all endpoint rows for an API version, then let the user choose which endpoint/tool projections are deployed to a specific gateway instance.

For runtime-executable projections, the gateway identity is:

hostId + serviceId + envTag

The access token used for portal catalog or gateway deployment APIs should carry matching host, sid, and env claims. Portal-query must verify those claims against the requested hostId, serviceId, and envTag before returning or changing catalog data.

Runtime verification means checking whether an endpoint projection is actually listed by a deployed gateway through tools/list. This should be done against the selected gateway instance when an operator is preparing or reviewing a gateway deployment. A later host-wide diagnostics view can aggregate all registered gateways, but phase 2 does not need host-wide verification as the default.

Runtime verification is not part of the persistence projection. The persistence layer should store catalog state, endpoint/tool metadata, and inactive drift state, but it should not call a live gateway. The portal UI, deployment review flow, or a diagnostics endpoint should call the selected gateway’s tools/list with the operator or service credential, compare the returned tool names and schemas with the catalog, and surface the result as deployment drift.

If a previously imported endpoint or tool disappears from the gateway, the sync process should mark the catalog projection inactive instead of deleting it immediately. This preserves skill mappings and gives operators a clear drift signal.

Current Data Model

The database already has the main tables needed for this design:

  • skill_t: skill name, description, content_markdown, embedding placeholder, version, and active flag.
  • tool_t: agent-facing tool catalog with name, description, implementation metadata, endpoint reference, and response schema.
  • tool_param_t: parameter-level metadata and validation schema.
  • agent_skill_t: maps agent definitions to skills.
  • skill_tool_t: maps skills to tools for progressive disclosure.
  • api_endpoint_t: MCP or REST endpoint metadata, including tool_schema and tool_metadata.
  • wf_definition_t: stores workflow definitions as YAML for the light-workflow runtime.

Phase 3.5 should add a skill-to-workflow mapping table rather than storing workflow YAML inside skill_t. The recommended table is:

ColumnPurpose
host_idTenant and ownership boundary.
skill_idSkill that can use or expose the workflow.
wf_def_idWorkflow definition stored in wf_definition_t.
workflow_roleRelationship type such as primary, validation, remediation, or test.
start_modeHow the workflow can be started, such as manual, agent, scheduled, or portal.
configJSONB overrides for workflow input defaults, disclosure settings, or skill-specific runtime hints.
aggregate_versionEvent-sourced concurrency/version field.
activeSoft delete and publication flag.

The current phase 2 persistence path can preserve semantic metadata in api_endpoint_t.tool_metadata before dedicated routing columns exist. That is acceptable for import/export compatibility and for small catalogs searched from the agent’s local cache. It should not be treated as the final indexed search shape. Before portal-query performs database-side macro-filtering over large catalogs or before vector ranking becomes a production dependency, promote the high-use routing fields to first-class columns or indexed relationships and backfill them from tool_metadata.

The existing MCP Registry design already maps MCP tools into api_endpoint_t. OpenAPI parsing also creates endpoint rows. This design uses tool_t as the agent-facing catalog row and links it back to api_endpoint_t when the tool originates from an API endpoint.

Recommended mapping for gateway-imported tools:

Gateway tool fieldPortal storage
nametool_t.name and api_endpoint_t.endpoint_name
descriptiontool_t.description and api_endpoint_t.endpoint_desc
inputSchemaapi_endpoint_t.tool_schema and generated tool_param_t rows
Gateway route metadatagateway exposure metadata keyed by hostId, serviceId, and envTag
Downstream REST pathtool_t.api_endpoint and api_endpoint_t.endpoint_path
Downstream methodtool_t.api_method and api_endpoint_t.http_method
Safety flagsindexed tool metadata plus api_endpoint_t.tool_metadata.safety

tool_t.implementation_type should be a standardized enum aligned with the LightAPI Description execution model. Endpoint-backed tools should use a LightAPI endpoint implementation type rather than preserving every downstream transport as a different tool implementation. The downstream protocol remains in the endpoint and LightAPI request metadata.

Recommended first enum values:

Implementation typeUse
lightapi_endpointAny agent-invokable API endpoint described by api_endpoint_t and LightAPI metadata.
javaIn-process Java implementation.
pythonScript-backed Python implementation.
javascriptScript-backed JavaScript implementation.

For lightapi_endpoint, execution still goes through gateway tools/call when the endpoint is exposed to a gateway. The source protocol, such as MCP, OpenAPI, JSON-RPC, OpenRPC, or gRPC, belongs in api_endpoint_t, tool_metadata, and the LightAPI request description.

Endpoint-First Capability Model

Agents and skills should operate over endpoint capabilities, not only over MCP tools. MCP remains the runtime protocol for tool-calling through the gateway, but the catalog should support any endpoint that can be represented as an agent-invokable capability.

Recommended capability layers:

  1. api_endpoint_t: canonical endpoint row for the API version.
  2. tool_t: agent-facing executable projection of the endpoint.
  3. tool_param_t: normalized top-level input parameters derived from the endpoint’s JSON Schema.
  4. skill_tool_t: curated relationship between a skill and a tool projection, including per-skill overrides such as priority, examples, or approval notes.
  5. agent_skill_t: assignment of skills to agent definitions.

This model supports these source types:

SourceEndpoint identityTool projection
MCP tools/list<toolName>@callTool name is the MCP tool name; method is call.
OpenAPI<path>@<method>Tool name comes from operation id or generated endpoint name.
LightAPI Descriptionoperation.endpointId or <operationId>@<method>Tool name comes from operation id or curated agent metadata.
JSON-RPC/OpenRPC<method>@callTool name is the method or curated operation name.
gRPC<service>/<method>@callTool name is the curated operation name.

tool_param_t should be generated from the logical input schema, not from wire transport details alone. For OpenAPI, the logical input schema should merge path parameters, query parameters, and request body into one object. For MCP, the logical input schema is the MCP inputSchema. For JSON-RPC, it is the logical params schema.

Semantic Routing Metadata

The customer-required semantic routing fields should be first-class indexed catalog data, not only JSON metadata. They are used for macro-filtering before expensive keyword, vector, or LLM ranking, so the common filter fields must be queryable through normal portal-query indexes.

Recommended indexed fields or relationships:

  • domain and semantic namespace,
  • sensitivity tier,
  • semantic weight,
  • target personas,
  • active state,
  • source protocol and implementation type,
  • portal category and tag relationships.

Recommended phase 2 column names for endpoint and tool projections:

FieldSuggested column or relationshipSource fallback
Domainrouting_domaintool_metadata.routing.domain, LightAPI capability group, OpenAPI tag.
Semantic namespacesemantic_namespacetool_metadata.routing.semanticNamespace, LightAPI info.namespace.
Sensitivity tiersensitivity_tiertool_metadata.routing.sensitivityTier, LightAPI visibility or safety metadata.
Semantic weightsemantic_weighttool_metadata.routing.semanticWeight, default 1.0.
Source protocolsource_protocolLightAPI operation protocol, OpenAPI, MCP, JSON-RPC, gRPC.
Target personasjoin table or indexed arraytool_metadata.routing.targetPersonas, LightAPI agent metadata.

The full structured payload should still be preserved in api_endpoint_t.tool_metadata so LightAPI import/export, gateway config generation, and agent cache payloads have one portable metadata object.

Recommended api_endpoint_t.tool_metadata shape:

{
  "routing": {
    "domain": "finance.accounts",
    "category": "account-management",
    "semanticNamespace": "prod.accounts.core",
    "targetPersonas": ["account-agent", "customer-support-agent"],
    "semanticDescription": "Retrieves account profile and status information when a user asks about an existing account.",
    "semanticKeywords": ["account lookup", "customer account", "balance", "status"],
    "contextRequirements": {
      "requiredInputs": ["accountNo"],
      "requiredContext": ["host_id"]
    },
    "dependencies": [
      {
        "endpoint": "/v1/accounts/{accountNo}@get",
        "relation": "frequently_chained_after"
      }
    ],
    "semanticWeight": 0.75,
    "sensitivityTier": "Internal-Only",
    "fallbackEndpoint": "/v1/accounts@get",
    "embedding": {
      "model": "tool-description-embedding",
      "source": "semanticDescription"
    }
  },
  "safety": {
    "read_only": true,
    "destructive": false,
    "humanApprovalRequired": false
  }
}

Recommended ownership:

MetadataPrimary storageNotes
Domain and namespaceIndexed endpoint/tool columns plus tool_metadata.routingUsed for macro-filtering before vector ranking.
Categories and tagsExisting portal tag/category tables plus tool_metadata.routingReuse the portal taxonomy instead of creating a separate endpoint taxonomy.
Target personasIndexed mapping or array plus tool_metadata.routing.targetPersonasUsed to filter the effective catalog for the current agent.
Rich capability descriptiontool_t.description plus tool_metadata.routing.semanticDescriptiontool_t.description should be the concise LLM-facing description.
Synonyms and keywordstool_metadata.routing.semanticKeywordsUsed by keyword search and embedding source text.
Embedding vectortool_t.description_embeddingThe embedding provider must produce the configured vector dimension, currently 384, or the column must be migrated.
Required state/context lockstool_metadata.routing.contextRequirementsThe router should exclude non-viable tools before LLM tool injection.
Dependency mappingstool_metadata.routing.dependenciesUsed for chain suggestions, prefetch, or warm-up.
Priority scoreIndexed column plus tool_metadata.routing.semanticWeightNumeric multiplier for ranking ties.
Sensitivity tierIndexed column plus tool_metadata.routing.sensitivityTierUsed before disclosure and before execution.
Fallback targettool_metadata.routing.fallbackEndpointRuntime fallback should still respect gateway policy.
Destructive/read-only flagstool_metadata.safety and existing gateway toolMetadataRuntime enforcement belongs in gateway or policy, not only in prompts.

The first semantic search implementation can work from the agent’s local cache:

  1. Filter by host, active flag, assigned skill, domain, namespace, target persona, and sensitivity tier.
  2. Exclude endpoints whose required context is not available in the current workflow or chat state.
  3. Rank by keyword matches over skill text, endpoint name, tool name, description, semantic keywords, and LightAPI capability text.
  4. When embeddings are populated, combine vector similarity with the keyword score and multiply by semanticWeight.
  5. Call gateway tools/list and intersect the ranked set with currently executable tools before exposing schemas to the LLM.

Embedding Recommendation

Keep the first production embedding dimension at 384 because the current Postgres vector column is already VECTOR(384) and the first catalog use case is routing over short endpoint descriptions, not long document retrieval.

Recommended model strategy:

  • Use a provider abstraction with configured embedding_model, embedding_dimension, and embedding_source.
  • For OpenAI-hosted embeddings, use text-embedding-3-small with the dimensions parameter set to 384.
  • For on-prem or firewall-restricted deployments, use a local embedding service that is configured to emit 384-dimensional vectors.
  • Store enough metadata to know how a vector was created: model, dimension, source text hash, source field, and generated timestamp.
  • Re-embed when the semantic description, keywords, domain, or model config changes.

The portal catalog write path should remain in the portal service layer that owns api_endpoint_t and tool_t persistence. Because the current portal command/query services are Java, the Java side should own transactions, versioning, and persistence of embedding results. A Rust service or worker can still generate embeddings behind an internal API or queue consumer, especially if local model performance is better there. In that model, Java requests or consumes the vector and writes it through the normal portal persistence path.

LightAPI Description Enrichment

LightAPI Description should be the preferred enrichment source for endpoint capabilities. OpenAPI and MCP tools/list are good at initial extraction, but LightAPI adds the agent-oriented context needed for high-accuracy routing:

  • endpoint identity and stable endpointId
  • domain, tags, lifecycle, visibility, and capability group
  • logical input schema and request mapping
  • result schema and result cases
  • examples and behavior notes
  • progressive disclosure metadata
  • agent-facing descriptions, personas, keywords, context requirements, and guardrails

Recommended merge priority for endpoint metadata:

  1. Portal operator overrides.
  2. Endpoint-level LightAPI Description.
  3. API-level inherited LightAPI Description context.
  4. OpenAPI/OpenRPC/protobuf/MCP source extraction.
  5. Gateway runtime tools/list discovery.

This keeps runtime discovery useful while letting curated LightAPI descriptions provide richer semantic routing without hand-authoring every endpoint as an independent skill.

Phase 2 persistence should be treated as the receiver for this metadata, not as the extractor. The openapi-parser, a LightAPI Description parser, or a dedicated ingestion worker must emit the enriched endpoint payload on the API version event. At minimum, the event payload for each endpoint should include:

  • endpointId, endpoint identity, protocol, method, path, name, and description,
  • logical toolSchema generated from the LightAPI operation input contract,
  • toolMetadata.routing with namespace, domain, capability group, personas, keywords, context requirements, sensitivity tier, and semantic weight where present,
  • toolMetadata.safety from LightAPI safety, visibility, idempotency, and destructive-operation hints,
  • response schema or result metadata when it is available for the tool projection.

If the parser only emits the base OpenAPI or MCP fields, the catalog remains valid but only has low-enrichment metadata. The phase 2 implementation should record that as an ingestion gap, not as a persistence defect.

Portal Catalog Contract

The agent should read skills and tools through the genai-query API in portal-query. The source spec is:

genai-query/src/main/resources/spec.yaml

The current spec already includes catalog endpoints for the main entities:

  • getAgentSkill and getFreshAgentSkill
  • getSkill and getFreshSkill
  • getSkillTool and getFreshSkillTool
  • getSkillDependency and getFreshSkillDependency
  • getTool and getFreshTool
  • getToolParam and getFreshToolParam

Phase 2 should add a dedicated effective catalog endpoint instead of forcing the agent to compose many generic query endpoints. The endpoint should still live in genai-query, not controller-rs.

Recommended endpoint behavior:

  • verify the caller’s token claims before reading catalog rows,
  • require request host_id, service_id, and env_tag,
  • match token host, sid, and env claims to those request values,
  • return only endpoint/tool projections valid for that host, service, and environment,
  • include active endpoint metadata, tool schemas, safety metadata, routing metadata, and skill mappings relevant to the agent,
  • support a freshness or version field so the agent can cache the result.

The agent should cache the returned structure locally:

{
  "host_id": "00000000-0000-0000-0000-000000000000",
  "agent_def_id": "00000000-0000-0000-0000-000000000000",
  "catalog_version": 42,
  "skills": [
    {
      "skill_id": "00000000-0000-0000-0000-000000000000",
      "name": "Account Management",
      "description": "Use account tools to inspect and manage customer accounts.",
      "content_markdown": "Prefer read-only tools before create or update tools.",
      "tools": [
        {
          "tool_id": "00000000-0000-0000-0000-000000000000",
          "endpoint_id": "00000000-0000-0000-0000-000000000000",
          "name": "getAccounts",
          "endpoint": "/v1/accounts@get",
          "api_type": "openapi",
          "description": "List account summaries.",
          "input_schema": {
            "type": "object",
            "properties": {}
          },
          "routing_metadata": {
            "domain": "finance.accounts",
            "semanticNamespace": "prod.accounts",
            "semanticKeywords": ["account list", "customer accounts"],
            "sensitivityTier": "Internal-Only"
          },
          "safety": {
            "read_only": true,
            "destructive": false
          }
        }
      ]
    }
  ]
}

For phase 2, the agent definition identity is the agent API version identity. agent_definition_t.agent_def_id stores the same UUID as api_version_t.api_version_id; the table is an agent-specific profile extension for model and runtime settings, not a second standalone agent registry. The agent display name comes from api_t.api_name, so agent_definition_t does not duplicate the API name. API Admin continues to own the API/API-version lifecycle, Instance Admin continues to own deployed instances, and the Agent Definition page edits the profile for that API version.

The previous registry skill/search response shape was:

{
  "skills": [
    {
      "skill_id": "00000000-0000-0000-0000-000000000000",
      "name": "Account Management",
      "description": "Use account tools to inspect and manage customer accounts.",
      "tool_name": "getAccounts",
      "input_schema": {
        "type": "object",
        "properties": {}
      }
    }
  ]
}

That flattened shape can remain as an internal compatibility DTO while the agent is migrated, but it should not be the long-term external contract. The target cache shape should support a skill with multiple tools:

{
  "skills": [
    {
      "skill_id": "00000000-0000-0000-0000-000000000000",
      "name": "Account Management",
      "description": "Use account tools to inspect and manage customer accounts.",
      "content_markdown": "Prefer read-only tools before create or update tools.",
      "tools": [
        {
          "name": "getAccounts",
          "description": "List account summaries.",
          "input_schema": {
            "type": "object",
            "properties": {}
          }
        }
      ]
    }
  ]
}

Migration rule:

  • Remove the controller-rs skill/search placeholder.
  • The agent can temporarily accept both the flattened shape and the nested tools shape while its portal-query client is being migrated.
  • After migration, the nested effective catalog shape becomes the preferred internal cache contract.

Agent identity can come from token claims, configured agent definition, or request fields. If inference is not enough, pass explicit fields to the portal-query catalog call:

{
  "agent_def_id": "00000000-0000-0000-0000-000000000000",
  "host_id": "00000000-0000-0000-0000-000000000000",
  "service_id": "com.networknt.account-agent-1.0.0",
  "env_tag": "dev"
}

Runtime Behavior

The agent should treat the portal catalog as helpful guidance, not as a hard dependency for basic tool use.

Recommended behavior:

  1. At startup, call the genai-query API to load the effective agent catalog.
  2. Cache the catalog locally under host_id, agent identity, and catalog version.
  3. During chat, search the local catalog with the user prompt.
  4. If matched skills are returned, add skill instructions to the prompt context.
  5. If matched skills include tool mappings, prefer those tools for the LLM tool list.
  6. Call gateway tools/list to verify executable tools and obtain the current runtime schemas.
  7. Intersect skill-selected tool names with gateway-listed tools.
  8. If no skills match, or the local catalog is unavailable, fall back to gateway tools/list.
  9. Execute all LLM tool calls through gateway tools/call.

When portal data changes, controller cache management can invalidate the agent’s local catalog cache. Reload behavior should match the agent’s initial loading strategy:

  • if the agent loads the catalog during startup, invalidation should trigger an eager reload so the next chat request sees current metadata;
  • if the agent loads the catalog on the first request, invalidation can clear the cache and let the next request reload lazily.

This keeps the account-agent usable before the portal skill catalog is fully populated and avoids making controller-rs part of the catalog query or execution path.

Portal UI

Endpoint Catalog And Tool Projection

The catalog UI should be endpoint-first but still show the tool projection that agents will see. It should let operators:

  • browse api_endpoint_t rows by API, API version, endpoint, method, source, and active state,
  • import or resync endpoint capabilities from OpenAPI, MCP tools/list, manually pasted MCP tools payloads, LightAPI descriptions, and selected gateway runtime surfaces,
  • publish selected endpoint rows into tool_t as agent-invokable tools,
  • generate or refresh tool_param_t rows from the logical input schema,
  • see tool name, description, input schema, downstream endpoint, API type, semantic namespace, domain, personas, sensitivity tier, and runtime executable state,
  • compare catalog metadata against source specs and current gateway tools/list,
  • mark missing endpoint projections inactive,
  • override operator-facing descriptions without changing gateway config,
  • review and edit semantic routing metadata such as keywords, context requirements, fallback endpoint, priority weight, read-only, destructive, sensitive, or human-approval-required.

The first implementation should not depend only on live gateway access. It can import from the endpoint rows produced by API version parsing, including manual MCP tools/list JSON pasted into the API version spec field. Gateway tools/list should then be used to verify which imported projections are currently executable by a deployed gateway.

Skill Editor

The Skill Editor should let operators:

  • create and update skill_t rows,
  • write content_markdown instructions,
  • link tools through skill_tool_t,
  • set tool access level and per-skill config,
  • preview which tools the skill would expose for a sample prompt,
  • optionally link the skill to one or more workflow definitions,
  • activate or deactivate skills.

Skill content should be short and operational. It should describe when to use the skill, how to interpret the tools, and any sequencing rules. It should not contain secrets.

Workflow-backed Skills

Some skills are only guidance plus a curated tool set. Other skills need a repeatable process that calls several tools, branches on results, waits for human input, runs assertions, or leaves an audit trail. Those skills should use light-workflow as the orchestration layer.

The boundary is:

LayerResponsibility
SkillDiscovery metadata, instructions, taxonomy, 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-backed skills should be optional. Use a workflow when the skill represents a durable or regulated process, such as API onboarding, approval, validation, remediation, scheduled live testing, or a multi-step operation with clear checkpoints. Do not require workflow backing for simple skills that only guide an agent toward one tool call or open-ended exploration.

The workflow definition remains canonical in wf_definition_t.definition as YAML. The skill workspace should link to the definition through skill_workflow_t and should reuse the generic workflow editor described in Workflow Editor. The skill workspace can constrain the editor with skill context, but it should not implement its own workflow runtime.

For workflow-backed skills, skill_tool_t becomes the allowed tool set. A save-time validator should reject workflow steps that reference a gateway tool not linked to the skill, unless the step is explicitly marked as a future or external dependency. This keeps progressive disclosure, operator review, and workflow execution aligned.

Recommended Skill Workspace tabs:

TabPurpose
OverviewEdit name, description, Markdown instructions, active state, tags, and categories.
ToolsLink tools, configure skill_tool_t.config, inspect schemas, sensitivity, and gateway availability.
WorkflowSelect or create workflow definitions, edit YAML, inspect the step outline, and link workflows through skill_workflow_t.
PreviewShow the effective prompt, allowed tool set, linked workflow graph, and disclosure payload.
TestStart a workflow with JSON input, watch instance events, complete waiting tasks, and inspect assertions or failures.

Agent Skill Assignment

The Agent Skill Assignment UI should let operators:

  • select an agent definition,
  • assign one or more active skills through agent_skill_t,
  • set priority and sequence,
  • preview the final skill list for that agent,
  • verify that each assigned skill still has at least one executable gateway tool.

Portal-query And Agent Cache Implementation

Catalog lookup should be implemented through the genai-query API. The agent should fetch the assigned active catalog, cache it locally, and run progressive disclosure search against the cache.

Phase 5 implements this for the Rust light-agent only. Other agent runtimes can adopt the same genai-query contract later, but they are not part of the Phase 5 implementation scope.

Initial algorithm:

  1. Resolve host_id from the agent runtime configuration and the catalog request token. Resolve agent_def_id from LIGHT_AGENT_AGENT_DEF_ID or LIGHT_AGENT_API_VERSION_ID. Resolve service_id and env_tag from the registered Rust agent service config.
  2. Call genai-query getEffectiveAgentCatalog.
  3. The endpoint loads active agent_skill_t rows and linked active skill_t, skill_tool_t, tool_t, tool_param_t, and skill_workflow_t rows.
  4. Build a nested effective catalog grouped by skill, with each skill carrying its mapped tools, schemas, endpoint identity, safety flags, and routing metadata.
  5. Cache the effective catalog locally with catalogVersion and catalogHash.
  6. During chat, macro-filter cached entries by agent persona, domain, namespace, sensitivity tier, active state, and available workflow context.
  7. Rank cached entries by simple text matching over skill_t.name, skill_t.description, skill_t.content_markdown, tool_t.name, tool_t.description, endpoint name, endpoint description, and semantic keywords.
  8. Intersect the final candidate list with gateway tools/list before exposing tool schemas to the LLM.

Controller cache management should invalidate this local cache when portal catalog data changes. After invalidation, the agent reloads from portal-query.

Later algorithm:

  • Add vector search over skill_t.description_embedding and tool_t.description_embedding.
  • Add vector search over endpoint semantic descriptions and LightAPI capability text.
  • Include skill dependency expansion from skill_dependency_t.
  • Use dependency mappings and fallback endpoints for chain planning, prefetch, and failure repair.
  • Include inactive or missing-tool diagnostics for portal admin views, not for normal agent search.

Gateway Implementation

The gateway should keep the MCP data-plane contract stable:

  • tools/list returns the executable tool set for the caller.
  • tools/call routes by tool name to downstream MCP servers or REST services.
  • Gateway policy remains authoritative at execution time.
  • Gateway does not depend on skill_t or agent_skill_t to execute tools.

The gateway can expose an administrative sync endpoint later, but the first portal sync can call the existing MCP tools/list endpoint with an operator or service credential.

mcp-router.tools in values.yml should stay a runtime execution projection, not the full semantic registry. It should include the fields the gateway needs to list and call tools, plus safety metadata that must be enforced at runtime. Richer semantic routing metadata should stay in portal-query and the agent cache unless the gateway needs it for a concrete runtime policy decision.

Security Rules

  • Skill assignment narrows what the agent should offer to the LLM, but it does not grant runtime authorization by itself.
  • Gateway access control, endpoint scopes, OAuth token claims, and downstream service authorization still decide whether a tool call is allowed.
  • Tool schemas and descriptions are not trusted input. They should be validated before storing and escaped when rendered.
  • Skill content must not contain secrets, tokens, private keys, or passwords.
  • A stale catalog row must not make a removed gateway tool executable.
  • A stale local agent cache must be intersected with gateway tools/list before exposing tools to the LLM.
  • Controller cache invalidation only forces reload; it does not grant access to catalog rows or executable tools.
  • Sensitive or destructive tool metadata should be enforced by the gateway or a policy layer, not only by prompt instructions.
  • Sensitivity tier must be checked before catalog disclosure. An agent without clearance for Restricted-PII should not receive the endpoint description or schema even if a skill references it.
  • Context requirements are not only prompt hints. If required context is missing, the endpoint should be excluded or routed to an ask/workflow step that obtains the missing value.

Failure Handling

FailureExpected behavior
Portal-query catalog load fails at startupStart with an empty catalog cache and fall back to gateway tools/list.
Portal-query catalog reload fails after invalidationKeep the previous cache if available, mark it stale, retry with backoff, and still verify tools through gateway tools/list.
Gateway tools/list failsContinue chat without tools or return a clear tool-unavailable response.
Skill references missing toolOmit the missing tool from the runtime tool list and surface drift in portal admin UI.
Gateway rejects tools/callReturn the tool error to the LLM loop and log the gateway response.
Catalog sync sees changed schemaUpdate catalog schema, mark the tool as changed, and preserve operator metadata.
LightAPI enrichment conflicts with source specPreserve the source invocation contract, mark the semantic metadata conflict for review, and do not overwrite operator overrides.

Phased Implementation

Phase 1: Preserve Direct MCP Baseline

  • Keep agent tool execution through gateway tools/call.
  • Remove the controller-rs skill/search placeholder before it becomes a dependency.
  • Ensure agent falls back to gateway tools/list when no catalog cache is available.
  • Keep direct gateway tools/list and tools/call working without portal skills.

Phase 2: API Endpoint Catalog Sync

  • Add portal UI for endpoint-first import and resync.
  • Use existing API version parsing to populate api_endpoint_t for OpenAPI and MCP tools, including manual MCP tools/list payloads accepted in the API version spec field.
  • Sync all endpoint rows for the API version into the endpoint catalog. Do not limit the catalog to the endpoints currently selected for one gateway instance.
  • Import or refresh LightAPI Description metadata for endpoint enrichment.
  • Publish selected endpoint rows into tool_t as agent-facing tool projections.
  • Generate tool_param_t from each endpoint’s logical input schema.
  • Link every API-origin tool projection back to api_endpoint_t.endpoint_id.
  • Store semantic routing metadata in indexed endpoint/tool fields and preserve the full metadata payload in api_endpoint_t.tool_metadata.
  • If the first code slice only writes tool_metadata, keep that as a compatibility step and add the indexed routing-column migration before database-side macro-filtering or production vector ranking is enabled.
  • Let users select which endpoint projections should be exposed to a specific gateway instance. This deployment selection is separate from endpoint catalog sync.
  • Verify runtime executability outside persistence with gateway tools/list for the selected gateway instance when a gateway is reachable.
  • Mark disappeared or non-executable projections inactive instead of deleting them.
  • Add drift indicators for schema, description, safety metadata, and semantic routing metadata changes.

Phase 3: Skill Authoring

  • Keep the existing skill_t CRUD page as the phase 3 authoring surface.
  • Add skill-scoped category and tag assignment to the create/update skill forms. The UI should use dropdowns populated from the existing portal taxonomy where entity_type = 'skill'.
  • Persist skill categories through entity_category_t and skill tags through entity_tag_t; do not add tags or categories columns to skill_t.
  • Implement skill save as a composite command: one event updates the skill row and one taxonomy event replaces the selected category/tag associations for the same skill.
  • Keep content_markdown as the instruction body. YAML or JSON skill files are import/export envelopes; if full structured skill authoring is introduced later, add a nullable JSONB skill-spec column beside content_markdown instead of replacing it.
  • Keep embeddings optional.

Phase 3.5: Skill Workspace And Structured Authoring

  • Add a richer Skill Workspace with Overview, Tools, Workflow, Preview, and Test tabs.
  • Add tool linking workflows for skill_tool_t and formalize skill_tool_t.config for per-skill tool overrides.
  • Add workflow-backed skill support through skill_workflow_t, with wf_definition_t.definition kept as the canonical workflow YAML.
  • Reuse the generic Workflow Editor in the Workflow tab for YAML editing, step preview, validation, and test runs.
  • Add validation that workflow tool-call steps reference tools linked to the skill through skill_tool_t.
  • Add “create skill from LightAPI/tool” flows that can generate a draft skill, link relevant tools, and optionally create a starter workflow definition.
  • Add YAML/JSON import/export for structured skill documents. Normalize YAML to JSON for storage when a persisted structured payload is needed, while keeping Markdown instructions in content_markdown.

Phase 4: Agent Assignment

  • Add portal UI for agent_skill_t.
  • Let operators assign active skills to agent definitions.
  • Add an Agent Definition assignment entry point in addition to the existing agent_skill_t table page, so operators can manage assigned skills from the agent context.
  • Add a batch assignment composite command that emits one AgentSkillCreatedEvent per selected skill.
  • Add validation that assigned skills have at least one active direct skill_tool_t link. A workflow-backed skill does not satisfy this by having only skill_workflow_t; the workflow must use the skill’s linked tools.
  • Enforce assignment validation in command handlers and mirror the same checks as UI preflight feedback.
  • Treat sequence_id as the deterministic effective prompt/display order and priority as a ranking weight for later catalog/search behavior.
  • Add the dedicated genai-query getEffectiveAgentCatalog endpoint with token verification against host, sid, and env claims.
  • The endpoint returns the active nested catalog for one hostId + agentDefId + serviceId + envTag: agent metadata, assigned skills, tags, categories, skill config, mapped tools, tool params, routing/safety fields, workflow references, catalogVersion, and catalogHash.
  • Implement the Rust light-agent portal-query client using that endpoint.
  • Build and cache the nested effective catalog inside the Rust agent.
  • Start with local macro-filtering and keyword matching over cached skills, endpoint metadata, and tool projections.
  • Intersect selected catalog tool names with gateway tools/list; execute only through gateway tools/call.
  • Wire controller cache-management invalidation to clear the Rust agent catalog cache. The next chat request lazily reloads from portal-query.
  • If portal-query is unavailable or no agent definition ID is configured, the Rust agent falls back to direct gateway tools/list without portal catalog filtering.
  • Add vector ranking after 384-dimensional embeddings are populated and combine it with semanticWeight.

Phase 6: Semantic Routing And Governance

  • Support the Rust light-agent only. Other agent runtimes can adopt the same catalog and diagnostics contracts later.
  • Use the normalized sensitivity tiers public, internal, confidential, and restricted. Treat missing or unknown tool tiers as internal.
  • Enforce sensitivity-tier disclosure before portal-query returns the effective catalog to the agent. Tools blocked by policy are omitted from the returned tools list and surfaced as diagnostics for admin review.
  • Block destructive or approval-required tools unless the skill/tool policy names an approval workflow. Until workflow-owned approval state exists, the current active row plus aggregate version remains the catalog versioning authority.
  • Keep gateway tools/list and tools/call as the runtime source of truth. The Rust agent must still intersect catalog-selected tools with live gateway tools/list.
  • Add Rust-agent diagnostics that compare the effective catalog against gateway tools/list at /diagnostics/tools, showing catalog tools missing from the gateway, gateway tools outside the catalog, and policy-blocked catalog tools.
  • Enforce the same destructive, approval-required, and sensitivity metadata at the gateway before tools/call execution. A blocked call should include auditInfo fields and gateway debug/warn logs with the tool name, endpoint, tier, policy reason, and approval state.
  • Do not write catalog-disclosure audit records into audit_log_t; it is reserved for workflow. Phase 6 uses auditInfo so the existing audit log file path captures blocked gateway decisions. A generic audit table can be added in a later governance phase if file logging is not enough.

Resolved Phase 2 Decisions

  • Phase 2 endpoint catalog sync covers all endpoint rows for an API version. Gateway exposure is a separate step where users select which endpoint/tool projections to deploy to a specific gateway instance.
  • Runtime verification means checking the selected gateway instance’s tools/list response to confirm that a deployed endpoint projection is executable there. It is not the same as endpoint catalog sync and should be implemented in the portal UI, deployment review flow, or diagnostics layer, not inside the persistence projection.
  • Gateway exposure identity is hostId + serviceId + envTag. The token used for portal APIs must carry matching host, sid, and env claims.
  • tool_t.implementation_type should be standardized and aligned with the LightAPI Description execution model. Endpoint-backed tools should use the standardized endpoint implementation type, with downstream protocol stored in endpoint and LightAPI metadata.
  • High-use semantic routing fields should be indexed columns or indexed relationships, with the full structured payload preserved in api_endpoint_t.tool_metadata. JSON-only persistence is only an interim import/export-compatible shape for small catalogs or local-cache search.
  • LightAPI Description enrichment requires an upstream parser or ingestion worker to emit enriched endpoint payloads. The persistence layer can store tool_schema, tool_metadata.routing, and tool_metadata.safety, but it does not derive those fields from the raw LightAPI document by itself.
  • Endpoint category and tag classification should reuse the existing portal tag and category system.
  • Embeddings should start at 384 dimensions to match the current VECTOR(384) schema. Use a provider abstraction so hosted OpenAI embeddings or local embedding services can be swapped without changing the catalog schema.
  • genai-query should expose a dedicated effective catalog endpoint. Its token verification must match request host_id, service_id, and env_tag against token host, sid, and env claims.
  • Cache reload behavior depends on the loading strategy. Startup-loaded catalogs should eagerly reload after invalidation. First-request-loaded catalogs can reload lazily on the next request.
  • Phase 2 focuses on tool and endpoint metadata. Skill-specific metadata and per-skill tool config should be designed later with the skill authoring phase.
  • Phase 3 uses the existing taxonomy join tables for skill tags and categories. Skill files may be YAML or JSON, but the database should keep content_markdown for the instruction body; a structured JSONB skill-spec column belongs in a later full authoring/import phase if it becomes needed.

Resolved Phase 3.5 Decisions

  • Use light-workflow for workflow-backed skills that need durable multi-tool orchestration, approvals, assertions, retries, scheduled tests, or audit history.
  • Do not force every skill into a workflow. Skills remain the discovery and guidance layer, and simple skills can stay instruction-and-tool based.
  • Keep light-gateway as the runtime tool execution path. Workflow tasks that call tools should still use gateway-visible tool identities and should not bypass gateway policy.
  • Keep workflow definitions in wf_definition_t.definition as YAML. Link skills to workflow definitions through skill_workflow_t instead of embedding workflow definitions in skill_t.
  • Treat skill_tool_t as the allowed tool set for workflow-backed skills. Save-time validation should flag workflow tool calls that are not linked to the skill.
  • Build the workflow authoring UI as a generic reusable editor first, then embed it inside the Skill Workspace with skill-aware reference filtering and validation.

Resolved Phase 4 Decisions

  • An assignable skill must be active and must have at least one active direct tool link through skill_tool_t. Active skill_workflow_t rows are useful orchestration metadata, but they do not replace the direct allowed-tool set.
  • Workflow-backed skill assignment should also rely on the Phase 3.5 validator: workflow tool-call references must resolve to tools linked through skill_tool_t.
  • Validation must be enforced server-side by createAgentSkill, updateAgentSkill, and the batch assignment composite command. The Portal UI should run the same checks as preflight feedback, but UI checks are not authoritative.
  • Keep the existing AgentSkill table page and add an Agent Definition assignment context so operators can assign and inspect skills from the agent they are configuring.
  • Batch assignment should be a composite command that creates multiple AgentSkillCreatedEvent events from one request.
  • sequence_id controls deterministic ordering when building the agent’s effective skill prompt/catalog. priority is reserved as a ranking weight for later effective-catalog and search behavior.
  • Live gateway runtime executability checks are not part of Phase 4 persistence validation. Keep them as a diagnostics or governance item that compares cataloged/assigned tools with the selected gateway instance’s tools/list response before deployment or runtime enablement.

Recommendation

Implement this as a progressive control-plane enhancement. The gateway remains the execution path, and portal-authored skills become the agent guidance layer served by portal-query. The agent should cache the effective catalog locally and reload it after controller cache-management invalidation. This lets MCP tools work immediately through tools/list and tools/call, while still giving portal operators a clean path to organize tools into skills, assign those skills to agents, and improve retrieval over time.

Agent API Compose And Multi-Agent Workflow

Problem

portal-config-loc and portal-config-dev are being updated so local Docker Compose stacks can run light-agent beside the portal services, demo APIs, and gateway. The first implementation adds one light-agent directly to the main compose file and points the gateway direct registry at http://light-agent:8083.

That works for a single account agent, but it does not scale cleanly for the next phase:

  • the base portal stack should remain usable without demo APIs or agents,
  • demo APIs and agents should be startable as an optional local package,
  • all services must still share the same Docker network,
  • multiple light-agent instances need unique runtime identities,
  • each agent needs a different effective skill/tool/workflow catalog,
  • workflows need to orchestrate API access across multiple specialized agents.

The design goal is to split deployment concerns without splitting the runtime network or the control-plane model.

Goals

  • Move the two demo APIs and local light-agent services into a separate Docker Compose overlay file.
  • Keep the overlay on the same Docker network as the portal stack, gateway, controller, config-server, Postgres, and hybrid services.
  • Support multiple light-agent containers from the same image with different service ids, advertised addresses, ports, model settings, agent definitions, skills, tools, and workflows.
  • Keep light-gateway as the MCP runtime path for tools/list and tools/call.
  • Keep portal-query as the source for the effective agent catalog.
  • Use skill_workflow_t and wf_definition_t to connect skills to executable workflows.
  • Use light-workflow for deterministic orchestration, retries, human tasks, assertions, audit, and multi-agent coordination.

Non-Goals

  • Do not move the demo APIs or agent services into a different Compose project by default.
  • Do not create a second Docker network for the demo APIs and agents.
  • Do not make one agent container host multiple unrelated agent definitions.
  • Do not move MCP tool execution into controller-rs or portal-query.
  • Do not require every gateway tool to be wrapped by a skill before baseline gateway tool execution works.
  • Do not store workflow definitions inside skill_t.

Compose File Split

Use the main compose files for platform services and a separate overlay for demo APIs plus agents.

Recommended files:

RepoBase filesAgent/API overlay
portal-config-devdocker-compose.ymldocker-compose.agent-api.yml
portal-config-loc/all-in-pgdocker-compose.yml, docker-compose-rust.ymldocker-compose.agent-api.yml
portal-config-loc/all-in-ltdocker-compose.yml, docker-compose-rust.ymldocker-compose.agent-api.yml

The base stack should own shared infrastructure:

  • Postgres,
  • config-server,
  • controller,
  • hybrid-query,
  • hybrid-command,
  • light-gateway,
  • light-workflow,
  • OAuth and other platform services.

The overlay should own optional local workloads:

  • demo-customer-profile-api,
  • demo-offer-decision-api,
  • light-agent-account,
  • light-agent-offer,
  • future specialized agents.

The overlay is intended to be started with the base files in the same Compose command. In that mode Docker Compose creates or reuses one project default network, and every service can resolve every other service by service name.

Example for portal-config-dev:

docker compose \
  -f docker-compose.yml \
  -f docker-compose.agent-api.yml \
  up -d

Example for portal-config-loc/all-in-pg:

docker compose \
  -f docker-compose.yml \
  -f docker-compose-rust.yml \
  -f docker-compose.agent-api.yml \
  up -d

If the overlay must be started separately, it must still use the same Compose project name as the base stack. Otherwise Docker will create a second default network and the gateway will not resolve the agent and demo API service names.

Network Contract

The preferred local contract is the Compose default network for the active project. Do not declare a separate network in the overlay when the overlay is run with the base stack.

Service-to-service URLs should use Compose service DNS names:

http://light-agent-account:8083
http://light-agent-offer:8083
http://demo-customer-profile-api:8080
http://demo-offer-decision-api:8080

Host port mappings are only for browser or curl access from the developer machine. They should not be used by gateway, agents, workflows, or demo APIs to call each other.

For agent containers, use a stable service name and advertised address:

server.advertisedAddress: ${LIGHT_AGENT_ADVERTISED_ADDRESS:light-agent-account}
server.httpPort: ${LIGHT_AGENT_HTTP_PORT:8083}

The internal port can stay 8083 for every agent because each agent is a different container. Only host-published ports must be unique.

Agent Service Identity

Each agent instance needs a unique runtime identity. The identity is not just the Docker service name.

Recommended identity fields:

FieldPurposeExample
Compose serviceDocker DNS name and local lifecycle unit.light-agent-account
server.serviceIdRuntime service id registered with controller and gateway.com.networknt.agent.account-1.0.0
server.environmentRuntime environment tag.dev
server.advertisedAddressAddress other services use for this agent.light-agent-account
LIGHT_AGENT_HOST_IDHost or tenant boundary for portal catalog and memory.01964b05-552a-7c4b-9184-6857e7f3dc5f
LIGHT_AGENT_AGENT_DEF_IDAgent definition id, currently aligned with API version id.account agent API version id
Model provider configRuntime model settings for the agent instance.codex, gpt-5.5

The same image can run multiple agents. Compose injects different environment variables and config-server startup values into each service.

Example overlay shape:

services:
  light-agent-account:
    image: ${LIGHT_AGENT_IMAGE:-networknt/light-agent:latest}
    ports:
      - ${ACCOUNT_AGENT_PORT:-8083}:8083
    volumes:
      - ./light-agent-rust/config:/config:ro
      - ./light-controller-rust/ca.pem:/keystore/ca.pem:ro
    environment:
      LIGHT_RS_CONFIG_DIR: /config
      DATABASE_URL: postgres://postgres:secret@postgres:5432/configserver
      LIGHT_PORTAL_AUTHORIZATION: "${LIGHT_AGENT_LIGHT_PORTAL_AUTHORIZATION:-}"
      LIGHT_AGENT_HOST_ID: "${LIGHT_AGENT_HOST_ID:-01964b05-552a-7c4b-9184-6857e7f3dc5f}"
      LIGHT_AGENT_AGENT_DEF_ID: "${ACCOUNT_AGENT_DEF_ID:-}"
      LIGHT_AGENT_SERVICE_ID: com.networknt.agent.account-1.0.0
      LIGHT_AGENT_ADVERTISED_ADDRESS: light-agent-account
      LIGHT_AGENT_MODEL: "${ACCOUNT_AGENT_MODEL:-gpt-5.5}"
      CODEX_API_KEY: "${ACCOUNT_AGENT_CODEX_API_KEY:-}"
      CODEX_ACCOUNT_ID: "${ACCOUNT_AGENT_CODEX_ACCOUNT_ID:-}"
      CODEX_REASONING_EFFORT: "${ACCOUNT_AGENT_CODEX_REASONING_EFFORT:-low}"
      RUST_LOG: "${ACCOUNT_AGENT_RUST_LOG:-info}"
      AGENT_LOG_ANSI: "false"

  light-agent-offer:
    image: ${LIGHT_AGENT_IMAGE:-networknt/light-agent:latest}
    ports:
      - ${OFFER_AGENT_PORT:-8084}:8083
    volumes:
      - ./light-agent-rust/config:/config:ro
      - ./light-controller-rust/ca.pem:/keystore/ca.pem:ro
    environment:
      LIGHT_RS_CONFIG_DIR: /config
      DATABASE_URL: postgres://postgres:secret@postgres:5432/configserver
      LIGHT_PORTAL_AUTHORIZATION: "${LIGHT_AGENT_LIGHT_PORTAL_AUTHORIZATION:-}"
      LIGHT_AGENT_HOST_ID: "${LIGHT_AGENT_HOST_ID:-01964b05-552a-7c4b-9184-6857e7f3dc5f}"
      LIGHT_AGENT_AGENT_DEF_ID: "${OFFER_AGENT_DEF_ID:-}"
      LIGHT_AGENT_SERVICE_ID: com.networknt.agent.offer-1.0.0
      LIGHT_AGENT_ADVERTISED_ADDRESS: light-agent-offer
      LIGHT_AGENT_MODEL: "${OFFER_AGENT_MODEL:-gpt-5.5}"
      CODEX_API_KEY: "${OFFER_AGENT_CODEX_API_KEY:-}"
      CODEX_ACCOUNT_ID: "${OFFER_AGENT_CODEX_ACCOUNT_ID:-}"
      CODEX_REASONING_EFFORT: "${OFFER_AGENT_CODEX_REASONING_EFFORT:-low}"
      RUST_LOG: "${OFFER_AGENT_RUST_LOG:-info}"
      AGENT_LOG_ANSI: "false"

The example deliberately avoids container_name. Compose service names already provide stable DNS on the project network, and omitting container_name avoids cross-project name collisions.

Gateway Registry

The gateway should route to agent services through Docker DNS names, not host addresses. For the local direct registry:

direct-registry.directUrls:
  com.networknt.agent.account-1.0.0: http://light-agent-account:8083
  com.networknt.agent.offer-1.0.0: http://light-agent-offer:8083

The same rule applies to demo APIs. Gateway route targets should be service names on the shared Compose network.

When an agent is registered through controller, its runtime identity should match the config-server tuple used by the container:

host + serviceId + envTag

The agent should keep server.enableRegistry: true so controller can discover it and send catalog cache invalidation notifications.

Effective Agent Catalog

Each agent loads its effective catalog from portal-query with:

hostId + agentDefId + serviceId + envTag

The effective catalog includes:

  • the agent definition,
  • assigned skills from agent_skill_t,
  • tool projections from skill_tool_t and tool_t,
  • tool parameters from tool_param_t,
  • workflow mappings from skill_workflow_t,
  • workflow definitions from wf_definition_t,
  • policy diagnostics for tools that should not be exposed.

The agent caches the effective catalog locally. Controller cache-management messages should clear that cache when skills, tools, workflows, or assignments change. On the next chat turn, the agent refreshes the catalog from portal-query.

Gateway execution stays separate from catalog reads:

portal-query
  -> effective catalog, skills, tools, workflows, policies

light-gateway
  -> tools/list
  -> tools/call

If portal-query is temporarily unavailable, the direct gateway tool list can remain usable for baseline tool execution. If a tool is in the catalog but is not returned by gateway tools/list, the agent must not execute it.

Capability Model

Agents should be specialized by catalog assignment rather than by image build.

Recommended specialization:

AgentService idSkillsTypical toolsWorkflows
Account agentcom.networknt.agent.account-1.0.0Account lookup, profile enrichmentcustomer profile API toolsprofile lookup, profile validation
Offer agentcom.networknt.agent.offer-1.0.0Offer eligibility, decision explanationoffer decision API toolsoffer decision, approval check
Advisor agentcom.networknt.agent.advisor-1.0.0Cross-domain recommendationaccount and offer read toolscustomer advisory orchestration
Coordinator agentcom.networknt.agent.coordinator-1.0.0Routing and task planningagent invocation tools, workflow toolsmulti-agent workflow start

The capability boundary is the effective catalog:

  • agent_skill_t assigns skills to the agent,
  • skill_tool_t controls which tools a skill can expose,
  • skill_workflow_t controls which workflows a skill can start or reference,
  • workflow and gateway policy still enforce runtime access.

This keeps the runtime image generic while making each agent instance purpose-built.

Workflow Orchestration

light-workflow should orchestrate multi-step API and agent flows. Agents provide reasoning and tool selection inside their assigned domain, while workflow provides deterministic control flow.

Recommended orchestration responsibilities:

ComponentResponsibility
PortalAuthor skills, tools, workflow mappings, and agent assignments.
portal-queryServe the effective catalog to each agent.
controllerRegister agents and invalidate agent catalog caches.
light-gatewayExecute MCP tools and route API calls.
light-agentReason over assigned skills and call allowed gateway tools.
light-workflowRun multi-agent plans, API sequences, assertions, retries, and human tasks.

Example advisory flow:

  1. A user starts an advisory request.
  2. The coordinator agent or portal UI starts a workflow in light-workflow.
  3. The workflow calls the account agent with the customer-profile skill.
  4. The account agent reads its effective catalog and calls customer profile tools through light-gateway.
  5. The workflow validates the profile response with an assert task.
  6. The workflow calls the offer agent with the offer-decision skill.
  7. The offer agent calls offer decision tools through light-gateway.
  8. The workflow applies policy checks, optional human approval, and final response shaping.

The workflow is the durable orchestration record. Agent chat history and memory can support reasoning, but they should not be the only source of orchestration state.

Skill To Workflow Mapping

Use skill_workflow_t to link a skill to one or more workflow definitions:

ColumnUse
host_idTenant boundary.
skill_idSkill that can use the workflow.
wf_def_idWorkflow definition in wf_definition_t.
workflow_roleprimary, validation, remediation, approval, or test.
start_modemanual, agent, portal, or scheduled.
configSkill-specific workflow input defaults and safety hints.
activePublication flag.

The effective catalog should include these mappings so the agent can decide whether a user request should be answered directly, routed to a tool, or handed to a workflow.

For destructive or externally visible operations, the skill should prefer a workflow mapping over direct tool execution. The workflow can add approval, assertions, idempotency keys, retries, and audit events.

API Access Pattern

Agents should not call downstream business APIs directly. They should use the gateway data plane:

light-agent
  -> light-gateway /mcp tools/list
  -> light-gateway /mcp tools/call
  -> downstream MCP server or REST/OpenAPI-backed tool

Workflows should also use gateway-backed calls when invoking API operations:

do:
  - get-profile:
      call: mcp
      with:
        session: gateway
        tool: customer_profile_get
        arguments:
          customerId: "${ .customerId }"

When workflow needs reasoning, it should call an agent task:

do:
  - review-offer:
      call: agent
      with:
        agent: com.networknt.agent.offer-1.0.0
        skill: offer-decision
        input:
          customerId: "${ .customerId }"
          profile: "${ .profile }"

The exact agent invocation transport can evolve, but the logical contract is stable: workflow names the agent and skill, and the called agent uses its effective catalog to constrain tools and workflow options.

Local Configuration Layout

Use one shared config template when agents differ only by environment variables:

light-agent-rust/
  config/
    startup.yml
    client.yml
    values.yml

Use per-agent config folders only when the bootstrap or runtime config needs to diverge beyond service id, advertised address, model, or agent definition:

light-agent-rust/
  account/config/
  offer/config/
  advisor/config/

The recommended first phase is the shared template plus per-service Compose environment overrides. This avoids copying the same config files for every agent.

Keep secrets outside git:

  • portal bearer token,
  • provider API keys,
  • provider account ids,
  • customer CA material,
  • database credentials outside local defaults.

Rollout Plan

Phase 1: Compose Overlay

  • Add docker-compose.agent-api.yml beside the current base compose files.
  • Move demo APIs and local light-agent services into the overlay.
  • Rename the first agent service to light-agent-account.
  • Remove container_name from agent services.
  • Update gateway direct registry entries to service DNS names.
  • Verify the rendered compose model with the base and overlay files together.

Phase 2: Multiple Agent Instances

  • Add one overlay service per specialized agent.
  • Assign unique service ids and host ports.
  • Add portal agent definitions for each service id and env tag.
  • Assign skills through agent_skill_t.
  • Assign tools through skill_tool_t.
  • Assign workflows through skill_workflow_t.
  • Verify each agent can load a distinct effective catalog.

Phase 3: Workflow Orchestration

  • Create workflow definitions for cross-agent API access.
  • Add workflow mappings to skills.
  • Let coordinator or portal UI start workflows for multi-step tasks.
  • Use gateway MCP calls for API operations.
  • Use agent tasks only where domain reasoning is required.
  • Add policy checks for destructive tools and approval-required workflows.

Phase 4: Operational Hardening

  • Add health checks for every agent and demo API.
  • Add startup validation that each agent has a non-empty effective catalog when LIGHT_AGENT_AGENT_DEF_ID is configured.
  • Add gateway drift diagnostics comparing catalog tools with gateway tools/list.
  • Add cache invalidation verification after skill, tool, or workflow changes.
  • Add docs for common local run commands and expected service URLs.

Design Decisions

  • Overlay Scope: The first overlay will include both the account and offer agents, together with the two demo APIs.
  • Port Publishing: Every agent will publish its own UI port and register with the control plane independently. Chat clients will discover agents via controller registration, and light-gateway will discover them via explicit direct-registry entries.
  • Workflow Triggers: Workflow start requests will go through a dedicated workflow command API for the first implementation.
  • Agent Orchestration: Agent-to-agent calls will not be exposed as direct gateway tools. Multi-agent flows are orchestrated exclusively via light-workflow. Currently, call: agent tasks are native, catalog-backed model calls executed directly by the workflow engine (bypassing the containerized light-agent tool loops). The containerized agents are primarily used by chat clients. Future implementations may choose to invoke the containerized agent services from workflow.

Agent Memory Event Refactor

Implementation Status

The development implementation now exposes a bank-first Hindsight workspace at /app/genai/MemoryBanks and a bank detail workspace at /app/genai/MemoryBanks/:bankId. The old flat session, user, agent, and organization memory pages and service actions have been removed. There is no legacy data migration or compatibility path: development databases are rebuilt from the current DDL.

The current Hindsight table family is:

agent_memory_bank_t
agent_memory_doc_t
agent_memory_unit_t
agent_memory_entity_t
agent_memory_unit_entity_t
agent_memory_entity_cooccur_t
agent_memory_link_t
agent_memory_directive_t
agent_memory_reflection_t
agent_session_history_t

The Portal command/event path projects current Hindsight events into these tables. light-agent remains an intentional direct writer in direct-PostgreSQL mode and is the owner of the session-history projection. The retained Portal-command runtime mode creates session history with durable_session_id = sessionId, allowing the runtime reconciler to rebuild it from agent_session_event_t.

GlobalSnapshotPersistenceImpl treats the memory family as one explicit agent_memory_t export entity. Session history is opt-in and sensitive; co-occurrence state is derived and is not exported as authoritative state. The agent_memory_t name in that export dispatch is a current compatibility alias for the Hindsight table set, not the removed legacy table.

Goal

Keep ownership explicit while supporting Portal administration:

command/event path -> event_store_t -> db-provider replay -> Hindsight tables
  • Portal administrators manage banks and supported bank-scoped resources through command events and current read-model queries.
  • light-agent owns runtime session history and may use either the direct-PG or Portal-command memory store.
  • derived and vector state is never presented as Portal-authored authoritative content.

Non-Goals

  • Do not add legacy table migration, dual reads, or compatibility writers.
  • Do not convert derived caches into authoritative state unless a product decision requires exact cache promotion.
  • Do not require every chat token or partial model response to become an event.
  • Do not remove the current direct PostgreSQL path; it remains the default development runtime store.

Current Portal Contract

All operations use service lightapi.net/genai, version 0.1.0.

Read actions (portal.r) are:

getAgentMemoryBanks              getFreshAgentMemoryBank
getAgentMemoryDocs               getFreshAgentMemoryDoc
getAgentMemoryUnits              getFreshAgentMemoryUnit
getAgentMemoryEntities           getFreshAgentMemoryEntity
getAgentMemoryUnitEntities       getAgentMemoryEntityCooccurrences
getAgentMemoryLinks              getFreshAgentMemoryLink
getAgentMemoryDirectives         getFreshAgentMemoryDirective
getAgentMemoryReflections        getFreshAgentMemoryReflection
getAgentSessionHistories         getAgentSessionHistoryProjection

Portal-enabled commands (portal.w) are:

createAgentMemoryBank            updateAgentMemoryBank
deleteAgentMemoryBank            createAgentMemoryDoc
updateAgentMemoryDoc             deleteAgentMemoryDoc
deleteAgentMemoryUnit            createAgentMemoryEntity
updateAgentMemoryEntity          deleteAgentMemoryEntity
linkAgentMemoryUnitEntity        unlinkAgentMemoryUnitEntity
createAgentMemoryLink            updateAgentMemoryLink
deleteAgentMemoryLink            createAgentMemoryDirective
updateAgentMemoryDirective       deleteAgentMemoryDirective
deleteAgentMemoryReflection

The direct retain and session-history command operations remain available for light-agent runtime use. They require a client-credentials token; an authorization-code token for a logged-in Portal user is rejected by the command handler even if it carries portal.w. The three update/create operations below are deliberately absent from both the published service action registry and the Portal until an asynchronous embedding owner and state machine exist:

updateAgentMemoryUnit            createAgentMemoryReflection
updateAgentMemoryReflection

Session-history commands are retained for light-agent runtime use only:

createAgentSessionHistory        appendAgentSessionHistory
compactAgentSessionHistory       deleteAgentSessionHistory

Read-model and lifecycle rules

  • Every child query uses the full (host_id, bank_id, resource key) identity.
  • List responses use agentMemoryBanks, agentMemoryDocs, agentMemoryUnits, agentMemoryEntities, agentMemoryUnitEntities, agentMemoryEntityCooccurrences, agentMemoryLinks, agentMemoryDirectives, agentMemoryReflections, or agentSessionHistories as appropriate.
  • Runtime-managed banks are identified structurally through agent_session_t.bank_id and excluded before default count and pagination. A session-ID equality fallback exists only for pre-binding interactive rows.
  • Interactive sessions bind their durable agent_session_t row after either memory store creates the bank. A conflicting binding fails closed. Workflow-job sessions are intentionally bankless.
  • Session history is bank-scoped, projection-aware, and read-only in the Portal. Lists omit message bodies; detail reads are bounded and redact credential-like fields.
  • Unit/entity associations are hard links without active/version fields. Entity co-occurrence is a derived, read-only diagnostic.
  • Bank deactivation is rejected while active children or active/closing bound interactive sessions exist.
  • Embeddings are never returned to the browser.

Development qualification

Run the source-only contract gate from the workspace root with:

./implementation/light-portal/scripts/run-hindsight-memory-phase-6-gate.sh --source-only

The full local gate installs light-portal artifacts first, then tests genai-query, genai-command, portal-view, light-agent, and this book:

./implementation/light-portal/scripts/run-hindsight-memory-phase-6-gate.sh

Pass --postgres-url jdbc:postgresql://... to execute the isolated-schema command-projection-query lifecycle test. After deploying the development services, set HINDSIGHT_SMOKE_PORTAL_URL, HINDSIGHT_SMOKE_HOST_ID, and either HINDSIGHT_SMOKE_BEARER_TOKEN or HINDSIGHT_SMOKE_COOKIE, explicitly authorize the disposable lifecycle with HINDSIGHT_SMOKE_ALLOW_WRITE=true, and add --live. The live gate creates a uniquely named bank, waits for the query projection, updates it, verifies isolated lookup, and deactivates it.

Historical Design Notes

The sections below record the design path that produced the implemented contract. Where they differ from the Current Portal Contract above, the current contract is authoritative.

Use events for durable memory state, and treat pure caches as rebuildable projection state.

Recommended ownership:

TableOwnership
agent_memory_bank_tEvent-backed aggregate
agent_memory_doc_tEvent-backed aggregate
agent_memory_unit_tEvent-backed aggregate
agent_memory_entity_tEvent-backed aggregate
agent_memory_unit_entity_tEvent-backed association
agent_memory_link_tEvent-backed association
agent_memory_directive_tEvent-backed aggregate
agent_memory_reflection_tEvent-backed aggregate
agent_session_history_tEvent-backed aggregate or explicit operational table
agent_memory_entity_cooccur_tDerived projection cache by default

agent_memory_entity_cooccur_t should stay projection-owned unless exact co-occurrence counts are considered business state. It can be rebuilt from memory units and unit-entity links during replay.

agent_session_history_t needs an explicit decision. It contains conversation content and may be high volume. The recommended first phase is to make it event-backed for correctness, but keep snapshot export opt-in because it can contain sensitive user text.

Event Model

Add explicit event constants and aggregate constants for the Hindsight schema. Use aggregate ids that include enough context to avoid cross-bank collisions.

Suggested aggregate ids:

AgentMemoryBank:        hostId|bankId
AgentMemoryDoc:         hostId|bankId|docId
AgentMemoryUnit:        hostId|bankId|unitId
AgentMemoryEntity:      hostId|bankId|entityId
AgentMemoryUnitEntity:  hostId|bankId|unitId|entityId
AgentMemoryLink:        hostId|bankId|fromUnitId|toUnitId|linkType
AgentMemoryDirective:   hostId|bankId|directiveId
AgentMemoryReflection:  hostId|bankId|reflectionId
AgentSessionHistory:    hostId|bankId|sessionId

Suggested events:

AgentMemoryBankCreatedEvent
AgentMemoryBankUpdatedEvent
AgentMemoryBankDeletedEvent

AgentMemoryDocCreatedEvent
AgentMemoryDocUpdatedEvent
AgentMemoryDocDeletedEvent

AgentMemoryUnitRetainedEvent
AgentMemoryUnitUpdatedEvent
AgentMemoryUnitDeletedEvent

AgentMemoryEntityCreatedEvent
AgentMemoryEntityUpdatedEvent
AgentMemoryEntityDeletedEvent

AgentMemoryUnitEntityLinkedEvent
AgentMemoryUnitEntityUnlinkedEvent

AgentMemoryLinkCreatedEvent
AgentMemoryLinkUpdatedEvent
AgentMemoryLinkDeletedEvent

AgentMemoryDirectiveCreatedEvent
AgentMemoryDirectiveUpdatedEvent
AgentMemoryDirectiveDeletedEvent

AgentMemoryReflectionCreatedEvent
AgentMemoryReflectionUpdatedEvent
AgentMemoryReflectionDeletedEvent

AgentSessionHistoryCreatedEvent
AgentSessionHistoryAppendedEvent
AgentSessionHistoryCompactedEvent
AgentSessionHistoryDeletedEvent

Do not reuse the current AgentMemoryCreatedEvent name for agent_memory_unit_t. That name already maps to legacy agent_memory_t and would create ambiguity. Either deprecate the legacy event family or keep it separate with a clear LegacyAgentMemory name in documentation and tests.

For session history, avoid Upserted as the long-term event name. The underlying table may use INSERT ... ON CONFLICT DO UPDATE, but the event log should express intent. Use AgentSessionHistoryCreatedEvent to start a session, AgentSessionHistoryAppendedEvent to add one or more messages, and AgentSessionHistoryCompactedEvent only when the retained JSON history is summarized or truncated.

db-provider Refactor

Add a dedicated Hindsight persistence component, for example:

HindsightMemoryPersistence
HindsightMemoryPersistenceImpl

Responsibilities:

  • replay Hindsight memory events into the current tables
  • preserve aggregate_version ordering on every mutable table
  • handle JSONB, vector(384), and UUID[] fields explicitly
  • maintain foreign-key order during replay
  • rebuild or incrementally update derived agent_memory_entity_cooccur_t

Update:

PortalConstants
EventTypeUtil
PortalDbProvider.handleEvent
PortalDbProviderImpl
GlobalSnapshotPersistenceImpl table-to-event overrides
GlobalSnapshotPersistenceImpl skip lists
importer/src/snapshot/table_rules.rs

The replay order must satisfy foreign keys:

agent_memory_bank_t
agent_memory_doc_t
agent_memory_unit_t
agent_memory_entity_t
agent_memory_unit_entity_t
agent_memory_link_t
agent_memory_directive_t
agent_memory_reflection_t
agent_session_history_t

If agent_memory_entity_cooccur_t remains derived, rebuild it after replay or update it from AgentMemoryUnitEntityLinkedEvent.

light-agent Refactor

Introduce a memory persistence abstraction:

MemoryStore
  DirectPgMemoryStore
  PortalCommandMemoryStore

DirectPgMemoryStore preserves the current local behavior during migration. It should be marked as a local/runtime compatibility mode and should not be considered portable event state.

PortalCommandMemoryStore should be the enterprise/default target once the command path is stable. It sends memory commands through the portal command API using the agent’s service token. This gives memory writes the same validation, event persistence, replay, and audit behavior as the rest of the portal.

Configuration:

memory:
  writeMode: portal-command # portal-command | direct-pg
  retainSessionHistory: true
  exportableMemory: false

Initial implementation uses environment variables in light-agent:

LIGHT_AGENT_MEMORY_WRITE_MODE=portal-command # portal-command | direct-pg
LIGHT_AGENT_PORTAL_COMMAND_URL=https://...   # optional; defaults from portal config

exportableMemory should default to false until privacy and environment promotion rules are finalized.

DirectPgMemoryStore should be phased out after PortalCommandMemoryStore is stable. Keeping two permanent write paths would reintroduce schema drift and make local development behave differently from production.

Read-Your-Writes

The agent currently reads directly from PostgreSQL after direct writes. Moving writes behind command/event processing creates a read-your-writes requirement. For Phase 1, the command endpoint should apply the projection synchronously before returning. This keeps light-agent simple and avoids session-local buffer race conditions.

Other options can be evaluated later if latency requires them:

  • agent keeps a small session-local memory buffer until replay catches up
  • agent reads through a query endpoint that can merge persisted memory with the session-local buffer

Snapshot Policy

After the event-backed path is implemented:

  1. Remove event-backed Hindsight tables from CONVERSION_SKIP_TABLES.
  2. Keep export opt-in for memory tables because they may contain private user content.
  3. Keep agent_memory_entity_cooccur_t skipped if it remains derived.
  4. Add explicit table-to-event overrides for each event-backed Hindsight table.
  5. Keep Java GlobalSnapshotPersistenceImpl and Rust importer skip lists in sync.

Suggested export behavior:

default snapshot export: skip memory content
entityTypes=agent_memory: include event-backed memory tables
entityTypes=agent_session_history: include session history only when explicitly requested

Production session history export should be blocked by default even when the entity type is requested. Allow production export only with an explicit administrative override and a masking/scrubbing step. Lower environments may allow opt-in export for debugging, but the export response should record that memory/session content was included.

Migration Plan

Phase 1: Align db-provider With Current Schema

  • Add HindsightMemoryPersistenceImpl.
  • Add constants and event dispatch for the current Hindsight schema.
  • Deprecate or rename legacy AgentMemory and old AgentSessionHistory methods that do not match the current tables.
  • Add db-provider tests for replaying bank, unit, session history, and one association table.

Phase 2: Add Command APIs

  • Add command schemas for Hindsight memory operations.
  • Validate hostId, bankId, and optional agentDefId ownership.
  • Generate events through the normal command path.
  • Add authorization checks so an agent can only write memory for its host and allowed bank.

Phase 3: Refactor light-agent

  • Introduce MemoryStore.
  • Move direct SQL writes behind DirectPgMemoryStore.
  • Add PortalCommandMemoryStore.
  • Default local development to direct mode if needed, but document it as non-portable.
  • Deprecate direct mode after the command path is stable and make PortalCommandMemoryStore the only supported production write path.
  • Validate service-token host, sid, and env before writing through command APIs.

Phase 4: Snapshot And Import

  • Add table-to-event overrides and conversion tests.
  • Remove event-backed tables from conversion skip lists.
  • Keep export of memory content opt-in.
  • Update Rust importer table rules and dependency graph.
  • Add replay-order tests for the FK chain.

Phase 5: Backfill Existing Rows

  • Build a one-time backfill tool that reads existing direct-write rows and emits synthetic Hindsight events in dependency order.
  • Preserve aggregate_version where possible.
  • Mark backfilled events with metadata such as:
{
  "source": "agent-memory-backfill",
  "backfilled": true
}

Do not remove skip rules for production exports until backfill has been run or the deployment has no legacy direct-write rows.

Testing

Add focused tests:

  • GlobalSnapshotPersistenceImplTest: memory tables remain skipped before event support; event-backed tables are included after the event-backed path is enabled.
  • db-provider replay tests for each Hindsight event family.
  • EventTypeUtil aggregate-id tests.
  • Rust importer table-rule parity tests.
  • light-agent MemoryStore tests using a mock command client.
  • end-to-end test: light-agent retain memory -> command event -> replay -> recall reads the memory.

Resolved Decisions

  • agent_session_history_t is exportable only as an explicit opt-in. Production export is blocked unless an administrative override and data masking/scrubbing step are provided.
  • agent_memory_entity_cooccur_t remains derived. Store the underlying facts as events and rebuild or update co-occurrence counts as projection state.
  • Direct PostgreSQL writes are a migration bridge only. They should be removed after the command-backed memory path is stable.
  • Memory vectors should not be stored in events. Events store source text, metadata, and embedding model metadata when needed. Projection rebuilds should generate vectors, preferably through the embedding task pipeline, so the platform can re-embed after model upgrades.

Tool Description Embedding Population

Problem

The GenAI Tool page lets users update tool_t.description through the updateTool form. Endpoint-backed tools are also projected into tool_t when api_endpoint_t is populated from OpenAPI, MCP tools/list, or LightAPI Description input.

The schema already has tool_t.description_embedding VECTOR(384), but the current write paths only populate the plain text description:

  • ApiServicePersistenceImpl.syncEndpointToolProjections(...) inserts or updates endpoint-backed tool_t rows from api_endpoint_t.
  • GenAIPersistenceImpl.createTool(...) inserts manually authored tools.
  • GenAIPersistenceImpl.updateTool(...) updates the Tool page edit form.
  • genai-command create/update tool contracts do not accept an embedding field, and the Portal UI should not expose raw vectors to users.

As a result, new endpoint-backed tool rows start with a null description_embedding, and user edits can leave any future vector stale unless the write path marks it for regeneration.

Goals

  • Populate tool_t.description_embedding for endpoint-backed and manually authored tools.
  • Regenerate the embedding whenever the effective embedding source text changes.
  • Keep Tool create/update latency independent from external embedding provider latency.
  • Avoid trusting browser-submitted vectors.
  • Preserve keyword search and normal CRUD behavior when embedding generation is disabled or temporarily failing.
  • Keep the first implementation aligned with the existing VECTOR(384) schema.

Non-Goals

  • Do not require every tool to have an embedding before it can be listed, edited, linked to a skill, or executed through the gateway.
  • Do not move MCP execution into portal-query or the controller.
  • Do not store API keys or provider secrets in tool metadata.
  • Do not expose raw embedding vectors in the Tool page by default.

Use asynchronous server-side embedding generation. Tool writes should save the description immediately, mark the embedding stale or pending, and record the embedding task in the same database transaction as the tool_t update. A worker then picks up committed tasks, generates a 384-dimensional vector from a normalized source string, and updates tool_t.description_embedding only if the tool row still matches the source that was embedded.

For phase 1, this should use a transactional work table or transactional outbox pattern. Do not call the external embedding provider inside the command transaction, but do insert or update the work item before that transaction commits. If a later implementation publishes tasks to Kafka or another queue, the database transaction should still write an outbox row first, and a dispatcher should publish after commit. This avoids a failure mode where the tool row commits successfully but the embedding task is never queued.

This keeps command handling reliable and makes the embedding field a derived read-model value, not user-authored command input.

API version import/update
  -> api_endpoint_t rows
  -> endpoint-backed tool_t projection
  -> upsert embedding_task_t in the same transaction

Tool create/update form
  -> ToolCreatedEvent or ToolUpdatedEvent
  -> tool_t row update
  -> upsert embedding_task_t in the same transaction

embedding worker
  -> poll committed pending tasks
  -> load current tool row
  -> build source text
  -> call configured embedding provider
  -> update tool_t.description_embedding with compare-and-set guard

Embedding Source Text

The vector should be generated from stable semantic fields, not audit fields or IDs. The default source can be:

name: <tool_t.name>
description: <tool_t.description>
endpoint: <tool_t.api_method> <tool_t.api_endpoint>
domain: <tool_t.routing_domain>
namespace: <tool_t.semantic_namespace>
protocol: <tool_t.source_protocol>
personas: <tool_t.target_personas>

For endpoint-backed tools, the projection can enrich the source with api_endpoint_t.endpoint_desc and semantic keywords from api_endpoint_t.tool_metadata.routing.semanticKeywords when available. The LLM-facing description remains tool_t.description; enrichment only improves semantic retrieval.

Staleness Tracking

The current table only has the vector. To make regeneration safe and auditable, add lightweight metadata beside it:

ColumnPurpose
description_embedding_modelProvider/model that produced the vector.
description_embedding_dimensionExpected to be 384 for the current schema.
description_embedding_source_hashSHA-256 of the normalized source text.
description_embedding_tsGeneration timestamp.
description_embedding_statuspending, ready, failed, disabled, or blank.
description_embedding_errorShort last error for diagnostics.

If the first implementation avoids schema expansion, it should at least set description_embedding = NULL whenever the description or semantic routing fields change. That prevents stale vector search, but it gives weaker operational visibility than explicit status and source-hash columns.

The metadata can live in tool_t beside the vector for simple read-heavy queries. If row width becomes a concern, move the vector and metadata to a 1:1 table such as tool_embedding_t or a generic entity_embedding_t; keep the same source-hash and status contract either way. The work table should not be the only durable location for ready-state metadata because completed work rows may be retried, compacted, or purged.

Write Path Hooks

The persistence hooks should be narrow:

  1. When syncEndpointToolProjections(...) inserts or updates a tool row, compute the source hash from the projected values. If it differs from the stored hash, store the new description_embedding_source_hash, mark embedding status pending, and upsert an embedding task in the same transaction.
  2. When createTool(...) writes a new row, store the source hash, mark the embedding pending, and upsert an embedding task in the same transaction unless the normalized source text is blank. For blank source text, clear the vector and mark the status blank without creating a task.
  3. When updateTool(...) changes description, name, endpoint, routing domain, namespace, source protocol, target personas, endpoint description, or semantic keywords, store the new source hash, mark the embedding pending, and upsert an embedding task in the same transaction.
  4. When a tool is deactivated, no embedding work is needed. Existing vectors can remain stored, but vector queries must filter active = TRUE.

The command contract should not add a descriptionEmbedding property. If a future admin API needs a manual vector load, it should be a separate privileged maintenance action, not part of the normal Tool form.

Embedding writes are read-model maintenance, not user-authored tool changes. The preferred implementation should not emit a normal ToolUpdatedEvent and should not advance the business aggregate version used for user edits. If the local persistence framework requires a row-level version for every physical update, store it separately on the embedding row or task row so embedding maintenance does not interfere with Tool form optimistic concurrency.

Endpoint Sync And Manual Overrides

Endpoint-backed tools need an explicit description ownership contract. Without one, a user can improve the Tool page description and later lose the edit when the API version is synced again from OpenAPI, MCP tools/list, or LightAPI Description input.

Recommended behavior:

  • api_endpoint_t remains the source of imported endpoint metadata.
  • tool_t.description is the user-facing LLM description.
  • When endpoint projection first creates a tool, copy the endpoint description into tool_t.description.
  • When a user edits tool_t.description for an endpoint-backed tool, mark the tool description as a manual override.
  • Later endpoint syncs should update generated endpoint fields and api_endpoint_t.endpoint_desc, but should not overwrite tool_t.description while the manual override is active.
  • Provide a later admin action to reset the description to the imported source.

Suggested columns:

ColumnPurpose
description_sourceendpoint_sync, manual, or another source label.
description_manual_overrideBoolean guard used by endpoint sync.
description_override_tsWhen the manual override was created.
description_override_userWho last changed the description manually.

If a deployment wants endpoint sync to be the absolute source of truth, the Tool page must make that clear before allowing edits, because later syncs will overwrite user-authored descriptions. The default portal behavior should favor manual overrides to avoid surprising users.

Work Queue Options

Three implementation options are viable:

OptionProsCons
Polling backfill jobSmallest first step; scans active tools with null or stale embeddings.Embeddings are eventually populated but not immediately after each edit.
Database work tableReliable retries, status, and batching without depending on Kafka.Adds one table and worker lifecycle.
Event-driven workerFits event-driven portal architecture and reacts immediately to tool events.Requires one more event/consumer contract and careful replay behavior.

Recommended phase 1 is a database work table or polling worker. It is simpler than putting provider calls inside the command request and safer than calling an external model from inside a database transaction.

Use a generic work table from the start so the same worker can later populate skill_t.description_embedding and other platform embeddings without adding one queue per entity type. The table can be named embedding_task_t.

ColumnPurpose
host_idTenant boundary.
task_idTask identity for retry and diagnostics.
entity_typetool, skill, agent, or another supported embedding target.
entity_idTarget row ID, such as tool_id or skill_id.
source_tableOptional source table hint, such as tool_t.
source_hashHash of the source text to embed.
source_versionOptional row version observed when queued; useful for diagnostics but not required for the final CAS guard.
statuspending, running, ready, failed.
attempt_countRetry count.
next_attempt_tsBackoff control.
last_errorShort diagnostic text.
update_tsQueue row update time.

Use a unique key such as (host_id, entity_type, entity_id, source_hash) so the transactional upsert is idempotent.

The worker should claim tasks with row locking, for example FOR UPDATE SKIP LOCKED, so multiple workers can run safely. The final tool update should use the source hash as the primary compare-and-set guard:

UPDATE tool_t
SET description_embedding = ?,
    description_embedding_model = ?,
    description_embedding_dimension = 384,
    description_embedding_ts = CURRENT_TIMESTAMP,
    description_embedding_status = 'ready',
    description_embedding_error = NULL
WHERE host_id = ?
  AND tool_id = ?
  AND active = TRUE
  AND description_embedding_source_hash = ?;

If the row no longer matches, the worker should drop that result and let the newer pending job win. This prevents stale vectors from overwriting a newer description.

Avoid using aggregate_version as a hard CAS requirement unless it is truly needed for local event-sourcing rules. The version may change because of fields that are not part of the embedding source, causing spurious worker failures even when the source hash is still valid. If aggregate_version must be checked, a CAS failure should reload the row; if the stored source hash is unchanged, retry the embedding update using the current version. If the source hash changed, drop the stale result.

Embedding Provider

Add a small server-side provider abstraction:

EmbeddingProvider.embed(model, dimension, inputText) -> float[384]

Configuration should include:

SettingPurpose
embedding.provideropenai-compatible, local-http, or disabled.
embedding.modelProvider model name.
embedding.dimensionMust match 384 until the schema is migrated.
embedding.batchSizeWorker batch size.
embedding.timeoutMsProvider call timeout.
embedding.maxRetriesRetry limit before failed.

For hosted providers, configure a model that can emit 384 dimensions, such as an OpenAI-compatible embedding endpoint with an explicit dimensions parameter. For restricted deployments, use a local embedding service that emits the same dimension.

Search And Indexing

Vector search should only use ready embeddings:

WHERE host_id = ?
  AND active = TRUE
  AND description_embedding IS NOT NULL
  AND description_embedding_status = 'ready'
ORDER BY description_embedding <=> ?

Add a pgvector index when catalog size makes sequential vector scans too slow:

CREATE INDEX idx_tool_description_embedding
    ON tool_t USING hnsw (description_embedding vector_cosine_ops)
    WHERE active = TRUE
      AND description_embedding IS NOT NULL
      AND description_embedding_status = 'ready';

genai-query can continue keyword search while embeddings are being populated. When vector ranking is enabled, combine vector distance with existing macro filters such as host, active flag, assigned skill, routing domain, semantic namespace, sensitivity tier, source protocol, and semantic_weight.

Vector nearest-neighbor search should run in genai-query against PostgreSQL with pgvector, not inside the agent’s local catalog cache. Database-side search scales better because it can apply tenant, active-state, RBAC, assigned-skill, domain, and sensitivity filters before returning a small top-K result. The agent can still keep a lightweight local cache for fallback keyword matching and gateway intersection, but it should not need to download every catalog vector to rank tools.

Backfill

Existing rows need a one-time backfill:

  1. Scan active tools with a non-blank description and null or stale embedding.
  2. Queue embedding work in batches per host.
  3. Generate and persist vectors with retry/backoff.
  4. Report counts: total tools, ready, pending, failed, disabled, blank source.

Backfill should be restartable and idempotent. It should not block portal startup or the Tool page.

Portal UI

The first UI change should be optional diagnostics, not vector editing:

  • Do not show description_embedding in create/update forms.
  • Optionally show read-only status columns on the Tool page: Embedding Status, Embedding Model, and Embedding Updated.
  • After a user updates the description, show the saved description immediately. The embedding can move from pending to ready asynchronously.
  • Add an admin action later for “Refresh Embedding” if operators need manual repair.

Failure Behavior

  • If embedding is disabled, save descriptions normally and mark status disabled.
  • If provider calls fail, keep the tool active and searchable by keyword.
  • Failed rows should retry with backoff and surface diagnostics.
  • A stale worker result must not overwrite a newer description’s embedding.
  • If the source text is blank, clear the embedding and mark the status disabled or blank.

Implementation Phases

Phase 1: Safe Population

  • Add embedding metadata columns to tool_t, or add a 1:1 embedding table, and add a generic embedding_task_t for queued work.
  • Add description manual-override metadata for endpoint-backed tools.
  • Add write-path hooks in endpoint projection and Tool create/update persistence. The hooks must upsert embedding work in the same database transaction as the tool row change.
  • Add a polling or queue-backed embedding worker.
  • Add a backfill command for existing active tools.
  • Add focused tests that endpoint projection and updateTool mark embeddings pending when descriptions change.

Phase 2: Diagnostics

  • Expose read-only embedding status through getTool and getFreshTool.
  • Add Tool page status columns or a diagnostics view.
  • Add retry and refresh operations for failed rows.

Phase 3: Retrieval

  • Add the pgvector index.
  • Add vector ranking to genai-query or the effective catalog path.
  • Combine vector score with keyword score, macro filters, and semantic_weight.
  • Keep gateway tools/list intersection as the runtime executability check.

Design Decisions

  • Use a transactional work table or outbox for phase 1. The provider call is asynchronous, but task creation must be committed atomically with the tool row change.
  • Use source hash as the primary stale-result guard. Treat aggregate_version as diagnostic or optional unless local persistence rules require it.
  • Make the task table generic with entity_type and entity_id, so skills and future entities can share the same worker.
  • Preserve manual Tool page description edits with a manual override flag for endpoint-backed tools.
  • Reuse the same worker for skill_t.description_embedding when skill semantic search is enabled. The task shape should already support entity_type = 'skill'.
  • Run vector ranking in genai-query with pgvector and return top-K results to the agent. Keep local agent ranking as a fallback or small-cache optimization, not the primary scalable path.

Workflow Editor

Purpose

The Workflow Editor is the generic Portal authoring surface for light-workflow definitions. It should replace the raw textarea-only workflow definition experience with a structured editor that still preserves YAML as the canonical workflow definition stored in wf_definition_t.definition.

The editor is reusable. It can be opened from the Workflow Definition page, embedded in the Skill Workspace, or used by future task-specific authoring flows such as API onboarding, scheduled live tests, and remediation playbooks.

Design Boundary

light-workflow owns workflow execution, task state, retries, waiting human tasks, and audit events. The Portal editor authors definitions and starts test runs, but it must not implement its own workflow runtime.

The gateway remains the runtime tool execution path. Workflow steps that invoke tools should reference gateway-visible tools or endpoint descriptions and then execute through the same runtime path used by agents.

The editor should not duplicate endpoint contracts. API, MCP, JSON-RPC, gRPC, and other endpoint details belong in LightAPI descriptions, OpenAPI/OpenRPC documents, protobuf metadata, or the portal endpoint catalog. Workflow tasks reference those descriptions and provide step-level wiring, guards, exports, and error handling.

Current State

The current Portal implementation already has the persistence and generic CRUD surface needed for a first editor:

  • wf_definition_t stores namespace, name, version, and definition.
  • workflow-command exposes create, update, delete, and start workflow commands.
  • workflow-query exposes workflow definition reads.
  • portal-view has a Workflow Definition table and generic create/update forms whose definition field is a YAML textarea.

The first Workflow Editor can therefore be an incremental UI improvement over the existing definition CRUD and start workflow command.

Goals

  • Keep workflow YAML as the canonical persisted artifact.
  • Provide a readable step outline or graph next to the YAML editor.
  • Validate definitions before save and before test runs.
  • Let users discover and reference endpoint descriptions, gateway tools, skills, rules, and human task types from a side panel.
  • Support workflow definition create, update, import, export, and start-test flows.
  • Make the editor embeddable so skill authoring can use the same workflow authoring component with skill-specific constraints.
  • Preserve owner scoping and existing Portal command/query conventions.

Non-Goals

  • Do not execute workflow logic in Portal View.
  • Do not make skills the workflow runtime.
  • Do not store workflow YAML in skill_t.
  • Do not require a visual drag-and-drop graph before the editor is useful.
  • Do not copy full API contracts into workflow steps when endpoint descriptions can be referenced.
  • Do not fork or embed the Apache KIE Serverless Logic Web Tools as the first implementation path. They are useful reference material for CNCF Serverless Workflow concepts, but they are tightly coupled to the strict upstream spec and would be expensive to adapt for Light-Fabric agentic extensions.

Authoring Model

The editor should maintain two synchronized representations:

RepresentationPurpose
YAML sourceCanonical text saved to wf_definition_t.definition.
Parsed view modelUI-only representation used for step outline, validation, references, and property panels.

All saves should serialize from the YAML source or from a parsed model that round-trips to the same specification format. If the visual editor changes a step, it should update the YAML and keep the YAML visible.

The editor should support progressive enhancement:

  1. YAML editor plus parsed step outline.
  2. Step palette and property panel that edit YAML safely.
  3. Read-only graph preview.
  4. Drag-and-drop graph editing once round-trip behavior is reliable.

Implementation Architecture

The recommended implementation is a custom React editor built from focused building blocks:

ComponentRecommended libraryResponsibility
Source editorCodeMirror 6 with JSON/YAML extensionsEdit YAML/JSON, provide immediate parse diagnostics, folding, and lint markers, and display authoritative server validation results.
Visual graphReact Flow / xyflowRender workflow states as nodes and transitions as edges, with custom node components for agentic task types.
Property panelsSchema-backed React forms, optionally JSONFormsEdit selected node/task properties without forcing users to hand-edit every YAML field.
State managerExisting portal state pattern or Zustand if a local editor store is neededHold the canonical workflow document, parsed model, diagnostics, selected node, dirty state, and test run state.

The workflow YAML or JSON document remains the source of truth. CodeMirror edits parse into the editor store. The parsed workflow model is then projected into React Flow nodes and edges. React Flow edits update the same model and then serialize back to the YAML document.

This avoids adding a second large browser editor runtime to portal-view, which already uses CodeMirror for Markdown and OpenAPI JSON/YAML editing. It also avoids fighting a visualizer that only understands the strict CNCF Serverless Workflow schema, while still letting Portal define first-class visual treatments for Light-Fabric task types such as agent, mcp, ask, assert, rule, switch, and future LLM or approval-oriented steps.

CodeMirror should continue to provide immediate YAML parse diagnostics while the server applies the canonical Agentic Workflow JSON Schema. The browser may later use schema-derived autocomplete and hover information, but it must not carry an independently maintained schema or become the authoritative validator. This keeps validation behavior consistent for editor actions and AI-generated drafts without adding a large schema-validation runtime to portal-view.

React Flow should not own the persisted shape. It owns layout, selection, edge creation, and node interaction. The persisted workflow definition should remain independent of the canvas library so a future editor or CLI can read the same definitions.

Recommended sync behavior:

  1. Parse CodeMirror content into a typed workflow model when the YAML is valid.
  2. Preserve text edits and show problems when YAML is invalid; do not destroy the user’s in-progress text.
  3. Project valid workflow models to React Flow nodes and edges.
  4. Let graph edge changes update transition targets in the model.
  5. Let property-panel changes update the model through schema-aware controls.
  6. Serialize model changes back into the YAML document using stable formatting.
  7. Keep conflict handling explicit when source edits and graph edits race.

Canonical Schema Ownership And Distribution

The canonical schema is workflow-specification/schema/workflow.yaml. It is a Draft 2020-12 JSON Schema expressed as YAML and identifies the supported Agentic Workflow DSL with its $id. Its local $ref values resolve through #/$defs, so runtime validation does not require network access.

workflow-query should carry an immutable copy at src/main/resources/schema/workflow-1.0.3.yaml. A neighboring manifest should record at least:

  • the schema $id and DSL version;
  • the SHA-256 digest of the exact bundled bytes;
  • the source repository and full source commit;
  • the pinned upstream Open Workflow commit and digest.

A synchronization script copies the schema and provenance from an explicit workflow-specification checkout and regenerates the manifest. A verification test recomputes the digest and rejects a missing, malformed, or mismatched resource. Schema updates must therefore be deliberate code changes reviewed with their source commit and conformance evidence.

Production code must not fetch the schema from GitHub, follow the moving master branch, or fall back to a remote copy. Remote retrieval introduces startup availability, latency, supply-chain, and version-drift risks. GitHub may be used by CI to detect an available specification update, but the running service always uses its bundled resource.

The schema is loaded and compiled once by a thread-safe WorkflowSchemaValidator. The validator verifies the manifest and schema identity during initialization. A missing or invalid schema prevents schema validation from reporting success.

Mermaid can be used for documentation or a lightweight read-only preview, but it is not the long-term authoring surface. JSONForms can be useful inside property panels, but it should not replace the graph/source editor combination.

Layout

Recommended first layout:

RegionContents
HeaderNamespace, name, version, owner, active state, save, validate, import, export, and test actions.
Left panelStep outline, problems, references, and search.
Main panelYAML editor with syntax highlighting and parse markers.
Right panelSelected step properties, input/output/export preview, and endpoint/tool metadata.
Bottom panelTest input, validation results, workflow events, waiting tasks, and output.

The generic Workflow Definition page can use the full layout. The Skill Workspace can embed the same editor with a narrower reference scope and a skill-aware validation profile.

Step Palette

The editor should understand the task types defined by the Light-Fabric agentic workflow design:

Step typeUse
askPause for human input, approval, or missing values.
assertValidate context, API results, or business rules.
http / openapiInvoke HTTP endpoints directly or through cataloged descriptions.
jsonrpc / openrpcInvoke JSON-RPC methods directly or through OpenRPC descriptions.
grpcInvoke cataloged gRPC methods.
mcpInvoke gateway-visible MCP tools, resources, or prompts.
ruleDelegate complex checks to Light-Rule.
agentDelegate a bounded task to an agent worker.
switch / conditionBranch based on workflow context or task output.
set / exportMove task results into workflow context.
waitRepresent a durable wait, timeout, or externally completed task.

The palette should create minimal valid YAML fragments. Users can then edit the full YAML when advanced options are needed.

The YAML cursor, Steps list, and Visual Graph share one selected top-level workflow step. Moving the cursor anywhere inside a do item selects that step in the other views. When a step is selected, the palette offers insertion immediately before or after it; with the cursor outside a recognized step, it appends to the workflow. Invalid or incomplete YAML must not cause an unstructured text insertion. If the selected anchor is no longer present, the editor reports the stale selection instead of silently appending. Step identity and container priority are shared by the cursor, Steps list, Visual Graph, and insertion path, including name/id-shaped and map-shaped legacy containers. An explicit fork-branch insertion target remains active while the YAML cursor moves and is cleared only after insertion, a different explicit selection, or the user chooses Top level.

Reference Panel

The editor should help authors reference existing catalog objects instead of typing fragile identifiers by hand:

  • workflow definitions and versions,
  • LightAPI endpoint descriptions,
  • API endpoints and tool projections,
  • gateway-visible MCP tools,
  • rule definitions,
  • agent definitions,
  • skills and skill-linked tools when the editor is embedded in the Skill Workspace.

For generic workflow authoring, the reference panel can show all objects the current user is allowed to read. For skill authoring, it should filter tools to the skill’s linked tools and flag references outside that set.

Validation

Validation should run in layers:

LayerChecks
SyntaxYAML parses, document shape is valid, and duplicate keys are rejected when possible.
SpecificationRequired workflow fields, step IDs, task type structure, branch targets, exports, and inputs are valid.
Catalog referencesReferenced endpoint descriptions, tools, rules, agents, and child workflows exist and are active.
SecuritySensitive or destructive steps have required approval, visibility, and ownership metadata.
Skill embeddingWorkflow tool calls are linked through skill_tool_t when editing a workflow-backed skill.
Runtime diagnosticsOptional gateway tools/list checks compare cataloged tool names with deployed gateway availability.

Runtime diagnostics should be separate from persistence validation. A workflow definition can be saved before a gateway is reachable, but the editor should make missing runtime executability visible before test or deployment.

Test Runner

The editor should support a test panel that starts a workflow instance through the existing workflow start command and then reads instance events and task state through the workflow query APIs.

The test panel should support:

  • JSON workflow input,
  • start run,
  • event stream or polling view,
  • current context and output preview,
  • waiting task completion for ask or approval steps,
  • assertion and rule failure display,
  • gateway or endpoint call failure display,
  • rerun with the same input.

The test runner is a client of light-workflow; it does not execute workflow steps in the browser.

Skill Workspace Integration

Phase 3.5 skill authoring should embed the Workflow Editor rather than create a second skill-specific workflow UI.

Recommended integration:

  1. The Skill Workspace has a Workflow tab.
  2. The tab lets the user choose none or workflow-backed.
  3. In workflow-backed mode, the user can select an existing workflow definition or create a draft definition.
  4. The link is stored in skill_workflow_t.
  5. The editor reference panel filters tool references to the tools linked by skill_tool_t.
  6. Validation rejects or warns on workflow tool calls not present in the skill’s allowed tool set.
  7. The Test tab starts the linked workflow with sample JSON input and displays the same workflow events used by the generic editor.

This keeps the skill as a discovery and guidance artifact while light-workflow owns deterministic orchestration.

Data And API Changes

The first generic editor can reuse existing workflow definition APIs. Later phases should add editor-friendly endpoints only when they remove real UI complexity.

Phase B uses the existing validation endpoint and keeps the reference catalog composed from existing read models. A single combined catalog endpoint remains optional if the multiple list queries become noisy or slow.

API or tablePurpose
validateWfDefinitionAuthoritative YAML, bundled JSON Schema, runtime-profile, and reference validation. Returns stable problem locations plus the schema id and digest.
formatWfDefinitionOptional canonical formatting if the workflow parser supports round-trip formatting.
Existing catalog queriesFetch endpoint, tool, rule, agent, and workflow labels for the reference panel.
getWorkflowReferenceCatalogOptional future consolidation into one reference-panel query.
startWorkflowStart an editor test run for the saved workflow definition with sample JSON input.
Workflow runtime read modelsRefresh process, task, task assignment, worklist, and audit-log projections for the current workflow instance.
completeTaskComplete a waiting ask or human task from the editor test panel by emitting a TaskInfoUpdatedEvent.
skill_workflow_tLink skills to workflow definitions without embedding workflow YAML in skills.
saveSkillWorkspaceComposite command that saves skill metadata, taxonomy, tool links, workflow links, and optional draft workflow updates from one workspace action.

Server-side validation is authoritative. Client-side parsing remains useful for responsiveness, but it is not sufficient before validating, saving, testing, or publishing a workflow definition. Those editor actions fail closed when the validation endpoint or bundled schema is unavailable; the UI must not translate an unavailable validator into a successful result.

Validation Pipeline

validateWfDefinition applies checks in a stable order so authors see syntax and structural failures before runtime-specific findings:

  1. Reject a blank definition, malformed YAML, duplicate mapping keys, and a non-object root.
  2. Apply the stricter AI-authoring and runtime policy checks first when that profile is requested, so repair guidance prioritizes actionable policy and authorization failures over JSON Schema oneOf branch detail.
  3. Convert the safely parsed YAML value to a Jackson tree and validate it with the bundled Draft 2020-12 schema.
  4. Normalize, de-duplicate, and cap schema failures into deterministic problems containing severity, instance path, schema path or keyword, and message.
  5. Apply the remaining Light runtime capability checks, including supported expression languages, task kinds, call variants, and transports.
  6. Validate authorization-filtered Tool references and durable Tool pins.

Schema acceptance and runtime executability are distinct. The JSON Schema defines a valid Agentic Workflow document; runtime checks may still reject a schema-valid feature that the deployed Light Workflow runtime does not execute. Policy and authorization checks must therefore remain after schema validation rather than being replaced by it.

The response includes schemaId, schemaVersion, and schemaDigest when the bundled schema loads, even when the definition fails. A schema-load failure is returned as a normal blocking validation problem with empty identity fields so the Portal fails closed. Policy problems retain priority; schema problems are sorted, de-duplicated, and capped so repeated validation produces stable, bounded output. The Portal problems panel should show the instance path and message without exposing Java implementation details.

AI-Assisted Authoring

Ask AI uses the same bundled schema snapshot and WorkflowSchemaValidator as the Validate button. A validation-equivalent prompt form strips annotation-only JSON Schema fields such as descriptions, titles, comments, examples, and defaults while retaining every constraint and $ref; it is placed in the trusted system-message prefix with the full schema’s id and digest. User intent, existing definitions, and authorization-filtered Tool descriptions remain bounded, sanitized data in a separate user message; Tool descriptions and their schemas are never treated as instructions.

The preferred model response contains the workflow definition as a JSON object inside the existing authoring result envelope. The server validates that object and serializes it to canonical YAML only after it passes. Providers that support strict structured output may receive the workflow schema as the definition subschema, but local deterministic validation is still mandatory because provider capabilities and supported JSON Schema keywords vary.

Schema text is part of the complete prompt budget. The generator must bound the assembled schema, authoring context, existing definition, approved operations, and requested output against the selected model’s context window; the existing authoring-context byte limit alone is not enough. A provider may cache the static schema prefix, but correctness cannot depend on prompt caching.

After the first model response, the server applies canonical schema, runtime, policy, and Tool-authorization validation. It may make one bounded repair request containing the same schema identity, the rejected candidate, and a limited deterministic error list. A second failure rejects the draft rather than looping or returning an invalid proposal. If the repair prompt does not fit the complete prompt budget, the original validation failure is returned instead of replacing it with a prompt-size error.

Authoring provenance records the workflow schema id, digest, bundled schema version, prompt-template version, source Tool schema digests, model, request digest, and generated-definition digest. Human review and the existing post-approval definition-digest check remain required.

Schema Validation And AI Authoring Implementation Plan

Status: implemented on 2026-08-14 across workflow-query, workflow-command, and portal-view. The stages below remain the maintenance and verification contract for future schema upgrades.

S1: Pin The Resource

Owners: workflow-specification, workflow-query, workflow-command.

  • Add the versioned schema and manifest under workflow-query resources.
  • Add the explicit synchronization script and digest verification test.
  • Run the existing workflow-specification Draft 2020-12 and fixture conformance checks before accepting a synchronized update.

Gate: the service test suite proves the resource is valid Draft 2020-12, has the expected $id, contains only resolvable local references, and matches the manifest digest.

S2: Make Validation Schema-Backed

Owner: workflow-query.

  • Add the singleton WorkflowSchemaValidator and compile the resource once.
  • Invoke it from ValidateWfDefinition after safe YAML parsing. Run the AI-profile and authorization checks first so bounded repair feedback retains actionable policy failures ahead of schema branch diagnostics.
  • Return stable schema locations, keywords, messages, and schema identity.
  • Replace tests that accept legacy steps-only or incomplete documents with canonical fixtures, while retaining explicit rejection coverage for those old shapes.

Gate: every valid specification fixture passes, every invalid fixture fails, and targeted tests independently cover schema-invalid/runtime-valid and schema-valid/runtime-unsupported definitions.

S3: Enforce The Editor Boundary

Owner: portal-view.

  • Display server schema paths in the existing Problems panel.
  • Make Validate, Save, Test, and Publish stop when authoritative validation is unavailable or returns a schema error.
  • Keep immediate browser YAML diagnostics, but do not duplicate the canonical validator or schema copy in the frontend bundle.

Gate: component tests prove schema errors are visible and no persistence or test request is sent after a failed or unavailable authoritative validation.

S4: Ground And Validate Ask AI

Owners: workflow-query, portal-view.

  • Add the compact pinned schema and identity to the trusted prompt prefix.
  • Bump the prompt-template version and enforce a complete prompt budget.
  • Prefer a structured workflow object, validate it locally, serialize it to YAML, and allow at most one validation-guided repair.
  • Add schema identity to authoring provenance and display it in the review dialog.

Gate: tests capture the prompt’s exact schema id and digest, reject an invalid first and repaired response, accept a valid repaired response, preserve Tool authorization boundaries, and verify the applied YAML against the same bundled validator.

S5: Persistence Admission Hardening

Owner: workflow-command.

The editor validation call protects the normal UI path but is not a security boundary for direct command callers. Create, update, and publish commands apply the identical schema snapshot before persistence alongside their existing Tool, runtime, and AI-authoring admission rules. Without a shared Maven artifact, the command service carries the same generated resource and manifest through the same synchronization process; the parity gate compares the two bundled schema identities and bytes to prevent drift. Definition conformance failures remain client input errors, while failure to load or compile the bundled schema is a logged server error and must never be attributed to the submitted definition.

Gate: command tests reject schema-invalid definitions without relying on a prior Portal query call, and a cross-repository check proves query and command schema ids and digests are identical.

Persisted Legacy Workflow Rollout

Enabling canonical admission is intentionally a compatibility break for stored definitions that use legacy roots such as steps, tasks, or states instead of the specification’s required document and do roots. Those definitions remain readable, but the editor reports them as invalid and publish admission rejects an unchanged legacy draft. Owners can update a draft by replacing its definition with canonical YAML; after that update passes validation, it can be published normally.

Before enabling this enforcement in an environment with existing workflow data, inventory persisted definitions by host, workflow id, and version using the same pinned schema digest deployed to query and command services. Notify owners of invalid drafts, migrate or re-author each definition, and validate the replacement through the editor before publishing. Existing published versions should remain immutable for auditability; create a new canonical version instead of rewriting published history. The rollout gate is zero unresolved legacy drafts that are expected to be published, plus an explicit owner disposition for every remaining invalid stored definition.

Known Debt: Secret Keyword Screening On Existing Definitions

The authoring guard screens existingDefinition with the secret key pattern in addition to the secret value pattern, and it matches anywhere in the text. Any definition that merely mentions a word such as authorization — an HTTP header name, an authorizationPolicy field, or the word inside a free-text description — is refused with WORKFLOW_AUTHORING_SECRET_IN_EXISTING_DEFINITION even though it carries no credential. This is pre-existing behaviour rather than a regression from schema-backed validation: the guard has always applied the key pattern to the whole definition with a substring match, so tightening the pattern’s anchoring did not change which definitions are refused.

The debt is that a keyword screen is the wrong instrument for a document body. Key-name matching is appropriate where a key name is being inspected, which is the sanitizer’s per-key path; for definition text only the value pattern distinguishes an actual credential from a field name. Resolving this means screening existingDefinition with the value pattern alone, and it must be taken as its own change with its own test coverage, because relaxing a guard that currently fails closed is a security-relevant decision that should not ride along with an unrelated fix. Until then, authors revising a definition that names an authorization concept must strip the wording or start from a new draft.

Phased Implementation

Phase A: Structured YAML Editor

  • Add a generic Workflow Editor component and route.
  • Replace create/update workflow definition textarea navigation with the editor where practical.
  • Keep YAML visible and canonical.
  • Reuse the existing portal-view CodeMirror editor stack for YAML parsing, folding, and parse markers, and display authoritative schema findings returned by the server.
  • Parse YAML client-side to render a step outline and problems panel.
  • Add import/export and basic validation before save.

Phase B: Catalog-Aware Authoring

  • Add a reference panel for endpoint descriptions, tools, rules, agents, and workflow definitions.
  • Add a step palette that inserts valid YAML snippets.
  • Add schema-backed property panels for selected steps. Use dropdowns for catalog references and constrained enums instead of free-text fields where Portal already has authoritative labels.
  • Complete schema-backed server validation through validateWfDefinition.
  • Add runtime diagnostics that compare MCP tool references with gateway tools/list or the Rust agent /diagnostics/tools endpoint when a gateway target is selected.

Phase C: Test And Worklist Integration

  • Add a test runner panel backed by light-workflow start and query APIs.
  • Show workflow events, current task state, waiting human tasks, assertions, and final output.
  • Let users complete ask tasks from the test panel.
  • Link failed test runs to remediation tasks or worklist entries.

Phase C uses the existing Portal workflow command/query boundary. The editor starts a test run through workflow/startWorkflow, then refreshes getProcessInfo, getTaskInfo, getTaskAsst, getWorklist, and getAuditLog for the returned wfInstanceId. The test panel completes a waiting human task through workflow/completeTask, which preserves the structured response in the event data and materializes the task as completed through the existing TaskInfoUpdatedEvent projection.

The panel should expose remediation links instead of silently creating production work. Failed process or task rows can open a prefilled remediation task form, and task assignments can jump to the workflow worklist with the current workflow instance context.

Phase D: Visual Graph Editing

  • Add a React Flow graph preview after the outline is stable.
  • Represent Light-Fabric task types with custom React Flow nodes and explicit transition edges.
  • Add drag-and-drop graph editing only after YAML/model round-trip behavior is reliable.
  • Keep YAML as the source of truth even when visual editing is enabled.

Phase D adds the graph as a projection of the parsed YAML model, not a separate persisted representation. The graph reads steps, tasks, states, or do containers and renders one custom React Flow node per detected step. Node styling reflects the Light-Fabric task type, and the graph can overlay runtime task status from the Phase C test-run read models when the workflow task id matches a graph step id.

Explicit transition fields such as next, then, to, and transition become solid graph edges. Ordered fallback edges are shown as dashed edges so authors can distinguish model transitions from inferred sequence. Creating an edge in React Flow updates the source step’s transition in YAML, and deleting an explicit edge removes that transition target from YAML. Dragging nodes changes only the authoring layout in the browser session; it does not mutate the saved workflow definition.

The graph must continue to tolerate partial or invalid authoring states. If the YAML cannot be parsed into a known workflow container, the editor keeps the source editor and validation panels usable and shows an empty graph state rather than blocking authoring.

Recommendation

Build the generic Workflow Editor before the Skill Workspace embeds workflow authoring. The skill UI should provide context and constraints, while the workflow editor provides YAML editing, step preview, validation, and test runs for every workflow authoring use case in Portal.

Portal Catalog Scope

Problem

Light Portal supports multiple tenants through host_id and can also host multiple runtime environments in one portal instance. A common deployment shape is:

Portal instanceRuntime environments
Instance Adev, sit
Instance Bstg, prd

Within an organization or a cloud deployment, operators need a catalog for APIs, API endpoints, tools, skills, schemas, rules, workflows, categories, and tags. Some catalog entries are reusable platform knowledge. Other entries are tenant-owned, environment-bound, or tied to a concrete gateway deployment.

The main design question is whether Light Portal should clone catalog rows into every host/tenant, or maintain one shared catalog per portal instance and expose it through a separate single page application and virtual host.

The recommended answer is neither full cloning nor a UI-only split. The portal should model catalog scope explicitly:

  • shared catalog definitions use global scope,
  • tenant-specific definitions and overrides use host scope,
  • environment-specific runtime bindings use host plus environment scope,
  • a separate SPA may expose the same backend catalog, but it should not become the catalog authority.

Goals

  • Avoid duplicating the full catalog for every tenant.
  • Prevent catalog drift between tenants and between portal instances.
  • Preserve tenant isolation for private APIs, private skills, secrets, access control, and runtime bindings.
  • Let dev and sit share one portal instance while still keeping their runtime endpoint targets separate.
  • Let stg and prd share another portal instance while keeping production controls stricter.
  • Support an effective catalog query that combines global definitions with host-specific rows and environment-specific bindings.
  • Reuse existing portal-query APIs and the genai-query catalog direction for agent-facing skills and tools.
  • Keep light-gateway as the runtime MCP execution path for tools/list and tools/call.
  • Support promotion or import/export between portal instances instead of relying on ad hoc row copies.

Non-Goals

  • Do not clone every global catalog row into every tenant by default.
  • Do not make a separate SPA the source of truth for catalog data.
  • Do not bypass host-scoped authorization just because a catalog item is global.
  • Do not put secrets, client credentials, runtime tokens, or deployment state in global catalog rows.
  • Do not move MCP tool execution from light-gateway into portal-query, controller-rs, or the catalog UI.
  • Do not require every MCP or API endpoint to be wrapped in a skill before the gateway can expose it as a runtime tool.

Current Model

The database already contains both global-capable and host-scoped patterns.

category_t and tag_t have nullable host_id. A null host_id means the category or tag is global. A non-null host_id means the row belongs to one host. Their unique indexes already separate global uniqueness from host-specific uniqueness.

The query behavior for category and tag labels returns both host-specific rows and global rows for a host. This is the right shape for taxonomy and catalog organization metadata.

Other catalog entities are currently host-scoped:

  • api_t
  • api_version_t
  • api_endpoint_t
  • agent_definition_t
  • skill_t
  • tool_t
  • tool_param_t
  • agent_skill_t
  • skill_tool_t
  • skill_dependency_t

Those tables use host_id NOT NULL and most query paths filter by host_id = ?. This is correct for private tenant data and runtime-bound data, but it is too narrow for reusable platform catalog definitions if the only sharing mechanism is row replication.

Design Decision

Use a scoped catalog inside Light Portal.

The portal backend remains the source of truth. The catalog UI can be part of the existing portal SPA or exposed through another SPA/virtual host, but both UI surfaces must read and write through the same portal-query and command APIs.

The durable model is:

global catalog definition
  -> host enablement or host override
    -> environment runtime binding

This model allows one shared definition for reusable knowledge and separate tenant or environment controls where isolation matters.

Scope Types

ScopeStorage meaningTypical data
Globalhost_id IS NULL or a dedicated global definition rowShared categories, tags, reusable schemas, rule templates, workflow templates, public tool definitions, shared skill templates
Hosthost_id = ?Tenant-owned APIs, private schemas, tenant skills, tenant tools, host-level enablement, access rules
Environmenthost_id = ? plus env_tag, service id, target host, instance, or deployment bindingdev/sit/stg/prd endpoint targets, gateway exposure, runtime service bindings, deployment state
InstanceSeparate portal database or portal deploymentPromotion boundary between dev/sit instance and stg/prd instance

Global rows are reusable definitions. Host rows are ownership and isolation. Environment rows are runtime selection.

Catalog Entity Guidance

EntityRecommended scopeReason
CategoryGlobal by default, host-specific when private taxonomy is neededExisting schema already supports nullable host_id
TagGlobal by default, host-specific when private taxonomy is neededExisting schema already supports nullable host_id
APIHost-scoped, with optional shared template support laterAPI ownership, lifecycle, and visibility are usually tenant-specific
API versionHost-scopedCarries env_tag, target_host, service id, spec, and runtime-facing version metadata
API endpointHost-scoped for concrete API versions; may be generated from shared templatesEndpoint availability depends on the owning API version and runtime
ToolShared definition when generic; host-scoped projection when executable for a tenantRuntime execution still depends on gateway, endpoint, policy, and service binding
SkillShared template when reusable; host-scoped copy or override when edited by a tenantSkills contain prompt guidance that tenants may customize
SchemaGlobal when it is a reusable contract; host-scoped when it contains tenant-private fields or lifecycleAvoid cloning standard contracts but protect tenant-specific schemas
RuleGlobal template or host-specific ruleA reusable rule definition is different from enabling that rule for a host
WorkflowGlobal template or host-specific workflowTemplates can be shared, execution bindings should be host or environment scoped

Effective Catalog

Consumers should not need to manually merge global and host rows. Portal-query should expose an effective catalog read model for each host and runtime context.

The effective catalog request should include:

  • hostId
  • serviceId when the catalog is for a gateway, agent, or runtime service
  • envTag when the result is environment-specific
  • optional agentDefId when the result is for an agent
  • optional filters for entity type, category, tag, protocol, routing domain, or capability

The effective catalog response should include:

  • global definitions visible to the caller,
  • host-specific definitions visible to the caller,
  • host overrides that shadow global defaults,
  • environment bindings for the requested envTag,
  • active state and catalog version or freshness metadata,
  • category and tag labels from both global and host-specific taxonomy rows,
  • enough provenance to show whether a row came from global scope, host scope, or an environment binding.

Recommended precedence:

environment binding > host override > global definition

This keeps shared definitions stable while allowing host and environment customization.

Data Model Direction

For tables that already support nullable host_id, keep the current pattern:

host_id IS NULL  -> global/shared row
host_id = ?      -> host-specific row

For strictly host-scoped catalog tables, do not simply make every host_id nullable without checking foreign keys and runtime assumptions. Some tables are correctly host-scoped because they point to tenant-owned APIs, credentials, gateway endpoints, or agent assignments.

Use one of these patterns per entity:

  1. Nullable host_id on the definition table when the entity can safely be global and all references can resolve global plus host rows.
  2. Separate template and binding tables when the definition is global but enablement is tenant-specific.
  3. Keep the current host-scoped table when the entity is inherently tenant or runtime bound.

For reusable skills and tools, the safest long-term shape is template plus binding:

catalog_skill_template_t
  -> host_skill_t or skill_t host override
    -> agent_skill_t assignment

catalog_tool_template_t
  -> host tool projection
    -> skill_tool_t mapping
    -> gateway runtime tools/list verification

If the implementation starts smaller, it can add nullable global scope to selected catalog definition tables first, but the query contract must still return the effective catalog and indicate scope provenance.

Separate SPA Or Virtual Host

A separate SPA deployed with LightAPI and sign-in as another BFF virtual host is useful as a catalog presentation surface. It can provide a marketplace-style view for shared APIs, tools, skills, schemas, rules, and workflows.

It should not own separate catalog state.

Recommended use:

  • browse global catalog definitions,
  • request enablement for a host,
  • compare host overrides with global definitions,
  • review environment bindings,
  • publish or promote catalog versions between portal instances.

Avoid using the separate SPA to bypass tenant-aware portal APIs. The BFF should still pass authenticated requests to portal-query or command APIs, and those APIs must enforce host, service, environment, and role checks.

Environment Handling

Within one portal instance, environments should be runtime bindings, not cloned catalog universes.

For a dev/sit instance:

  • one shared catalog can describe a capability,
  • dev and sit get separate env_tag bindings,
  • runtime endpoints can differ through target_host, service_id, instance, deployment, or gateway registration,
  • a tool can be visible in both environments but executable only where the gateway lists it.

For a stg/prd instance:

  • stg and prd can share approved global definitions,
  • production enablement should require stricter workflow or authorization,
  • secrets, tokens, OAuth clients, runtime instances, and deployment state remain environment-specific,
  • catalog promotion into prd should preserve stable IDs and versions.

Promotion Between Portal Instances

The boundary between dev/sit and stg/prd is an instance boundary. Treat it as a promotion boundary, not as live replication between tenants.

Recommended promotion flow:

  1. Author or import catalog definitions in the lower portal instance.
  2. Review and approve the global or host-scoped definitions.
  3. Export selected catalog rows with their versions and dependencies.
  4. Import into the target portal instance.
  5. Resolve environment bindings for stg or prd.
  6. Verify runtime exposure through the selected light-gateway tools/list.
  7. Activate the target bindings.

Promotion should be idempotent. A repeated import of the same catalog version should update or confirm the same target definition instead of creating duplicates.

Security And Authorization

Global catalog visibility does not mean global execution permission.

Authorization must be checked at these layers:

  • portal UI and BFF authentication,
  • portal-query read authorization,
  • command API write authorization,
  • host and environment claim matching,
  • category/tag visibility when private taxonomy is used,
  • gateway tools/list availability,
  • gateway tools/call policy,
  • downstream service authorization.

For runtime catalog reads used by gateways and agents, the token should include host, sid, and, when environment-specific data is requested, env. The query handler should compare those claims with the requested hostId, serviceId, and envTag.

UI Guidance

The portal UI should show catalog scope explicitly:

  • Global
  • Host
  • Environment

For list pages, include filters for scope, environment, category, tag, active state, and source protocol. For detail pages, show whether a host row inherits from a global definition, overrides it, or is private to the host.

For destructive changes, make the target scope clear. Updating a global catalog definition can affect many hosts, while updating a host override should affect only that host.

Migration Approach

  1. Keep the existing category and tag nullable host_id behavior.
  2. Add effective catalog read APIs before broad schema changes so callers have a stable contract.
  3. Identify which catalog entities need global definitions versus host-only rows.
  4. Add template or nullable-scope tables for reusable definitions.
  5. Add host enablement or override tables for tenant-specific activation.
  6. Add environment binding views or APIs for dev, sit, stg, and prd.
  7. Add import/export or snapshot support for promotion between portal instances.
  8. Update portal-view to expose scope and provenance.
  9. Keep existing host-scoped APIs working during the migration.

Open Questions

  • Should global reusable skills and tools use nullable host_id in the existing tables, or separate template tables with host bindings?
  • Which catalog entities require approval workflow before production activation?
  • Should category and tag assignment tables store additional scope metadata, or is scope fully inherited from the referenced category or tag?
  • What stable external identity should be used during cross-instance catalog promotion when UUIDs differ between portal databases?
  • Should portal-query expose one broad effective catalog endpoint or multiple entity-specific effective endpoints?

OAuth Audit

The OAuth services keep authorization codes and refresh tokens as operational state. These rows are short lived and are now written directly to auth_code_t and auth_refresh_token_t instead of being created through the general event store. This avoids high-volume login and refresh-token churn in event_store_t and outbox_message_t.

Audit and login history are recorded separately in append-oriented OAuth audit tables.

Goals

  • Show administrators who is currently online.
  • Show a user the last login time and session history.
  • Track refresh-token rotation and rejected refresh attempts.
  • Preserve enough history for support and security review without storing raw secrets.
  • Keep the hot login and token-refresh path simple and transactional.

Tables

auth_session_t stores one row per login session. It is the current and historical session summary.

  • session_id identifies the browser/device session.
  • login_ts, last_refresh_ts, logout_ts, and expires_ts describe the session lifetime.
  • status is ACTIVE, LOGGED_OUT, EXPIRED, or REVOKED.
  • refresh_count is incremented on each successful refresh-token rotation.
  • ip_address, user_agent, and device_id are optional request context fields.

auth_session_audit_t stores append-only auth audit entries.

  • LOGIN_SUCCEEDED
  • LOGIN_FAILED
  • AUTH_CODE_ISSUED
  • AUTH_CODE_CONSUMED
  • REFRESH_TOKEN_ISSUED
  • REFRESH_TOKEN_ROTATED
  • REFRESH_TOKEN_REJECTED
  • LOGOUT
  • SESSION_EXPIRED
  • SESSION_REVOKED

auth_refresh_token_t.session_id links the currently valid refresh token to the session that owns it. This removes ambiguity when the same user is logged in from multiple browsers or devices.

Audit rows keep session_id as data, but do not use a hard foreign key to auth_session_t. Audit history must remain groupable by session even if operational session rows are later archived or removed.

Login Flow

When /oauth2/{providerId}/code authenticates the user:

  1. Insert the authorization code into auth_code_t.
  2. Insert an ACTIVE session into auth_session_t.
  3. Insert LOGIN_SUCCEEDED and AUTH_CODE_ISSUED audit rows.
  4. Include the session_id in the auth code row so the token exchange can attach the refresh token to the same session.

Failed logins write LOGIN_FAILED with the available host, provider, client, request metadata, and failure reason.

Authorization Code Exchange

When grant_type=authorization_code succeeds:

  1. Delete the consumed auth code from auth_code_t.
  2. Insert the refresh token into auth_refresh_token_t with the auth code’s session_id.
  3. Insert AUTH_CODE_CONSUMED and REFRESH_TOKEN_ISSUED audit rows.

Refresh Token Rotation

When grant_type=refresh_token succeeds, the service performs one transaction:

  1. Insert the replacement refresh token.
  2. Delete the previous refresh token with its expected aggregate version.
  3. Update auth_session_t.last_refresh_ts and increment refresh_count.
  4. Insert REFRESH_TOKEN_ROTATED with the old and new token ids.

If a refresh token is missing, invalid, or belongs to the wrong client, the service writes REFRESH_TOKEN_REJECTED when enough context is available. Raw refresh-token values must not be stored in audit metadata.

Admin Revocation

Administrators can kick out a user by revoking the user’s current refresh token. Operationally, deleting the refresh token is enough to stop the session from renewing once the current access token expires. The audit/session model adds explicit session state to that behavior.

The revocation operation must run as one transaction:

  1. Find the refresh token row and its session_id.
  2. Delete the refresh token from auth_refresh_token_t.
  3. Update auth_session_t:
    • status = 'REVOKED'
    • logout_ts = CURRENT_TIMESTAMP
    • end_reason = 'ADMIN_REVOKED'
  4. Insert SESSION_REVOKED into auth_session_audit_t.

The database patch provides revoke_auth_session_by_refresh_token(host_id, refresh_token, admin_user, reason) for this workflow. Admin screens should call the revoke operation instead of issuing a plain refresh-token delete when the intent is to kick out a logged-in user.

If the refresh token has no session_id, the operation still deletes the token and returns NULL. This preserves backward compatibility with refresh-token rows created before session tracking.

Admin Queries

Current online users:

SELECT *
FROM auth_session_t
WHERE status = 'ACTIVE'
  AND (expires_ts IS NULL OR expires_ts > CURRENT_TIMESTAMP);

User login history:

SELECT *
FROM auth_session_t
WHERE host_id = $1
  AND user_id = $2
ORDER BY login_ts DESC;

Session duration:

SELECT
    login_ts,
    COALESCE(logout_ts, last_refresh_ts, CURRENT_TIMESTAMP) - login_ts AS duration
FROM auth_session_t
WHERE host_id = $1
  AND session_id = $2;

Retention

auth_session_t can be retained longer than operational token tables. auth_session_audit_t should use a retention policy appropriate for the deployment, for example 90 days or one year. Retention jobs should delete audit rows by event_ts and optionally archive them before deletion.

Multi-Tenant

Database Schema

Adding a host_id to every table is one approach, but it does lead to composite primary keys and can impact performance. Using UUIDs as primary keys, even in a multi-tenant environment, is another viable option with its own set of trade-offs. Let’s examine both strategies:

  1. Host ID on Every Table (Composite Primary Keys)

Schema: Each table would have a host_id column, and the primary key would be a combination of host_id and another unique identifier (e.g., user_id, endpoint_id).

CREATE TABLE user_t (
    host_id UUID NOT NULL,  -- References hosts table
    user_id INT NOT NULL, 
    -- ... other columns
    PRIMARY KEY (host_id, user_id),
    FOREIGN KEY (host_id) REFERENCES hosts_t(host_id)
);

Pros:

  • Data Isolation: Clear separation of data at the database level. Easy to query data for a specific tenant.

  • Backup/Restore: Simplified backup and restore procedures for individual tenants.

Cons:

  • Composite Primary Keys: Can lead to more complex queries, especially joins, as you always need to include the host_id. Can affect query optimizer performance.

  • Storage Overhead: host_id is repeated in every row of every table, adding storage overhead.

  • Index Impact: Composite indexes can sometimes be less efficient than single-column indexes.

  1. UUIDs as Primary Keys (Shared Tables)

Schema: Tables use UUIDs as primary keys. A separate table (tenant_resources_t) maps UUIDs to tenants.

CREATE TABLE user_t (
    user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    -- ... other columns
);


CREATE TABLE tenant_resource_t(
    host_id UUID NOT NULL,
    resource_type varchar(255) NOT NULL, --e.g., 'user', 'api_endpoint'
    resource_id UUID NOT NULL,
    PRIMARY KEY(host_id, resource_type, resource_id),
    FOREIGN KEY (host_id) REFERENCES hosts_t(host_id)
);

Pros:

  • Simplified Primary Keys: Easier to manage single-column UUID primary keys. Simpler joins.

  • Reduced Storage Overhead: No need to repeat host_id in every table.

  • Application Logic: Multi-tenancy is handled mostly in the application logic by querying tenant_resources_t to ensure a user belongs to the correct tenant, adding a layer of flexibility. (This is also a con if not carefully implemented.)

Cons:

  • Data Isolation (slightly reduced): Data is logically separated but resides in shared tables. Robust application logic is essential to prevent data leakage between tenants.

  • Backup/Restore (more complex): Backing up/restoring for a single tenant requires filtering based on the tenant_resources_t table.

  • Query Performance (potential issue): Queries might require joining with tenant_resources_t table which will add a bit overhead. Proper indexing and query optimization become crucial.

  1. Hybrid Approach (Recommended in many cases)
  • Combine the strengths of both approaches.

  • Use UUIDs as primary keys for most tables for simplicity.

  • Add host_id to tables where data isolation is paramount (e.g., tables containing sensitive financial or personal data) or where frequent tenant-specific queries are performed. This provides a more granular level of control and allows for optimized queries when needed.

  • Use the tenant_resources_t table to maintain an overview of which resources belong to which tenant, supporting the application logic’s multi-tenancy enforcement.

Choosing the Right Strategy:

  • Small Number of Tenants, Low Data Volume: UUIDs might be sufficient.

  • Strict Data Isolation Requirements, Frequent Tenant-Specific Queries, High Data Volume: host_id on every relevant table or a hybrid approach is often preferable.

  • Flexibility in Multi-Tenancy Implementation: Favors the UUID approach or Hybrid approach, with logic predominantly handled in the application layer.

Additional Considerations:

  • Database Support: Check if database (PostgreSQL) has specific features for multi-tenancy.

  • Indexing: Carefully plan indexes for optimal performance in any multi-tenant scenario.

  • Schema Design: Consider creating views for tenant-specific data access to simplify queries in the application layer.

  • Security: Implement robust security measures to prevent data leakage between tenants, regardless of the chosen approach.

The hybrid approach usually provides the best balance between performance, data isolation, and development complexity in real-world multi-tenant applications. You have more direct control where strict isolation is required and can maintain the simplicity of UUIDs where it’s beneficial. Using tenant_resources_t for application logic enforcement offers flexibility and a central point of management for tenant resource association. This approach also prepares the application for potential future expansion and different multi-tenancy demands as the application grows.

Should host_id part of the PK

This is a classic multi-tenancy design question. Both approaches have implications. Let’s analyze them:

Option 1: Current Approach - PK (host_id, instance_id)

  • Pros:
    • Excellent for Tenant-Specific Queries: This is the biggest advantage. Queries like SELECT ... FROM instance_t WHERE host_id = ? AND ... or SELECT ... FROM instance_t WHERE host_id = ? ORDER BY instance_id can directly and efficiently use the primary key index. The index is naturally ordered by tenant first, then by instance within that tenant. This improves data locality for a specific tenant’s data.
    • Clear Logical Grouping: The primary key explicitly represents the concept that an instance belongs to a specific host (tenant).
    • Enforces Uniqueness Per Tenant: Guarantees that instance_id is unique within a given host_id. (Although UUIDv7 makes global collisions highly unlikely anyway).
  • Cons:
    • Wider Primary Key: The PK is 32 bytes (16+16).
    • Wider Foreign Keys: Any table referencing instance_t would need both host_id and instance_id as its foreign key columns.
    • Slightly Larger Secondary Indexes: Other indexes on instance_t will implicitly include both PK columns, making them slightly larger than if the PK was just 16 bytes.

Option 2: Alternative - PK (instance_id)

  • Pre-requisite: This only works if your application guarantees that instance_id is globally unique across all hosts/tenants. Given you’re using UUIDv7, this is a safe assumption in practice, but the schema wouldn’t enforce uniqueness per host explicitly via the PK itself.
  • Pros:
    • Narrower Primary Key: The PK is only 16 bytes.
    • Simpler Foreign Keys: Tables referencing instance_t only need a single instance_id column for the foreign key.
    • Slightly Smaller Secondary Indexes: Other indexes on the table will be marginally smaller.
  • Cons:
    • Requires Separate Index for Tenant Queries: You would absolutely need a separate index on (host_id, instance_id) (or at least (host_id)) for efficient tenant-specific queries (WHERE host_id = ?). Without it, querying for a specific tenant’s data would require less efficient scans. This index would likely be a UNIQUE index anyway to enforce the logical relationship: CREATE UNIQUE INDEX instance_t_host_instance_idx ON instance_t (host_id, instance_id);
    • Potential Reduced Locality: While the separate index helps, the primary key index itself (based only on instance_id) might interleave data from different tenants physically, potentially slightly reducing cache efficiency for queries scanning many instances for a single tenant compared to the composite PK approach.

Recommendation:

Stick with the composite primary key: PRIMARY KEY(host_id, instance_id).

Reasoning:

  1. Performance for Core Use Case: In multi-tenant systems, filtering by the tenant identifier (host_id) is almost always the primary access pattern. Having host_id as the leading column in the PK index directly optimizes this critical path.
  2. Index Necessity: Even if you chose instance_id as the sole PK, you would still need to create an index on (host_id, instance_id) for performance. Making this essential index the primary key index is often the most straightforward and efficient approach.
  3. Clarity: The composite key clearly reflects the logical relationship and ownership.
  4. Cost: The “cost” of a 32-byte PK vs. a 16-byte PK is often negligible compared to the performance gains achieved by aligning the PK index with the dominant query patterns in a multi-tenant architecture. The impact on FKs and secondary indexes is real but usually acceptable.

Using just instance_id as the PK prioritizes global uniqueness and FK simplicity over optimizing tenant-specific queries directly via the PK index. In most multi-tenant scenarios, optimizing tenant queries is more important.

Citus PostgreSQL Extension

Citus, now fully integrated into PostgreSQL as a distributed database extension, can be very helpful in scaling your multi-tenant application, especially if you anticipate significant data growth and high query loads. Here’s how Citus can fit into your use case and the factors to consider:

How Citus Helps:

  • Horizontal Scalability: Citus allows you to distribute the data across multiple PostgreSQL nodes (servers), enabling horizontal scaling. This is crucial for handling increasing data volumes and query loads in a multi-tenant environment.

  • Improved Query Performance: By distributing data and queries, Citus can significantly improve the performance of many types of queries, especially analytical queries that operate on large datasets. This is particularly beneficial if we have tenants with substantially different data volumes or query patterns.

  • Shard Placement by Tenant: One of the most effective ways to use Citus for multi-tenancy is to shard the data by host_id (or a tenant ID). This means that all data for a given tenant resides on the same shard (a subset of the distributed database). This allows for efficient tenant isolation and simplifies queries for tenant-specific data.

  • Simplified Multi-Tenant Queries: When sharding by tenant, queries that filter by host_id become very efficient because Citus can route them directly to the appropriate shard. This eliminates the need for expensive scans across the entire database.

  • Flexibility: Citus supports various sharding strategies, allowing you to choose the best approach for the data and query patterns. You can even use a hybrid approach, distributing some tables while keeping others replicated across all nodes for faster access to shared data.

Example (Sharding by Tenant):

Create a distributed table: When creating tables (e.g., user_t, api_endpoint_t, etc.), we would declare them as distributed tables in Citus, using the host_id as the distribution column:

CREATE TABLE user_t (
    host_id UUID NOT NULL,
    user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    -- ... other columns
) DISTRIBUTE BY HASH (host_id);

Querying: When querying data for a specific tenant, include the host_id in the WHERE clause:

SELECT * FROM users_t WHERE host_id = 'your-tenant-id';

Citus will automatically route this query to the shard containing the data for that tenant, resulting in much faster query execution.

Citus Cost:

  • Citus Open Source: The Citus open-source extension is free to use and is included in the PostgreSQL distribution. We can self-host and manage it.

  • Azure CosmosDB for PostgreSQL (Managed Citus): Microsoft offers a fully managed cloud service called Azure CosmosDB for PostgreSQL, which is built on Citus. This service has usage-based pricing, and the cost depends on factors like the number of nodes, storage, and compute resources used. This managed option reduces the operational overhead of managing Citus yourself.

Recommendation:

Don’t automatically add host_id to every table just because we are using Citus. Carefully analyze the data model, query patterns, and multi-tenancy requirements.

  • Distribute tables by host_id (tenant ID) when data locality and isolation are paramount, and we want to optimize tenant-specific queries.

  • Consider replicating smaller, frequently joined tables to avoid unnecessary joins and host_id overhead.

  • Use a central mapping table (tenant_resources_t) to manage tenant-resource associations and enforce multi-tenancy rules in the application logic where appropriate.

This more nuanced approach provides a balance between the benefits of distributed data with Citus and avoiding unnecessary complexity or performance overhead from overusing host_id. Choose the Citus deployment model (self-hosted open source or managed cloud service) that best suits our needs and budget.

Primary Key Considerations in a Distributed Citus Environment

When a table includes host_id (due to sharding requirements), it is important to include host_id as part of the primary key. This ensures proper functioning and optimization within the Citus distributed database.

  1. Distribution Column Requirement
    In Citus, the distribution column (e.g., host_id) must be part of the primary key. This is essential for routing queries and distributing data correctly across shards.

  2. Uniqueness Enforcement

    • The primary key enforces uniqueness across the entire distributed database.
    • For example, if user_id is unique only within a tenant (host), then (host_id, user_id) is required as the primary key to ensure uniqueness across all shards.
  3. Data Locality and Co-location
    Including host_id in the primary key ensures that all rows for the same tenant (identified by the same host_id) are stored together on a single shard. This provides:

    • Efficient Joins: Joins between tables related to the same tenant can be performed locally on a single shard, avoiding expensive cross-shard data transfers.
    • Optimized Queries: Queries filtering by host_id are efficiently routed to the appropriate shard.
  4. Referential Integrity
    If other tables reference the users_t table and are also distributed by host_id, including host_id in the primary key of users_t is essential to maintain referential integrity across shards.

Multi-Host User Session Management

In a multi-host environment where multiple hosts reside on the same server, users must associate with one host at a time. The session management is handled as follows:

  1. Host Association on Login:

    • Once a user logs in, a host cookie is returned, derived from the JWT token.
    • The user’s session defaults to the associated host in the cookie.
  2. Switching Hosts:

    • If a user wishes to switch to another host, they can:
      • Access the User Menu to select a different host.
      • Log out of the current session.
    • During the next login, the session will be tied to the newly selected host.
  3. Host in API Requests:

    • For all API requests sent to the server, the host is typically included as part of the request payload.
    • For login users, the host is in the JWT token as a custom claim.
    • For guest users, the default host is used until the user is signed in.
    • This ensures proper routing and handling of requests in a multi-host environment.

By associating users to a specific host for each session, this approach ensures clear separation of data and responsibilities across hosts, while providing users the flexibility to switch hosts as needed.

Event Header

As the portal is based on the event sorucing, all events will be responsible for populating the database. So, they need to be separated by host_id as well. In the event header, we have one unique id which is generated when event is created. Also, it has host_id and user_id in the EventId which is included in every events.

Reference and Shared Tables

In an application there are some data that is shared by all tenants. For example, the dropdown options on the UI and business validation. We call them reference data and have defined several tables to manage them centrally. For each reference data type, there is a logical table defined in the ref_table_t and marked as common or not. Common means the table can be shared with other tenants. Otherwise, it is only private for the owner tenant.

Some other entities are very similar but they cannot be fit into the reference tables. For example, category_t table contains all the category definitions for different entities. These tables are designed with an optional host_id. Here is an exmaple.

CREATE TABLE category_t (
    category_id          VARCHAR(22) NOT NULL,   -- unique id to identify the category
    host_id              VARCHAR(22),            -- null mean global category
    entity_type          VARCHAR(50) NOT NULL,   -- the version of the schema
    category_name        VARCHAR(126) NOT NULL,  -- category name, must be url friendly.
    category_desc        VARCHAR(1024) NOT NULL, -- decription
    parent_category_id   VARCHAR(22) REFERENCES category_t(category_id) ON DELETE SET NULL, -- parent category id, null if there is no parent.
    sort_order           INT DEFAULT 0,          -- sort order on the UI
    update_user          VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (category_id)
);

-- 1. Unique index for GLOBAL categories (where host_id IS NULL)
-- Ensures uniqueness of (entity_type, category_name, parent_category_id) ONLY when host_id is NULL
CREATE UNIQUE INDEX idx_category_unique_global
ON category_t (entity_type, category_name, parent_category_id)
NULLS NOT DISTINCT -- Handles NULLs in parent_category_id correctly
WHERE host_id IS NULL;

-- 2. Unique index for TENANT-SPECIFIC categories (where host_id IS NOT NULL)
-- Ensures uniqueness of (host_id, entity_type, category_name, parent_category_id)
-- for rows that belong to a specific host.
CREATE UNIQUE INDEX idx_category_unique_tenant
ON category_t (host_id, entity_type, category_name, parent_category_id)
NULLS NOT DISTINCT -- Handles NULLs in parent_category_id correctly
WHERE host_id IS NOT NULL;


CREATE INDEX idx_category_entity_type ON category_t (entity_type);
CREATE INDEX idx_category_parent ON category_t (parent_category_id);
CREATE INDEX idx_category_name ON category_t (category_name);
CREATE INDEX idx_category_host_id ON category_t (host_id);

On the UI, the host_id will be auto populated according to the associated host_id by the user in readonly mode. There is a checkbox “Is Global Category” in the form. If checked, the backend service will have an FGA rule to ensure that the user is admin and the host_id will be removed in the event. This works for both create and update.

When viewing categories, the super admin might see all categories by default, possibly with a column or indicator showing the host_id (or “Global”). Filters should allow viewing global only, or a specific tenant’s categories.

Tenant Admin / Host Owner:

When a tenant admin accesses the category management UI, their context is fixed to their own host_id.

They should only be able to create/edit categories associated with their specific host_id.

The UI should not offer them the option to create/edit global categories or categories for other hosts. The host_id is implicitly set or displayed as read-only based on their logged-in context.

When viewing categories, they should see their own tenant-specific categories plus all applicable global categories. The UI should clearly differentiate between these (e.g., using grouping, labels, icons).

System Integration

System integrations must preserve the same identity and tenant boundaries as interactive portal workflows. The integration token is not only an access credential; it is also the source of audit metadata, event metadata, row filtering, and host scoping.

Command Side

The command side uses event sourcing. Every accepted command writes one or more domain events, and those events are later projected into query-side tables. Because events become the durable system of record, command calls need a stable user identity and host identity.

For command APIs, use an authorization code token whenever possible. The token must contain the real portal user id so command handlers can derive the correct userId, host, nonce, and CloudEvent metadata. This is the preferred path for browser flows, operator tools, and integrations that can act on behalf of a known user.

If the integration has no user session in the request context, do not submit anonymous command events. First onboard a real user in the system for the integration actor or service account. That user becomes the durable audit principal for the commands emitted by the integration.

After the user is onboarded, create an auth client for the integration and set custom claims that carry the command identity:

{
  "host": "<host-id>",
  "elm": "<integration-user-email>",
  "uid": "<integration-user-id>",
  "uty": "<user-type>"
}

The uid claim must reference the onboarded user. The host claim must match the tenant boundary where commands are allowed to run. The elm and uty claims should match the onboarded user’s email and user type so downstream authorization, audit, and support workflows can identify the actor without guessing.

For an integration auth client whose type is trusted, the client application can call Light OAuth with the client_credentials grant type when the auth client has these custom claims configured. Light OAuth issues a token that carries the custom claims, allowing the token to act as an id-token-like access token for command APIs, similar to the user-bearing token produced by the authorization code grant. This path is only acceptable for trusted client types because the client, not an interactive browser session, is asserting the user and host identity through the auth client configuration.

Command-side integration rules:

  • Prefer an authorization code token tied to the real interactive user.
  • Use a dedicated onboarded integration user only when no user session exists.
  • For non-session integrations, use a trusted auth client with custom claims and request the token from Light OAuth with grant_type=client_credentials.
  • Do not use a token that lacks a usable userId/uid for event-sourced commands.
  • Do not allow non-trusted clients to mint user-bearing command tokens from client credentials.
  • Keep host ownership explicit; never infer host scope from the client id alone.
  • Treat the auth client and custom claims as deployment configuration, not as a substitute for user onboarding.

Query Side

The query side serves read models built from command-side events and operational tables. Query APIs do not create domain events, do not allocate command nonces, and should not mutate event-sourced state.

Query integrations still need authorization and tenant scoping. The request token must provide enough identity to determine the host and the effective user or service account. For user-scoped reads, use the same authorization code token or integration-user token described for the command side so row and column filters can apply consistently.

If authorization code flow is not available for a query integration, the client_credentials flow is acceptable only for auth clients whose type is trusted. The token must carry host, sid, and, when environment-specific data is requested, env. Here sid is the service id for the gateway, agent, or other Light-Fabric runtime calling portal-query. Query handlers must compare these claims with the requested hostId, serviceId, and optional envTag before returning service-scoped data.

Light-Fabric ecosystem components such as gateways and agents may use a long-lived token for query-side access when the token was issued through this trusted client_credentials path. That access is not general portal read access. It is limited to query endpoints built for those runtime components, such as gateway, agent, discovery, or catalog endpoints, and those endpoints must enforce the claim match before returning data.

For host-scoped or service-level reads, a client token can be used only when the auth client type is trusted and the token carries the required host, service, and environment claims. The query service should apply the same host boundary as the command side and return only data visible to that actor. A missing user session may reduce the allowed result set, but it must not broaden access.

Query-side integration rules:

  • Read from projected/query tables; do not write command events from query handlers.
  • Resolve host scope from the validated token claims and request parameters.
  • When authorization code flow is unavailable, accept client_credentials only from auth clients whose type is trusted.
  • Require host and sid token claims; require env when the endpoint or request is environment-scoped.
  • Match token host, sid, and optional env to requested hostId, serviceId, and optional envTag.
  • Allow long-lived Light-Fabric runtime tokens only on endpoints designed for gateways, agents, and similar ecosystem components.
  • Do not use long-lived runtime tokens for broad user-facing query access.
  • Apply user, role, position, group, attribute, and fine-grained filters when the endpoint requires them.
  • Use the onboarded integration user for auditability when a human user is not present.
  • Keep query tokens least-privileged; read-only integrations should not receive command scopes.

Portal Event

Light Portal is using event sourcing and CQRS. Any update to the system will generate an event and there are hundreds of event types.

All events are in Avro format and will be pushed to a Kafka cluster for stream processing. Each event has an EventId that contains common info for events and it is reside in light-kafka repo.

Here is one of the events in the light-portal.

{
  "type": "record",
  "name": "ApiRuleCreatedEvent",
  "namespace": "net.lightapi.portal.market",
  "fields": [
    {
      "name": "EventId",
      "type": {
        "type": "record",
        "name": "EventId",
        "namespace": "com.networknt.kafka.common",
        "fields": [
          {
            "name": "id",
            "type": "string",
            "doc": "a unique identifier"
          },
          {
            "name": "nonce",
            "type": "long",
            "doc": "the number of the transactions for the user"
          },
          {
            "name": "timestamp",
            "type": "long",
            "default": 0,
            "doc": "time the event is recorded"
          },
          {
            "name": "derived",
            "type": "boolean",
            "default": false,
            "doc": "indicate if the event is derived from event processor"
          }
        ]
      }
    },
    {
      "name": "hostId",
      "type": "string",
      "doc": "host id"
    },
    {
      "name": "apiId",
      "type": "string",
      "doc": "api id"
    },
    {
      "name": "ruleIds",
      "type": {
        "type": "array",
        "items": "string"
      },
      "doc": "one or many rule ids that link to the apiId"
    }
  ]
}

Kafka Key

When pushing events into a Kafka topic, the record key will be used to distribute record between different Kafka partitions. Here is the key selection for the system.

  • multi-tenent

The key will be the hostId

  • single-tenent

The key will be the userId

Promotion or Replay

Promotion approaches

  1. When promote from dev to sit, we can export all event from dev and update the event json file and then replay to the sit.
  2. We can import the original event json from dev to sit and then update some on the sit host.

Promotable Event Type

There are two type of events: configurable event vs transactional event. We should only promote the configurable events from dev to sit. Not the deployment logs from dev to sit. We need a table to define the promotable event types.

Reference Table

When building a web application, there would be a lot of dropdown selects in forms. The form itself only cares about the id and label list to render the form and only the id will be submitted to the backend API for single select and several ids for multiple select.

To save the effort to create many similar tables, we can craete a set of tables for all dropdowns. For some of the reference tables, dropdown should be the same across all hosts and we can set common flag to ‘Y’ so that they are shared by all hosts. If the dropdown values might be different between hosts, we can create a reference table per host and link the reference table with host in a separate table that support sharding.

Reference Schema

CREATE TABLE ref_host_t (
  table_id             VARCHAR(22) NOT NULL,
  host_id              VARCHAR(22) NOT NULL,
  PRIMARY KEY (table_id, host_id),
  FOREIGN KEY (table_id) REFERENCES ref_table_t (table_id) ON DELETE CASCADE,
  FOREIGN KEY (host_id) REFERENCES host (host_id) ON DELETE CASCADE
);

CREATE TABLE ref_table_t (
  table_id             VARCHAR(22) NOT NULL, -- UUID genereated by Util
  table_name           VARCHAR(80) NOT NULL, -- Name of the ref table for lookup.
  table_desc           VARCHAR(1024) NULL,
  active               CHAR(1) NOT NULL DEFAULT 'Y', -- Only active table returns values
  editable             CHAR(1) NOT NULL DEFAULT 'Y', -- Table value and locale can be updated via ref admin
  common               CHAR(1) NOT NULL DEFAULT 'Y', -- The drop down shared across hosts
  PRIMARY KEY(table_id)
);


CREATE TABLE ref_value_t (
  value_id              VARCHAR(22) NOT NULL,
  table_id              VARCHAR(22) NOT NULL,
  value_code            VARCHAR(80) NOT NULL, -- The dropdown value
  start_time            TIMESTAMP NULL,       
  end_time              TIMESTAMP NULL,
  display_order         INT,                  -- for editor and dropdown list.
  active                VARCHAR(1) NOT NULL DEFAULT 'Y',
  PRIMARY KEY(value_id),
  FOREIGN KEY table_id REFERENCES ref_table_t (table_id) ON DELETE CASCADE
);


CREATE TABLE value_locale_t (
  value_id              VARCHAR(22) NOT NULL,
  language              VARCHAR(2) NOT NULL,
  value_desc            VARCHAR(256) NULL, -- The drop label in language.
  PRIMARY KEY(value_id,language),
  FOREIGN KEY value_id REFERENCES ref_value_t (value_id) ON DELETE CASCADE
);



CREATE TABLE relation_type_t (
  relation_id           VARCHAR(22) NOT NULL,
  relation_name         VARCHAR(32) NOT NULL, -- The lookup keyword for the relation.
  relation_desc         VARCHAR(1024) NOT NULL,
  PRIMARY KEY(relation_id)
);



CREATE TABLE relation_t (
  relation_id           VARCHAR(22) NOT NULL,
  value_id_from         VARCHAR(22) NOT NULL,
  value_id_to           VARCHAR(22) NOT NULL,
  active                VARCHAR(1) NOT NULL DEFAULT 'Y',
  PRIMARY KEY(relation_id, value_id_from, value_id_to)
  FOREIGN KEY relation_id REFERENCES relation_type_t (relation_id) ON DELETE CASCADE,
  FOREIGN KEY value_id_from REFERENCES ref_value_t (value_id) ON DELETE CASCADE,
  FOREIGN KEY value_id_to REFERENCES ref_table_t (value_id) ON DELETE CASCADE
);

Authentication & Authorization

Light-Portal is a single-page application (SPA) that utilizes both the OAuth 2.0 Authorization Code and Client Credentials flows.

The following pattern illustrates the end-to-end process recommended by the Light Platform for an SPA interacting with downstream APIs.

Sequence Diagram

sequenceDiagram
    participant PortalView as Portal View
    participant LoginView as Login View
    participant Gateway as Light Gateway
    participant OAuthKafka as OAuth-Kafka
    participant AuthService as Auth Service
    participant ProxySidecar as Proxy Sidecar
    participant BackendAPI as Backend API

    PortalView ->> LoginView: 1. Signin redirect
    LoginView ->> OAuthKafka: 2. Authenticate user
    OAuthKafka ->> AuthService: 3. Authenticate User<br/>(Active Directory<br/>for Employees)<br/>(CIF System<br/>for Customers)
    AuthService ->> OAuthKafka: 4. Authenticated
    OAuthKafka ->> OAuthKafka: 5. Generate auth code
    OAuthKafka ->> PortalView: 6. Redirect with code
    PortalView ->> Gateway: 7. Authorization URL<br/>with code param
    Gateway ->> OAuthKafka: 8. Create JWT access<br/>token with code
    OAuthKafka ->> OAuthKafka: 9. Generate JWT<br/>access token<br/>with user claims
    OAuthKafka ->> Gateway: 10. Token returns<br/>to Gateway
    Gateway ->> PortalView: 11. Token returns<br/>to Portal View<br/>in Secure Cookie
    PortalView ->> Gateway: 12. Call Backend API
    Gateway ->> Gateway: 13. Verify the token
    Gateway ->> OAuthKafka: 14. Create Client<br/>Credentials token
    OAuthKafka ->> OAuthKafka: 15. Generate Token<br/>with Scopes
    OAuthKafka ->> Gateway: 16. Return the<br/>scope token
    Gateway ->> Gateway: 17. Add scope<br/>token to<br/>X-Scope-Token<br/>Header
    Gateway ->> ProxySidecar: 18. Invoke API
    ProxySidecar ->> ProxySidecar: 19. Verify<br/>Authorization<br/>token
    ProxySidecar ->> ProxySidecar: 20. Verify<br/>X-Scope-Token
    ProxySidecar ->> ProxySidecar: 21. Fine-Grained<br/>Authorization
    ProxySidecar ->> BackendAPI: 22. Invoke<br/>business API
    BackendAPI ->> ProxySidecar: 23. Business API<br/>response
    ProxySidecar ->> ProxySidecar: 24. Fine-Grained<br/>response filter
    ProxySidecar ->> Gateway: 25. Return response
    Gateway ->> PortalView: 26. Return response

  1. When a user visits the website to access the single-page application (SPA), the Light Gateway serves the SPA to the user’s browser. Each single page application will have a dedicated Light Gateway instance acts as a BFF. By default, the user is not logged in and can only access limited site features. To unlock additional features, the user can click the User button in the header and select the Sign In menu. This action redirects the browser from the Portal View to the Login View, both served by the same Light Gateway instance.

  2. On the Login View page, the user can either input a username and password or choose Google/Facebook for authentication. When the login form is submitted, the request is sent to the Light Gateway with the user’s credentials. The Gateway forwards this request to the OAuth Kafka service.

  3. OAuth Kafka supports multiple authenticator implementations to verify user credentials. Examples include authenticating via the Light Portal user database, Active Directory for employees, or CIF service for customers.

  4. Once authentication is successfully completed, the OAuth Kafka responds with the authentication result.

  5. Upon successful authentication, OAuth Kafka generates an authorization code (a UUID associated with the user’s profile).

  6. OAuth Kafka redirects the authorization code back to the browser at the Portal View via the Gateway.

  7. Since the Portal View SPA lacks a dedicated redirect route for the authorization code, the browser sends the code as a query parameter in a request to the Gateway.

  8. The StatelessAuthHandler in the Gateway processes this request, initiating a token request to OAuth Kafka to obtain a JWT access token.

  9. OAuth Kafka generates an access token containing user claims in its custom JWT claims. The authorization code is then invalidated, as it is single-use.

  10. The access token is returned to the Gateway.

  11. The StatelessAuthHandler in the Gateway stores the access token in a secure cookie and sends it back to the Portal View.

  12. When the Portal View SPA makes requests to backend APIs, it includes the secure cookie in the API request sent to the Gateway.

  13. The StatelessAuthHandler in the Gateway validates the token in the secure cookie and places it in the Authorization header of the outgoing request.

  14. If the token is successfully validated, the TokenHandler in the Gateway makes a request to OAuth Kafka for a client credentials token, using the path prefix of the API endpoint.

  15. OAuth Kafka generates a client credentials token with the appropriate scope for accessing the downstream service.

  16. The client credentials token is returned to the Gateway.

  17. The TokenHandler in the Gateway inserts this token into the X-Scope-Token header of the original request.

  18. The Gateway routes the original request, now containing both tokens, to the downstream proxy sidecarof the backend API.

  19. The proxy sidecar validates the Authorization token, verifying its signature, expiration, and other attributes.

  20. The proxy sidecar also validates the X-Scope-Token, ensuring its signature, expiration, and scope are correct.

  21. Once both tokens are successfully validated, the proxy sidecar enforces fine-grained authorization rules based on the user’s custom security profile contained in the Authorization token.

  22. If the fine-grained authorization checks are passed, the proxy sidecar forwards the request to the backend API.

  23. The backend API processes the request and sends the full response back to the proxy sidecar.

  24. The proxy sidecar applies fine-grained filters to the response, reducing the number of rows and/or columns based on the user’s security profile or other policies.

  25. The proxy sidecar returns the filtered response to the Gateway.

  26. The Gateway forwards the response to the Portal View, allowing the SPA to render the page.

Fine-Grained Authorization

What is Fine-Grained Authorization?

Fine-grained authorization (FGA) refers to a detailed and precise control mechanism that governs access to resources based on specific attributes, roles, or rules. It’s also known as fine-grained access control (FGAC). Unlike coarse-grained authorization, which applies broader access policies (e.g., “Admins can access everything”), fine-grained authorization allows for more specific policies (e.g., “Admins can access user data only if they belong to the same department and the access request is during business hours”).

Key Features

  • Granular Control: Policies are defined at a detailed level, considering attributes like user role, resource type, action, time, location, etc.
  • Context-Aware: Takes into account dynamic conditions such as the time of request, user’s location, or other contextual factors.
  • Flexible Policies: Allows the creation of complex, conditional rules tailored to the organization’s needs.

Why Do We Need Fine-Grained Authorization?

1. Enhanced Security

By limiting access based on detailed criteria, fine-grained authorization minimizes the risk of unauthorized access or data breaches.

2. Regulatory Compliance

It helps organizations comply with legal and industry-specific regulations (e.g., GDPR, HIPAA) by ensuring sensitive data is only accessible under strict conditions.

3. Minimized Attack Surface

By restricting access to only the required resources and operations, fine-grained authorization reduces the potential impact of insider threats or compromised accounts.

4. Improved User Experience

Enables personalized access based on roles and permissions, ensuring users see only what they need, which reduces confusion and improves productivity.

5. Auditing and Accountability

Detailed access logs and policy enforcement make it easier to track and audit who accessed what, when, and why, fostering better accountability.

Examples of Use Cases

  • Healthcare: A doctor can only view records of patients they are treating.
  • Government: A government employee can access to data and documents based on security clearance levels and job roles.
  • Finance: A teller can only access transactions related to their assigned branch.
  • Enterprise Software: Employees can edit documents only if they own them or have been granted editing permissions.

Fine-Grained Authorization in API Access Control

In API access control, fine-grained authorization governs how users or systems interact with specific API endpoints, actions, and data. This approach ensures that access permissions are precisely tailored to attributes, roles, and contextual factors, enabling a secure and customized API experience. As the Light Portal is a platform centered on APIs, the remainder of the design will focus on the API access control context.

Early Approaches to Fine Grained Authorization

Early approaches to fine grained authorization primarily involved Access Control Lists (ACLs) and Role-Based Access Control (RBAC). These methods laid the foundation for more sophisticated access control mechanisms that followed. Here’s an overview of these primary approaches:

Access Control Lists (ACLs):

  • ACLs were one of the earliest forms of fine grained authorization, allowing administrators to specify access permissions on individual resources for each user or group of users.

  • In ACLs, permissions are directly assigned to users or groups, granting or denying access to specific resources based on their identities.

  • While effective for small-scale environments with limited resources and users, ACLs became cumbersome as organizations grew. Maintenance issues arose, such as the time required to manage access to an increasing number of resources for numerous users.

Role-Based Access Control (RBAC):

  • RBAC emerged as a solution to the scalability and maintenance challenges posed by ACLs. It introduced the concept of roles, which represent sets of permissions associated with particular job functions or responsibilities.

  • Users are assigned one or more roles, and their access permissions are determined by the roles they possess rather than their individual identities.

  • RBAC can be implemented with varying degrees of granularity. Roles can be coarse-grained, providing broad access privileges, or fine-grained, offering more specific and nuanced permissions based on organizational needs.

  • Initially, RBAC appeared to address the limitations of ACLs by providing a more scalable and manageable approach to access control.

Both ACLs and RBAC have their shortcomings:

  • Maintenance Challenges: While RBAC offered improved scalability compared to ACLs, it still faced challenges with role management as organizations expanded. The proliferation of roles, especially fine grained ones, led to a phenomenon known as role explosion where the number of roles grew rapidly, making them difficult to manage effectively.

  • Security Risks: RBAC’s flexibility also posed security risks. Over time, users might accumulate permissions beyond what they need for their current roles, leading to a phenomenon known as permission creep. This weakened overall security controls and increased the risk of unauthorized access or privilege misuse.

Following the discussion of early approaches to fine grained authorization, it’s crucial to acknowledge that different applications have varying needs for authorization.

Whether to use fine grained or coarse-grained controls depends on the specific project. Controlling access becomes trickier due to the spread-out nature of resources and differing levels of detail needed across components. Let’s delve into the differentiating factors:

Standard Models for Implementing FGA

There are several standard models for implementing FGA:

  • Attribute-Based Access Control (ABAC): In ABAC, access control decisions are made by evaluating attributes such as user roles, resource attributes (e.g., type, size, status), requested action, current date and time, and any other relevant contextual information. ABAC allows for very granular control over access based on a wide range of attributes.

  • Policy-Based Access Control (PBAC): PBAC is similar to ABAC but focuses more on defining policies than directly evaluating attributes. Policies in PBAC typically consist of rules or logic that dictate access control decisions based on various contextual factors. While ABAC relies heavily on data (attributes), PBAC emphasizes using logic to determine access.

  • Relationship-Based Access Control (ReBAC): ReBAC emphasizes the relationships between users and resources, as well as relationships between different resources. By considering these relationships, ReBAC provides a powerful and expressive model for describing complex authorization contexts. This can involve the attributes of users and resources and their interactions and dependencies.

Each of these models offers different strengths and may be more suitable for different scenarios. FGA allows for fine grained control over access, enabling organizations to enforce highly specific access policies tailored to their requirements.

Streamlining FGA by Implementing Rule-Based Access Control:

ABAC (Attribute-Based Access Control) focuses on data attributes, PBAC (Policy-Based Access Control) centers on logic, and ReBAC (Relationship-Based Access Control) emphasizes relationships between users and resources. But what if we combined all three to leverage the strengths of each? This is the idea behind Rule-Based Access Control (RuBAC).

By embedding a lightweight rule engine, we can integrate multiple rules and actions to achieve the following:

  • Optimize ABAC: Reduce the number of required attributes since not all rules depend on them. For example, a standard rule like “Customer data can only be accessed during working hours” can be shared across policies.

  • Flexible Policy Enforcement: Using a rule engine makes access policies more dynamic and simpler to manage.

  • Infer Relationships: Automatically deduce relationships between entities. For instance, the rule engine could grant a user access to a file if they already have permission for the containing folder.

Principle of Least Privilege

The principle of least privilege access control widely referred to as least privilege, and PoLP is the security concept in which user(s) (employee(s)) are granted the minimum level of access/permissions to the app, data, or system that is required to perform his/her job functions.

To ensure PoLP is effectively enforced, we’ve compiled a list of best practices:

  • Conduct a thorough privilege audit: As we know, visibility is critical in an access environment, so conducting regular or periodic access audits of all privileged accounts can help your team gain complete visibility. This audit includes reviewing privileged accounts and credentials held by employees, contractors, and third-party vendors, whether on-premises, accessible remotely, or in the cloud. However, your team must also focus on default and hard-coded credentials, which IT teams often overlook.

  • Establish the least privilege as the default: Start by granting new accounts the minimum privileges required for their tasks and eliminate or reconfigure default permissions on new systems or applications. Further, use role-based access control to help your team determine the necessary privileges for a new account by providing general guidelines based on roles and responsibilities. Also, your team needs to update and adjust access level permissions when the user’s role changes; this will help prevent privilege creep.

  • Enforce separation of privileges: Your team can prevent over-provisioning by limiting administrator privileges. Firstly, segregate administrative accounts from standard accounts, even if they belong to the same user, and isolate privileged user sessions. Then, grant administrative privileges (such as read, write, and execute permissions) only to the extent necessary for the user to perform their specific administrative tasks. This will help your team prevent granting users unnecessary or excessive control over critical systems, which could lead to security vulnerabilities or misconfigurations.

  • Provide just-in-time, limited access: To maintain least-privilege access without hindering employee workflows, combine role-based access control with time-limited privileges. Further, replace hard-coded credentials with dynamic secrets or use one-time-use/temporary credentials. This will help your team grant temporary elevated access permissions when users need it, for instance, to complete specific tasks or short-term projects.

  • Keep track and evaluate privileged access: Continuously monitor authentications and authorizations across your API platform and ensure all the individual actions are traceable. Additionally, record all authentication and authorizaiton sessions comprehensively, and use automated tools to swiftly identify any unusual activity or potential issues. These best practices are designed to enhance the security of your privileged accounts, data, and assets while ensuring compliance adherence and improving operational security without disrupting user workflows.

OpenAPI Specification Extensions

OpenAPI uses the term security scheme for authentication and authorization schemes. OpenAPI 3.0 lets you describe APIs protected using the following security schemes. The fine-grained authorization is just another layer of security and it is natural to define the fine-grained authorization in the same specification. It can be done with OpenAPI specification extensions.

Extensions (also referred to as specification extensions or vendor extensions) are custom properties that start with x-, such as x-logo. They can be used to describe extra functionality that is not covered by the standard OpenAPI Specification. Many API-related products that support OpenAPI make use of extensions to document their own attributes, such as Amazon API Gateway, ReDoc, APIMatic and others.

As OpenAPI specification openapi.yaml is loaded during the light-4j startup, the extensions will be available at runtime in cache for each endpoint just like the scopes definition. The API owner can define the following two extensions for each endpoint:

  • x-request-access: This section allows designer to specify one or more rules as well as one or more security attributes for the input of the rules. For example, roles, location etc. The rule result will decide if the user has access to the endpoint based on the security attributes from the JWT token in the request chain.

  • x-response-filter: This section is similar to the above; however, it works on the response chain. The rule result will decide which row or column of the response JSON will return to the user based on the security profile from the JWT token.

Example of OpenAPI specification with fine-grained authorization.

paths:
  /accounts:
    get:
      summary: "List all accounts"
      operationId: "listAccounts"
      x-request-access:
        rule: "account-cc-group-role-auth"
        roles: "manager teller customer"
      x-response-filter:
        rule: "account-row-filter"
        teller:
          status: open
        customer:
          status: open
          owner: @user_id
        rule: "account-col-filter"
          teller: ["num","owner","type","firstName","lastName","status"]
          customer: ["num","owner","type","firstName","lastName"]
      security:
      - account_auth:
        - "account.r"

FGA Rules for AccessControlHandler

With the above specification loaded during the runtime, the rules will be loaded during the server startup for the service as well. In the Rule Registry on the light-portal, we have a set of built-in rules that can be picked as fine-grained policies for each API. Here is an example of rule for the above specification in the x-request-access.

account-cc-group-role-auth:
  ruleId: account-cc-group-role-auth
  host: lightapi.net
  description: Role-based authorization rule for account service and allow cc token and transform group to role.
  conditions:
    - conditionId: allow-cc
      variableName: auditInfo
      propertyPath: subject_claims.ClaimsMap.user_id
      operatorCode: NIL
      joinCode: OR
      index: 1
    - conditionId: manager
      variableName: auditInfo
      propertyPath: subject_claims.ClaimsMap.groups
      operatorCode: CS
      joinCode: OR
      index: 2
      conditionValues:
        - conditionValueId: manager
          conditionValue: admin
    - conditionId: teller
      variableName: auditInfo
      propertyPath: subject_claims.ClaimsMap.groups
      operatorCode: CS
      joinCode: OR
      index: 3
      conditionValues:
        - conditionValueId: teller
          conditionValue: frontOffice
    - conditionId: allow-role-jwt
      variableName: auditInfo
      propertyPath: subject_claims.ClaimsMap.roles
      operatorCode: NNIL
      joinCode: OR
      index: 4
  actions:
    - actionId: match-role
      actionClassName: com.networknt.rule.FineGrainedAuthAction
      actionValues:
        - actionValueId: roles
          value: $roles

All rules are managed by the light-portal and shared by all the services. In addition, developers can create their customized rules for their own services.

Response Filter

There are two type of filters. Row and Column.

Row

For row filter, we need to check the condition defined for some of the properties in order to make the filter decision. In database, for each endpoint, we have colName, operator and colValue defined for the condition.

The operator supports the following enum: [“=”,“!=”,“<”,“>”,“<=”,“>=”,“in”,“not in”, “range”]

For the colValue, we do support variables from the jwt token with @. For example, @eid will be replaced with the eid claim from the jwt token.

Col

For column filter, we need to include a list of columns or exclude a list of columns in json format.

[“accountNo”,“firstName”,“lastName”]

or

![“status”]

Light Portal Fine-Grained Authorization

Overview

The existing fine-grained authorization model describes how Light Portal manages access control for APIs and MCP tools owned by customers. This document applies the same ideas to Light Portal itself.

Light Portal has two different authorization surfaces:

  • the browser application, where menus, routes, tasks, and action buttons decide what the user can discover and click
  • the backend portal handlers, where query and command services read or mutate tenant data

The browser must improve usability by hiding irrelevant admin menus, but it must not be the security boundary. The security boundary must be enforced by the gateway and by the portal query and command handlers.

Goals

  • Limit admin menus based on the user’s roles, positions, groups, and attributes.
  • Let admin access all eligible admin pages for data within all hosts.
  • Let a host-admin access all eligible admin pages for data within the current tenant host only.
  • Keep global platform administration separate from tenant administration.
  • Enable request access control (req-acc) and response filtering (res-fil) for Light Portal hybrid handlers.
  • Use owner_user_id and owner_position_id as the primary row ownership model for self-service admin pages.
  • Keep authorization rules declarative enough that they can be managed from the existing rule and access-control pages.

Non-Goals

  • Do not rely on menu hiding as authorization.
  • Do not make host-admin a global portal super admin.
  • Do not replace existing host scoping with ownership scoping. Host scoping remains mandatory.
  • Do not require every portal table to be migrated before the model can be rolled out.
  • Do not duplicate every rule in React. React should consume an effective menu and capability model from the backend over time.

Use three layers.

LayerPurposeEnforcement
Menu and route visibilityUsability and discoverabilityportal-view hides menus and blocks client routes
Handler request accessDecide whether a user may call a query or command service/actionlight-gateway req-acc for /portal/query and /portal/command
Data scope and response filteringDecide which tenant rows and fields the user may see or mutateservice-side owner predicates and gateway/service res-fil

This keeps the user experience responsive without trusting the browser.

Roles And Scopes

Separate page access from row scope.

Role or claimMeaningPage accessData scope
adminglobal portal administratorall portal admin pagesall hosts, only for global administration
host-admintenant administratortenant-safe admin pagescurrent hostId only
access-admintenant access-control administratoraccess-control administration pagescurrent hostId only
<entity>-adminentity-specific administrator, such as api-admin or instance-adminpages for that entitycurrent hostId, all rows for that entity
userself-service userapproved self-service pagesowned rows only
positions claimteam or org-unit membershipdoes not grant pages by itself unless mapped by rulerows owned by matching effective positions
groups and attributesadditional authorization dimensionsrule-dependentrule-dependent

The important distinction is that host-admin is powerful inside one tenant but must not bypass host ownership. If the current session host is 01964b05-..., every query and command still needs that hostId enforced.

Host Admin

host-admin should be the standard tenant administrator role.

A host-admin can:

  • see tenant administration menus that are safe within the current host
  • query all records whose host_id is the current session host
  • create and update tenant-scoped records for the current host
  • assign ownership inside the current host when the command supports it

A host-admin cannot:

  • access another hostId by changing a request payload
  • manage global reference data unless explicitly granted a global role
  • manage platform deployment records that are not tenant scoped
  • manage access-control policy unless explicitly granted access-admin inside the current host
  • bypass command-specific invariants, such as optimistic concurrency checks

Backend handlers must treat hostId from the request as untrusted. The trusted tenant comes from the authenticated audit context or from a verified user-host membership lookup.

Access Administration

Access-control administration is separate from general tenant administration. Changing role, group, position, attribute, row-filter, or column-filter policy can change who may read or mutate tenant data, so it should require access-admin within the current host instead of being implied by host-admin.

An access-admin can manage policy for tenant-owned APIs, apps, clients, instances, workflows, schemas, schedules, and other tenant-scoped assets in the current host. An access-admin cannot manage global platform policy unless the user also has the global admin role.

This keeps host-admin useful for normal tenant operations while preserving separation of duties for security policy changes.

Platform And Tenant Deployment Pages

Deployment administration should be split into tenant deployment pages and global platform pages.

Tenant deployment pages can be visible to host-admin when every operation is scoped to the current hostId, such as deploying tenant APIs, checking route health, or managing tenant client registrations.

Global platform pages must require admin. These pages manage shared infrastructure, gateway clusters, physical deployment targets, shared database configuration, or cross-host platform state. They must not be exposed through a tenant-scoped host-admin rule.

The current sidebar already supports role-based visibility with exact role tokens and treats admin and host-admin as broad admin roles. The design should evolve this into a backend-driven capability model.

Phase 1: Local Menu Policy

Keep a local page registry in portal-view, but normalize it around page capabilities.

{
  id: "api-admin",
  route: "/app/service/admin",
  requiredAny: ["admin", "host-admin", "api-admin", "user"],
  scope: "owner-or-host",
  entity: "api"
}

The UI can show:

  • all admin menus for admin
  • tenant-safe admin menus for host-admin
  • entity menus for <entity>-admin
  • approved self-service menus for user

Menus with no explicit rule inside the Administration group should not be shown to normal users.

Phase 2: Backend Menu Policy

Add a backend query such as:

lightapi.net/portal/getEffectiveMenu/0.1.0

or:

lightapi.net/portal/getEffectiveCapabilities/0.1.0

The response should contain route-level capabilities, not raw policy internals.

{
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "pages": [
    {
      "pageId": "api-admin",
      "route": "/app/service/admin",
      "visible": true,
      "readScope": "owned",
      "writeScope": "owned"
    },
    {
      "pageId": "instance-admin",
      "route": "/app/instance/InstanceAdmin",
      "visible": true,
      "readScope": "host",
      "writeScope": "host"
    }
  ]
}

The sidebar, task launcher, command palette, and route guards should consume the same capability response.

Request Access For Portal Handlers

Light Portal uses hybrid RPC-style endpoints:

POST /portal/query
POST /portal/command

The request body identifies the logical handler:

{
  "host": "lightapi.net",
  "service": "service",
  "action": "getApi",
  "version": "0.1.0",
  "data": {
    "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f"
  }
}

For req-acc, the gateway must authorize the logical service id, not only the HTTP path. The effective route key should be derived as:

lightapi.net/{service}/{action}/{version}

Example:

lightapi.net/service/getApi/0.1.0
lightapi.net/service/createApi/0.1.0
lightapi.net/role/createRolePermission/0.1.0

This lets the access-control registry treat portal handlers exactly like API operations.

Request Context

The req-acc rule context should include:

{
  "serviceId": "lightapi.net/service/createApi/0.1.0",
  "transport": "hybrid",
  "portal": true,
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "entity": "api",
  "action": "create",
  "jwt": {
    "userId": "01964b05-5532-7c79-8cde-191dcbd421b8",
    "roles": ["user", "api-admin"],
    "positions": ["team-api"],
    "groups": ["engineering"],
    "attributes": {
      "department": "platform"
    }
  },
  "requestData": {
    "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f"
  }
}

Recommended built-in request rules:

RulePurpose
portal-admin-globalallow admin for global admin handlers
portal-host-adminallow host-admin only when requestData.hostId matches the session host
portal-access-adminallow access-admin for tenant access-control handlers in the current host
portal-entity-adminallow <entity>-admin for entity handlers in the current host
portal-owner-readallow user to call approved read handlers; row scope is applied later
portal-owner-writeallow user to call approved write handlers only when ownership can be verified

Response Filtering For Portal Handlers

Response filtering has two jobs:

  • remove rows that are outside the caller’s authorized scope
  • optionally remove columns the caller should not see

For list queries, service-side SQL filtering is preferred over gateway-only filtering because it protects pagination, counts, and performance. The gateway or common service layer can still apply res-fil as a defense-in-depth step.

Recommended order:

  1. req-acc decides whether the user may call the logical handler.
  2. Query handler injects host and owner predicates into SQL.
  3. Query handler returns only authorized rows and an authorized total.
  4. Shared query serialization or service-side res-fil removes rows only when it can also preserve authorized pagination totals.
  5. Gateway res-fil removes sensitive fields and can perform defense-in-depth row removal for non-paginated responses.
  6. Portal-view renders the already-authorized result.

Gateway-only row filtering must not be the primary implementation for paginated lists. If rows are removed after the backend has already computed total, offset, or limit, the grid metadata becomes inaccurate. Row predicates belong in SQL or in shared query serialization that controls both the returned rows and the total count. Column filtering can run in the gateway because it does not change pagination.

For command handlers, response filtering is less important than request authorization and command-side ownership checks. A command must verify that the target aggregate belongs to the current host and that the caller can mutate it.

Owner Position

The owner model should prefer explicit owner fields:

owner_user_id
owner_position_id

owner_user_id is assigned from the authenticated user on create. Normal forms should not submit it.

owner_position_id lets a team or org unit own a record. Users with an effective matching position can see or manage the record when the page rule allows owner-scoped access.

Owner assignments must always remain inside the current host. When a command sets or transfers owner_user_id or owner_position_id, the command handler must verify that the target user or position belongs to the trusted session hostId. The browser-supplied owner value is not enough. Cross-host owner assignment must be rejected even when the caller has host-admin for the current host.

Owner changes are security-sensitive events. Create, transfer, and clear operations for owner_user_id or owner_position_id must be written to the audit log with the old owner, new owner, entity id, trusted host, acting user, and logical portal service id.

For owner-scoped reads, the service predicate should be:

AND (
  owner_user_id = :currentUserId
  OR owner_position_id = ANY(:effectivePositions)
)

If the database dialect does not support array binding, use an IN list with validated position ids. Owner-scoped tables should index host and owner columns together, such as (host_id, owner_user_id) and (host_id, owner_position_id), so owner predicates remain efficient.

Rows with both owner fields null are unassigned legacy rows. They should be visible only to all-scope roles such as admin, host-admin, or an applicable <entity>-admin until ownership is assigned.

Effective Positions

The JWT may contain direct positions, but direct positions are not always enough. The service should resolve effective positions from:

  • direct position claims in the token
  • user_position_t
  • position inheritance rules when enabled

The effective set should be computed in one shared utility and reused by query and command handlers. Existing OwnerScopeUtil is the right direction for query handlers; it should become the standard path rather than a page-specific helper.

Position inheritance should not be recursively expanded inside every portal query. Materialize the transitive closure in a table such as position_closure_t and refresh it when position relationships change, or cache the user’s flat effective-position set in session state and invalidate it when membership changes. The query layer should receive a bounded, validated list of effective positions.

Command Authorization

Commands need stronger checks than queries because they mutate state.

Every tenant-scoped command should verify:

  • the requested hostId is the authenticated session host, unless the caller is a global admin
  • the target aggregate exists in that host for update/delete commands
  • owner-scoped users own the target through owner_user_id or owner_position_id
  • entity admins and host admins are still limited by host
  • owner transfer is explicit and restricted
  • target owners for owner_user_id and owner_position_id belong to the trusted session host
  • owner transfer is audit logged with old and new owner values

Recommended command scopes:

ScopeMeaning
own:createuser can create records owned by self and optional owner position
own:updateuser can update records they own
own:deleteuser can delete records they own if the entity allows it
host:readuser can read all rows in the current host
host:writeuser can mutate all rows in the current host
global:adminuser can operate across hosts for platform administration

Portal Access-Control Registry

The access-control registry should support portal handlers as first-class endpoints.

Proposed endpoint identity:

FieldValue
apiIdPORTAL or light-portal
apiVersionportal release version or 1.0.0 for the logical control plane
endpointlightapi.net/{service}/{action}/{version}
httpMethodPOST
endpointPath/portal/query or /portal/command
sourceProtocolhybrid

This allows the existing Role Permission, Group Permission, Position Permission, Attribute Permission, Row Filter, and Column Filter pages to manage portal handler access without a separate policy store.

The portal-handler catalog should be generated from service annotations and spec.yaml metadata during build or deployment. Manual registration may be used only as an override for descriptions, classifications, or temporary exclusions. Generation prevents drift when handlers are added, renamed, or removed.

Example Policies

Host Admin Can Manage Tenant APIs

Request rule:

ruleId: portal-host-admin-current-host
ruleType: req-acc
description: Allow host-admin to call tenant handlers for the current host.
conditions:
  - conditionId: role-host-admin
    variableName: jwt
    propertyPath: roles
    operatorCode: CS
    conditionValues:
      - conditionValue: host-admin
  - conditionId: same-host
    variableName: requestData
    propertyPath: hostId
    operatorCode: EQ
    conditionValues:
      - conditionValue: "@host_id"
actions:
  - actionClassName: com.networknt.rule.FineGrainedAuthAction

The @host_id placeholder means the trusted host from the authenticated context, not a host id supplied by the browser.

User Can See Owned APIs

Request rule allows the list handler:

endpoint: lightapi.net/service/getApi/0.1.0
ruleType: req-acc
roles:
  - user
  - api-admin
  - host-admin
  - admin

The data rule is applied in SQL:

WHERE host_id = :hostId
AND (
  :allScope = TRUE
  OR owner_user_id = :currentUserId
  OR owner_position_id IN (:effectivePositions)
)

Owner Position Can Manage Team Client Apps

If a client app has:

owner_position_id = api-platform-team

and the user has effective position:

api-platform-team

then the user can see and update the app when the page grants owner-scoped access. The user does not need a broad app-admin role.

Handler Enablement Plan

Phase 1: Inventory

  • Register every portal query and command handler as a logical access-control endpoint from service annotations and spec.yaml.
  • Classify each handler by entity, operation, and scope:
    • global admin
    • host admin
    • entity admin
    • owner scoped
    • public authenticated
  • Identify handlers that cannot yet be owner scoped because the table lacks owner fields.

Implementation path:

  • service-command parses apiType: hybrid spec.yaml files in SpecUtil.parseSpec.
  • Hybrid handlers are stored as logical endpoints such as lightapi.net/service/getApi/0.1.0, with httpMethod: post and endpointPath set to /portal/query or /portal/command.
  • Handler name, request schema, transport path, action, version, scope, operation classification, and skipAuth are captured in endpoint metadata.
  • Existing legacy hybrid endpoint ids keyed by logicalEndpoint@post are reused during migration so policy assignments can keep the same endpointId.

Phase 2: Menu And Capability Cleanup

  • Normalize sidebar and task page registry roles around exact tokens.
  • Treat host-admin as tenant admin, not global admin.
  • Add route guards that use the same page capability model as the menu.
  • Keep React-side hiding as usability only.

Phase 3: Query Enforcement

  • Standardize OwnerScopeUtil for all owner-aware query handlers.
  • Pass ownerUserId, ownerPositions, and ownerScoped into db-provider query methods.
  • Ensure counts and pagination are computed after host and owner predicates.
  • Return owner fields only when the caller has a reason to see them.

Phase 4: Command Enforcement

  • Add common command guard helpers:
    • resolve trusted host
    • verify target aggregate host ownership
    • verify owner or all-scope access
    • enforce owner-transfer rules
    • verify transferred owner user or position belongs to the trusted host
    • audit owner changes
  • Add explicit owner-transfer commands for records that need ownership changes.
  • Reject requests where browser-supplied hostId conflicts with the trusted session host.

Phase 5: Gateway req-acc And res-fil

  • Update light-gateway access-control extraction for hybrid portal requests.
  • Derive logical service id from host, service, action, and version.
  • Build the CEL/rule context with JWT claims, trusted host, request data, and handler metadata.
  • Run req-acc before forwarding to the portal handler.
  • Run gateway res-fil for column filtering and defense-in-depth response filtering where endpoint filters are configured.

Phase 6: Policy Management UI

  • Reuse existing access-control pages to assign portal handler permissions.
  • Add a portal-handler catalog view that lists logical handlers and their current permission configuration.
  • Add an overview page for effective menu and data access per role or user.
  • Make access-control pages require access-admin for tenant policy changes and admin for global policy changes.

Recommendations

  1. Use host-admin as the tenant administrator role and keep admin as global super admin.
  2. Make every backend handler validate host scope, even when the UI already selected the host.
  3. Prefer service-side row filtering over response-only filtering for list queries.
  4. Use owner_position_id for team ownership instead of adding group ownership to every table.
  5. Keep owner_user_id server-assigned and make ownership transfer explicit.
  6. Validate transferred owners against the trusted host and audit all ownership changes.
  7. Materialize or cache effective positions before query execution instead of recursively resolving position inheritance on every request.
  8. Register portal handlers in the same access-control registry used for customer APIs so req-acc and res-fil are managed consistently.
  9. Generate the portal-handler catalog from service annotations and spec.yaml, with manual metadata overrides only where needed.
  10. Split tenant deployment pages from global platform pages.
  11. Require access-admin for tenant access-control administration instead of granting it implicitly to host-admin.
  12. Roll out one entity family at a time, starting with API, client app, instance, workflow, schema, and schedule pages because they already have the clearest ownership model.

Design Decisions

QuestionDecision
Access-control administrationRequire access-admin inside the host; do not grant it implicitly to host-admin.
Deployment pagesSplit tenant deployment pages from global platform pages. Tenant pages can use host-admin; global platform pages require admin.
Position inheritanceMaterialize position_closure_t or cache the effective-position set; do not recursively compute inheritance in every query.
Portal handler registrationGenerate the catalog from service annotations and spec.yaml, with manual metadata overrides only.
Portal response filteringApply row filtering in SQL or shared query serialization so pagination totals remain exact. Use gateway res-fil mainly for column filtering and defense-in-depth checks.

Light Portal

LLM Gateway Configuration Topology

Decision

Light Portal is the authoring control plane for LLM providers, deployments, aliases, pricing, and policy. The standard Portal configuration system is the only delivery channel to light-gateway.

For each selected gateway instance, Portal compiles the active GenAI records into typed llm-router.* instance properties. The user creates and promotes a normal immutable config snapshot. Config server exposes that snapshot as values.yml, and the gateway consumes it only during startup or an explicit module reload.

The former LLM-specific filesystem projection, continuous polling worker, sequence/checkpoint protocol, and per-replica publication acknowledgement are not part of this architecture.

Source of Truth

GenAI control-plane records
          |
          | deterministic compilation and instance publication
          v
Typed llm-router.* instance properties
          |
          | normal snapshot creation and promotion
          v
Immutable config snapshot / values.yml
          |
          +---- startup -------------------------+
          |                                      |
          +---- selected llm-router reload ------+
                                                 v
                                      compiled immutable LLM runtime

The config snapshot is the runtime authority. Control-plane tables remain the authoring authority, but changing them alone cannot alter a running gateway. Publishing properties alone also cannot alter a running gateway: the intended snapshot must be promoted and then consumed by startup or an explicit reload.

Gateway Instances and Replicas

An LLM gateway instance is a Portal instance with its own properties and current config snapshot. A host and environment may have a normal production instance plus separate test or canary instances. Applying one immutable configuration revision to multiple instances supports canary qualification and exact promotion without regenerating mutable control-plane state.

Replicas of one instance share the same current snapshot. Replica identity is useful for runtime health, audit, and diagnostics, but it is not a separate configuration partition and does not require an LLM publication ACK protocol.

Startup

At startup:

  1. startup.yml identifies config server and the target instance context.
  2. The runtime downloads the current immutable values.yml snapshot.
  3. The config loader resolves llm-router.yml from that values document.
  4. LlmCompiler validates and compiles the complete provider, deployment, alias, pricing, policy-derived, and runtime-material graph.
  5. The gateway atomically publishes one immutable LLM runtime snapshot.

If the enabled LLM configuration is invalid, the gateway starts with LLM routing unavailable and reports the configuration error. It does not assemble a second candidate from local projection files.

Explicit Module Reload

The control plane uses the existing module reload operation. It may request one or more modules. The runtime downloads the current values.yml once for that operation, creates a fresh reload context, and invokes only the requested reloaders.

When llm-router is selected, LlmRouterReloader compiles a candidate from the fresh context. The candidate replaces the active LLM runtime only after the entire graph validates. A failed compile leaves the previous runtime active. Requests already in flight continue using the immutable snapshot they captured.

Reloading an unrelated module cannot change LLM routing.

Configuration Contract

Portal-owned typed properties include:

PropertyMeaning
llm-router.enabledEnables the module when valid topology is present.
llm-router.developmentFixturesExplicit development-only validation mode.
llm-router.providersProvider endpoints, auth references, headers, network profiles, and quota ownership.
llm-router.deploymentsPhysical model IDs, capabilities, runtime capacity, prices, and provider references.
llm-router.aliasesPublic/internal aliases, ordered routes, limits, audit, PII, and required capabilities.
llm-router.openaiExtensionAllowlistExplicit request-extension policy.
llm-router.runtimeMaterialNon-secret credential mappings, trust-bundle mappings, evidence keys, and reasoning-seal references.

Maps and lists must remain typed YAML nodes in values.yml; they must not be double-encoded as quoted JSON. Publication must be deterministic, bounded, and must reject dangling or cross-host references.

Secret Boundary

Portal, instance properties, config snapshots, and values.yml contain only credential references. Raw provider keys and reasoning-seal key bytes are injected into the gateway environment or another locally supported secret materialization mechanism.

runtimeMaterial.credentialEnvironment may map an opaque credential:// reference to an allowed environment-variable name. Direct env:NAME references are also supported. Trust bundles use approved references plus local paths and digests. The compiler fails closed if required material cannot be resolved and never logs resolved values.

Publication Workflow

  1. Select the logical environment and target LLM gateway instance.
  2. Generate a read-only preview from active control-plane records.
  3. Verify exact model IDs, route order, capabilities, pricing, and credential references; verify no secret value appears.
  4. Publish the canonical typed property set to that instance.
  5. Create and promote a config snapshot through the normal Config workflow.
  6. Restart the gateway, or request an explicit reload containing light-pingora/llm-router.
  7. Confirm startup/module-reload success, then test provider behavior through the gateway.

Publication and snapshot loading prove configuration consistency and application. They do not prove provider reachability, credential validity, quota availability, or model quality.

Canary and Rollback

For canary promotion, apply the same immutable property revision and digest to the production instance; do not regenerate from mutable records after the canary test. Create/promote the production instance snapshot and perform its normal restart or reload.

Rollback applies an earlier immutable property revision to the affected instance, creates/promotes a new snapshot, and restarts or reloads. Historical revisions and snapshots are never mutated.

Operational Guarantees

  • One config-server snapshot supplies every selected module in a reload.
  • Configuration changes only at startup or explicit reload.
  • Only requested modules reload.
  • LLM publication has no independent file poller or ACK state machine.
  • Invalid LLM reloads retain the last-known-good runtime.
  • In-flight requests remain pinned to one immutable compiled generation.
  • Secrets remain outside Portal and config-server artifacts.
  • Local, dev, and install environments use the same exported event baseline and standard snapshot workflow.

Control-Plane Policy Publication Through Config Server

Status

Proposed target architecture.

This document defines how Light Portal publishes immutable policy and runtime configuration to independently operated workloads such as Agent and Gateway. It also defines how publication identity is shared with the internal Workflow and Knowledge services without pretending that those services are external Config Server clients.

Decision Summary

Light Portal is the policy authoring and publication control plane. Agent, Gateway, and other independently operated application runtimes are external policy consumers. They receive configuration only from Config Server and have no Light Portal database credentials.

Workflow and Knowledge are internal platform services. In the current architecture they consume Portal events and read the event-backed projections needed to admit and pin work. They also write their own operational tables. Those accesses use dedicated least-privilege database roles; neither service may insert or update event-backed authoring projections directly. Moving their internal policy views to Config Server is an optional future decoupling step, not a requirement of this external publication contract.

Config Server renders the immutable configuration snapshot selected as current for an authenticated external workload instance. The caller has read-only access to its own audience-specific values.yml document.

Publishing a policy consists of:

  1. resolving and validating the effective policy in Light Portal;
  2. creating a separate least-privilege projection for each publication target;
  3. emitting events that create or update the target instance properties;
  4. waiting until those events and required internal views have been projected completely;
  5. creating an immutable configuration snapshot for every external target instance;
  6. validating and digesting the snapshots; and
  7. moving the target instances’ current pointers to the new snapshots as one coordinated release.

instance_property_t is mutable desired configuration. It is not a publication staging artifact or runtime contract. An immutable publication manifest records the exact intended property set before property events are emitted. config_snapshot_t and its snapshot content are immutable deployable artifacts. The current flag is a projected pointer to one of those immutable artifacts.

For Gateway tools, publication is an explicit Tool-catalog action after tool authoring is complete. A single-tool or batch publication pins each current tool revision, including its published workflow-version binding, and stages the corresponding mcp-router.yml entries for the selected Gateway instance. The Instance Admin then creates a configuration snapshot and moves the current pointer to activate it. Changing a tool or selecting another workflow version requires another publication; it never mutates an existing snapshot.

Configuration snapshots replace user-managed digest fields, not integrity checks. End users neither enter nor see schema, definition, or policy digests on the tool form. Portal derives those values from canonical server-side content where an internal admission or audit contract still requires them. Config Server separately computes the snapshot and artifact digests described below.

Implemented Gateway Tool publication slice

Gateway Tool publication has one authoring path: the Tool catalog at /app/genai/Tool. The old Instance API MCP Tool route is a compatibility redirect to that catalog. Opening the catalog from an Instance API applies the API-version filter; the operator may then select one or more endpoint and/or workflow-backed Tools and choose an active gtw instance.

The persistence layer enforces this boundary: new create or update events for the legacy Instance API mcp-router.tools property are rejected with guidance to use the Tool catalog. Historical-import projections remain accepted so an existing event store can be rebuilt before the first Tool-catalog publication migrates and deactivates those legacy rows.

Portal produces a server-side preview and never asks the operator to enter or inspect a digest. A selection containing only endpoints from one API version uses REPLACE_API_SCOPE: it replaces that API version’s endpoint Tools while preserving Tools from other APIs and all workflow Tools. Mixed selections and workflow Tools use ADD_OR_UPDATE, which preserves every unselected Tool.

GatewayToolPublicationUpdatedEvent stores the exact compiled mcp-router.tools array and its exact source-binding records. Consequently, event replay projects the event payload and does not re-resolve mutable Tool, API, Instance API, or Workflow records. Publications share the ordered hostId + instanceId event stream, so a competing stale publication is rejected at append instead of becoming a projection failure. Synchronous projection:

  • records the immutable attempt in gateway_tool_publication_t;
  • writes the complete desired array to the instance-level instance_property_t row for mcp-router.tools;
  • records the endpoint or published Workflow version pin for each Tool in gateway_tool_binding_t; and
  • deactivates legacy instance_api_property_t rows for the same property so the Gateway has one configuration source.

On the first Tool-catalog publication, Portal folds the active legacy per-Instance-API Tool arrays into the new instance-level array before those legacy rows are deactivated. Existing API Tools are therefore preserved during the one-path migration.

The publication response is STAGED. It deliberately does not create or activate a snapshot. An Instance Admin creates the immutable config snapshot and moves the instance’s current snapshot pointer in the existing snapshot workflow. A later Tool or Workflow change has no effect on the live Gateway until another Tool publication is staged and another snapshot is activated.

Context

Agent configuration is authored in Light Portal from multiple control-plane resources. Depending on the Agent and its capabilities, the effective policy may include:

  • Agent definition and product profile;
  • prompt and model selection;
  • tool and skill grants;
  • execution, token, cost, and concurrency limits;
  • memory and data-boundary rules;
  • Knowledge Base bindings and retrieval profiles;
  • Gateway routing and delegation constraints; and
  • tenant, environment, and instance ownership.

The resolved policy must be immutable for an execution or session. At the same time, runtime deployment configuration must support controlled rollout, rollback, and forward activation.

The existing configuration subsystem already provides useful foundations:

  • instance-level properties are stored in instance_property_t;
  • snapshot creation copies instance overrides and produces merged effective values in config_snapshot_property_t;
  • snapshot files and other scoped overrides are copied into snapshot tables;
  • config_snapshot_t.current identifies the selected snapshot; and
  • a partial unique index permits only one current snapshot for a (host_id, instance_id) pair.

Config Server already resolves effective values from the selected current snapshot by host, environment, service ID, configuration phase, and property type. This design extends that mechanism into the only supported policy delivery path for external runtime services.

Terminology

TermMeaning
Control-plane policyMutable authoring resources and rules managed in Light Portal.
Effective policyThe fully resolved policy after defaults, bindings, ownership, and limits have been applied.
Domain policy snapshotAn immutable semantic policy for an Agent or another governed subject. It is pinned to sessions and requests.
Audience projectionThe least-privilege subset of an effective policy needed by one runtime service.
Instance propertyMutable desired configuration for one registered service instance.
Configuration snapshotAn immutable, deployable values.yml configuration for one instance.
PublicationOne attempt to compile, stage, validate, and activate policy projections.
ReleaseA coordinated set of audience-specific configuration snapshots sharing one publication identity.
Current pointerThe mutable selection of the configuration snapshot served to an instance by Config Server.
Last known goodThe most recent snapshot a runtime successfully validated and applied.

Goals

  • Prevent external runtimes from using Light Portal database tables as a control-plane policy source.
  • Define safe internal database boundaries for Workflow and Knowledge while preserving event sourcing for authoring state.
  • Publish only the policy fields required by each runtime audience.
  • Preserve an immutable policy identity for every session and request.
  • Reuse the existing instance-property and configuration-snapshot model.
  • Support deterministic rollback and forward activation by moving a current pointer rather than rewriting historical configuration.
  • Prevent partially projected policy changes from becoming current.
  • Coordinate compatible policy generations across multiple independently operated services.
  • Let a runtime retain and enforce its last known good configuration when a new snapshot is invalid or temporarily unavailable.
  • Provide sufficient identifiers and digests for audit, diagnosis, and cross-service consistency checks.

Non-Goals

  • Config Server does not make policy decisions for a runtime request.
  • Light Portal does not become the runtime session, retrieval, or Gateway data store.
  • A service token does not replace the end-user or delegated request identity.
  • This design does not require simultaneous process restarts for publication.
  • This design does not distribute provider credentials as ordinary policy values. Production secrets remain references resolved through the appropriate secret provider.
  • This design does not require all audiences to receive identical policy documents.
  • This design does not require Knowledge and Workflow operational state to move out of the shared PostgreSQL deployment immediately.

Trust Boundary

Light Portal control plane

Light Portal owns:

  • mutable authoring resources;
  • validation and effective-policy resolution;
  • domain policy snapshots and publication history;
  • audience projection;
  • instance-property events and their projections;
  • configuration snapshot creation;
  • release activation and rollback; and
  • publication audit and operator-facing status.

Portal command, query, projection, and Config Server components may access the Portal database according to their narrowly assigned roles.

Config Server

Config Server is the read-only delivery boundary. It may read Portal-owned configuration snapshots, but a runtime caller may retrieve only configuration bound to its authenticated host, environment, service ID, instance ID, and audience.

Config Server must never resolve policy from live authoring tables on behalf of a runtime. It serves immutable snapshot content only.

External runtime services

Agent, Gateway, and other independently operated application runtimes:

  • load configuration from Config Server;
  • validate the delivered identity, schema, and digest;
  • cache the last known good configuration;
  • enforce their local audience projection; and
  • store runtime state only in service-owned storage.

They have no Light Portal database credentials and use their own storage where persistence is required.

Internal Workflow and Knowledge services

Knowledge and Workflow are platform services, not external applications. They currently use the same physical PostgreSQL deployment as Light Portal and may read the event-backed projections necessary to resolve and pin internal work.

Knowledge may use its database role for Knowledge-owned operational data such as ingestion jobs, sync runs, documents, chunks, index generations, and runtime evidence. Workflow may use its database role for Workflow-owned operational data such as workflow instances, tasks, attempts, leases, artifacts, and execution history.

These reads are read-only and must select an immutable definition, version, and digest at admission. All authoring mutations still enter through commands and events; Workflow and Knowledge never insert or update event-backed projection rows. Direct writes are limited to explicitly operational state owned by the service.

Workflow versions use one stable wfDefId for their complete history. A user may save a DRAFT version repeatedly. Publishing that version freezes its YAML; the next edit must create a new version under the same wfDefId. Tools bind to the pair (wfDefId, workflowVersion) and only published versions are selectable. This permits an operator to roll a tool back to a previously published workflow version without inventing a second workflow identity. Portal provides a side-by-side, normalized YAML comparison between versions.

The shared database roles must be least-privilege roles restricted to the exact projection reads and operational writes each service needs. They must not be database superusers. A future physical database split may deliver the same internal policy views through events or Config Server without changing their semantic contracts.

Required Invariants

  1. An external runtime never queries a Light Portal domain or projection table to resolve control-plane policy. Internal Workflow and Knowledge may read explicitly granted event-backed projections and write explicitly granted operational tables.
  2. Config Server never renders runtime configuration from mutable authoring state.
  3. Configuration snapshot bytes and their integrity/publication metadata are immutable after creation. Only separately stored descriptive labels and the event-projected current pointer may change.
  4. At most one configuration snapshot is current for a host and instance.
  5. Every published snapshot identifies the source publication and the event watermark from which it was built.
  6. Snapshot creation begins only after all property events for the publication have reached their required projections and every inherited input version plus the projected property-set digest equals the immutable staged manifest.
  7. Each audience receives only its explicitly compiled projection.
  8. Every audience projection has a canonical digest and is bound to its host, environment, service, instance, and schema version.
  9. A runtime applies a new snapshot only after complete validation. Failure preserves the last known good snapshot.
  10. A session or long-running operation remains pinned to its domain policy snapshot unless that policy is explicitly revoked.
  11. Moving a current pointer affects new work; it does not silently change the policy already pinned to in-flight work.
  12. Cross-service requests identify the policy publication and digest under which they were authorized.
  13. Rollback selects an existing immutable snapshot. It does not edit that snapshot or reverse authoring events.
  14. An unavailable Config Server does not cause an external runtime to accept unknown or unvalidated policy.
  15. Workflow and Knowledge never mutate event-backed authoring projections directly; operational writes are not authoring shortcuts.
  16. A tool may bind only to a published workflow version. A published workflow version is immutable and remains addressable by its stable wfDefId and version string.

Architecture

Light Portal policy authoring
            |
            v
Effective-policy resolver and validator
            |
            v
Immutable domain policy snapshot
            |
            v
Publication target compiler
      /-------------------------\
      v                          v
External workload         Internal service view
projection                (Workflow/Knowledge)
      |                          |
      v                          v
immutable publication      event-backed projection
manifest                   + immutable work pin
      |
      v
instance-property events
      |
      v
immutable configuration snapshots
      |
      v
coordinated release activation event
      |
      v
Config Server read-only API
      |
      v
Agent / Gateway / application runtimes
local last-known-good cache

Two Immutable Snapshot Layers

The domain policy snapshot and configuration snapshot solve different problems and must have different identities.

Domain policy snapshot

The domain policy snapshot captures the semantic authority for an Agent or another governed subject. It includes stable component digests and the resolved policy document. A session or request records this identity so its authority can be reproduced later.

Updating an Agent definition produces a new domain policy snapshot. It does not mutate the snapshot used by existing sessions.

Instance configuration snapshot

The configuration snapshot captures everything one external service instance needs to start or reload, including its audience projection and ordinary runtime settings. It is the artifact rendered as values.yml by Config Server.

Several external instance snapshots and internal pinned policy views may be derived from the same domain policy snapshot. They share the same publicationId and policySnapshotId, even though the internal views do not have to be transported through Config Server.

Publication Targets And Internal Policy Views

Projection is a compile-time allowlist. It must not be implemented as a runtime filter over a shared complete policy document.

Agent projection

The Agent projection may contain:

  • definition and product-profile identity;
  • prompt, model Alias, and model-action limits;
  • tool and skill grants with schema digests;
  • execution placement and approval requirements;
  • memory and data-boundary policy;
  • channel configuration;
  • Knowledge binding identifiers and retrieval contract digests;
  • Gateway delegation constraints; and
  • session lifetime and concurrency limits.

It must not contain Knowledge repository credentials, Knowledge index internals, Gateway provider credentials, or unrelated tenants’ bindings.

Gateway projection

The Gateway projection may contain:

  • Agent and subject bindings relevant to Gateway enforcement;
  • permitted model and tool Aliases;
  • routing, delegation, budget, and rate-limit constraints;
  • accepted issuers, audiences, claim requirements, and signing-key references;
  • policy and catalog digests; and
  • compatibility rules for accepted publication generations.

It must not contain Agent prompts, memory content, Knowledge documents, or provider secret material.

Internal Knowledge policy view

The internal Knowledge policy view may contain:

  • Agent-to-Knowledge-Base authorization bindings;
  • tenant, environment, and ownership constraints;
  • retrieval-profile identity and immutable digest;
  • permitted released index generations;
  • query limits, graph behavior, and result constraints;
  • applicable ingestion or retrieval ceilings; and
  • accepted Agent and publication identities.

It must not contain Agent prompts, unrelated tool grants, or Gateway routing internals.

Internal Workflow policy view

The internal Workflow policy view may contain:

  • immutable workflow definition and execution-policy identity;
  • allowed Agent, tool, runner, and sandbox bindings;
  • task, retry, timeout, concurrency, and cost ceilings;
  • approval and human-task requirements;
  • data-boundary and artifact-retention rules;
  • accepted publication and delegation identities; and
  • compatibility requirements for Workflow runtime components.

It must not contain unrelated Agent prompts, Knowledge document content, Gateway provider credentials, or authoring-only Portal metadata. Workflow pins the exact definition, binding, endpoint target set, and policy digests accepted for an invocation so a later projection update cannot change in-flight work.

These internal views are still explicit, least-privilege projections. Their current delivery mechanism is the shared event/projection boundary. They may be published through Config Server later if Workflow or Knowledge is separated from the Portal trust domain.

Publication Data Contract

Each external audience projection must carry a common envelope. Internal views must retain the equivalent publication and policy identity with the work they admit. The field names below are normative even if the transport representation evolves.

runtimePolicy:
  publicationId: "019f..."
  releaseVersion: 12
  policySnapshotId: "019f..."
  policyVersion: 7
  policyDigest: "sha256:..."
  contentDigest: "sha256:..."
  audience: "gateway"
  hostId: "0196..."
  environment: "dev"
  serviceId: "com.networknt.light-gateway-1.0.0"
  instanceId: "019f..."
  sourceEventSequence: 4812
  schemaVersion: 1
  createdAt: "2026-08-13T14:00:00Z"
  validFrom: "2026-08-13T14:00:00Z"
  refreshAfter: "2026-08-13T14:05:00Z"
  expiresAt: "2026-08-13T14:15:00Z"
  revocationEpoch: 4
  compatibilityGeneration: 3

Audience-specific configuration follows this envelope under a dedicated namespace, for example gatewayPolicy.

Digest inputs are non-self-referential and normative:

  • policyDigest is SHA-256 over the RFC 8785 canonical JSON bytes of the immutable domain policy object;
  • contentDigest is SHA-256 over the RFC 8785 canonical JSON bytes of the audience-specific namespace only; it excludes runtimePolicy, signatures, delivery metadata, and every digest field; and
  • artifactDigest is SHA-256 over the exact UTF-8 values.yml bytes returned by Config Server. It is stored in snapshot metadata and returned in HTTP headers, not embedded in the bytes it hashes.

The compiler rejects data outside the supported canonical-JSON subset. Time is normalized to UTC RFC 3339, object members use their exact schema names, and no volatile delivery timestamp participates in a semantic digest.

Publication Lifecycle

1. Resolve

The publisher reads Portal-owned authoring projections and resolves defaults, bindings, ownership, environment, and limits into one complete effective policy. Resolution is deterministic for a declared source event watermark.

2. Validate

Validation confirms:

  • every reference exists and is visible to the owning host;
  • required runtime instances are registered;
  • every audience has a supported schema version;
  • no projection exceeds platform ceilings;
  • policy and configuration ownership agree; and
  • the proposed release is compatible with the runtime versions receiving it.

Failure terminates publication without changing any current pointer.

3. Freeze the domain policy

The canonical effective policy is persisted with a new policySnapshotId and digest. An existing snapshot with the same digest may be reused if its ownership and revocation state match exactly.

4. Compile audience projections

The publisher generates external Agent/Gateway projections and coordinated internal Workflow/Knowledge views from explicit schemas and field allowlists. Each target is canonicalized and digested independently.

5. Stage instance properties

For every external target instance, the publisher first persists an immutable staged target manifest containing:

  • publicationId, target identity, audience, and source watermark;
  • the observed versions and digests of every inherited environment, product, product-version, instance, file, and certificate input;
  • the complete resolved target configuration and desired instance-property change set, including removals;
  • a canonical property-set digest; and
  • the expected audience content digest and compatibility generation.

The publisher then emits idempotent create, update, and deactivate events for that exact property set. All events carry the same publication and staged-target identity. Concurrent authoring or instance-property changes do not become part of this publication merely because they project before snapshot creation.

Property events may be projected asynchronously. Before creating a snapshot, the publisher verifies both the projection watermark and that every input version plus the projected instance property-set digest equals the immutable staged manifest. A time delay is not sufficient. A mismatch marks the target STALE; the publisher must resolve and compile a new publication instead of snapshotting mixed state.

6. Create configuration snapshots

Once the exact staged target is projected, snapshot creation copies from the immutable staged artifact in one transaction and creates the merged effective config_snapshot_property_t rows. It must not reread an unconstrained mutable instance_property_t state that may include another publication. New snapshots start as staged and are not served as current.

Snapshot metadata must retain:

  • publication and release identity;
  • audience;
  • content digest;
  • exact rendered artifact digest;
  • staged property-set digest and inherited input versions/digests;
  • source event sequence;
  • schema and compatibility versions; and
  • validation state.

These fields may be added to config_snapshot_t or stored in a publication manifest linked to each snapshot_id.

7. Validate deployable artifacts

Validation renders the exact values.yml that Config Server will serve and checks:

  • schema and type correctness;
  • required keys and files;
  • canonical digest agreement;
  • instance and audience binding;
  • runtime-version compatibility; and
  • absence of fields forbidden for the audience.

The validated UTF-8 bytes, media type, renderer profile/version, and artifactDigest are stored as one immutable snapshot artifact. Config Server serves those stored bytes; it does not rerender the workload response from mutable properties at request time.

8. Activate the release

After every required target snapshot is ready, Portal emits one PolicyPublicationActivatedEvent containing the publication ID, expected prior release, and the complete target-to-snapshot mapping. One projection transaction validates every target and updates all current flags plus release state. There is no handler-side or operator-side direct pointer mutation.

Database transactions can make the Portal-side pointer changes atomic, but independently operated services will observe them at different times. The release therefore requires either backward-compatible adjacent generations or a staged protocol in which runtimes prefetch before activation.

Rollback emits the analogous PolicyPublicationRolledBackEvent with an exact previous target mapping and is projected through the same all-target operation.

9. Acknowledge runtime application

Each external runtime reports or exposes:

  • current configuration snapshot ID;
  • publication ID;
  • content and policy digests;
  • load timestamp;
  • last validation result; and
  • last known good snapshot ID.

Acknowledgement is operational evidence. It does not make an invalid snapshot valid and does not grant authority beyond the snapshot itself.

Config Server Contract

The workload API is versioned separately from the existing operator and legacy configuration endpoints:

  • GET /v2/runtime-config/current returns the authenticated instance’s current immutable YAML artifact;
  • GET /v2/runtime-config/snapshots/{snapshotId} returns one authorized historical artifact; and
  • POST /v2/runtime-config/acknowledgements records load or rejection evidence for the authenticated instance.

The current and historical responses use Content-Type: application/yaml and return ETag, RFC 9530 Repr-Digest, X-Config-Snapshot-Id, X-Policy-Publication-Id, X-Policy-Digest, X-Content-Digest, X-Artifact-Digest, validity-window headers, X-Manifest-Key-Id, and X-Manifest-Signature. The signature covers workload target identity, publication/snapshot identity, all digests, compatibility generation, revocation epoch, and the validity window. If-None-Match is supported. The acknowledgement request contains identifiers, digests, timestamp, and a bounded stable result code; it never contains the rendered document or secrets.

The legacy /configs API remains a migration path for existing clients. An external workload must not select live configuration by supplying productId, productVersion, host, environment, service, audience, or instance query parameters.

Current configuration

An external runtime requests current configuration using a workload JWT and, where required by the deployment, mTLS. Config Server derives:

  • host;
  • environment;
  • service ID;
  • instance ID;
  • audience; and
  • permitted configuration phase.

The token or certificate contract must expose stable host, environment, service ID, instance ID, and audience claims. Caller-controlled query values cannot override or widen them. Config Server returns the exact immutable snapshot selected as current; its ETag is the quoted artifactDigest.

Historical configuration

Config Server should also support retrieval by an explicit snapshotId for:

  • session resume under a pinned policy;
  • rollback preparation;
  • audit and diagnosis; and
  • recovery of a runtime that did not persist an older pinned policy locally.

Historical access is subject to the same host, service, instance, and audience checks as current access. An arbitrary service cannot read another instance’s history.

Read-only semantics

Runtime credentials authorize only configuration reads and optional delivery acknowledgement through a separate, narrowly scoped endpoint. They do not authorize property editing, snapshot creation, current-pointer changes, or policy publication.

Config Server access logs record identifiers and response size only. Trace, debug, error, and acknowledgement paths must never log the rendered YAML body.

Runtime Loading And Enforcement

At startup, an external runtime:

  1. authenticates to Config Server using its workload identity;
  2. requests the current configuration for its bound instance;
  3. verifies the response identity, audience, schema, signature, digests, and validity window;
  4. compiles the audience policy into its local enforcement representation;
  5. stores the immutable policy in service-owned storage when necessary;
  6. atomically replaces its in-memory current configuration; and
  7. records acknowledgement and health evidence.

During operation, the runtime polls, watches, or refreshes Config Server with a conditional request. A candidate is fully parsed and validated before it replaces the current in-memory object.

If Config Server is unavailable, the runtime may use its cryptographically validated last known good snapshot only while now < expiresAt, allowing a bounded clock-skew tolerance. It begins refreshing no later than refreshAfter and must not apply a candidate before validFrom. It must not invent defaults that broaden access. Once the lease expires, protected operations fail closed. Emergency revocation while disconnected is therefore bounded by the published lease; deployments needing a shorter revocation objective configure a shorter lease or an authenticated push channel.

Session And Request Pinning

The current configuration selects the default policy for new work. It does not rewrite the authority of existing work.

An Agent session records at least:

  • policySnapshotId;
  • policyDigest;
  • publicationId; and
  • relevant data-boundary and execution digests.

Each downstream call to Gateway or Knowledge carries the applicable policy and publication identity in trusted request metadata. The receiving service checks that it recognizes a compatible projection and then applies the authenticated end-user or delegated principal to the locally loaded rules.

The workload credential authenticates the calling service. The end-user or delegated identity remains request-specific and must not be stored as a static instance configuration value.

Rollback And Forward Activation

Rollback changes current pointers to a previously validated release. It does not:

  • edit the historical snapshot;
  • overwrite current instance properties with old values;
  • reverse domain events; or
  • silently change sessions pinned to another policy.

Before rollback, Portal verifies that every target snapshot still exists, is not revoked, is compatible with the running service version, and belongs to the same host, environment, instance, and audience.

Forward activation uses the same event/projection operation to point current back to a newer validated release. Every pointer movement records actor, reason, timestamp, previous snapshot, next snapshot, and publication ID.

Emergency revocation is separate from rollback. A revoked domain policy may terminate or deny already pinned work according to explicit policy; merely publishing a newer version does not revoke the older one.

Coordinated Multi-Service Releases

External Agent/Gateway runtimes cannot be assumed to refresh at the same instant, and internal Workflow/Knowledge projections may advance on a different checkpoint. The publication protocol therefore uses a shared release manifest containing:

  • publication ID and release version;
  • domain policy snapshot and digest;
  • required audience targets;
  • external target instance and configuration snapshot IDs;
  • internal view identifiers and projection checkpoints where they participate;
  • each audience content digest;
  • compatibility generation;
  • source event watermark;
  • staged, active, failed, or rolled-back state; and
  • validation and acknowledgement evidence.

Adjacent releases should normally support an overlap window:

  • an old Agent may call a new Gateway or internal Knowledge view;
  • a new Agent may call an old Gateway or internal Knowledge view; and
  • the receiver can distinguish compatible transition traffic from an unknown or forged publication.

If overlap is impossible, activation requires a two-phase rollout: prefetch and validate all external targets, verify required internal views are pinable, then activate traffic only after every required external audience acknowledges readiness.

Security Requirements

  • Bind every response to the authenticated workload’s host, service, instance, environment, and audience.
  • Use TLS with normal CA and hostname verification in production. Local development may use explicitly configured local CA material and disabled hostname verification, but that exception must remain deployment-scoped.
  • Sign every workload snapshot manifest; deployments crossing company trust boundaries use a key and verification policy independent of transport TLS.
  • Calculate digests from canonical bytes and verify them after transport.
  • Reject unknown schema versions and unknown mandatory fields.
  • Prevent rollback to a revoked or ownership-incompatible snapshot.
  • Never include provider API keys, private signing keys, or unrelated tenant policy in an audience projection.
  • Treat secret references and ordinary configuration values differently.
  • Audit publication, validation, activation, rollback, runtime load, and rejection.
  • Do not use a long-lived workload token as evidence of end-user authority.

Failure Handling

FailureRequired behavior
Effective policy cannot be resolvedFail publication; do not stage or activate.
One required publication target failsFail the release; do not activate the other targets.
Property projection is behindKeep waiting or time out; never snapshot partial state.
Snapshot rendering or validation failsMark the target and release failed; retain current pointers.
One runtime rejects a staged snapshotRetain the old release and expose the reason.
Config Server is temporarily unavailableUse last known good within policy; otherwise fail closed.
Current pointer references no readable snapshotReport a control-plane incident; never fall back to mutable properties.
Multiple snapshots are currentReject as a cardinality violation. The database uniqueness invariant should prevent this.
Cross-service publication is unknownReject or use an explicitly declared overlap rule; never infer compatibility.
Snapshot is revokedStop admitting new work and apply the declared policy to pinned work.

Observability And Audit

The Portal UI should show one publication timeline with per-audience status:

Publication 12
  effective policy        VALIDATED
  Agent snapshot          APPLIED      digest sha256:...
  Gateway snapshot        APPLIED      digest sha256:...
  Workflow internal view  PINNABLE     digest sha256:...
  Knowledge internal view PINNABLE     digest sha256:...
  release                 CURRENT
  previous release        Publication 11

Required operational signals include:

  • publication duration and failures by phase;
  • projection watermark lag;
  • staged and current snapshot identity per instance;
  • snapshot validation failures by audience;
  • runtime current and last-known-good identity;
  • acknowledgement lag and digest divergence;
  • rejected cross-service publication identities; and
  • rollback and revocation counts.

Logs and traces should carry publicationId, policySnapshotId, configSnapshotId, policyDigest, host, environment, service ID, and instance ID where applicable.

Current Implementation Gaps

The current external delivery path is not yet the workload contract in this design:

  • light-config-server exposes /configs; when productId and productVersion are supplied it deliberately reads live instance data rather than the current immutable snapshot;
  • its request authorization binds host, service ID, and environment, but the endpoint has no normative instance/audience claim binding, publication envelope, artifact digest/ETag, validity lease, or acknowledgement contract;
  • ConfigsGetHandler trace logging can emit the entire rendered YAML result; that must be removed before policy or secret references use this path; and
  • config-query can read a historical snapshot by hostId and snapshotId, but that Portal query operation is not an external workload-identity API.

The current snapshot schema also lacks an immutable staged publication target, coordinated release manifest, property-set/content/artifact digests, source event watermark, audience, validation state, validity lease, and compatibility generation. Snapshot creation from mutable instance rows is vulnerable to mixing a concurrent change into a publication unless the staged target and projected digest are checked.

Current Workflow database access

light-workflow currently creates one SQLx PostgreSQL pool from DATABASE_URL and shares it across admission, execution, event consumption, and reconcilers. The local composition points it at the same configserver database as Portal; the development credential is a database superuser and is not an acceptable production role.

Its access falls into three categories:

CategoryCurrent tables/pathTarget boundary
Portal event consumptionoutbox_message_t, consumer_offsets, notification/counter stateRead event outbox and write only Workflow consumer checkpoint/quarantine state.
Admission projection readsworkflow_tool_binding_t, wf_definition_t, tool_t, dependency/approval projectionsRead-only; validate versions/digests and copy the accepted immutable definition, binding, policy, and endpoint set into invocation-owned state.
Workflow operational stateprocess_info_t, task_info_t, workflow_invocation_t, budgets, leases, audit outbox, bounded encrypted invocation credentialsWorkflow-owned writes with explicit grants and retention.

Definition execution is normally pinned through process_info_t.definition_snapshot; the legacy fallback that rereads mutable wf_definition_t must be removed after migration. Endpoint dispatch currently reads workflow_endpoint_target_t live by host and endpoint reference. Although workflow_invocation_t pins a binding and definition/policy digests, the live endpoint lookup can change an in-flight invocation. Admission must therefore copy the binding-scoped endpoint target set into immutable invocation-owned operational rows and dispatch only from that copy.

These Workflow projection reads are accepted internal service access, not a reason to publish Workflow through Config Server now. The required fixes are least-privilege roles, immutable admission pinning, removal of the legacy live definition fallback, and an explicit inventory/test of every grant. Knowledge requires the same read-projection/write-operational classification before its role is narrowed.

Migration Plan

Phase 1: Publication contract

  • Define the common policy envelope, external Agent/Gateway schemas, and the coordinated identity carried by internal Workflow/Knowledge views.
  • Define canonical serialization and digest rules.
  • Add immutable staged target manifests, publication/release metadata, and target snapshot linkage.
  • Add source-event watermark and validation state to the snapshot contract.

Phase 2: Portal compiler and staging

  • Implement deterministic effective-policy resolution.
  • Compile audience projections through explicit allowlists.
  • Freeze each external target’s complete resolved configuration, desired property changes, and every inherited input version/digest.
  • Emit correlated, idempotent instance-property events.
  • Verify projection checkpoints and exact property-set digests before snapshot creation; mark mismatches stale rather than snapshotting live rows.
  • Render and validate staged values.yml artifacts.

Phase 3: Config Server delivery

  • Implement the versioned /v2/runtime-config workload endpoints.
  • Bind current and historical reads to host, environment, service, instance, and audience workload claims.
  • Return snapshot and publication metadata with content.
  • Add artifact-digest ETag/conditional retrieval and canonical digest verification.
  • Remove rendered-body trace logging and add bounded acknowledgement.
  • Enforce signed validity windows and fail-closed last-known-good behavior.

Phase 4: Runtime consumers

  • Add typed Config Server policy loaders to external Agent and Gateway runtimes.
  • Persist immutable pinned policies in service-owned storage where needed.
  • Inventory Workflow and Knowledge database access; grant read-only projection access and service-owned operational writes through separate roles.
  • Pin their internal policy views at work admission and remove mutable fallback reads during execution.
  • Carry publication and policy identity on cross-service calls.
  • Add runtime acknowledgement and divergence metrics.

Phase 5: Coordinated activation

  • Stage all target snapshots under one publication.
  • Validate compatibility and optionally prefetch.
  • Activate and roll back all target pointers through one audited release event and one projection transaction.
  • Add Portal status, failure details, and operator controls.

Phase 6: Boundary enforcement

  • Remove Portal database credentials from Agent and Gateway deployments.
  • Replace the local Workflow superuser credential with a dedicated role and restrict Workflow/Knowledge to enumerated projection reads and operational writes. Deny direct authoring-projection mutation.
  • Add network and database policy enforcing these differentiated boundaries.
  • Prove configuration delivery across independently operated environments.
  • Run rollback, partial outage, stale snapshot, and mixed-generation exercises.

Acceptance Criteria

The design is complete when:

  1. Agent and Gateway start and serve authorized traffic without Portal database credentials. Knowledge and Workflow use only enumerated read-only projections and service-owned operational tables through non-superuser roles.
  2. Each external workload can retrieve only its own audience projection from Config Server; query parameters cannot override workload identity.
  3. A publication produces immutable snapshots for all required target instances from one declared event watermark.
  4. No current pointer changes when any required projection or validation fails.
  5. A successful release exposes matching publication identity across external snapshots and participating internal policy views.
  6. Existing sessions remain pinned to their original domain policy snapshot after a new release.
  7. Rollback and forward activation work by pointer movement without modifying historical snapshot content.
  8. Config Server unavailability preserves last known good behavior only until the signed expiresAt lease and fails closed afterward.
  9. An external runtime rejects a snapshot with the wrong host, audience, instance, schema, digest, signature, or validity window.
  10. External runtime database access is denied. Workflow and Knowledge prove least-privilege projection reads, immutable work pinning, operational-only writes, and denial of direct authoring-projection mutation.
  11. Concurrent inherited- or instance-configuration changes cannot contaminate a staged publication, and activation/rollback changes all target pointers through one event-backed transaction.

Design Consequences

This architecture adds a publication compiler and coordinated release state, but it establishes a clean company and security boundary. Light Portal owns authoring and immutable publication. Config Server owns read-only delivery. External Agent/Gateway workloads own enforcement and runtime state without Portal database access. Internal Workflow/Knowledge services own operational state and consume narrowly scoped event-backed projections.

The result is independently deployable services, reproducible authorization, safe rollback, and no external runtime dependency on Light Portal database tables for control-plane policy. The accepted Workflow/Knowledge shared-database topology remains an explicit internal contract that can later be replaced by event or Config Server delivery without changing pinned policy identities.

Global And Tenant Entity Scope

Status

Proposed design for discussion.

Decision Summary

Use host_id IS NULL as the storage representation for a global row when one table intentionally contains both global and host-scoped definitions. Use a non-null host_id for a host-owned row. Do not add a second common flag to represent the same scope decision.

Treat scope, ownership, visibility, and enablement as different concepts:

  • host_id IS NULL means the platform owns one global definition.
  • host_id = ? means one host owns the definition.
  • a visibility or publication field says who may discover an owned definition; it does not change its ownership or identity scope.
  • a host registration, installation, or binding says that a host may use a global definition; global visibility alone never grants runtime use.

For entities that are always global, omit host_id entirely. For entities that are always tenant-bound, keep host_id NOT NULL. Nullable host_id is for the specific case where the same definition type genuinely supports both scopes.

Apply these choices to the two motivating entities as follows:

  • Make llm_model_t a global-only canonical model catalog and remove host_id. Keep llm_model_registration_t host- and environment-scoped and reference the global model_id.
  • Keep executable wf_definition_t rows host-scoped. Add an immutable global workflow template/version catalog and install or fork a selected template into wf_definition_t. This preserves the existing workflow runtime foreign keys and prevents a global edit from changing tenant execution behavior.

Context

Light Portal currently contains two apparent patterns for shared entities:

  1. a populated host_id plus common = 'Y', represented by rules;
  2. nullable host_id, where null means global and non-null means host-specific, represented by reference tables, categories, and tags.

The implementations show that these are not equivalent encodings. The rule pattern mixes the owner of a row with its cross-host visibility. The reference pattern uses the row scope itself as the source of truth.

This decision also has to work with event aggregate identities, semantic uniqueness, soft deletion, snapshot export, authorization, foreign keys, and effective catalog queries. Choosing a convention based only on the shape of one DDL table would leave those contracts ambiguous.

Terminology

TermMeaning
Global definitionPlatform-owned reusable definition with no tenant owner.
Host definitionDefinition owned and mutable within one host_id.
Published definitionOwned definition made discoverable beyond its owner. Publication is visibility, not global ownership.
RegistrationHost decision to enable a global reference entity, optionally for an environment.
InstallationHost-local, version-pinned operational copy of a global template.
OverrideHost row that intentionally replaces selected behavior of a global definition in an effective read model.
Effective catalogDeterministic composition of global rows, host rows, registrations, and environment bindings for one caller.

Current Implementation Findings

rule_t Is A Hybrid Model

portal-db/postgres/ddl.sql currently defines all of the following:

  • nullable host_id, documented as null for a global rule;
  • common CHAR(1), used by queries as a shared-visibility flag;
  • PRIMARY KEY (rule_id);
  • partial global and host indexes that also include rule_id.

The primary key on rule_id already makes the partial identity indexes redundant for that identifier. A global rule and a tenant rule cannot reuse the same rule_id, even though the partial indexes imply that scope-specific reuse was intended.

The list and label queries use conditions such as:

host_id = :host_id OR common = 'Y'

This means a row can remain owned by Host A while being returned to Host B. It is shared by a visibility flag, not global by scope. Other paths use host_id IS NULL for global rules or for related rule test cases. Snapshot export also determines global scope with host_id IS NULL, not common = 'Y'.

Consequently, the following states are possible and do not have one consistent meaning across the code:

host_idcommonPossible interpretation
nullNStructurally global but hidden by common-based queries.
nullYGlobal and shared.
Host ANHost A private rule.
Host AYHost A-owned rule published to every host.

Event aggregate identity is derived as hostId|ruleId when hostId exists and as ruleId otherwise. That identity follows nullable-host scope rather than the common flag. The projection primary key, however, remains only rule_id. These contracts can disagree.

The rule pattern therefore should not be copied to new entity families. If tenant-owned rules must be publishable, that requirement should be modeled as visibility or publication with explicit moderation and mutation authority.

ref_table_t Uses Nullable Host Scope

ref_table_t uses:

host_id IS NULL  -> global reference table
host_id = ?      -> host-specific reference table

It has a globally unique surrogate table_id and partial semantic unique indexes:

UNIQUE (table_name)          WHERE host_id IS NULL
UNIQUE (host_id, table_name) WHERE host_id IS NOT NULL

This permits the same semantic name in global, Host A, and Host B scope while rejecting duplicates within each scope. Child ref_value_t rows reference the globally unique table_id, so they inherit scope from the parent without repeating a nullable host column.

Host-aware list and label queries compose:

host_id = :host_id OR host_id IS NULL

Snapshot export uses the same representation for host, global, and both selection. Event aggregate identity also distinguishes hostId|tableId from a global tableId.

This is the more internally consistent existing model. It still has gaps that must not be copied blindly:

  • current by-ID reads use table_id without a host/scope visibility predicate;
  • update projections can change host_id, effectively moving an entity between scopes instead of requiring a controlled publish or clone operation;
  • a combined list returns both rows when a host and global row have the same semantic name, but it does not define shadowing or deduplication;
  • global creation and mutation require an explicit platform-admin authorization policy, not merely a client-supplied flag;
  • OR predicates should be backed by suitable indexes or implemented as UNION ALL when query plans require it.

These are handler and policy issues, not reasons to add a duplicate common scope flag.

llm_model_t Is Currently Host-scoped

The current model table has:

PRIMARY KEY (host_id, model_id)
UNIQUE (host_id, provider_type, physical_model_id)

llm_model_registration_t references it with:

FOREIGN KEY (host_id, model_id)
  REFERENCES llm_model_t(host_id, model_id)

The persistence resource descriptor, list query, fresh query, label query, and command reference validation also treat models as host-scoped. Therefore the current implementation does not yet support one platform catalog referenced by registrations from many hosts.

wf_definition_t Is An Operational Host Entity

wf_definition_t currently has host_id NOT NULL, a composite primary key, and host-scoped semantic uniqueness. process_info_t and skill_workflow_t reference (host_id, wf_def_id). Runtime queries join workflow definitions on the same host.

This is more than a catalog display constraint. It ensures that running processes and skill mappings resolve a workflow owned by the same tenant. A simple change from host_id NOT NULL to nullable would not let a composite foreign key reference a global row: SQL null equality does not make (tenant_host, wf_def_id) match (NULL, wf_def_id).

Workflow definitions also contain executable behavior. Allowing tenants to run one mutable global row directly would let a platform edit alter future tenant executions without an explicit adoption decision.

Options

Option A: Host-owned Row With common = Y/N

The row always has an owner host. common = 'Y' makes it visible to other hosts.

Advantages

  • Preserves the original author or owning tenant.
  • Allows tenant-authored content to be published without copying its body.
  • Can support a marketplace submission model when publishing is moderated.
  • Keeps a concrete owner for support, attribution, and update responsibility.

Disadvantages

  • It does not represent a platform-global entity; it represents a tenant-owned public entity.
  • Scope is ambiguous when code also treats null host as global.
  • A tenant owner can affect every consumer unless publication separates the public version from the editable source.
  • Tenant deletion, suspension, migration, or cloning creates unclear behavior for globally visible data.
  • Host-based row-level security, foreign keys, exports, and joins need special exceptions for common = 'Y'.
  • common does not say whether it means discoverable, selectable, executable, editable, or inherited.
  • Every query must remember both ownership and common-visibility rules.
  • It is easy for a by-ID lookup or relationship join to bypass the intended visibility rule.
  • Changing common can silently change cross-tenant impact without changing the entity’s aggregate scope.

Appropriate Use

Use this concept only when the domain explicitly supports tenant-authored content publication. Model it with names such as visibility_scope, publication_status, and owner_host_id, or with a separate publication row. Do not call the published row global and do not reuse common as its authorization policy.

Option B: Nullable host_id

Null means a platform-global definition; a value means a host definition.

Advantages

  • One authoritative column defines scope.
  • Aligns naturally with partial unique indexes and scoped semantic identity.
  • Aligns with current reference, category, tag, snapshot, and aggregate-ID conventions.
  • Global rows are independent of any tenant lifecycle.
  • Host and global creation can have distinct authorization policies.
  • Scope is easy to expose as a derived API field.
  • Child rows can inherit scope from a globally unique parent ID.
  • Host-specific and global definitions can reuse a semantic name when the effective-catalog policy allows it.

Disadvantages

  • Null must be treated deliberately in keys, joins, predicates, ORM mappings, and test fixtures.
  • PostgreSQL uniqueness with null requires partial unique indexes or an explicit normalized scope key.
  • A composite foreign key containing tenant host_id cannot directly refer to a global parent row.
  • Combined reads can produce both host and global rows with the same semantic identity unless precedence is defined.
  • A bare by-ID query can expose a row outside the caller’s effective scope if globally unique IDs are treated as authorization.
  • Moving a row between null and non-null scope is dangerous and should not be a normal update.

Appropriate Use

Use nullable host_id for definition metadata that is safe to read or reference directly in both scopes and whose relationships use globally unique surrogate IDs. It is especially suitable for taxonomy and reference data.

Option C: Separate Global Definition And Host Adoption Tables

The definition is global. A registration, installation, or binding records the host’s decision to use it.

Advantages

  • Separates shared knowledge from tenant authorization and lifecycle.
  • Supports host- and environment-specific restrictions without duplicating the global definition.
  • Provides a natural place for approval, status, pinned version, overrides, rollout, and audit fields.
  • Avoids nullable-parent composite foreign-key problems.
  • Makes global retirement different from deleting a tenant adoption.
  • Works well for runtime-sensitive entities.

Disadvantages

  • Adds a table and lifecycle operations.
  • Queries must join definition and adoption state.
  • Deletion and compatibility rules must account for references from many hosts.
  • Template installation may require version pinning or copying when global changes must not propagate immediately.

Appropriate Use

Use this for global definitions whose availability or behavior must be approved per host or environment. LLM registrations are this pattern. Workflow template installation is a safer variation for executable workflow behavior.

Comparison

ConcernHost plus commonNullable host_idDefinition plus adoption
Represents platform ownershipNoYesYes
Represents tenant publicationYes, but ambiguouslyNo; add visibility separatelyYes, with a publication layer
Single scope source of truthNo when null is also allowedYesYes
Tenant lifecycle independent from global rowNoYesYes
Per-host enablementNot inherentlyNot inherentlyYes
Environment enablementNot inherentlyNot inherentlyYes
Runtime safetyWeakDepends on entityStrongest
Database uniquenessOften contradictoryClear with partial indexesClear per table
Existing snapshot alignmentWeakStrongStrong when both tables are exported
Best fitPublished tenant contentSimple dual-scope metadataRuntime-governed shared definitions

Recommendation

General Rule

Choose scope from domain ownership, not from UI visibility:

Entity behaviorStorage recommendation
Always globalNo host_id column.
Always host-ownedhost_id NOT NULL.
Definition may independently be global or host-ownedNullable host_id; null is global.
Global definition needs host approval or configurationGlobal definition plus host registration/binding.
Global executable template must not change tenant behavior automaticallyImmutable global versions plus host installation/copy.
Tenant-owned content may be publicly discoveredKeep owner host and add explicit publication/visibility state.

Do not use common as a generic scope field. If the product needs publication, replace it over time with a vocabulary that says what is being granted:

owner_host_id
visibility_scope = PRIVATE | PORTAL | PUBLIC
publication_status = DRAFT | PENDING | PUBLISHED | WITHDRAWN

Published content should have platform moderation and an immutable published revision or snapshot. Consumers should not execute a tenant’s mutable draft.

Scope Contract

For mixed global and host definition tables:

  1. host_id is immutable after creation.
  2. Global create/update/delete requires a global platform-admin endpoint policy.
  3. Host scope comes from authenticated context, never an arbitrary request body.
  4. APIs return a derived scope value of GLOBAL or HOST and provenance.
  5. IDs are globally unique surrogate identifiers.
  6. Semantic uniqueness is scope-aware through partial unique indexes.
  7. By-ID reads enforce host_id IS NULL OR host_id = :trusted_host.
  8. By-ID mutations require exact scope and mutation authority.
  9. A host list composes global and its own rows only.
  10. Global and host rows with the same semantic key use an entity-specific, documented precedence rule; they are never deduplicated accidentally.
  11. A scope change is publish, clone, install, or withdraw—not an update of host_id.
  12. Soft delete preserves scope and identity reservations.

Effective Read Policy

Three read modes should be explicit:

GLOBAL_ONLY  -> host_id IS NULL
HOST_ONLY    -> host_id = :trusted_host
EFFECTIVE    -> host_id = :trusted_host OR host_id IS NULL

When host override semantics exist, resolve them deterministically. For example:

SELECT *
FROM (
  SELECT item.*,
         ROW_NUMBER() OVER (
           PARTITION BY semantic_key
           ORDER BY CASE WHEN host_id = :host_id THEN 0 ELSE 1 END
         ) AS precedence
  FROM item
  WHERE active = TRUE
    AND (host_id = :host_id OR host_id IS NULL)
) effective
WHERE precedence = 1;

If the entity does not support override, return both rows with scope provenance or reject the conflicting semantic identity. Do not silently invent override behavior in a generic query helper.

LLM Model Decision

Target Model

llm_model_t should be the platform-owned canonical catalog:

llm_model_t
  model_id                  global PK
  provider_type
  canonical_model_id
  model_family
  model_version
  lifecycle_status
  token limits
  modalities
  operations
  declared capabilities
  aggregate version and audit fields

It should not have host_id or common. Only platform catalog administrators may mutate it. All authenticated tenants may browse active catalog entries, subject to any product-level catalog visibility policy.

llm_model_registration_t remains the tenant adoption record:

llm_model_registration_t
  host_id
  model_registration_id
  model_id                  FK -> llm_model_t(model_id)
  environment
  regions
  data classifications
  capability restrictions
  lifecycle status

The uniqueness rule remains one registration for a canonical model in a host and environment unless a later requirement introduces named registration profiles.

Global model metadata must not absorb provider-account-specific values. Azure deployment names, Bedrock inference-profile or regional identifiers, private OpenAI-compatible endpoints, local Ollama tags, account quota groups, and secret references belong to Provider Deployment, Account, and Credential records. A canonical upstream identifier may be stored in the catalog only when it is stable across accounts.

Why Not Nullable Host For Models

Nullable host is technically workable, but it is unnecessary if all model definitions are curated globally. Allowing tenant model rows would recreate two questions that Registration and Deployment already answer: which tenant may use the model and what physical provider target it calls.

If a future requirement permits tenant-private custom model definitions, first decide whether they are genuinely new catalog identities or merely private Deployments of a generic provider-compatible model. If true tenant catalog definitions are required, either add nullable host scope with explicit effective catalog semantics or use a separate custom-model table. Do not add common.

Required LLM Migration

  1. Establish the canonical identity rule, including provider-aware identifiers.
  2. Deduplicate existing host model rows into global catalog rows.
  3. Create a stable old (host_id, model_id) to new model_id mapping.
  4. Remap every Registration before changing its foreign key.
  5. Replace the composite Registration foreign key with model_id only.
  6. Remove host_id from the model projection and its uniqueness constraints.
  7. Make model list, fresh, and label queries global rather than host-filtered.
  8. Make Registration reference validation resolve a global active model.
  9. Restrict model commands to platform catalog administrators while keeping Registration commands host-authorized.
  10. Update CloudEvent aggregate identity, snapshots, taxonomy assignments, forms, Marketplace reads, and dynaselect endpoints.
  11. Prevent hard removal while active registrations reference a model; use lifecycle deprecation and retirement.

Workflow Definition Decision

Target Model

Workflow definitions are executable and tenant-customizable. Use a template plus installation model rather than making the current operational table nullable:

wf_template_t
  wf_template_id             global PK
  namespace
  name
  version
  immutable definition
  lifecycle/publication status
  taxonomy and audit fields
  UNIQUE(namespace, name, version)

wf_definition_t
  host_id                    existing tenant scope
  wf_def_id
  source_wf_template_id      nullable provenance FK
  source_template_version    nullable pinned version
  tenant-owned definition
  existing ownership, catalog, taxonomy, and audit fields

An Install action copies a selected immutable template version into a new host-scoped wf_definition_t row. A Fork action does the same but explicitly allows tenant customization. Tenant-authored workflows have null template provenance.

This keeps existing (host_id, wf_def_id) foreign keys from process_info_t and skill_workflow_t. It also ensures an update to a global template never changes a tenant workflow or an in-flight process implicitly.

If storage duplication becomes material, the installation table may instead pin an immutable template revision and runtime processes may reference that installation. That is a larger schema migration and should not be introduced until its operational value outweighs the simpler copy-on-install model.

Workflow Lifecycle

  1. A platform curator publishes an immutable global template version.
  2. A host administrator browses the global Workflow Marketplace.
  3. The administrator installs or forks a version into the host.
  4. Host-specific validation, secrets, tools, Agents, Policies, and environment bindings are resolved before activation.
  5. Process instances reference only the installed host definition.
  6. A new global version creates an upgrade opportunity, not an automatic mutation.
  7. Upgrade produces a reviewed new host definition version and leaves existing process history resolvable.

Guidance For Other Entities

Entity familyRecommended pattern
Categories and tagsNullable host_id; effective read composition.
Reference tables and relation typesNullable host_id; child values inherit parent scope.
RulesGlobal rule templates plus host bindings, or host-owned rules plus explicit publication. Retire common as scope.
LLM modelsGlobal-only definition plus host/environment Registration.
Provider Accounts, Deployments, CredentialsHost-scoped; never global.
LLM Aliases, Routes, Policies, BindingsHost/environment-scoped unless a separate global template requirement is approved.
Workflow templatesGlobal immutable versions.
Executable workflow definitionsHost-scoped installation or fork.
Reusable schemasNullable scope for safe definitions, or template plus installation when tenant lifecycle differs.
Skills and toolsGlobal immutable templates plus host projection/binding when execution depends on tenant APIs or credentials.

Authorization Requirements

Global visibility must not imply global mutation or runtime permission.

  • Light Gateway logical-endpoint policy authorizes global catalog mutations.
  • Command handlers derive host scope from authenticated context.
  • Global commands remove host ownership only after the global branch is authorized; a request flag alone is insufficient.
  • Query handlers distinguish public catalog reads, authenticated effective reads, and administrative reads.
  • A global by-ID read is allowed only according to that entity’s visibility policy.
  • Host rows are readable and mutable only within the trusted host unless a separately authorized platform operation applies.
  • Registrations, installations, and bindings require host administration even when the referenced definition is global.
  • Runtime queries consume only active host adoption and environment binding state, never the global catalog alone.

Event And Projection Requirements

The event model must use the same scope truth as the projection:

  • global aggregate IDs exclude host identity;
  • host aggregate IDs include the trusted host or use a globally unique ID with a separately persisted trusted scope;
  • common or visibility never changes aggregate identity;
  • a publish/install/clone operation emits a different event from an update;
  • projection replay cannot move an aggregate between global and host scope;
  • snapshot global, host, and both selection follows definition scope and exports adoption rows in their host scope;
  • semantic uniqueness reservations use (scope_type, scope_id, identity) even when the projection physically represents global scope as null.

Verification Matrix

Every global-capable entity should have contract tests for:

ScenarioExpected result
Global and Host A use the same semantic nameAllowed when entity policy supports overrides or parallel definitions.
Host A creates the same semantic name twiceRejected.
Host B reads effective catalogGlobal plus Host B; never Host A private rows.
Host B reads Host A row by guessed UUIDNot found or forbidden according to the API disclosure policy.
Tenant administrator requests global createRejected unless the global endpoint policy explicitly authorizes it.
Global row is updated through a host mutation pathRejected.
Host row attempts to change host_id to nullRejected; use publish/install workflow.
Global definition is soft-deleted while active adoptions existRejected or moved through an explicit retirement policy.
Effective catalog contains a host override and global defaultDeterministic documented precedence.
Snapshot exports globalOnly global definitions and their global-owned children.
Snapshot exports hostOnly that host’s definitions and adoption rows.
Event replay occurs out of orderScope remains unchanged and aggregate version remains monotonic.

For LLM Models, additionally verify registrations from two hosts can reference the same global model_id while their Accounts, Deployments, Credentials, Policies, and gateway publications remain isolated.

For Workflows, verify that installing the same template into two hosts creates independent host definitions and that publishing a newer template version does not alter either installed definition or an existing process.

Migration Order

  1. Adopt this scope vocabulary and classify each candidate entity as global-only, host-only, mixed definition, registered, or installed template.
  2. Add source-grounded tests for current behavior before schema migration.
  3. Separate ownership, visibility, and enablement fields in APIs and forms.
  4. Fix scoped by-ID reads and prohibit scope-changing updates in existing nullable-host entities.
  5. Implement the LLM global catalog migration and Registration foreign key.
  6. Add immutable workflow templates and Install/Fork operations while retaining the operational host table.
  7. Migrate rules away from common scope ambiguity. Preserve tenant publication only if it is an explicit product requirement.
  8. Align event identities, semantic uniqueness, snapshots, taxonomy, and promotion for every migrated entity.
  9. Add effective catalog endpoints with explicit scope and provenance.
  10. Update portal-view so users can distinguish Browse, Register, Install, Publish, Fork, and Edit actions.

LLM Catalog Cutover

Treat the llm_model_t migration as a coordinated maintenance cutover. Pause LLM model commands and projection consumers, back up the database, apply the global-catalog patch, deploy the matching command/query/persistence services and portal UI, and then resume processing. The patch deliberately aborts if host copies disagree, if a model uses host-scoped taxonomy, or if registrations would collide after deduplication; curate those records and rerun the patch.

Take a post-migration projection baseline before retiring the old deployment. A from-zero replay that includes pre-cutover host-scoped model streams also needs the one-time old-to-canonical model-id mapping produced during migration; without that mapping, two historical streams for the same provider model can attempt to recreate duplicate global rows.

Consequences

The platform gains one consistent meaning for global ownership and avoids copying the ambiguous rule model. LLM Models become a true platform catalog, while tenant use remains controlled by Registration and Deployment data. Workflow templates become reusable without weakening the tenant boundary or allowing mutable platform content to alter execution unexpectedly.

The cost is that global sharing is not implemented by one generic flag. Some entity families need a registration, binding, installation, or publication table because their runtime and ownership semantics are different. That extra structure is intentional: it makes cross-tenant impact explicit and auditable.

Global And Tenant Knowledge Bases For Shared Agent Retrieval

Status

Proposed design for discussion.

Decision Summary

Add Knowledge Bases as a Light Portal resource with two ownership scopes:

  • knowledge_base_t.host_id IS NULL represents a platform-owned global Knowledge Base that is discoverable and bindable by every tenant;
  • knowledge_base_t.host_id = the trusted host represents a Knowledge Base owned and administered by that tenant.

Do not add a common flag. Global scope comes only from the nullable host_id defined by Global And Tenant Entity Scope.

A global Knowledge Base is not automatically queried by every Agent. Each tenant uses an explicit host-local Agent binding to enable it. This separates platform ownership and global visibility from tenant enablement and avoids silently changing every Agent’s context.

Keep the product and administration experience in Light Portal, but run projection, ingestion, and retrieval in one independently deployable light-knowledge service under light-fabric/apps. Build its API on the light-axum framework and ship one long-running container by default. Inside that process, supervise the retrieval API, durable Portal-event consumer, runtime projection, lightweight job scheduler, and on-demand build tasks as separate lifecycle components. Retain the build engine as reusable library and CLI/Kubernetes-Job functionality for exceptional heavy or offline work, but do not require an idle worker deployment in the normal topology.

Keep authoritative Knowledge Base administration in the Config Server PostgreSQL database, and materialize the runtime authorization projection plus all high-volume Knowledge data-plane state in a logically separate knowledge database. A deployment may place both logical databases in one PostgreSQL database for a small installation or use separate databases or PostgreSQL instances for stronger isolation. The target runtime behavior and transaction boundaries are identical across those deployment modes, but compatibility mode still adopts the new explicit two-pool delivery boundary rather than preserving the predecessor project-loop’s single physical transaction. Every retrieval request resolves Knowledge Base state, source state, Agent bindings, retrieval-profile selection, and strategy qualification from one transactionally consistent snapshot in knowledge; it never joins to Config Server. Security-removing control-plane actions do not report effective completion until that projection acknowledges them, and a projection whose heartbeat or sequence is stale beyond policy fails closed.

Use a generally available PostgreSQL release with pgvector as the initial metadata, lexical-search, vector, authorization, graph-projection, and job-state database. PostgreSQL 19 SQL/PGQ is a future qualification target after general availability; it exposes relational tables as read-only property graphs and is not a native graph-storage engine. Store original binary documents in object storage. Do not introduce a separate vector database, graph database, or Turso backend for the first release. Turso remains a possible later embedded or local-first storage profile only after it passes the same authorization, transaction, concurrency, recovery, vector-recall, and operational gates.

Treat embedding models as replaceable dependencies. Persist normalized document versions and immutable chunk artifacts independently from embedding vectors and generation-specific search projections. A model upgrade builds an isolated candidate vector space from those reusable artifacts, catches up concurrent source changes, passes retrieval-quality and authorization gates, and changes the active generation only through an atomic promotion. Retain the predecessor generation for a bounded rollback window; never mix incompatible vectors in one similarity index or rewrite a promoted generation in place.

Treat routine source additions, edits, deletions, and permission changes as incremental operations. A logical index generation may reuse an immutable base segment plus ordered immutable delta segments instead of copying or rebuilding the complete physical index. Each validated update publishes a new generation manifest atomically; periodic compaction replaces accumulated deltas with a new base only when measured size, latency, or dead-record thresholds require it.

Implement hybrid RAG first:

  1. filter by trusted consumer tenant, effective Knowledge Base visibility, active Agent binding, source state, and document authorization;
  2. generate lexical and vector candidates;
  3. fuse the candidate lists;
  4. optionally rerank the small fused set after a canonical rerank operation is separately designed and qualified;
  5. return bounded passages with stable citations.

Keep the retrieval contract framework-independent. External RAG projects and papers can inform evaluation hypotheses, but the Portal resource model, authorization boundary, canonical data, APIs, and lifecycle remain owned by Light Portal. Do not expose or copy another framework’s workspace, schema, or query contract into tenant-facing APIs.

Do not implement GraphRAG in the first release. Add it only as an optional derived index and retrieval strategy when a measured evaluation set shows that relationship-heavy or corpus-wide questions are not served adequately by hybrid RAG. A graph index never becomes the source of truth or an authorization boundary.

If a graph-assisted strategy is qualified later, prefer a bounded relational path planner over returning every neighbor or complete community by default. The planner operates only on authorized evidence contributions, prunes noisy or low-value traversal, and preserves useful relationship ordering as optional evidence structure. Canonical chunks and citations remain the returned evidence; a path score or generated graph description never becomes factual authority.

Keep Knowledge Bases separate from Hindsight Agent Memory. Hindsight stores session and Agent experience; a Knowledge Base stores governed source material. They may reuse common embedding, authorization, audit, and job-processing primitives, but they must not share aggregate identities, lifecycle rules, or Portal workspaces.

Make the embedding-space registry, deployment-conformance rules, and protected gateway workload-lane machinery shared platform components. Knowledge Base, Hindsight, and tool-description embedding remain separate consumers with their own declared spaces, policies, quotas, and lifecycle; they must not implement three drifting copies of the compatibility contract.

Context

The current Agent Memory Event Refactor implements a bank-first Hindsight workspace and the following runtime-oriented data:

  • documents, memory units, entities, co-occurrences, links, directives, and reflections;
  • session history associated with interactive Agent execution;
  • a direct PostgreSQL runtime path and an event-backed Portal administration path;
  • a fixed-dimension vector index for memory units.

That schema is useful evidence that PostgreSQL and pgvector already fit the platform, but it is not a Knowledge Base implementation:

  • a Hindsight bank can be bound to one Agent or user, while one Knowledge Base must be reusable by many Agents;
  • current memory document records do not model connector cursors, external versions, source permissions, parse artifacts, or index generations;
  • current memory recall is vector-oriented and does not provide the lexical fusion, citation, source synchronization, or principal-level ACL contract required for enterprise documents;
  • Confluence and SharePoint content has an external lifecycle and an authorization model that must remain visible after ingestion;
  • source documents can contain instructions that are untrusted from the Agent’s perspective.

The workflow schema already has a data-store concept for RAG. Knowledge Bases should become the concrete Light Portal resource referenced by that concept rather than creating workflow-local copies of documents.

Goals

  • Let each tenant create and administer multiple Knowledge Bases.
  • Let platform administrators create global Knowledge Bases that every tenant can discover and bind without copying their documents.
  • Let a Knowledge Base be bound to zero or more Agents and workflows.
  • Ingest uploaded files, Confluence content, and SharePoint content incrementally.
  • Enforce tenant, Agent-binding, source, and end-user authorization before any content is returned.
  • Provide useful hybrid retrieval with citations and deterministic version evidence.
  • Keep ingestion failures and source staleness visible and recoverable.
  • Support asynchronous parsing, chunking, embedding, ACL refresh, and reindexing.
  • Support planned embedding-model migrations with cost estimation, resumable backfill, source-delta catch-up, isolated evaluation, atomic promotion, and bounded rollback without interrupting retrieval from the active generation.
  • Give Portal administrators a retrieval playground and quality evidence before enabling a Knowledge Base for production Agents.
  • Preserve a path to additional retrieval engines and GraphRAG without making them first-release dependencies.
  • Let retrieval strategies evolve behind one authorization, evidence, citation, and audit contract without adopting an external framework’s public API.
  • Apply routine source changes by rebuilding only artifacts whose versioned inputs or contracts changed, while keeping deletion, permission, metadata, citation, and retrieval behavior transactionally consistent.

Non-Goals

  • A Knowledge Base is not conversational memory or session history.
  • It is not a general file-authoring or content-management system.
  • The first release does not generate answers. It retrieves evidence; the Agent or workflow owns answer generation. A future qualified reranker may score candidates without becoming an answer-generation path.
  • Answer-faithfulness and no-answer evaluation use a fixed, version-pinned external answer model in the evaluation harness. That model is outside the knowledge service and its identity is recorded with the evaluation result.
  • The first release does not provide GraphRAG, autonomous ontology generation, or a separate graph database.
  • The design does not require, embed, fork, or reproduce an external RAG framework. External implementations are comparative references only.
  • It does not copy provider credentials or long-lived source tokens into Portal tables or browser responses.
  • It does not turn every document, chunk, embedding, or sync progress update into a Portal business event.
  • It does not let a tenant publish a tenant-owned Knowledge Base globally by setting a common flag. A global Knowledge Base is a separate platform-owned scope with host_id IS NULL.
  • Global visibility does not mean anonymous access, automatic Agent enablement, or permission to mutate platform-owned sources.
  • Phase 1a does not perform OCR, image understanding, audio/video transcription, or spreadsheet-specific semantic extraction. Unsupported or scanned-only content is reported explicitly rather than silently indexed as empty text.

Terminology

TermMeaning
Knowledge BaseLogical corpus, retrieval policy, and active index generation with either global or tenant ownership.
Global Knowledge BasePlatform-owned Knowledge Base whose host_id is null and which is visible and bindable by every tenant.
Tenant Knowledge BaseKnowledge Base whose non-null host_id identifies its owning tenant.
Consumer hostTenant whose Agent is using a tenant or global Knowledge Base. It can differ from the owner scope of a global Knowledge Base.
Effective catalogGlobal Knowledge Bases combined with Knowledge Bases owned by the trusted consumer host.
Projection replayReapplication of authoritative administrative history to rebuild projections in the same environment, with external effects suppressed.
Logical environment publicationCreation of equivalent desired state in another environment or ownership scope through new target commands and target-local identities.
Physical restoreRecovery of the same environment from compatible Portal-history, Knowledge PostgreSQL, and versioned object-store checkpoints while preserving identities.
Portability manifestVersioned publication package containing a canonical desired-state payload plus a signed export envelope; it is not an event stream or physical backup.
SourceOne configured input such as a Confluence space, SharePoint site or library, or upload collection.
Source objectProvider object identified by a stable external identifier, such as a Confluence page or SharePoint drive item.
DocumentNormalized, versioned representation of one source object.
ChunkImmutable, citation-addressable passage derived from one document version and chunker contract. A chunk is reusable across index generations when its document version, parser output, chunker contract, and normalized text are unchanged.
Passage anchorStable structural identity for a passage across document versions when the connector or parser can prove continuity. It is separate from the immutable chunk ID used for exact historical evidence.
Embedding space contractImmutable identity for one mathematically compatible document-vector space: space ID, revision, dimension, normalization, distance metric, and document input-transform version. Equal dimensions alone do not imply compatibility.
Embedding profileApproved reference from a Knowledge Base owner scope to one model-authority host, internal embedding Alias, and immutable embedding-space contract.
Model-authority hostPlatform or tenant host that owns the LLM control-plane Alias used by the knowledge service. It is distinct from the nullable owner scope of a Knowledge Base or embedding profile.
Index generationImmutable logical manifest of document versions, chunks, ACLs, embedding space, and ordered physical index segments promoted together for retrieval.
Index segmentImmutable BASE or DELTA lexical/vector projection. A BASE represents a complete logical snapshot; a DELTA contains additions, replacements, tombstones, metadata changes, or ACL changes relative to earlier segments.
Embedding migrationOperational workflow that builds and evaluates a candidate generation under a target embedding profile while the current generation continues serving.
Canonical content watermarkMonotonic knowledge-service boundary identifying the exact document-version, chunk, ACL, and tombstone state that a candidate generation has incorporated. It is distinct from an opaque connector cursor.
Agent bindingExplicit host-local many-to-many authorization and configuration relationship between a tenant Agent and a visible Knowledge Base.
Source ACLProvider permission information normalized into subjects that the platform can compare with trusted caller identity.
ACL revisionImmutable, monotonically ordered authorization snapshot for one stable document. It changes independently from content versions and is referenced explicitly by every index segment that exposes the document.
Retrieval profileCandidate sizes, fusion, result limit, and token budget used by a binding or Knowledge Base. A rerank policy is added only after a canonical rerank operation exists.
Lexical contractVersioned identity for lexical input construction, language selection, tokenizer/parser, dictionaries, stemming, stopwords, identifier and phrase normalization, ranking function, and optional trigram or BM25 implementation.
Embedding workload laneIndependently admitted gateway ingress and memory pool for either latency-sensitive KB queries or asynchronous KB indexing. An Alias alone is not a lane because global admission occurs before Alias parsing.
Retrieval strategyServer-owned implementation selected by an authorized retrieval profile, such as HYBRID or GRAPH_ASSISTED.
Derived indexRebuildable lexical, vector, or graph projection created from canonical document versions, chunks, ACLs, and provenance.
Evidence contributionLink from a derived entity, relation, or summary to the exact chunk and document version that contributed information.
Relational evidence pathRequest-scoped ordered sequence of authorized entities and typed relations used to discover, group, and rank canonical chunk evidence. It is a retrieval artifact, not canonical Knowledge Base state.
Evidence groupOptional additive response structure that records why and in what order existing result chunks belong together, without adding uncited text or granting access to new evidence.

Phase tags on invariants mean the first delivery phase in which the invariant is binding: P0 is contract qualification, P1a is the documentation pilot, P1b is incremental and multi-KB retrieval, P2 is enterprise ACLs, P3 is production migration and scale, and P4 is optional graph-assisted retrieval. A later-phase tag does not weaken an earlier authorization invariant.

Required Invariants

  1. [P1a] A Knowledge Base has exactly one immutable owner scope: host_id IS NULL for platform-global ownership or a non-null host_id for tenant ownership.
  2. [P1a] There is no common flag. Scope, ownership, visibility, and tenant enablement are represented separately.
  3. [P1a] Only an authorized platform administrator can create, update, deactivate, delete, synchronize, or reindex a global Knowledge Base and its sources.
  4. [P1a] A tenant administrator can create and manage Knowledge Bases owned by the trusted tenant, and can bind or unbind visible global Knowledge Bases, but cannot mutate their global definitions or content.
  5. [P1a] Consumer host identity is derived from trusted authentication and routing context. A request body cannot select a different tenant.
  6. [P1a] Effective catalog queries return rows where host_id equals the trusted consumer host or host_id IS NULL. By-ID reads repeat the same visibility predicate.
  7. [P1a] An Agent can retrieve from a tenant or global Knowledge Base only through an active binding owned by the Agent’s host and valid for the same environment.
  8. [P1a] A caller-supplied Knowledge Base list can only narrow the bound set; it can never grant access or convert global visibility into runtime enablement.
  9. [P1a] Document authorization is evaluated before content is returned. A post-retrieval UI filter is not an authorization control.
  10. [P2] When source ACL mapping is incomplete, ambiguous, stale beyond policy, or unsupported, source-ACL mode fails closed.
  11. [P1a] Source content is untrusted data. Retrieved text does not override system, developer, policy, or tool-authorization instructions.
  12. [P1a] Original documents, normalized text, chunks, and embeddings have explicit versions. A retrieval result identifies the promoted index generation and document version used.
  13. [P1a] A failed or partial synchronization never replaces the last valid promoted generation.
  14. [P1a] Embedding vectors and connector secrets are never returned to the browser or ordinary Agent clients.
  15. [P1a] Deactivation, deletion, retention, and legal-hold behavior apply to both metadata and object-store content.
  16. [P1a] All retrieval results have a stable citation containing at least the Knowledge Base, source, document, content version, and source URI or equivalent locator.
  17. [P1a] Operational ingestion records are rebuildable from source and control-plane configuration. They are not treated as Portal-authored business state. A Portal event replay rebuilds configuration and projections only; it does not prove that target-environment documents, chunks, embeddings, indexes, or object-store artifacts exist. A logical publication into another environment creates new target commands and resource identities, while only an exact disaster-recovery restore preserves the original identities and effective generation.
  18. [P1a] Every retrieval audit distinguishes the nullable owner host of the Knowledge Base from the non-null consumer host whose Agent issued the query.
  19. [P1a] A retrieval strategy can rank or expand only the document versions already authorized for the request. It cannot widen tenant, binding, source, or principal scope.
  20. [P4] Every derived entity, relation, description, or summary retains complete evidence contributions. If safe authorized reconstruction is impossible, the artifact is excluded from retrieval.
  21. [P1a] Runtime callers cannot select an arbitrary engine, workspace, graph partition, or index namespace. The service resolves the active strategy and generation from trusted bindings and profiles.
  22. [P1a] Every promoted index generation is bound to one immutable embedding-space ID and revision. Dimension equality, a shared provider protocol, or a common public Alias name is not proof of vector-space compatibility.
  23. [P1a] Every embedding deployment eligible for an indexing or query Alias must declare the same complete embedding-space contract. Gateway publication rejects a mixed-space Alias, including incompatible fallback and canary routes.
  24. [P1a] Indexing and query embedding requests send the expected space ID and revision. A mismatch fails before provider dispatch; it never silently follows an Alias update or replacement.
  25. [P3] A change to model weights, dimension, normalization, distance metric, or document input transform creates a new embedding-space revision and a new candidate index generation. A query-transform-only change creates a new embedding-profile revision and evaluation; it may reuse document vectors only under the equivalence gate defined below. Existing promoted generations remain queryable only through their recorded space and profile revision.
  26. [P1a] A global Knowledge Base uses a platform-owned workload identity and an Alias owned by a designated platform model-authority host. Consumer tenants never select the embedding Alias or embed its queries directly.
  27. [P1a] The active index generation is resolved exactly once at retrieval start. Lexical, vector, graph, citation, and audit queries all use that pinned ID, even if promotion occurs while the request is running.
  28. [P1a] Global Knowledge Base retrieval is metered and admitted per consumer host. One tenant cannot exhaust another tenant’s query concurrency or cost allocation through the shared platform embedding authority.
  29. [P1a] Normalized document versions and immutable chunk artifacts are canonical intermediate state. They are not owned by one vector space. An embedding-only migration reuses them and does not refetch, reparse, or rechunk content whose relevant contracts and hashes are unchanged.
  30. [P1a] Index-generation membership is separate from chunk identity and embedding identity. A generation-specific lexical or ANN projection is rebuildable from canonical document versions, chunks, ACL revisions, and immutable embedding artifacts.
  31. [P1a] The promoted generation is authoritative for the runtime embedding profile. Selecting a target profile for a migration never mutates the profile or vector space of the promoted generation. Runtime retrieval does not read a desired or candidate profile from mutable Knowledge Base settings.
  32. [P1a] A candidate generation records a starting canonical content watermark, backfills that snapshot, consumes every subsequent content, ACL, and tombstone delta, and passes a final reconciliation fence at a recorded promotion watermark. A partial or stale candidate cannot be promoted.
  33. [P3] Old-space and new-space vectors remain in separate physical ANN indexes. Evaluation compares ranked documents, citations, and task metrics; it never treats raw similarity scores from different spaces as comparable or merges those scores into one ranking.
  34. [P3] The first release exposes candidate generations only through authorized, budgeted evaluation paths. It does not randomly route ordinary Agent requests between generations. Any future shadow or cohort rollout requires explicit privacy policy, stable cohort assignment, separate accounting, and complete per-request generation pinning.
  35. [P3] Promotion retains the predecessor generation for a bounded rollback window. Rollback is an atomic pointer transition and is allowed only while the predecessor has remained current through dual-applied deltas or has passed a fresh reconciliation gate. Rollback retention never outranks authorization removal or an approved erasure: a revoked or erased predecessor immediately loses rollback eligibility. A valid legal hold may retain inaccessible bytes when law or policy requires it, but can never keep them retrievable.
  36. [P3] Fine-tuning, provider-side weight changes, or a supposedly equivalent model replacement are embedding-space changes unless compatibility is positively proven. Open weights alone do not make existing vectors reusable. Only the query-transform-only equivalence case described above may avoid document re-embedding.
  37. [P1b] A routine source update creates an immutable DELTA segment and a new logical generation manifest that reuses unchanged segments. It does not require a complete physical vector-index rebuild merely because the generation ID changes.
  38. [P1b] Every generation’s ordered segment manifest has deterministic latest-wins semantics for stable documents, document versions, and passage anchors. A document-scoped supersede operation suppresses every older chunk for that document, including passages that disappeared and therefore have no new anchor match. Tombstones and ACL revocations suppress older segment records before content is returned; an older base hit cannot bypass a newer deny or replacement delta.
  39. [P1b] All segments in one generation use compatible parser, chunker, metadata, citation-anchor, ACL-normalization, lexical, embedding-space, and distance contracts. An incompatible contract change builds a new BASE candidate rather than adding an ambiguous delta.
  40. [P1a] Each normalized document, chunk, embedding, and derived projection records the digest of the exact inputs and versioned contracts that produced it. Invalidation walks those dependencies and rebuilds only affected descendants.
  41. [P1b] Embedding reuse is keyed by the digest of the exact transformed embedding input plus space ID/revision and document-transform version. Reuse is limited to an approved Knowledge Base or owner-policy scope; it never crosses tenant boundaries or tenant/global scope by default.
  42. [P1b] A stable passage anchor never replaces immutable evidence identity. Runtime results and audit retain document-version ID and chunk ID, while the passage anchor supports current-section resolution and relationship continuity.
  43. [P1b] Deletions and ACL revocations have scheduling priority over content additions, ordinary modifications, reconciliation repair, and bulk backfill. A model migration cannot delay a known authorization removal.
  44. [P1b] Every BASE and DELTA segment publishes count and digest evidence for documents, versions, chunks, embeddings, metadata, ACL state, and tombstones. Promotion and scheduled anti-entropy checks compare the manifest with canonical state and the physical projections.
  45. [P4] Graph seed retrieval, subgraph construction, path planning, description construction, and evidence grouping operate only on the request’s authorized evidence-contribution set. Post-traversal filtering is not sufficient.
  46. [P4] A path retrieval score is a versioned ranking signal derived from query and graph structure. It is not factual confidence, authorization evidence, or a substitute for complete node, relation, and chunk provenance.
  47. [P4] Every member of a relational evidence path resolves to canonical chunks already eligible for the request. An optional evidence group may order or group those chunks but cannot introduce generated claims as evidence.
  48. [P4] Relational paths are request-derived rather than durable graph truth. Any cache is bounded and keyed by generation, authorization boundary, planner version, retrieval profile, and normalized query-signal digest; it cannot be reused across an incompatible tenant, principal, or policy boundary.
  49. [P4] Graph traversal has server-owned limits for seeds, seed pairs, fan-out, hops, visited nodes and edges, paths, tokens, wall time, and memory. Empty, disconnected, pruned, or timed-out traversal safely falls back to authorized hybrid evidence and never widens retrieval.
  50. [P1a] Runtime authorization is resolved from one transactionally consistent knowledge-service database snapshot. A cross-database join, best-effort cache, or stale control-plane projection cannot authorize a request; projection lag beyond its signed heartbeat lease fails closed.
  51. [P2] ACL state has an immutable identity and sequence independent of a document content version. An ACL-only change publishes a new ACL revision, and every segment membership references the exact revision it enforces.
  52. [P1b] Multi-Knowledge-Base retrieval ranks within each Knowledge Base and embedding space before cross-KB fusion. It never compares raw lexical, vector, reranker, or graph scores across incompatible profiles or spaces.
  53. [P1a] A binding-selected retrieval profile can only narrow a Knowledge Base’s active qualified-strategy set and server-owned budgets. It cannot enable GRAPH_ASSISTED or any other strategy the Knowledge Base has not qualified.
  54. [P1a] Lexical projections are bound to one immutable lexical contract. Segments with different tokenization, dictionaries, stemming, stopwords, identifier handling, language rules, or ranking implementation are not silently fused as one compatible lexical index.
  55. [P2] ACL correctness is bounded by discovery as well as application. Every MIRROR_SOURCE_ACL source has a maximum reconciliation interval, a maximum ACL age, and a measured revocation-visibility SLO; exceeding any of them removes the affected source from eligibility until reconciliation.
  56. [P1a] Initial crawl, resynchronization, reindex, and migration all have explicit tenant and Knowledge Base ceilings for discovered objects, chunks, source bytes, stored bytes, embedding tokens and spend, wall time, and concurrent work. Reaching a ceiling pauses or fails boundedly and never changes the active generation.
  57. [P1a] A source trust tier and approval policy are retrieval metadata, not authorization or instruction priority. Global content cannot be promoted without its configured change-review gate, and every citation exposes the source trust tier so Agent policy can treat global evidence appropriately.

Bounded Context And Topology

Use Light Portal as the control plane and a knowledge service as the data plane:

                          Light Portal control plane
                    ┌─────────────────────────────────┐
Portal administrator│ Knowledge Bases, Sources,       │
───────────────────>│ Agent Bindings, Policies, Jobs  │
                    └──────────────┬──────────────────┘
                                   v
                    Config Server PostgreSQL database
                    authoritative commands and events
                                   │ read/catch-up
                                   v
             ┌─────────────────────────────────────────────┐
             │ one long-running light-knowledge container  │
             │                                             │
             │ durable event consumer -> runtime projection│
Controller ─>│ bounded control handler      │              │
             │                              v              │
Confluence ─>│ connector ─┐       durable job queue        │
SharePoint ─>│ connector ─┼─> on-demand build tasks        │
Uploads ────>│ connector ─┘                 │              │
             │                              v              │
             │ retrieval API <──── knowledge database      │
             │                       PostgreSQL + pgvector │
             └──────────┬────────────────────┬─────────────┘
                        │                    └─ object storage
                        v
               Agents, workflows, MCP

Implement the knowledge data plane in the light-fabric workspace. The apps/light-knowledge application is the stateless API artifact and uses light-axum for its HTTP runtime, middleware, configuration integration, security integration, health endpoints, and REST and MCP transports. Keep the Portal command/query services as the control-plane boundary; do not move their administrative actions into light-knowledge.

The default deployment contains only light-knowledge. Its internal components remain explicit rather than being folded into HTTP handlers:

  • the REST and MCP retrieval API;
  • one durable Portal-event consumer and idempotent runtime projector;
  • one lightweight job supervisor that waits without polling aggressively, performs recovery and scheduled reconciliation, and starts bounded job tasks;
  • on-demand connector, parsing, chunking, ACL, embedding, compaction, migration, and maintenance tasks; and
  • a bounded controller command handler for runtime operations.

Domain, authorization, storage, projection, and ingestion logic belongs in shared light-fabric/crates/knowledge-* crates. The service binary composes those libraries; it does not duplicate the former worker implementation inside HTTP handlers. The job engine remains callable from a CLI or an optional Kubernetes Job for large Confluence, SharePoint, migration, restore, or backfill work. That escape hatch is execution on demand, not a required always-running second service.

The single process keeps a read-only control-plane event pool and distinct Knowledge API, projector, and job pools and database roles for accidental-misuse containment, query attribution, and auditing. A narrowly scoped acknowledgement client may call the Portal command API; it does not gain general Config Server write access. This does not preserve process-level credential isolation: a complete process compromise can reach every credential held by the container. Production deployments that require that stronger boundary must select the optional external job-execution mode and prove contract parity with the embedded engine.

Indexing is admitted behind bounded semaphores and separate database and gateway lanes so it cannot consume the final capacity reserved for retrieval. CPU-heavy or blocking work uses bounded blocking pools or child processes whose lifetime is owned by the job task. A job panic, connector failure, or exhausted build budget fails that job without terminating the HTTP server. Container-level OOM, disk exhaustion, and image/tooling exposure remain shared risks and are covered by the capacity and isolation gates below.

Configuration and bootstrap

Do not deploy a light-knowledge-bootstrap container. light-runtime loads non-secret configuration and approved files from Config Server into its merged runtime configuration before binding the server. light-knowledge consumes that merged configuration rather than reopening a separately mounted knowledge.yml behind the runtime’s back.

Only the minimum information needed to reach Config Server remains local or embedded: its URI, client authentication, TLS trust, service identity, and environment. The merged configuration identifies a control-plane event source and a Knowledge data-plane database, while their credentials remain deployment secrets. A compatibility deployment may resolve both to one PostgreSQL database; an isolated deployment uses different database or instance endpoints. The service validates both connections and their expected database identities before readiness and refuses a configuration that points the Knowledge write roles at the control-plane schema. Delegation and heartbeat secrets, query-cache keys, connector credentials, and the distinct kb-index and kb-query workload credentials also come from deployment secret references. Direct environment values are permitted for local development, but production prefers _FILE, orchestrator-secret, or secret-provider references because plain environment values are visible through common container inspection paths.

Configuration reload is explicit. Safe limits and feature switches may reload through a registered runtime module. Database endpoints, credential rotations, embedding-space identity, object-store roots, and other construction-time settings require a coordinated component rebuild or process restart; a generic reload_modules acknowledgement must not claim they changed in place.

Durable events and controller commands

Portal commands and their committed CloudEvents remain the durable source of administrative intent. The embedded consumer establishes PostgreSQL LISTEN on the control-plane event source before catch-up, but notification is only a wake hint. Its durable source cursor and inbox live in knowledge. A batch is read from Config Server after the local cursor, then the relevant inbox rows, runtime projections, jobs, acknowledgements, and new cursor are committed in one knowledge transaction. Uninterested events still advance that local cursor. Because no distributed transaction spans the two databases, a crash may reread an event; event ID, aggregate sequence, and payload digest make that delivery idempotent. Source retention must exceed maximum supported outage and rebuild time, and a cursor older than retained history fails closed into an explicit snapshot/reseed workflow rather than skipping missing events.

The controller command stream is an optional low-latency operational path, not a replacement for committed Portal intent. A Portal UI mutation commits its command/event first; the controller may then tell light-knowledge to wake and catch up to the named event or offset. Direct controller tools may expose status, reload, retry, and wake operations. Any tool that creates work returns a durable job ID, uses an idempotency key and trusted host/environment scope, and never keeps the control stream open for the duration of a build. If the service is offline or the WebSocket acknowledgement is lost, replay of the committed event still converges to the same projected state and job.

Embedded job execution

There is no permanently active builder task consuming resources. A lightweight supervisor owns queue notification, startup recovery, expired-lease handling, scheduled maintenance, promotion acknowledgement, and a periodic fallback scan. The database emits a notification when work becomes eligible; notification is only a wake-up hint, and the durable knowledge_job_t row remains authoritative.

Each claimed job runs in a bounded supervised task and exits when it reaches a terminal, paused, or retryable state. Claims retain lease tokens, FOR UPDATE SKIP LOCKED, bounded renewal, idempotent effects, and terminal compare-and-set updates so several light-knowledge replicas can safely share the queue. Singleton projection and scheduling work uses a durable consumer lease or database advisory leadership with takeover; it is not inferred from a container name.

An external CLI or Kubernetes Job invokes the same job engine and claims one explicit durable job. It cannot bypass authorization, budgets, leases, generation validation, promotion, acknowledgement, audit, or purge contracts.

Database boundary and physical isolation

The control plane and data plane are separate logical databases even when a small deployment colocates them physically:

ModeDeploymentIsolation
CompatibilityConfig Server and Knowledge schemas share one PostgreSQL database.Lowest operational cost; no resource or failure isolation.
Separate databaseconfigserver and knowledge are different databases in one PostgreSQL cluster.Separate credentials, namespaces, logical backup and connection budgets; shared CPU, memory, I/O, WAL and failure domain.
Separate instanceconfigserver and knowledge use different PostgreSQL instances or managed clusters.Strongest resource, maintenance, failure-domain and restore isolation.

Code and migrations target the logical boundary, not the compatibility mode. There are no cross-database foreign keys, joins, writes, or distributed transactions. Control-plane UUIDs stored in knowledge are external identities validated through ordered projections and reconciliation. Local foreign keys may reference local projection roots. Backup and restore validate the Portal event watermark, Knowledge projection cursor, Knowledge checkpoint, and object manifest as one compatibility set.

Compatibility mode adopts the new two-boundary delivery semantics; it does not preserve the predecessor project-loop transaction that reads event_store_t and writes Knowledge projections through one database transaction. Even when both logical roles resolve to one physical database, the service uses the explicit control-event and Knowledge pools and accepts idempotent redelivery across their commit boundary. Colocated-mode crash-window tests are therefore mandatory, not implied by isolated-mode coverage.

The local effective Knowledge Base projection root carries environment and all other immutable or versioned fields required by data-plane constraints. Predecessor database functions and triggers that read authoritative control-plane tables are repointed for isolation. In particular, validate_knowledge_index_generation_profile() validates against the local embedding-profile projection, and promote_knowledge_base_generation() reads the local Knowledge Base environment while keeping pointer compare-and-set, history, outbox, and acknowledgement evidence in one Knowledge transaction. Fresh-schema gates inspect function and trigger dependencies and reject any data-plane routine that still resolves a Config Server table.

cascade_relationship_policy_t is also boundary-owned metadata. Policies for foreign keys that move with Knowledge data are installed and validated in the Knowledge database; Config Server retains only policies for constraints that remain there. Migration removes or relocates obsolete registry rows in the same release that moves their constraints, and both fresh and upgraded Config Server schema gates must pass validate_cascade_relationship_policies().

Portal’s event store is authoritative for the administrative history, but it is not joined across databases on the retrieval hot path. The projector scans only Knowledge event families through a partial (event_ts, id) keyset index and drains bounded batches until caught up. Database and transport failures retain the cursor for retry. Referential dependency gaps are durably parked while the source cursor advances. Retries use a five-second exponential backoff capped at five minutes and are dead-lettered after eight attempts with KNOWLEDGE_PROJECTION_GAP_RETRY_EXHAUSTED; an operator may requeue a repaired event through knowledge.retry_projection_event. Only deterministic rejected event content is dead-lettered immediately. Heartbeat renewal is scheduled independently from projection leadership and catch-up work. The knowledge-service PostgreSQL database contains a narrow runtime authorization projection with:

  • Knowledge Base owner, environment, effective lifecycle state, active qualified strategies, and control-plane version;
  • source effective state, ACL mode, trust tier, approval state, quota policy, and ACL freshness policy;
  • host-local Agent binding state, retrieval profile, priority, and version; and
  • the last applied Portal event sequence, signed heartbeat time, and projection acknowledgement.

The retrieval API first serializes quota admission in a short read-committed transaction that locks the consumer quota row. It then reads the runtime authorization projection, active generation pointer, source ACL revisions, and candidate rows in one separate transaction at a repeatable snapshot. It never authorizes from a Portal-database join or a process-local cache. The projection consumer applies events in aggregate order and uses an idempotent inbox; applied state and acknowledgement are committed together. A sequence gap parks only the affected aggregate and cannot stop progress for unrelated aggregates. The signed heartbeat and authorization-lease renewal run on an independent timer, so a parked or malformed event cannot expire every healthy binding.

An authorization-removing action such as unbind, source deactivation, Knowledge Base deactivation, or strategy revocation is PENDING until the knowledge database acknowledges the deny fence. The initial SLO is five seconds from accepted command to effective deny. The control plane emits a signed heartbeat at least every ten seconds; if the knowledge service is more than thirty seconds behind the advertised sequence or heartbeat, new retrieval fails closed for the affected environment. These are launch ceilings and may be tightened, not silently relaxed, by deployment configuration. Portal shows desired versus effective state and the measured revocation lag.

Projection and promotion acknowledgements are accepted only from an allowlisted Knowledge workload principal. During topology rollback compatibility, KNOWLEDGE_WORKLOAD_PRINCIPALS admits both the existing light-knowledge-worker principal and the consolidated light-knowledge principal. The consolidated principal must pass acknowledgement and five-second deny-fence tests before cutover; the worker principal is removed only after the R7 rollback window closes. Local, development, installer, and production-like configuration use the same identity migration contract rather than environment-specific literal names.

Ownership And Event Model

Use the existing Portal command, event store, and projection model for low-volume durable configuration:

  • Knowledge Base identity, display metadata, state, and policy;
  • Source configuration without secret material;
  • Agent-to-Knowledge-Base bindings;
  • requested lifecycle actions such as synchronize, reindex, compact, embedding migration, generation promotion or rollback, deactivate, retire, and purge approval.

Suggested configuration events are:

KnowledgeBaseCreatedEvent
KnowledgeBaseUpdatedEvent
KnowledgeBaseDeactivatedEvent
KnowledgeBaseDeletedEvent

KnowledgeSourceCreatedEvent
KnowledgeSourceUpdatedEvent
KnowledgeSourceDeactivatedEvent
KnowledgeSourceDeletedEvent
KnowledgeSourceSyncRequestedEvent
KnowledgeSourceConnectivityTestRequestedEvent

AgentKnowledgeBaseBoundEvent
AgentKnowledgeBaseBindingUpdatedEvent
AgentKnowledgeBaseUnboundEvent

KnowledgeBaseReindexRequestedEvent
KnowledgeBaseCompactionRequestedEvent
KnowledgeBaseEmbeddingMigrationRequestedEvent
KnowledgeBaseEmbeddingMigrationPauseRequestedEvent
KnowledgeBaseEmbeddingMigrationResumeRequestedEvent
KnowledgeBaseEmbeddingMigrationCancelRequestedEvent
KnowledgeBaseIndexGenerationPromotionRequestedEvent
KnowledgeBaseIndexGenerationRollbackRequestedEvent
KnowledgeBaseIndexGenerationRetirementRequestedEvent
KnowledgeBasePurgeRequestedEvent

KnowledgeBasePortabilityManifestIssuedEvent

KnowledgeBaseImportStartedEvent
KnowledgeBaseImportDependenciesBoundEvent
KnowledgeBaseImportBuildApprovedEvent
KnowledgeBaseImportAbandonedEvent

KnowledgeBaseRetrievalStrategyQualifiedEvent
KnowledgeBaseRetrievalStrategyRevokedEvent
KnowledgeBaseIndexGenerationPromotedEvent
KnowledgeBaseIndexGenerationRolledBackEvent

Do not create one event for each document, chunk, embedding, permission row, or sync progress transition. Those are high-volume operational projections owned by the knowledge service. A control-plane request may create an operational job, and job status is then queried from the operational read model.

Cross-environment publication uses two separate aggregates, one on each side. Neither is part of the Knowledge Base aggregate, because both must outlive the Knowledge Base rows they describe.

KnowledgeBasePortabilityManifestIssuedEvent belongs to a source-side manifest-export aggregate identified by source owner scope, source environment, and publication_id. The KnowledgeBaseImport* events belong to a target-side publication aggregate identified by target owner scope, target environment, and publication_id, never the target knowledgeBaseId, because the publication identity must survive target deletion:

Global export:  environment|publicationId  (manifest-export aggregate)
Tenant export:  hostId|environment|publicationId
Global import:  environment|publicationId  (publication aggregate)
Tenant import:  hostId|environment|publicationId

The two aggregates share a publication_id but are distinct streams in different environments and are never merged, correlated into one lifecycle, or used to authorize each other. A target may import a manifest whose source environment has no reachable history, and a source may issue a manifest that is never imported.

Export appends exactly one KnowledgeBasePortabilityManifestIssuedEvent after successful signing and before the artifact is released. It records only the publication_id, payload_digest, source Knowledge Base UUID and version, manifest-format version, exporter reference, issuance time, signing-key ID, signature digest, and delivery classification. It never records payload content, signature bytes, display names, source URIs, credentials, or artifact contents. KMS audit logs remain authoritative for cryptographic key use; this event records the administrative issuance decision, not the key operation. Export is synchronous in the first release. If it later becomes asynchronous, split issuance into distinct requested and completed events rather than redefining this one.

KnowledgeBaseImportStartedEvent records the manifest digest and the generated target identities, so the ordinary target KnowledgeBaseCreatedEvent and KnowledgeSourceCreatedEvent carry no import-specific fields. Abandonment is recorded on the publication aggregate, not on the imported Knowledge Base.

The event-backed configuration must remain usable if the knowledge service is temporarily unavailable. Conversely, the retrieval API must continue serving the last promoted generation while a new synchronization or reindex is running, provided its runtime authorization projection remains within the signed freshness lease. A stale projection does not trade availability for an unbounded authorization-revocation window.

Cross-environment publication, replay, and restore

Projection replay, logical publication, and physical restore are different operations and must not share an ambiguous “import events” control:

OperationPurposeIdentity and data behavior
Projection replayRebuild Portal or knowledge authorization projections for the same environment from authoritative administrative history.Preserves aggregate identities and versions, suppresses external side effects, and does not recreate operational Knowledge data.
Logical environment publicationCreate an equivalent Knowledge Base definition in another environment or ownership scope.Submits new target-environment commands, creates new Knowledge Base/source identities and target-local dependency mappings, and builds a new generation from source or approved canonical source artifacts.
Physical restoreRecover the same environment and administrative history after loss.Preserves identities only when Portal history, the Knowledge PostgreSQL checkpoint, and the matching versioned object-store checkpoint are restored at compatible watermarks.

A raw chronological event stream is not a portable Knowledge Base package. Historical synchronize, reindex, migration, promotion, rollback, retirement, delete, and purge requests must not execute merely because a projection is being rebuilt or events are being inspected in another environment. Projection application and external-effect dispatch therefore use explicit live versus replay/import modes. A replay updates the target projection and inbox/checkpoint state without fetching sources, calling an embedding provider, changing an active generation pointer, or issuing a second acknowledgement.

Logical publication uses a versioned portability manifest derived from accepted current desired state, not by rewriting immutable source events. Its two layers have different canonicalization rules:

  • desired_state_payload is deny-unknown, canonically serialized, and content-addressed. It contains its payload schema version, source environment, source Knowledge Base identity and version, source lineage, portable Knowledge Base/source metadata, immutable repository revision or provider version where available, bounded include/exclude policy, trust and approval policy, schedules, ingestion/retrieval policy, required processing-contract digests, required embedding-space and workload-lane characteristics, and named target binding slots. Target requirements do not assume that a source-environment Alias, profile UUID, provider, endpoint, credential, or Agent exists in the target.
  • publication_envelope is not part of the payload digest. It contains the manifest-format version, explicit publication_id, digest algorithm, payload_digest, export time, exporter identity, signing-key identity, and signature. The signature covers the canonical envelope fields other than the signature, including payload_digest; the digest is computed only from canonical desired_state_payload bytes and therefore never includes itself.

publication_id is a UUIDv7 generated by the source environment’s export command, never supplied by a caller and never derived from the payload. Each export attempt mints a new one, and the signature binds it to that exporter and digest. A caller-chosen or reused identifier is rejected, because the identity below is permanent in the target and a squatted value would otherwise burn it for an unrelated publication.

Two exports of unchanged desired state have the same payload_digest even when their export time, exporter, signature, or publication_id differs. Reusing a publication_id means retrying the same intended target publication; selecting a new publication_id explicitly requests a separate publication attempt.

The portability manifest never contains secret values, bearer tokens, raw connector credentials, vectors, query-audit text, job progress, source environment pointer state, or promotion/rollback acknowledgements. Active Agent bindings are excluded by default because Agents and consumer hosts are environment-local; an authorized target administrator may map selected bindings explicitly after their Agent definitions and policies are validated.

The target command processor re-authorizes the importing actor, derives the target environment and permitted owner scope from trusted context, verifies the envelope and payload digest, generates new resource UUIDs, records the source-to-target identity map, and emits ordinary target creation events. A TENANT target derives its host from the authenticated target context. A GLOBAL target requires the explicit platform Knowledge Base administration capability; neither an authorized tenant importer nor a tenant-authored manifest can grant or translate itself into GLOBAL scope. A GLOBAL target also inherits the global source trust, approval, and change-review gate before target-local promotion.

The idempotency identity is target owner, target environment, and publication_id, permanently bound to one payload_digest. The same publication_id and digest always returns the same import and target identities and may resume it only while its state is explicitly retryable. The same publication_id with another digest is rejected. Abandonment is terminal: an abandoned import cannot resume, and abandoning, deleting, retiring, or purging its target never releases the publication identity. A new attempt requires a new publication_id; a new ID with the same payload is an explicit separate publication and remains subject to ordinary name, scope, quota, and authorization rules. A retained import tombstone preserves this guarantee after target deletion.

That tombstone is content-minimized and retained indefinitely by design. It holds only the publication identity, payload_digest, terminal state and reason code, timestamps, the authorizing actor reference, the generated target identities that must never be reissued, and bare source lineage identifiers: source environment plus source Knowledge Base UUID and version. It holds no manifest payload, display names, source URIs, policy or schedule detail, credential or secret reference, document content, or personal data, so indefinite retention is compatible with the erasure and retention rules for every other artifact. Purge and erasure workflows may remove the imported Knowledge Base and its content but never the tombstone.

Abandonment requires the same target-scope administration capability as the import that created the publication: an authorized tenant administrator for a TENANT target, and the platform Knowledge Base administration capability for a GLOBAL target. It is irreversible, so it is authorized and audited as an administrative action rather than as an ordinary cleanup.

Import creates the Knowledge Base in DRAFT with no active generation pointer and no effective promotion state. Source credentials, secret references, model-authority host, Alias, embedding profile, budgets, feature flags, and any selected Agent bindings must be rebound and validated in the target before one explicit reconciliation/build request is admitted.

The target worker then obtains the pinned source revision and runs the normal idempotent pipeline. If that revision is unavailable, changed, outside policy, or incompatible with the target processing contracts, publication fails closed without promoting partial data. An approved publication may reference canonical original or normalized source artifacts in a separately encrypted and authorized object bundle; those artifacts remain outside Portal events and use the same hashes, retention, malware/type, ownership, and object-checkpoint rules as ordinary ingestion.

A source-environment KnowledgeBaseIndexGenerationPromotedEvent is lineage and audit evidence only in a logical publication. It cannot establish target effective state because the referenced generation, vectors, ACL revisions, citations, object versions, and embedding deployment have not been proven in the target. The target publishes its own promotion acknowledgement only after its complete generation passes authorization, quality, citation, capacity, and embedding-space gates.

Physical restore is the only portability mode that preserves resource UUIDs, aggregate versions, active pointer, and historical acknowledgement state. It uses the backup contract below rather than logical publication, rejects partial Portal/database/object-store combinations, and keeps retrieval disabled until the restored authorization projection, generation manifest, object versions, embedding-space availability, and environment identity are mutually consistent.

For Phase 1a, a clean target fixture means fresh isolated Portal and Knowledge schemas, an isolated object-store namespace, and the deterministic fake embedding provider. It is an integration/CI fixture, not a requirement for a second deployed environment. Phase 3 production qualification adds an exercise against a separately deployed clean target environment before operator-driven cross-environment publication is enabled.

Global And Tenant Scope

Knowledge Bases intentionally support both scopes in one root table:

knowledge_base_t.host_idOwnershipPortal visibilityRuntime use
nullPlatform-globalEvery authenticated tenant can discover it.A tenant Agent needs an active host-local binding.
trusted host IDTenant-ownedOnly that tenant can discover it.An Agent in the same tenant needs an active host-local binding.

The effective catalog for a trusted consumer host is logically:

SELECT *
FROM knowledge_base_t
WHERE host_id = :trusted_host_id
  AND environment = :trusted_environment
  AND status <> 'DELETED'
UNION ALL
SELECT *
FROM knowledge_base_t
WHERE host_id IS NULL
  AND environment = :trusted_environment
  AND status <> 'DELETED';

Equivalent OR predicates are valid when query plans and indexes remain predictable. By-ID reads must apply the same visibility rule; a globally unique UUID alone is not authorization.

The first release does not apply name shadowing. If a tenant Knowledge Base and a global Knowledge Base have the same display name, the effective catalog returns both with a visible Global or Tenant badge and their authoritative UUIDs. This avoids a tenant row silently replacing platform content.

Create commands accept a semantic scope such as GLOBAL or TENANT, not an arbitrary host_id:

  • for TENANT, the command handler derives host_id from the authenticated Portal context;
  • for GLOBAL, the handler requires a platform-admin capability and persists host_id as null;
  • an update cannot change scope; moving content between scopes requires an explicit clone or publication workflow with a new Knowledge Base identity.

Suggested event aggregate identities follow the same convention:

Global Knowledge Base:  knowledgeBaseId
Tenant Knowledge Base:  hostId|knowledgeBaseId

Knowledge Base, source, document, and chunk UUIDs are globally unique. Child resources inherit owner scope from knowledge_base_t through knowledge_base_id instead of using a caller-supplied host value. A denormalized owner_host_id may be maintained for partition pruning, but it is derived and validated by the knowledge service.

agent_knowledge_base_t remains host-scoped even when it references a global Knowledge Base. Its host_id is the consuming tenant, not the owner of the global row. Binding validation requires:

binding.host_id = agent.host_id
and binding.environment = agent.environment
and (
  knowledge_base.host_id = binding.host_id
  or knowledge_base.host_id is null
)

This permits one global Knowledge Base to have independent bindings from many tenants without copying its sources, documents, chunks, or embeddings.

Data Model

Control-plane tables

These authoritative event-backed tables reside in the Config Server database. Use globally unique resource IDs and explicit nullable owner scope:

TablePurposeImportant fields
knowledge_base_tGlobal or tenant lifecycle root.host_id nullable, knowledge_base_id, name, description, environment, status, desired_embedding_profile_id, version
knowledge_source_tConnector configuration inheriting owner scope from the Knowledge Base. ACL mode is deliberately per source so one KB may combine curated uploads with provider-mirrored content.knowledge_base_id, source_id, source_type, display_name, config_json, secret_reference, status, acl_mode, source_trust_tier, approval_policy, schedule, ACL reconciliation/freshness policy, ingestion_policy_id, version
agent_knowledge_base_tHost-local many-to-many Agent binding to a visible Knowledge Base.host_id non-null, agent_id, knowledge_base_id, environment, retrieval_profile_id, priority, active
knowledge_retrieval_profile_tGlobal or tenant-owned bounded retrieval settings.host_id nullable, profile_id, strategy, candidate limits, fusion method, top_k, token_budget, optional qualified graph-planner contract and hard bounds, version
knowledge_base_strategy_qualification_tActive KB-level strategy eligibility and evidence.knowledge_base_id, strategy, status, compatible profile constraints, qualification evidence ID, qualified_at, expires_at, version
knowledge_ingestion_policy_tGlobal or tenant-owned hard crawl and indexing ceilings.host_id nullable, policy_id, max documents/chunks/source bytes/stored bytes/embedding tokens/spend/wall time/concurrency, version
knowledge_embedding_profile_tApproved immutable embedding-space and query-policy reference.host_id nullable, profile_id, profile_revision, alias_owner_host_id non-null, public_alias_id, expected_space_id, expected_space_revision, dimension, normalization, distance_metric, document_input_transform_version, query_input_transform_version, active
knowledge_base_manifest_export_tStandalone source-side projection of the manifest-export aggregate. It records one audited issuance of a portability manifest.manifest_export_id, host_id nullable, environment, publication_id, payload_digest, source knowledge_base_id and version, manifest_format_version, exporter reference, issued_at, signing_key_id, signature_digest, delivery_classification
knowledge_base_import_tStandalone projection of the publication aggregate. It records one target-scope publication attempt, its permanent identity binding, and its terminal tombstone. It is not a child of the imported Knowledge Base.knowledge_base_import_id, host_id nullable, environment, publication_id, payload_digest, manifest_format_version, exporter identity, signing-key identity, source environment and source knowledge_base_id/version lineage, state, terminal reason code, authorizing actor, target_knowledge_base_id nullable, timestamps, version
knowledge_base_import_identity_map_tSource-to-target identity lineage for one import.knowledge_base_import_id, source_resource_type, source_resource_id, generated target resource ID

Important rules:

  • knowledge_base_id, source_id, and profile IDs are globally unique UUIDs.
  • Tables with nullable owner host IDs use their globally unique resource UUID as the primary key, for example PRIMARY KEY (knowledge_base_id) and PRIMARY KEY (profile_id). A nullable host_id never participates in a primary key; ownership uniqueness is enforced with partial indexes.
  • knowledge_base_t.host_id is null for a global row and non-null for a tenant-owned row. There is no common flag.
  • Use partial semantic unique indexes such as UNIQUE (environment, name) WHERE host_id IS NULL and UNIQUE (host_id, environment, name) WHERE host_id IS NOT NULL.
  • Scope is immutable after creation.
  • knowledge_base_manifest_export_t and knowledge_base_import_t follow the nullable-owner convention above with a UUIDv7 surrogate key: PRIMARY KEY (manifest_export_id) and PRIMARY KEY (knowledge_base_import_id). Scoped uniqueness is enforced by partial indexes on both tables, UNIQUE (environment, publication_id) WHERE host_id IS NULL and UNIQUE (host_id, environment, publication_id) WHERE host_id IS NOT NULL. publication_id alone is never a primary key, because the same manifest may legitimately be imported by different tenants or into different environments; uniqueness is per owner scope and environment, matching the aggregate identity.
  • These surrogate keys are projection storage identities only. The event aggregate identity remains source or target owner scope, environment, and publication_id, and no external contract, manifest, or API response identifies a publication by its surrogate key.
  • knowledge_base_import_identity_map_t is keyed through its parent import with PRIMARY KEY (knowledge_base_import_id, source_resource_type, source_resource_id). It does not repeat host_id, environment, or publication_id, which it inherits from knowledge_base_import_t.
  • knowledge_base_manifest_export_t stores the source scope in its host_id/environment, unlike the target scope stored on the import tables. It is append-only and content-minimized, and unlike the import tombstone it is not retained indefinitely: it follows an explicit declared retention rule alongside other control-plane administrative audit records, and expiry deletes the whole row rather than degrading it.
  • knowledge_base_manifest_export_t, knowledge_base_import_t, and knowledge_base_import_identity_map_t have no foreign key to knowledge_base_t. target_knowledge_base_id is a nullable recorded value that is set when the target is created and retained after the target is deleted or purged. Deleting a Knowledge Base must never cascade to, release, or rewrite a publication identity or an issuance record.
  • An import row is never physically deleted. It transitions to its terminal tombstone state, and the content-minimization rule above bounds what it keeps: the terminal transition clears every portable-policy and exporter detail beyond that field set, and the identity map retains the generated target identities plus bare source resource IDs.
  • Child source configuration inherits scope from the Knowledge Base. A tenant administrator cannot mutate a source owned by a global Knowledge Base.
  • agent_knowledge_base_t is explicit. Do not add a single knowledge_base_id column to the Agent definition because both sides are many-to-many.
  • The binding’s non-null host_id always identifies the consuming tenant. It may reference a Knowledge Base with the same host_id or a null host_id.
  • Bindings are environment-aware so a development Agent cannot silently use a production corpus.
  • priority is an integer budget weight from 1 through 100, default 50. It allocates bounded per-KB candidate and token budget only after every selected KB receives its qualified minimum. It is not an authorization precedence or a raw-score boost; equal weights use stable Knowledge Base UUID order as a tie-break.
  • HYBRID is qualified by the Phase 1a release gate. Any other strategy requires an active knowledge_base_strategy_qualification_t row. A binding profile is rejected if its strategy is absent, expired, or incompatible with that set; tenant bindings cannot qualify a global Knowledge Base.
  • Graph-planner parameters are server-owned qualified profile settings. Runtime clients may narrow result and token limits but cannot select a planner, increase traversal bounds, or supply decay, pruning, edge-weight, or fallback behavior.
  • A global Knowledge Base uses platform-owned connector secret references. Tenant users can see redacted source health but cannot resolve or replace those credentials.
  • A global Knowledge Base can use only a global embedding profile. A tenant Knowledge Base can use a visible global profile or a profile owned by the same tenant.
  • The profile’s nullable host_id is its Knowledge Base ownership scope; alias_owner_host_id is always non-null and identifies the LLM model authority that owns public_alias_id. Do not overload either field with the other meaning.
  • A global embedding profile resolves through a designated platform model-authority host and a platform-owned internal Alias. The knowledge service uses its workload credential for both ingestion and query embedding.
  • Portal creates an embedding profile only from the LLM control-plane and conformance read model. /v1/models is an OpenAI discovery surface and is not sufficient qualification evidence because it intentionally omits space, operation, dimension, and revision details.
  • An Alias replacement or retirement never rewrites an active embedding profile. A new space or revision requires a new profile and candidate index generation.
  • knowledge_base_t.desired_embedding_profile_id is configuration for the first generation or a future migration. Runtime retrieval derives its profile only from the request-pinned promoted generation. Changing the desired profile therefore cannot move live queries into an unbuilt space.
  • Parser, chunker, and lexical contract digests belong to the artifacts they create, not the shared embedding profile. A generation records the compatible contract set that it composes. Changing a contract invalidates its affected descendants and requires a candidate generation; it requires a complete BASE only when the new artifacts cannot be composed compatibly with retained segments. It does not by itself redefine the vector space.
  • query_input_transform_version is profile policy rather than part of the immutable document-vector identity. A query-transform-only change creates a new profile revision, evaluation, and generation pointer. It may reuse the existing embedding artifacts and compatible segment set only when space ID/revision and document transform are unchanged and an equivalence gate proves the new query transform meets the release-quality floor.
  • Connector authentication uses secret_reference following the existing external-secret pattern. config_json contains non-secret provider selectors only.
  • The active index generation changes only through an atomic promotion.
  • The event-backed control-plane rows are projected into the knowledge-service database as the narrow runtime authorization projection defined above. That projection is physically local to operational retrieval tables even when Portal’s event store is deployed in another database.
  • Control-plane foreign keys to host_t, agent_definition_t, LLM Alias, and other Portal tables remain in Config Server. The Knowledge database does not reproduce those remote constraints. It stores globally unique external IDs in local projection roots, rejects out-of-order or invalid events, and reconciles projection digests and versions against authoritative events.
  • Data-plane foreign keys reference local projection roots such as the effective Knowledge Base, source, profile, and binding rows. They never reference a Config Server table through a foreign data wrapper or application-managed cross-database check on the retrieval path.

Operational tables

The knowledge service owns operational records:

TablePurpose
knowledge_sync_run_tOne scheduled or requested synchronization, cursor inputs, counts, status, and error summary.
knowledge_source_cursor_tOpaque provider delta token, cursor, watermark, and last full reconciliation time.
knowledge_document_tStable source-object identity and current lifecycle state.
knowledge_document_version_tImmutable normalized content version, content hash, source version, parser contract digest, metadata-schema version, timestamps, and object-store references.
knowledge_document_acl_tImmutable normalized ACL revision for a stable document. Rows include acl_revision_id, monotonic acl_sequence, document_id, allow/deny subject or provider-effective decision, observed/fresh-until timestamps, ACL-normalization contract digest, completeness state, and source permission evidence; content version is not its identity.
knowledge_document_relationship_tVersioned connector-proven containment, attachment, amendment, or reference relationship with source evidence and explicit lifecycle/cascade policy.
knowledge_passage_anchor_tStable provider or structural passage identity, continuity evidence, current document-version mapping, and lifecycle state.
knowledge_chunk_tImmutable reusable passage artifact with document version, passage anchor when available, parser-output identity, chunker contract digest, offsets, section path, text, token count, lexical input, lexical-contract input digest, metadata-schema version, and content hash.
knowledge_embedding_artifact_tSecurity-scoped immutable vector keyed by exact transformed-input digest, space ID/revision, dimension, and document-transform version. It may be referenced by multiple eligible chunks under the same approved reuse policy.
knowledge_chunk_embedding_tAuditable association from a chunk to an embedding artifact with creating profile/revision, request evidence, and reuse decision. A later query-only profile revision may reuse the artifact only through the defined equivalence gate.
knowledge_index_segment_tImmutable BASE or DELTA segment with watermark range, compatible parser/chunker/metadata/citation/ACL/lexical/embedding contract digests, physical projection locator, vector projection precision, state, document/chunk/vector/ACL/tombstone counts, and manifest digests.
knowledge_segment_document_tDocument-scoped BASE membership or DELTA operation. It activates one content version and exact acl_revision_id, supersedes every older version/chunk, publishes an ACL-only revision, or tombstones the document independent of passage-anchor continuity.
knowledge_segment_chunk_tBASE membership or DELTA operation for a canonical chunk/document version/passage anchor and resolved acl_revision_id; operation is upsert, replace, tombstone, or metadata-only. A document-level ACL-only delta does not duplicate one operation per chunk.
knowledge_segment_vector_tRebuildable segment-specific ANN row referencing a chunk embedding; a physical backend may materialize vector bytes in a segment-owned partition.
knowledge_generation_segment_tOrdered generation manifest linking one logical generation to compatible BASE and DELTA segments with deterministic precedence.
knowledge_index_generation_tBuilding, catching-up, validating, ready, promoted, failed, superseded, or purged logical generation; embedding profile/revision, space ID/revision, compatible parser/chunker/metadata/citation-anchor/ACL-normalization/lexical contract-set digest and member identities, query-transform version, canonical watermarks, ordered-segment manifest digest, available strategy projections and their graph/extractor/prompt contracts, and aggregate evidence.
knowledge_index_pointer_tAtomically selected active generation for one Knowledge Base and environment. The active embedding profile is derived from that generation.
knowledge_index_pointer_history_tImmutable promotion or rollback transition with previous and selected generations, authorization, evaluation evidence, reason, release notes, and rollback deadline.
knowledge_embedding_migration_tPlanned target profile, source/candidate generations, estimates, state, snapshot and catch-up watermarks, progress, evaluation evidence, promotion, rollback, and error summary.
knowledge_query_audit_tBounded retrieval evidence including strategy/planner versions, segment manifest, graph path/group aggregates, fallback, and exact result identities without embedding values or full document duplication.
knowledge_ingestion_error_tPer-object retryable or terminal failure with redacted diagnostic details.
knowledge_runtime_authorization_tTransaction-local projection of effective KB/source/binding/profile/strategy state plus applied event sequence, signed heartbeat, and acknowledgement evidence.

The Knowledge database also owns content-minimized local projection roots for the effective Knowledge Base, source, embedding profile, retrieval profile, ingestion policy, strategy qualification, and Agent binding fields required by the operational tables. Their names are finalized during schema reconciliation; they are explicitly derived rows, not a second administrative write model.

Optional strategies may add derived operational tables for extracted entities, aliases, relationships, summaries, and evidence contributions. Those tables must include knowledge_base_id and index_generation_id or an immutable segment referenced by that generation, and every generated artifact must link to its contributing chunk and document-version records. A strategy may use compatible BASE/DELTA projections only when replacement, deletion, and contribution invalidation semantics are proven; otherwise it rebuilds a complete candidate projection. Derived records never become independent Portal aggregates.

Suggested optional graph-projection tables are:

TablePurpose
knowledge_entity_tGeneration-scoped canonical entity, type, aliases, retrieval text, and origin classification.
knowledge_entity_contribution_tEntity evidence from one chunk and document version, including structural or extractor contract, version, and diagnostic confidence.
knowledge_relation_tGeneration-scoped directed typed edge between two canonical entities, including explicit, structural, or extracted origin.
knowledge_relation_contribution_tRelation evidence from one or more exact chunks and document versions, including source relation, extractor contract, and lifecycle state.
knowledge_graph_summary_tOptional theme or community retrieval text for one uniform visibility boundary.
knowledge_graph_summary_contribution_tComplete source contribution set for one generated summary.

Do not keep a merged entity, relation, or summary description as the only representation. UNIFORM_SCOPE may materialize one generation-scoped merged description because every contribution has the same visibility. A future MIRROR_SOURCE_ACL implementation must construct or cache descriptions by a proven visibility partition; it cannot reuse text synthesized from a broader contribution set.

Do not materialize all possible entity-to-entity paths as canonical rows. A path is derived for one pinned generation, authorized contribution set, query, and retrieval profile. Query audit records the planner version, bounds, aggregate seed/path/pruning counts, optional evidence-group mapping, and contribution digest without storing an uncited path narrative as source truth. If an implementation later caches paths, reference accounting and invalidation follow the same generation and authorization-boundary rules as other derived caches.

Every operational table includes knowledge_base_id and inherits its owner scope from knowledge_base_t. Tables may include a nullable owner_host_id for physical partitioning and query pruning, but the worker derives it from the Knowledge Base root and prevents it from drifting. Runtime query-audit rows additionally store a non-null consumer_host_id because a global Knowledge Base owner scope does not identify the tenant that used it.

Document identity and version identity must be separate. A source page keeps a stable document_id while edits create immutable document-version rows. An immutable chunk belongs to one document version and parser/chunker contract. A passage_anchor_id may connect corresponding chunks across versions only when provider or structural continuity is proven. Ordered knowledge_index_segment_t and knowledge_generation_segment_t records supply logical generation membership. Every MODIFY first adds a knowledge_segment_document_t supersede operation for the stable document and then adds the new version’s chunks. The supersede operation makes chunks that disappeared from the new version ineligible even when there is no corresponding passage anchor. Provider deletion creates a document tombstone DELTA operation; it does not silently leave old chunks active.

An embedding-only migration reads knowledge_chunk_t directly. It creates a new knowledge_embedding_artifact_t for each distinct eligible transformed input in the target scope and associates every eligible chunk through knowledge_chunk_embedding_t. It then builds an isolated BASE knowledge_index_segment_t and knowledge_segment_vector_t projection. It does not rerun connector fetch, parsing, or chunking unless an artifact is missing, corrupt, outside retention, or bound to a changed parser/chunker contract. A physical pgvector layout may copy vector bytes into a segment-owned partition for ANN pruning; that copy is a rebuildable search projection, not a second embedding-provider call.

Persistent embedding reuse starts conservatively within one Knowledge Base. Broadening reuse to several tenant-owned Knowledge Bases requires identical owner host, encryption, residency, retention, legal-hold, transform, and data-use policy. Global and tenant scopes never share artifacts. Reference accounting prevents one chunk deletion from removing an artifact still used by another, while purge evidence proves that no prohibited reference or physical copy survives when the last eligible reference is removed.

Document-level ACLs are preferred. ACL revisions belong to the stable document_id and advance independently of immutable content versions. An ACL_ONLY change writes a new acl_revision_id and acl_sequence without mutating the prior revision or creating a content version; the next segment references that revision exactly. Chunks inherit the document authorization unless a connector can prove that a smaller section has a distinct ACL. Avoid duplicating the same permission set on every chunk.

Document relationships do not imply deletion cascade. Only a connector-proven relationship type with an explicit lifecycle policy—for example an attachment owned exclusively by one parent—may generate dependent tombstones. A reference, link, shared file, or graph-extracted semantic relationship never authorizes cascade deletion. Every dependent action is visible in the DELTA manifest and remains subject to retention, legal hold, and authorization validation.

Object storage

Store large original binaries and optional normalized parse artifacts in an S3-compatible object store. Store object keys, content hashes, encryption metadata, and retention state in PostgreSQL.

Use a path or metadata layout that begins with an explicit global or tenant owner scope, followed by Knowledge Base, source, document, and immutable content version. Object-store policies must prevent a tenant-facing client from constructing a key for another tenant or directly accessing a global object. Downloads are served through an authorized service or short-lived signed URL after the same effective-catalog, Agent-binding, and document ACL checks.

PostgreSQL and object storage do not provide one atomic transaction. Uploads therefore use a staged object key and checksum: write the object, commit its pending reference and expected object version in PostgreSQL, then mark it committed through an idempotent finalization job. A scheduled orphan collector deletes unreferenced staged objects only after a grace period and proves that no active, candidate, rollback, backup, retention, or legal-hold manifest refers to them. It also reports missing referenced objects; it never converts a missing object into an empty document.

Backups use object versioning plus a PostgreSQL checkpoint manifest containing every required object key, version, and digest. A restore is accepted only when that manifest can be resolved completely and its generation/segment digests match the restored database. A PostgreSQL snapshot without its matching object checkpoint is not a consistent Knowledge Base backup.

Database Decision

Initial choice: PostgreSQL plus pgvector

PostgreSQL is the recommended first database because the platform already operates it and the Knowledge Base needs more than nearest-neighbor search:

  • owner-scope-aware relational constraints and transactions;
  • Agent binding and source-ACL joins;
  • full-text lexical search;
  • JSON metadata and provider cursor state;
  • asynchronous job claiming and retry state;
  • HNSW vector indexes through pgvector;
  • atomic generation promotion and consistent audit evidence.

Production starts on a generally available PostgreSQL release. PostgreSQL 19 may enter compatibility CI while it is beta, but it cannot become the production baseline until PostgreSQL 19 is generally available and the exact pgvector, backup/restore, driver, migration, extension, replication, and workload image combination passes the full qualification matrix. An upgrade is a database release change with rollback evidence, not a prerequisite for the database split.

PostgreSQL 19 SQL/PGQ declares a property graph over ordinary relational vertex and edge tables and queries it through GRAPH_TABLE. The graph is a read-only logical view using PostgreSQL’s normal planning and execution infrastructure; it is not a separate native graph storage engine. This is useful for the later bounded graph-assisted strategy because canonical entity, relation, ACL, generation, and provenance rows remain relational and transactional. SQL/PGQ does not by itself qualify traversal latency, memory bounds, ACL filtering, path semantics, or graph-derived text.

Use an HNSW index for a sufficiently large BASE or DELTA vector segment and a PostgreSQL full-text index over normalized chunk text and selected title or heading fields. Prefer an exact scan for a small recent DELTA until measurement justifies a separate HNSW build. Give (knowledge_base_id, index_segment_id) a physical partition boundary—for example list/subpartitioning or a segment-owned vector table—so both values are resolved before ANN traversal. A logical generation manifest references one compatible BASE plus bounded ordered DELTAs; it does not copy unchanged vector rows merely to obtain a new generation ID.

Core PostgreSQL full-text ranking is not assumed to provide BM25 or robust exact identifier matching. The initial lexical candidate path combines an explicitly named text-search configuration with an identifier-preserving field and pg_trgm candidates for punctuation-heavy tokens and phrases such as light-portal.knowledge.base. Language selection, parser, dictionaries, stemming, stopwords, field weights, phrase behavior, trigram thresholds, and ranking/fusion formula form one immutable lexical contract. A BM25 extension may replace or supplement this path only after extension operations, upgrade behavior, recall, latency, and license are qualified and recorded as a new lexical contract.

A candidate segment or manifest is never attached to the active pointer before it passes promotion gates. Detach and later purge unreferenced segments under retention policy and the rollback deadline. Do not depend on a single cross-KB, cross-space HNSW graph plus a post-filter, and do not let DELTA count grow without a measured compaction policy.

Document ACL eligibility remains an exact predicate. With HNSW, selective ACL predicates are applied after approximate index traversal, so a fixed candidate count can under-fill results and damage recall. On pgvector 0.8 or later, enable bounded iterative scans and escalate hnsw.ef_search, hnsw.max_scan_tuples, and scan memory only within the retrieval profile’s latency and memory limits. For a sufficiently small authorized subset, prefer an exact scan over that subset. Generate lexical and vector candidates independently and fuse them with reciprocal-rank fusion. First-release HYBRID does not rerank.

Qualification compares filtered ANN results with exact authorized nearest neighbors at 100%, 25%, 5%, and 1% authorized-corpus fractions. The initial release floor is Recall@10 >= 0.90 at every stratum, with no authorization leakage. The initial end-to-end service ceiling, including authorization, query embedding, database search, and fusion but excluding Agent answer generation, is p95 <= 1,000 ms for one KB and p95 <= 1,500 ms for up to four KBs at the declared reference concurrency and corpus envelope. Warm-cache and cold-cache results are reported separately. Phase 0 may tighten these proposed ceilings; relaxing them requires an explicit design decision rather than an unnamed “separately measured” target. MIRROR_SOURCE_ACL cannot graduate from Phase 2 until the measured corpus meets every recall and latency floor at its representative selectivity.

Segmented retrieval qualification additionally compares the BASE-plus-DELTA result with an exact evaluation over the generation’s resolved logical corpus. Candidate budgets account for stale base hits suppressed by replacement, tombstone, and ACL deltas; fixed per-segment top-k values are not assumed to preserve recall. Compaction must reproduce the same eligible corpus and meet the same retrieval-quality floor before its new BASE manifest is promoted.

The current Hindsight vector dimension must not become an accidental Knowledge Base contract. The first release approves one immutable embedding-space contract per profile and records its ID and revision on every generation and chunk embedding. Two 1,024-dimensional models may occupy unrelated vector spaces. Dimension is therefore one checked property, not the identity. A model, weight, dimension, normalization, distance-metric, or document-transform change creates a new embedding-space revision, profile, and generation rather than updating an active vector index in place. A query-transform-only revision may reuse the document vectors only under the explicit equivalence gate above.

Model replacement therefore requires re-embedding every eligible chunk, not merely chunks changed since the last source synchronization. Because normalized documents and chunks are canonical reusable artifacts, that work starts at the embedding stage rather than the connector or parser stage. Capacity planning must include concurrent old/new vector storage, candidate HNSW build overhead, provider tokens and cost, delta catch-up, and the retained rollback generation.

Capacity envelope and build isolation

Phase 0 produces a capacity sheet for each launch tier with expected documents, average and p95 chunks per document, vector count, embedding dimension, vector projection precision, normalized-text bytes, metadata/index bytes, source growth, and query/ingestion concurrency. Raw full-precision vector payload is approximately chunk_count * dimension * 4 bytes: ten million 1,024-dimensional vectors are about 40.96 GB before row, HNSW, WAL, backup, dead-tuple, and build workspace overhead. Measured HNSW size and build peak replace estimates in the release gate.

The canonical embedding artifact retains the precision required for audit and qualified rescoring. A segment projection may use vector, halfvec, or binary quantization with higher-precision rescoring. Projection precision is not part of mathematical embedding-space identity, but it is an immutable segment property and must pass the same exact-neighbor recall floor before promotion.

Steady-state projected disk must remain below 60% of provisioned capacity and a candidate build plus rollback predecessor below 80%. A build is rejected or paused before crossing either ceiling. Re-evaluate partitioning or a separate vector engine when the HNSW working set exceeds 70% of serving-node memory, the declared one-year growth envelope exceeds those disk ceilings, or tuned PostgreSQL misses a recall/latency/ingestion SLO in two qualification runs.

Bulk HNSW creation, compaction, or migration cannot run as unbounded maintenance on the database instance serving the protected query lane. Production uses a dedicated candidate-build instance or an equivalently isolated build pool whose I/O, maintenance_work_mem, WAL, and parallel-worker caps have been load-tested against query p95. A candidate is copied or replayed to the serving topology and validated there before pointer promotion. The small Phase 1a pilot may share an instance only while the same caps and a concurrent-load gate prove it stays within the declared query ceiling.

Partitioning should be driven by measured data volume. The logical keys and queries must make nullable owner scope and Knowledge Base pruning possible from the start. PostgreSQL row-level security can be added as defense in depth, but its policies must explicitly distinguish platform-global rows from tenant-owned rows. It does not replace effective-catalog predicates, binding validation, or trusted service identity.

When to consider a separate vector engine

Do not add a second vector database only because the feature is called RAG. Evaluate one when production measurements show that PostgreSQL cannot meet an agreed recall, latency, ingestion, replication, or isolation objective after normal indexing, partitioning, and query tuning.

The knowledge service owns a retrieval interface so the physical engine can be replaced later without changing Agent contracts. Metadata, tenant ownership, bindings, and audit should remain authoritative in PostgreSQL even if vector candidate generation moves elsewhere.

Turso qualification boundary

Turso is not an initial alternative to PostgreSQL for the shared light-knowledge service. Distinguish production-proven libSQL/Turso Cloud vector indexing from the newer Rust Turso Database engine: their vector-index, concurrency, synchronization, SQL-compatibility, and operational contracts are not interchangeable. Neither product is assumed to provide PostgreSQL 19 SQL/PGQ or a native property-graph contract merely because an ANN vector index uses a graph internally.

Turso may be evaluated later for a single-user embedded, offline/local-first, edge-cache, or database-per-tenant profile. Such a profile must not weaken the authoritative Config Server event model or runtime authorization. Before it is supported, a code-grounded spike must replace or account for PostgreSQL-specific JSONB, arrays, TSVECTOR, GIN/pg_trgm, PL/pgSQL functions and triggers, deferrable constraints, LISTEN/NOTIFY, advisory leadership, and FOR UPDATE SKIP LOCKED; then pass identical schema, ACL non-disclosure, generation promotion, crash recovery, concurrent writer, exact/ANN recall, backup/restore, and operational gates. Keep the Rust storage boundary narrow, but do not build an unqualified lowest-common-denominator SQL abstraction in Phase 1a.

No graph database in the first release

No first-release requirement needs a graph database. If GraphRAG is later justified, begin with relational entity, relation, community, and provenance tables in PostgreSQL. Introduce a graph database only when measured traversal patterns and operational scale make the relational representation the bottleneck.

Canonical documents, ACLs, chunks, versions, citations, and generation state remain in the Light Portal knowledge schema even if an optional vector or graph engine is introduced. Engine-specific namespaces are internal mappings from knowledge_base_id and index_generation_id; they are never caller-controlled tenant identifiers.

Ingestion Pipeline

Each source object moves through two idempotent stages separated by durable canonical artifacts:

Phase 1a implements the source/change-planning stages but always builds one complete BASE candidate from their output. The routine DELTA generation stage, passage-anchor continuity, and artifact reuse shown below become active in Phase 1b only.

source stage:
  discover
  -> fetch metadata and permissions
  -> fetch changed content
  -> malware/type/size validation
  -> normalize and parse
  -> identify structure and language
  -> chunk with stable anchors
  -> persist immutable document versions, ACL revisions, and chunk artifacts

change-planning stage:
  compare source identity/version/content hash and artifact contract digests
  -> classify ADD | MODIFY | DELETE | ACL_ONLY | METADATA_ONLY
  -> invalidate only affected descendants

routine generation stage:
  build an immutable DELTA segment from changed artifacts
  -> validate the new BASE-plus-DELTA logical manifest
  -> atomically promote the manifest

incompatible-contract or model-migration stage:
  build an isolated complete BASE candidate
  -> catch up content, ACL, and tombstone deltas
  -> validate and atomically promote

The source-operation idempotency identity is based on source_id, stable external object ID, operation kind, and provider version or content hash. Artifact identity additionally includes the relevant parser, metadata, chunker, lexical, citation-anchor, ACL-normalization, document-transform, and embedding-space contract digests. Replaying a cursor page, webhook, or job must not create duplicate document versions, artifacts, or DELTA operations.

An immutable chunk ID identifies exact evidence and therefore includes document version, parser-output identity, chunker contract, offsets or section path, and normalized text hash. It changes whenever that evidence changes. A separate passage anchor remains stable across versions only when a provider anchor or versioned structural-matching algorithm proves continuity. Citations retain both identities plus source offsets or provider anchors where available.

Chunking contract

The documentation pilot starts with structure-first chunks targeting 450 tokens, a hard maximum of 800 tokens, and up to 64 tokens of overlap only when a natural heading, paragraph, list, table, or code boundary cannot provide continuity. These values are a versioned baseline to evaluate, not mutable worker defaults. The chunker contract records target/max size, overlap, tokenizer identity, boundary precedence, language handling, heading-prefix construction, and normalization rules.

Heading hierarchy is prepended as labeled context to lexical and embedding inputs while the returned passage preserves the exact source offsets. Code fences and tables remain whole when they fit the hard maximum. Oversized code is split on line/block boundaries; oversized tables are split by row while repeating the header as derived context. A chunk never combines different document versions or ACL revisions.

An optional small-to-big expansion may return the containing section around a ranked child chunk only when the section belongs to the same document version and ACL revision, fits the response budget, and repeats authorization. Ranking and audit retain the child chunk as the evidence hit and identify added parent context separately. Phase 1a returns child chunks only; context expansion must qualify in Phase 1b before enablement.

Ingestion budgets

Before discovery, every initial crawl and later synchronization reserves a bounded run budget from knowledge_ingestion_policy_t. Connectors stop paginating before exceeding object, source-byte, wall-time, or provider-call ceilings; workers stop parse/chunk/embed work before exceeding stored-byte, chunk, embedding-token, spend, or concurrency ceilings. A run that reaches a ceiling becomes PAUSED_BUDGET or FAILED_BUDGET, reports the exact bounded counters, and leaves the active generation unchanged. An authorized administrator must approve a new estimate or narrower source scope before resume. Initial sync is never exempt from these controls.

Derived projections maintain reverse provenance from each artifact to every input artifact and contributing chunk. An edit, deletion, ACL change, metadata schema change, parser change, or chunker change invalidates only descendants whose recorded input or contract digest no longer matches. The worker either reuses an exact compatible artifact, reconstructs the artifact from remaining eligible contributions, or removes it; it never leaves a description or relationship synthesized from stale source material.

Before promotion, validate at least:

  • every active document version has a parse outcome;
  • every searchable chunk has the expected embedding profile;
  • every document, chunk, filter field, citation anchor, and ACL record conforms to the generation’s declared metadata, lexical, and normalization contracts;
  • ACL synchronization completed under source-ACL mode;
  • deleted or deactivated documents have no active-generation chunks;
  • vector dimensions and embedding-space ID/revision are uniform and match the approved profile;
  • every eligible indexing and query fallback route has the same declared embedding-space contract;
  • candidate membership, embeddings, ACL state, and tombstones have reached the recorded promotion watermark, with no undrained migration deltas;
  • ordered segment precedence resolves to one current document state/version and passage anchor, document supersession leaves no older orphan chunk eligible, and segment counts/digests match the generation manifest;
  • sampled citations resolve to the expected source object;
  • aggregate source, document, chunk, and error counts are internally consistent.

The last valid generation remains active when validation fails. Portal shows the failed candidate and error counts without exposing secret or document content in logs.

Embedding execution and workload isolation

The knowledge service, not an Agent or consumer tenant, calls POST /v1/embeddings. It resolves the approved profile and sends the profile’s expected space ID and revision on every indexing and query request. The gateway returns the actual space ID, revision, and accepted configuration generation in non-provider-sensitive response headers only for a request that supplied the expected-space contract or used an Alias that requires it. Ordinary SDK calls do not receive the replica-global configuration generation. The service rejects a missing or mismatched response before storing a vector or running similarity search.

For a Knowledge Base Alias, every eligible deployment declares exactly the single supported dimension in the space contract. The gateway injects that dimension when the OpenAI request omits dimensions and rejects any different value. Merely listing the contract dimension among several provider dimensions is insufficient.

Use two internal workload Aliases and two independently admitted gateway workload lanes:

  • kb-index is asynchronous and throughput-oriented;
  • kb-query is latency-oriented and has protected capacity.

The Alias names are kb-index and kb-query; the distinct workload-lane identifiers remain kb_index and kb_query. Alias names and lane identifiers are separate contracts and must not be substituted for one another.

The immutable space registry and gateway conformance/admission implementation are shared platform services used by Knowledge Base, Hindsight, and tool-description embedding. Each consumer registers its own operation, space, data-use policy, lane, and quota. Reuse means common enforcement code and telemetry, not shared vectors, accidental equal dimensions, or one consumer borrowing another consumer’s reserved capacity.

The Aliases may have different budgets and retry policies, but Alias permits alone do not isolate the lanes: the shipped gateway’s ingress and embedding memory semaphores are acquired before Alias parsing. The implementation must therefore provide distinct query and index ingress plus memory pools selected before body capture. The preferred production mechanism is a dedicated internal listener or gateway deployment for each lane, protected by network policy and workload identity; a path or untrusted header alone is not an authorization boundary. The index lane cannot borrow query-reserved ingress, memory, provider-account, or deployment capacity.

Within the indexing lane, use bounded fair scheduling with reserved worker and database capacity in this order:

  1. known ACL revocations and document/source tombstones;
  2. ordinary incremental additions and modifications needed to meet freshness SLOs;
  3. reconciliation and anti-entropy repair; and
  4. bulk reindex, compaction, and embedding-model migration backfill.

Lower-priority work cannot consume the final reserved slots for a higher-priority class. A revocation or tombstone that requires no embedding bypasses embedding capacity entirely but still passes the same manifest validation and atomic publication path.

Both lanes must reference the same embedding-space ID and revision. Admission metrics and saturation tests are reported separately. The knowledge service also applies per-consumer-host concurrency, rate, and cost quotas before using the shared global-KB query lane; the platform Alias ledger is a backstop, not the tenant accounting mechanism. Query audit and chargeback retain the consumer host even though the provider call uses a platform workload credential.

The gateway’s capability-ceiling reservation remains the safe default. Before large ingestion runs, qualify a model-specific tokenizer or conservative input estimator for the selected space. Start with measured batches of roughly 32 to 128 chunks, not the provider maximum. When a batch has an item-specific failure, bisect it until the bad chunk is isolated; preserve input order and an input-hash idempotency key throughout retries.

Continuous updates use latency-bounded micro-batches with both a maximum item count and maximum wait time; migration and compaction use separately measured throughput batches. No fixed external-provider batch or concurrency number is a platform default. Qualification and current provider limits determine both.

Incremental Corpus Updates And Compaction

Routine source synchronization must be proportional to the change, not the corpus size. A new logical generation provides an immutable audit and rollback boundary, but it does not imply a new physical copy of every unchanged lexical row, vector, or HNSW node.

Change classification and dependency-scoped work

The worker determines work from trusted source identity/version evidence, content hashes, normalized metadata, permissions, and recorded artifact contract digests:

ChangeRequired work
ADDCreate one document version, parse and chunk the new content, reuse or create exact-input embeddings, and add lexical/vector records in a DELTA segment.
MODIFYCreate a new immutable document version, add a document-scoped supersede operation before its chunks, preserve passage anchors only where continuity is proven, and rebuild changed descendants. The supersede kills disappeared passages without requiring an anchor match.
DELETEAdd an immediate document tombstone and remove every version/chunk from eligibility without parsing or embedding; physical purge follows the precedence rules for erasure, retention, and legal hold.
ACL_ONLYCreate a new immutable ACL revision and add an exact authorization delta referencing its acl_revision_id without parsing, chunking, embedding, or mutating the content version. A revocation is freshness-critical and fails closed until published.
METADATA_ONLYRebuild filter, citation, or lexical artifacts whose metadata inputs changed. Re-embed only when the qualified document input transform actually includes the changed metadata.

A parser, chunker, metadata-schema, lexical, citation-anchor, or ACL-normalization change invalidates only artifacts produced by the affected contract when mixed contracts remain semantically valid. If compatibility cannot be proven, build a complete BASE candidate. For example, a PDF-parser upgrade need not reparse Markdown documents merely because both source types share a Knowledge Base.

Immutable BASE-plus-DELTA manifests

A routine promotion creates a new generation manifest by referencing one compatible BASE and one or more ordered DELTAs:

generation G42
  BASE B7        complete corpus at watermark W100
  DELTA D108     additions and replacements through W108
  DELTA D109     ACL changes and tombstones through W109

DELTA records use deterministic operation IDs and latest-wins precedence first by stable document identity and then by passage anchor/chunk. A document supersede operation declares that every chunk for document D from earlier segments is dead unless the same or a later segment activates it for the new document version. This catches removed passages for which no replacement anchor exists. A replacement never deletes or mutates the prior segment during construction. The new manifest becomes visible only after validation and one atomic knowledge_index_pointer_t transition. Failure leaves the previous manifest and all in-flight requests unchanged.

One generation cannot compose segments from different embedding spaces, distance metrics, or incompatible parser/chunker/metadata/ACL/lexical contracts. A model migration creates a new BASE in the target space and may then catch up with target-space DELTAs before promotion; it never attaches a target-space DELTA to the old-space BASE.

Segmented retrieval

Retrieval resolves and pins the logical generation once, then:

  1. loads its ordered segment manifest and exact document-supersede, replacement, tombstone, and ACL-revision overlay;
  2. runs lexical and vector candidate generation against the BASE and eligible DELTAs, using exact vector scans for small DELTAs and qualified ANN indexes for larger segments;
  3. rejects superseded, deleted, deactivated, or unauthorized evidence before it can be returned, even when an older BASE produced the candidate;
  4. merges vector candidates only because every segment has the same exact embedding-space contract, merges lexical candidates only under the same lexical contract, then performs the normal lexical/vector fusion;
  5. resolves each surviving passage anchor to the exact document version and chunk selected by the pinned manifest; and
  6. records segment IDs and manifest digest in retrieval audit without returning physical namespaces to the caller.

Per-segment top-k and over-fetch are bounded retrieval-profile settings, not constants. Qualification measures recall against the exact resolved logical corpus because tombstones, replacements, selective ACLs, or many small segments can otherwise exhaust approximate candidate budgets.

Compaction

Compaction is a derived-index maintenance operation. Trigger it from measured segment count, delta-to-base ratio, tombstoned/superseded candidate rate, retrieval fan-out latency, index size, or maintenance cost—not from every source update or deletion.

The compactor resolves one pinned manifest at a canonical watermark, reuses eligible chunks and embedding artifacts, and builds a complete replacement BASE without connector fetches or embedding-provider calls when inputs and contracts are unchanged. Before promotion it proves:

  • document-version, passage-anchor, chunk, ACL, tombstone, lexical, and vector counts and digests equal the resolved source manifest;
  • exact authorized retrieval returns the same eligible corpus;
  • ANN and fused retrieval continue meeting approved recall and latency floors;
  • no newer source delta was silently omitted; and
  • rollback retention preserves the predecessor manifest and referenced segments.

Compaction publishes a new logical generation with reason COMPACTION through the normal atomic pointer and audit path. Unreferenced old segments are detached and purged only after every active, candidate, rollback, backup, retention, and legal-hold reference has expired.

Scoped embedding reuse and anti-entropy

Before calling the gateway, compute a security-scoped digest over the exact post-transform embedding input, including any qualified title, section, language, or task prefix. Reuse an embedding artifact only when this digest, space ID/revision, dimension, normalization, and document-transform version all match. A raw-text hash alone is insufficient. Cache hits and misses are audited as bounded counts without exposing text or digests to tenant callers.

Every segment carries a versioned manifest of counts and digests by source and artifact type. Promotion verifies it synchronously. A scheduled anti-entropy job compares canonical documents, resolved generation membership, embedding references, physical lexical/vector rows, ACL state, and tombstones. Missing, orphaned, or inconsistent artifacts quarantine the affected candidate or create bounded repair work; they never silently broaden retrieval.

Embedding Model Upgrade And Re-Embedding

Embedding-model replacement is an expected Knowledge Base lifecycle operation, not an emergency repair. A new provider model, fine-tuned weights, dimension, normalization, distance metric, tokenizer behavior, or document input transform creates a target embedding profile and isolated candidate generation. The promoted generation continues serving through its recorded profile and space until the candidate is explicitly promoted. Unlike a routine source update, the migration constructs a complete BASE segment in the target vector space.

Preflight and migration state

Before starting provider work, the knowledge service calculates a bounded estimate from canonical chunk token counts and the qualified target profile:

  • eligible document and chunk counts, total estimated input tokens, and objects excluded by retention or error state;
  • provider cost range, qualified batch size, rate limits, available index-lane capacity, and estimated duration;
  • temporary vector, HNSW-build, WAL, backup, and predecessor-retention storage;
  • changed parser, chunker, or document-transform contracts that would prevent chunk reuse and expand the work to earlier pipeline stages; and
  • source freshness, unresolved ACL state, legal holds, and other conditions that would block eventual promotion.

Estimates are planning evidence rather than billing guarantees. Starting a migration requires an authorized target profile, an approved budget ceiling, available temporary capacity, and an explicit decision about the rollback window. A global Knowledge Base additionally requires platform authorization; consumer tenants cannot initiate or alter its migration.

Use an operational migration state machine equivalent to:

PLANNED -> BACKFILLING -> CATCHING_UP -> VALIDATING -> READY
READY -> PROMOTED -> SOAKING -> COMPLETED
SOAKING -> ROLLED_BACK
BACKFILLING | CATCHING_UP -> PAUSED -> prior resumable state
PLANNED | BACKFILLING | CATCHING_UP | VALIDATING | READY -> FAILED | CANCELED

Pause and resume preserve content watermarks, per-chunk idempotency, retry state, and budget evidence. Cancel removes only unpromoted derived projections and provider work; it never deletes canonical documents/chunks or changes the active pointer.

Snapshot, backfill, and catch-up

At migration start, record a canonical content watermark from the knowledge service’s own committed document-version, chunk, ACL, and tombstone log. An opaque SharePoint or Confluence connector cursor is source evidence but is not a cross-source promotion boundary.

The migration then:

  1. creates the candidate generation, target-space BASE segment/partition, and idempotent work manifest without changing Knowledge Base runtime configuration;
  2. reads reusable knowledge_chunk_t artifacts at the starting watermark and creates or reuses exact-input target-space embedding artifacts in resumable batches, associates them with every eligible chunk, and materializes the BASE vector projection;
  3. keeps ordinary synchronization and the old-space active generation running;
  4. records every later document, chunk, ACL, and tombstone change in an ordered, idempotent candidate-delta stream;
  5. applies those changes as compatible target-space DELTA segments in the candidate manifest, including replacements, removals, metadata, and permission changes; and
  6. uses a short promotion fence to establish a final canonical watermark, drain all deltas through it, reconcile aggregate counts and source state, and then release ordinary synchronization.

Backfill can be incremental at the system level, but a partially populated target space is never treated as compatible with the old space and never serves ordinary Agent traffic. The old and new ANN partitions do not share an HNSW graph or generation manifest. New source updates may be embedded in both spaces during catch-up, but each provider request declares and verifies its own exact space contract.

Evaluation and promotion

Evaluation compares complete retrieval outcomes rather than vector values or raw similarity scores. At minimum, compare active and candidate generations on the same authorized query set using Recall@k, nDCG or MRR, citation precision, no-answer behavior, filtered-ANN recall, latency, and cost. A candidate must also pass source-count, ACL, deletion, citation, expected-space, and drift gates at its final watermark.

Start with curated offline evaluation in the Quality workspace. An optional shadow evaluation may duplicate a production query only when tenant and data policy permit that processing, uses an evaluation budget and isolated capacity, does not expose candidate results to the caller, and does not retain raw query text beyond the approved policy. The first release does not randomly send a percentage of ordinary Agent requests to the candidate. A future live cohort rollout requires stable binding-level assignment and explicit product and privacy design; per-request random selection is prohibited.

The Portal evaluation command may address an authorized candidate generation, but POST /v1/knowledge/retrieve cannot. Promotion is one transaction that:

  1. verifies the candidate is still READY at the approved final watermark and that its evaluation evidence has not expired;
  2. appends immutable pointer-transition and authorization evidence;
  3. atomically changes knowledge_index_pointer_t to the candidate;
  4. derives the runtime embedding profile from the new active generation without writing an event-sourced control-plane projection;
  5. marks the predecessor superseded but retained through the rollback deadline; and
  6. publishes an idempotent promotion acknowledgement through the knowledge service outbox. The Portal command processor appends KnowledgeBaseIndexGenerationPromotedEvent and may align desired configuration in its own event stream.

Every in-flight request continues using the generation it pinned at request start. New requests see the new generation only after the committed pointer transition. A global promotion requires release notes because it changes shared retrieval for every active tenant binding in that environment.

Rollback and retirement

During the bounded soak period, keep the predecessor’s query Alias, embedding profile, referenced BASE/DELTA segments, citations, and configuration evidence available. To remain rollback-eligible after new source changes, the predecessor must receive the same post-promotion content, ACL, and tombstone deltas under its own space, or pass a complete reconciliation to the rollback watermark before pointer restoration. Portal shows the extra embedding and storage cost of maintaining this protection.

Rollback uses the same authorization and atomic pointer machinery as promotion. It never rewrites vectors or repoints an embedding profile. If the predecessor is stale, incomplete, outside its deadline, or depends on a retired Alias, an emergency rollback is rejected until reconciliation or a new candidate rebuild restores a valid generation.

After the soak window closes, retire the previous query/index Aliases when no other generation uses them, invalidate profile-scoped query caches, detach the superseded segment set, and purge it only after retention, backup, legal-hold, audit, and explicit purge gates pass. Canonical source objects, document versions, and reusable chunks follow their own retention policies and are not deleted merely because one embedding space is retired.

Authorization removal is different from ordinary retirement. A permission revocation, source/KB deactivation, or approved erasure immediately removes the affected predecessor from rollback eligibility and suppresses it in every serving generation. Rollback retention and ordinary retention cannot postpone that suppression or an approved physical erasure. A valid legal hold may keep inaccessible evidence in a sealed retention class, but rollback can never make it searchable again.

Connector Framework

Implement providers behind a connector interface rather than embedding provider-specific behavior in Portal handlers.

A connector needs operations equivalent to:

validateConfiguration()
testConnection()
discoverChanges(cursor)
fetchObject(externalId, version)
fetchPermissions(externalId)
resolveCitation(externalId, version, anchor)
refreshAuthorizationSubjects(subjects)

The normalized connector output includes:

  • stable external object ID and parent hierarchy;
  • display title, canonical URI, media type, language, and source timestamps;
  • provider version, ETag, or content hash;
  • normalized content or an object-store artifact;
  • permission subjects and inheritance evidence;
  • deletion or tombstone state;
  • the next cursor or delta token.

Connector code treats cursors and delta links as opaque. It persists the value only after the corresponding page has been processed successfully.

Git and Markdown repository source

Add GIT_REPOSITORY (or the narrower MARKDOWN_REPOSITORY) as a first-class source type for maintained documentation. Its non-secret configuration contains the repository URL, branch or immutable tag, included path patterns, and excluded path patterns; authentication remains an external secret reference. Each successful synchronization records the last indexed commit.

Use the commit SHA plus Git blob SHA as source-version evidence. Citations add repository, commit, path, and heading anchor to the common citation contract. The parser preserves Markdown heading hierarchy, code fences, tables, and links so chunks remain useful and citation anchors remain stable.

The first global pilot should be a uniform-scope Light Platform Documentation Knowledge Base containing at least:

  • light-portal-doc/src/**/*.md;
  • light-fabric/docs/src/**/*.md.

This corpus is suitable for hybrid lexical-plus-vector retrieval and does not require GraphRAG for the first release.

Global sources

Sources under a global Knowledge Base are configured and synchronized by platform administrators and workers using platform-owned secret references. Tenant administrators can inspect redacted source metadata, freshness, and quality evidence, but cannot change the connector scope, schedule, credential, ACL mode, document lifecycle, or index generation.

Only content deliberately approved for cross-tenant use belongs in a global Knowledge Base. A tenant-specific Confluence space or SharePoint library must not become global merely because its connector can be reached by a platform credential.

Every source has a trust tier such as CURATED_PLATFORM, TRUSTED_OWNER, or EXTERNAL_UNTRUSTED, but all retrieved text remains untrusted instructions. A global source also has a change-review policy. New sources, connector-scope or credential changes, and content outside an approved signed/allowlisted release policy remain in a candidate generation until an authorized platform review approves them. The review records source version or commit, diff/count evidence, reviewer, reason, and policy version. Trust tier is returned with citations so the Agent can prefer curated evidence without treating it as authorization.

SharePoint

Use Microsoft Graph drive and site APIs. Prefer incremental synchronization through delta tokens, supplemented by a periodic full reconciliation and provider notifications where practical.

Configuration should select approved sites, libraries, folders, file types, and size limits. Use least-privilege application access, such as selected-site permissions where it satisfies the deployment, and store credentials in the external secret system.

Permission changes are content changes from the retrieval perspective. A SharePoint item must not remain searchable under an old ACL merely because the file bytes did not change.

For MIRROR_SOURCE_ACL, request the documented hierarchical-sharing and sharing-change delta behavior where the tenant and application permissions support it, then fetch the complete effective permission detail for changed hierarchies. Delta annotations are discovery hints, not the sole correctness path: inheritance changes, missed notifications, resync responses, scope changes, and connector bugs are covered by a full permission reconciliation. If the permission scan requires a broader Microsoft Graph permission than the deployment approves, the source cannot qualify for MIRROR_SOURCE_ACL; it does not silently fall back to a partial scan.

Sharing-link scopes require explicit handling. Anonymous links never grant Knowledge Base retrieval because possession of a link is not represented in the trusted Agent principal. An organization link maps only to a proven Microsoft tenant/consumer-host organization subject. A users link maps only its resolved recipients. Unknown, expired, password-only, or unresolvable link semantics fail closed for that document.

Confluence

Use the Confluence REST API with cursor pagination. Scope each source to approved spaces and optional content filters. Incremental discovery can use provider timestamps or CQL, but a periodic reconciliation remains necessary for deletions, moves, permission changes, and missed updates.

Preserve the canonical page URL, page ID, version, space, ancestor path, title, headings, and permission evidence for citations and administration.

Confluence content timestamps and CQL are not a permission-change feed. A MIRROR_SOURCE_ACL source therefore performs a complete bounded restriction and effective-access reconciliation on its configured interval. Page/content restrictions alone do not prove view access: the connector must also account for space permission, product access, inherited restrictions, users/groups, guests or external collaborators, and the provider’s operation semantics. It stores the provider-effective decision and evidence rather than flattening these layers into an unordered principal list. Any unsupported precedence or unresolved layer denies the document.

Uploads

Uploads use the same pipeline as remote connectors. Portal accepts the file and metadata, stores the binary in object storage, and enqueues ingestion. It does not parse or embed the file in the browser or the Portal request thread.

Define an allowlist of media types and bounded file sizes. Scan files before parsing and reject encrypted or unsupported content with a visible, non-sensitive reason.

Authorization Model

Support two explicit source ACL modes. Mode belongs to knowledge_source_t, not knowledge_base_t, because a single Knowledge Base may combine a curated upload collection with a SharePoint source that mirrors provider permissions:

ModeBehavior
UNIFORM_SCOPEEvery caller allowed by consumer-tenant policy and an active Agent binding may retrieve all active documents from that source.
MIRROR_SOURCE_ACLA caller must also match the current immutable ACL revision for each document from that source.

UNIFORM_SCOPE is suitable only for a source whose complete contents are approved for every authorized consumer of the bound Agent. It must not be used as an implicit fallback when source permissions cannot be read.

MIRROR_SOURCE_ACL normalizes provider identities into platform subject types such as user, group, organization role, or an explicitly approved everyone subject. The service compares those records with trusted principal claims and resolved group membership. A provider-effective deny wins over an allow whenever the provider semantics define that precedence. Provider IDs and claim mappings for a tenant-owned Knowledge Base are host-scoped.

For a source in a global Knowledge Base, MIRROR_SOURCE_ACL is permitted only when the platform can map every source subject into a platform-wide identity or into an unambiguous subject for the current consumer host. If a provider group from a global source cannot be resolved safely for a tenant, retrieval fails closed for that document. Curated cross-tenant corpora will commonly use UNIFORM_SCOPE after the platform has approved the entire corpus for all authorized tenant consumers. Mixing source modes does not create a least-common denominator: retrieval evaluates the policy of the source that owns each document before cross-source fusion.

Every MIRROR_SOURCE_ACL source records acl_reconciliation_interval, acl_max_age, and revocation_visibility_slo. The initial Phase 2 ceiling for all three is fifteen minutes; configuration may shorten it but cannot lengthen it without a new qualification. Provider notifications or delta annotations may reduce observed lag but never replace the sweep. If a sweep cannot complete within the ceiling, the source must be narrowed or sharded, or remains ineligible for mirrored retrieval. Once acl_max_age is crossed, the complete affected source is excluded until a successful reconciliation publishes fresh immutable ACL revisions.

Global catalog visibility is not document authorization. It permits a tenant administrator to discover the Knowledge Base and create a host-local binding; it does not let arbitrary users retrieve content without the Agent, tenant policy, source state, and document ACL checks.

The runtime authorization sequence is:

  1. authenticate the calling workload and obtain host, environment, principal, Agent actor, and delegated user evidence;
  2. resolve active Knowledge Base bindings whose binding host matches the Agent’s trusted consumer host;
  3. join each binding to a Knowledge Base owned by that host or to a global Knowledge Base with host_id IS NULL;
  4. intersect the visible bound set with an optional caller-requested Knowledge Base list;
  5. enforce environment, Knowledge Base, source state, source approval, and the runtime authorization projection freshness lease;
  6. build the eligible document set from consumer-tenant policy, each source’s ACL mode, and the exact current document ACL revision;
  7. run lexical and vector candidate generation within that eligible set;
  8. apply any future qualified reranker only to this eligible set, then return only eligible chunks.

Optional graph-assisted retrieval follows the same ordering. It first computes the eligible document-version set, then limits graph expansion and description construction to evidence contributions from that set. Filtering citations only after graph traversal is insufficient because a node or summary may already contain information synthesized from an unauthorized document.

The initial graph-assisted pilot is therefore restricted to Knowledge Bases whose every included source uses UNIFORM_SCOPE, so the complete promoted generation has one visibility boundary. MIRROR_SOURCE_ACL is not eligible until automated tests prove that additions, edits, deletions, permission removals, and shared entities cannot leak excluded contributions. A global Knowledge Base remains subject to the same rule; global catalog visibility does not make a mixed-permission graph safe.

The delegated-token boundary used by Agent runtime should be extended rather than replaced. Retrieval audit records should include consumer_host_id, the nullable Knowledge Base owner_host_id, Agent actor, caller subject, session or workflow correlation, policy digest, data-boundary digest, Knowledge Base IDs, and active index generations.

Retrieval Design

Strategy boundary

The knowledge service owns a small internal retrieval interface equivalent to:

retrieve(
  authorizedKnowledgeBases,
  authorizedDocumentVersions,
  query,
  retrievalProfile,
  narrowingFilters,
  budget
) -> ranked evidence

HYBRID is the required production strategy. GRAPH_ASSISTED is an optional strategy that may be enabled only when both the Knowledge Base has an active strategy-qualification record and the binding-selected retrieval profile names that strategy within the qualified constraints. The profile may reduce budgets or select HYBRID; it cannot expand the KB’s qualified set. Binding creation or update rejects an ineligible strategy instead of waiting for runtime fallback. Additional implementations can be evaluated behind this boundary, but all strategies receive an already authorized corpus and return the same stable chunk-level evidence and citation contract.

The strategy may derive separate query signals for precise entities or rare identifiers and for broader themes or relationships. These signals can select different candidate sources before rank fusion. This is a useful general retrieval principle, not a requirement to reproduce another project’s keyword format, graph schema, prompts, or algorithm.

Graph descriptions and summaries can help locate evidence, but final results remain bounded canonical chunks. Generated graph text is diagnostic retrieval context unless it has complete contribution provenance; it is not presented as an authoritative source citation.

Optional authorized path planner

GRAPH_ASSISTED may use a path-pruned planner to reduce redundant graph context while retaining the relationship between evidence items. This is one internal planner under the existing strategy, not a caller-selectable PathRAG mode. For a pinned generation and retrieval profile, it:

  1. retains the ordinary lexical and vector chunk candidates and retrieves a bounded set of entity or relationship seeds from precise and thematic query signals;
  2. constructs an eligible subgraph only from entity and relation contributions whose exact document versions and chunks are authorized for the request;
  3. considers a bounded set of seed pairs, propagates a decaying graph signal, and stops expansion when the configured contribution, fan-out, hop, visited node/edge, memory, or wall-time bounds are reached;
  4. ranks surviving paths with a versioned pathRetrievalScore, retaining disconnected seeds when no supported path exists instead of forcing a narrative connection;
  5. resolves every node and relation in each path back to its complete canonical chunk contributions, removes repeated passage text while preserving path membership and order, and excludes any path with incomplete provenance;
  6. fuses the resulting chunk candidates with ordinary hybrid candidates, then applies diversity, per-document, byte, and token limits; and
  7. returns the same ranked chunk evidence plus optional additive evidence groups that an Agent may use to preserve relationship order under its own prompt and model policy.

The retrieval profile owns maximum seeds, seed pairs, paths, hops, fan-out, visited nodes and edges, graph tokens, graph latency, and memory. Decay, pruning, edge-origin weights, and activation thresholds are versioned planner settings qualified per corpus; research-paper values are not defaults. A high-degree hub, edge direction, or structural path length can be useful ranking evidence but is not proof that a relation is important or true.

The planner may activate only when the server-owned strategy detects sufficient multi-entity or relationship evidence and the graph budget is available. A single-fact query, weak or ambiguous seeds, a disconnected authorized graph, or planner failure continues through HYBRID. This fallback is recorded and uses the same already-authorized corpus.

Default hybrid pipeline

The first release uses:

  1. Resolve and pin the active logical generation, ordered segment-manifest digest, and embedding-profile revision once for the complete request.
  2. Query normalization and optional language detection.
  3. Query embedding by the knowledge service with the active generation’s exact expected space ID and revision. A mismatch fails before vector search.
  4. Resolve the generation’s latest-wins document-supersede, replacement, tombstone, metadata, and ACL-revision overlay.
  5. PostgreSQL lexical candidate generation across eligible BASE/DELTA segments.
  6. pgvector or exact candidate generation across the same compatible segments, with per-segment budgets and suppression of stale base hits.
  7. Merge segment-local candidates, then apply reciprocal-rank fusion to the lexical and vector lists.
  8. Diversity and per-document limits.
  9. Token-budget truncation that preserves complete citation metadata.

The query string for Phase 1 must be self-contained. The Agent owns conversation-aware rewriting of turns such as “how do I promote it?” before it calls retrieval and records the rewritten query in its own execution evidence. The service does not silently read conversation history. A future optional rewrite stage must be server-owned, versioned, evaluated for semantic drift and prompt injection, included in audit, and run before per-space query embedding.

Lexical search handles identifiers, names, exact phrases, and rare terms that semantic search can miss. Vector search handles paraphrases and conceptual similarity. A reranker is deferred until the LLM gateway has a canonical rerank operation, pricing, usage, latency, and determinism contract; do not model first-release reranking as an ordinary generate Alias.

Use a bounded, short-lived query-embedding cache keyed by consumer policy boundary, space ID, space revision, query-transform version, normalization version, and normalized-query hash. The cache never stores raw query text in its key, logs, or metrics; a persistent key uses a rotated keyed digest rather than a bare hash. Cache entries are bounded by bytes and count, expire by TTL, are invalidated on profile retirement, and contain no provider metadata. The consumer-policy component prevents cache timing or data-policy behavior from becoming a cross-tenant side channel.

Retrieval policy defines hard upper bounds for candidate counts, top_k, returned bytes, chunks per document, and total tokens. Client parameters may only narrow those limits.

Multi-Knowledge-Base retrieval and fusion

Phase 1a limits one runtime request to one Knowledge Base. Phase 1b supports up to four selected, authorized bindings by default; a server-owned profile may set a lower cap. For more than one KB, retrieval:

  1. resolves each binding, qualified strategy, source policy, active generation, and ordered segment manifest independently in the same authorization snapshot;
  2. groups query embedding work by exact space ID/revision and query-transform version, computing one query vector per compatible group and never reusing it across a different consumer-policy boundary;
  3. gives each KB a qualified minimum lexical, vector, and token budget, then allocates remaining bounded budget by binding priority without starving any selected KB;
  4. produces a fully authorized lexical/vector/optional-graph rank within each KB;
  5. rank-normalizes each KB list and applies a second reciprocal-rank fusion over per-KB ranks. Raw similarity, FTS, reranker, graph, or component scores are never compared across KBs or spaces; and
  6. applies global diversity, byte, result, and token limits while retaining all citations when duplicate text from different KBs is collapsed for context.

The request audit records distinct embedding spaces, per-KB budgets, candidate counts, local ranks, fusion contribution, and fan-out timing. Binding priority affects work allocation and deterministic tie-breaks only; it never converts a low-ranked result into a raw cross-space score or grants access.

The response returns evidence, not an answer. Optional evidence groups describe relationships among chunks already present in results; they do not contain a generated answer or uncited graph narrative. This keeps citation and authorization behavior independently testable and lets Agents decide how to use the evidence and structure under their own prompt and model policy.

Citation contract

Every result includes:

  • knowledge_base_id, source_id, document_id, document_version_id, chunk_id, and passage_anchor_id when continuity is available;
  • title, canonical source URI, section path, and optional page or anchor;
  • source ACL mode, source trust tier, and approved source version or review ID;
  • bounded text passage;
  • content version and active index generation;
  • combined rank and optional component scores;
  • retrieval methods that contributed to the rank, such as lexical, vector, or graph expansion; add reranker only after that operation is qualified;
  • timestamps needed to show source freshness.

When present, each evidence group has a strategy-independent group ID and type, an ordered list of result chunk IDs, and optional typed relations between those members. A RELATIONAL_PATH group may also include a diagnostic pathRetrievalScore and planner version. Every referenced chunk must already appear in results, every relation must resolve to authorized contribution records, and clients may ignore the complete additive field. The response never includes graph database identifiers, internal namespaces, raw entity embeddings, propagation state, or an unsupported generated edge description.

chunk_id and document_version_id are the immutable evidence identity. passage_anchor_id is a stable navigation and continuity aid, not permission to substitute a newer document version into an audited historical result. Resolving an anchor to current content repeats the complete Knowledge Base, binding, source-state, document-ACL, and generation checks.

Rank scores are diagnostic, strategy/profile-scoped, and not a calibrated similarity, authorization, or answer-confidence guarantee. The Portal may show them in the retrieval playground, but normal Agents should consume ranked passages, citations, and the top-level retrieval disposition. A client must not apply a universal numeric threshold to an RRF score.

API Contracts

Portal control-plane actions

Keep the existing Light Portal action-based command and query style. The first implementation can use service lightapi.net/genai, version 0.1.0, with a Knowledge Base action family.

Suggested command actions are:

createKnowledgeBase
updateKnowledgeBase
deactivateKnowledgeBase
deleteKnowledgeBase

createKnowledgeSource
updateKnowledgeSource
deactivateKnowledgeSource
deleteKnowledgeSource
testKnowledgeSource
requestKnowledgeSourceSync
requestKnowledgeSourceAclReconciliation
receiveKnowledgeSourceProviderNotification (workload only)

bindAgentKnowledgeBase
updateAgentKnowledgeBaseBinding
unbindAgentKnowledgeBase

requestKnowledgeBaseReindex
requestKnowledgeBaseCompaction
requestKnowledgeBaseEmbeddingMigration
pauseKnowledgeBaseEmbeddingMigration
resumeKnowledgeBaseEmbeddingMigration
cancelKnowledgeBaseEmbeddingMigration
promoteKnowledgeBaseIndexGeneration
rollbackKnowledgeBaseIndexGeneration
retireKnowledgeBaseIndexGeneration
requestKnowledgeBaseBackupCheckpoint
verifyKnowledgeBasePhysicalRestore
requestKnowledgeBasePurge
testKnowledgeRetrieval

exportKnowledgeBasePortabilityManifest
importKnowledgeBasePortabilityManifest
bindImportedKnowledgeDependencies
approveKnowledgeBaseImportBuild
abandonKnowledgeBaseImport

acknowledgeKnowledgeProjection
acknowledgeKnowledgeBaseIndexGenerationPromotion
acknowledgeKnowledgeBaseIndexGenerationRollback

Suggested query actions are:

getKnowledgeBases
getFreshKnowledgeBase
getKnowledgeSources
getFreshKnowledgeSource
getKnowledgeSyncRuns
getFreshKnowledgeSyncRun
getKnowledgeDocuments
getFreshKnowledgeDocument
getKnowledgeIndexGenerations
getKnowledgeIndexSegments
getFreshKnowledgeIndexSegment
estimateKnowledgeBaseEmbeddingMigration
getKnowledgeBaseEmbeddingMigrations
getFreshKnowledgeBaseEmbeddingMigration
getKnowledgeMigrationEvaluations
getKnowledgeGenerationRetention
getKnowledgeBackupCheckpoints
getKnowledgePurgeEvidence
getAgentKnowledgeBaseBindings
getKnowledgeBaseImport
getKnowledgeBaseImportLineage

exportKnowledgeBasePortabilityManifest creates the canonical desired-state payload and signed publication envelope, then appends exactly one KnowledgeBasePortabilityManifestIssuedEvent before releasing the artifact. A signing or append failure releases nothing. importKnowledgeBasePortabilityManifest verifies them and establishes the target DRAFT plus identity lineage. bindImportedKnowledgeDependencies records authorized target-local mappings; approveKnowledgeBaseImportBuild admits the single target-local reconciliation only after every required mapping and policy gate passes; and abandonKnowledgeBaseImport makes an incomplete import terminal without releasing its publication identity. The two import queries expose minimized status and source-to-target lineage without returning secrets or source content. The three acknowledge* actions are workload-authenticated internal actions, not administrator or browser operations. They make projection progress and knowledge-service pointer outcomes durable in Portal history through an idempotent reverse acknowledgement path.

estimateKnowledgeBaseEmbeddingMigration accepts a visible qualified target profile and returns chunk/token counts, cost and duration ranges, temporary storage, changed-contract consequences, and blocking conditions. It does not change desired or active state. requestKnowledgeBaseEmbeddingMigration captures that estimate version, budget ceiling, rollback window, expected current active generation, and target profile. It returns an operational migration ID and candidate generation ID rather than holding the command open.

Promotion and rollback commands require the caller’s expected active-generation ID and Knowledge Base version for optimistic concurrency. Promotion additionally requires the READY candidate, final watermark, unexpired evaluation-evidence ID, and release notes. Rollback requires a retained rollback-eligible predecessor and records why the candidate was rejected. Neither command accepts host_id, Alias, provider, space identity, or arbitrary vector-index namespace from the browser.

Pause, resume, and cancel commands are idempotent and require the expected migration version. Cancellation is rejected after promotion. Retirement is allowed only for a superseded generation that is not active, is no longer rollback-eligible, is not referenced by another active migration, and has no retention or legal-hold blocker; physical deletion remains an asynchronous purge operation.

requestKnowledgeBaseCompaction is an authorized derived-index maintenance request. It accepts expected active-generation and Knowledge Base versions plus an optional bounded reason, but never a caller-selected physical namespace. The knowledge service chooses the eligible manifest and compaction watermark, returns a job/candidate generation ID, and uses the normal validation, atomic promotion, rollback retention, and purge path. Automatic compaction uses the same contract and records the measured trigger.

testKnowledgeRetrieval may select an active/candidate generation pair only in the separately authorized Portal evaluation contract. The knowledge service resolves both profiles and spaces from those generation records. This diagnostic selection is never copied into the ordinary runtime retrieval API.

Portal authorization initially follows portal.r and portal.w conventions. Fine-grained actions can later distinguish source credential administration, binding, retrieval testing, and purge approval. Global creation and mutation also require an explicit platform Knowledge Base administration capability; ordinary portal.w in a tenant context is insufficient.

Manifest export is a separately audited data-egress capability. Import into a GLOBAL target and approval of its build require the platform Knowledge Base administration capability and the global source change-review gate, regardless of the source manifest’s owner scope or approvals. abandonKnowledgeBaseImport requires that same target-scope administration capability because it is irreversible and permanently retires the publication identity.

testKnowledgeRetrieval is a command because it consumes embedding capacity, budget, and audit storage even though it returns diagnostic results. It is separately authorized, rate-limited per user and consumer host, charged to an evaluation budget, and scheduled so playground traffic cannot consume the protected production query lane.

createKnowledgeBase accepts scope but never accepts an authoritative host_id:

{
  "scope": "GLOBAL",
  "name": "Light Platform Documentation",
  "environment": "prod"
}

The command handler derives a tenant host for TENANT or persists null for an authorized GLOBAL request. updateKnowledgeBase does not accept scope changes. createKnowledgeSource or updateKnowledgeSource carries aclMode because that choice belongs to the source, plus its trust tier, approval policy, ACL freshness policy, reconciliation interval, and ingestion policy.

Commands return accepted configuration state or a job identifier. They do not hold a request open for a crawl or reindex. getKnowledgeBases returns the effective catalog for the trusted host and labels each row GLOBAL or TENANT. Global rows are read-only unless the caller has platform administration authority. Query responses are visibility-scoped, paginated, and content-minimized. Document list responses omit full text; detail access is separately authorized and bounded.

Runtime retrieval API

Expose a stable service-owned contract:

POST /v1/knowledge/retrieve
Authorization: Bearer <delegated workload token>
Content-Type: application/json

Example request:

{
  "knowledgeBaseIds": ["5b6f8d30-7d57-4d36-910a-8e4094b522e5"],
  "query": "How is a production API promoted?",
  "topK": 8,
  "filters": {
    "sourceIds": ["c2aecb62-b451-4e79-a725-367893ac8c1a"],
    "languages": ["en"]
  }
}

The body deliberately has no host_id, owner scope, principal ID, claim set, or arbitrary ACL expression. knowledgeBaseIds, sourceIds, languages, and topK only narrow the server-authorized result. The service resolves the consumer host from the token and derives the Knowledge Base owner scope from storage.

The query must be the self-contained retrieval query defined above. The service also honors the trusted request deadline propagated by the gateway, clamps it to the retrieval-profile maximum, passes the remaining budget to embedding and database stages, and cancels outstanding fan-out when the deadline expires.

The runtime request also does not accept an engine name, graph workspace, or index namespace. The active Agent binding and retrieval profile select a qualified server-owned strategy. An authorized Portal evaluation endpoint may compare candidate profiles, but that diagnostic capability does not widen the production runtime contract.

The runtime request also never accepts an embedding Alias, model, space ID, or provider. The knowledge service resolves those values from the promoted index generation and embedding profile, then calls the gateway with its platform or tenant workload credential. Tenant Agents do not call /v1/embeddings as part of Knowledge Base retrieval.

Example response:

{
  "queryId": "8d0eef7d-97d1-4676-aeb5-b24d1e2eb09b",
  "status": "COMPLETE",
  "retrievalDisposition": {
    "status": "EVIDENCE_FOUND",
    "policyVersion": "kb-evidence-gate-v1"
  },
  "results": [
    {
      "knowledgeBaseId": "5b6f8d30-7d57-4d36-910a-8e4094b522e5",
      "knowledgeBaseScope": "GLOBAL",
      "sourceId": "c2aecb62-b451-4e79-a725-367893ac8c1a",
      "documentId": "7a47556a-e97e-4f16-b418-d3bcb7c9ca4b",
      "documentVersionId": "18f9af71-5689-4b34-a9bd-c9346b89ecfb",
      "chunkId": "7d281955-0f99-40d5-83c7-62df079e73dd",
      "passageAnchorId": "49cc592d-2c9d-4a03-b22e-51d2d441a63b",
      "title": "Production Promotion",
      "uri": "https://example.atlassian.net/wiki/spaces/OPS/pages/1234",
      "section": "Approval and rollout",
      "text": "A bounded passage returned by retrieval.",
      "contentVersion": "42",
      "indexGenerationId": "56a9e721-35c6-4f06-b6a4-c5db08e6a0de",
      "sourceAclMode": "MIRROR_SOURCE_ACL",
      "sourceTrustTier": "TRUSTED_OWNER",
      "rank": 1,
      "rankScore": 0.0328,
      "retrievalMethods": ["lexical", "vector"]
    }
  ],
  "warnings": []
}

The example omits the optional top-level evidenceGroups field. When supplied, it contains only groupId, type, ordered member chunkIds already present in results, provenance-backed relation types between those members, plannerVersion, and diagnostic pathRetrievalScore. It is an additive common response feature, not a different graph endpoint, and clients that do not assemble structured context can ignore it. The runtime request cannot require a particular planner, set propagation parameters, or request an evidence group that the authorized retrieval result did not produce.

retrievalDisposition is EVIDENCE_FOUND, NO_QUALIFIED_EVIDENCE, or UNKNOWN. It is produced by a versioned retrieval-profile gate evaluated on authorized rank, coverage, and citation signals; it is not answer-model confidence. NO_QUALIFIED_EVIDENCE is the service’s no-answer signal and may still include bounded diagnostic warnings. rankScore is explicitly an uncalibrated within-request fusion value and must not be treated as similarity or compared across profiles, KBs, or requests.

For multi-KB retrieval, EVIDENCE_FOUND requires at least one returned fused result, NO_QUALIFIED_EVIDENCE requires every selected KB to complete its gate with no qualifying evidence, and UNKNOWN is used when permitted partial failure prevents that conclusion.

Retrieval errors, warnings, and partial results

The stable warning codes initially include:

CodeMeaning
KB_SKIPPED_ACL_STALEAn otherwise authorized KB/source was excluded because its ACL revision exceeded the freshness ceiling.
KB_SKIPPED_GENERATION_UNAVAILABLEAn authorized KB had no usable promoted generation.
SOURCE_PARTIAL_INGESTIONResults use the last valid generation while a source has bounded ingestion failures.
DEADLINE_BUDGET_REDUCEDOptional work was skipped to honor the propagated deadline.
GRAPH_FALLBACK_HYBRIDQualified graph planning failed or exhausted its bounds and authorized hybrid results were used.

Warnings contain code, affected knowledgeBaseId/sourceId when disclosure is authorized, retryability, and a redacted correlation ID; they never contain document text, hidden resource names, principal lists, or provider errors.

Each binding’s server-owned retrieval profile chooses FAIL_REQUEST or RETURN_PARTIAL for operational failure across several already authorized KBs; the strictest selected profile wins for the aggregate request. A caller may narrow RETURN_PARTIAL to strict failure but cannot opt into partial behavior that the binding disallows. Under RETURN_PARTIAL, an ACL-stale or unavailable KB contributes no candidates, the response status is PARTIAL, and a warning identifies the skipped authorized KB. Authorization-projection staleness, authentication failure, an unbound requested ID, or inability to prove owner scope always fails the complete request; partial results never bypass a trust decision.

HTTP mapping is stable: 400 malformed or over-limit input, 401 authentication, 403 visible-but-unbound or operation-forbidden selection, 404 unknown, not-visible, scoped document, or citation absence, 409 incompatible/effective configuration state, 422 valid but unsupported filter or query contract, 429 quota, 503 stale authorization projection or unavailable required dependency, and 504 propagated deadline. Complete, empty, and permitted partial retrieval use 200 with the explicit body status. Every error uses a stable code, retryable flag, and correlation ID.

The initial frozen error-code mapping is:

CodeHTTPRetryable
KNOWLEDGE_INVALID_REQUEST400No
KNOWLEDGE_AUTHENTICATION_REQUIRED401No
KNOWLEDGE_FORBIDDEN403No
KNOWLEDGE_NOT_FOUND404No
KNOWLEDGE_STATE_CONFLICT409No
KNOWLEDGE_UNSUPPORTED_CONTRACT422No
KNOWLEDGE_QUOTA_EXCEEDED429Yes
KNOWLEDGE_PROJECTION_STALE503Yes
KNOWLEDGE_DEPENDENCY_UNAVAILABLE503Yes
KNOWLEDGE_DEADLINE_EXCEEDED504Yes

Also expose an authorized document-resolution endpoint for a user following a citation:

GET /v1/knowledge/documents/{documentId}/versions/{versionId}

This endpoint returns bounded normalized content or redirects to the canonical provider URI. It repeats the current authorization check and does not assume that possession of a citation grants access.

When a result contains passageAnchorId, an authorized client may also resolve that stable anchor against the currently pinned or current active generation:

GET /v1/knowledge/documents/{documentId}/passages/{passageAnchorId}

The response identifies the exact resolved documentVersionId and chunkId. A missing, ambiguous, deleted, moved-without-continuity, or unauthorized anchor is not guessed from heading text; the endpoint returns a scoped not-found or conflict result. Historical audit continues to use the exact version endpoint.

MCP surface

Expose a small MCP adapter from the light-knowledge API application alongside the REST routes through light-axum:

knowledge.search
knowledge.get_document

knowledge.search accepts a query and narrowing filters. knowledge.get_document resolves one authorized citation. Do not expose connector administration, embedding values, arbitrary SQL or vector search, cross-tenant identifiers, or provider credentials as MCP tools.

The REST and MCP transports must remain thin adapters over the same shared authorization and retrieval application layer so they cannot drift. Do not run MCP as a separate service or give it a distinct authorization path.

Agent And Workflow Integration

An Agent may be bound to several Knowledge Bases, and a Knowledge Base may be bound to several Agents. A binding can select a retrieval profile and priority without copying the corpus.

A tenant binding to a global Knowledge Base references the global UUID directly. It does not create a tenant copy of the corpus, connector, ACL, chunks, or embeddings. Binding state and tenant-specific retrieval settings remain owned by the consumer host.

At runtime:

  • Hindsight recall supplies session and experience context.
  • Knowledge retrieval supplies governed source evidence.
  • The Agent composes both under an explicit context-token budget.
  • Each category is labeled so the model can distinguish memory from source evidence.
  • Source evidence retains citations through answer generation.

The Agent should not query every bound Knowledge Base blindly for every turn. It can use a simple policy or routing description to choose relevant bindings, but the retrieval service still authorizes the selected set.

Workflow data-store references should resolve to a versioned Knowledge Base binding or identifier. Workflow definitions store references, not connector credentials or copied chunks. Executions record the active generation used so that a result can be investigated later.

Portal Management Experience

Add a separate top-level GenAI workspace:

/app/genai/KnowledgeBases
/app/genai/KnowledgeBases/:knowledgeBaseId

Do not add Knowledge Bases as a tab in the Hindsight Memory Bank workspace. The navigation may use similar bank-first workspace patterns, but the concepts, permissions, and lifecycle are different.

Knowledge Base list

Provide My Knowledge Bases and Global Catalog filters over the same effective catalog query. Every row has a prominent Global or Tenant badge. Global rows are read-only for tenant administrators but offer Bind to Agent and Open actions.

Show:

  • name, description, environment, owner scope, and state;
  • source ACL-mode summary, including any MIRROR_SOURCE_ACL freshness failure;
  • source trust/change-review state and ingestion-quota utilization;
  • number and health of sources;
  • last successful sync and current staleness;
  • active index generation and document or chunk counts;
  • active embedding profile plus candidate migration state, progress, and promotion/rollback warnings when present;
  • active BASE/DELTA segment count, delta-to-base ratio, last compaction, and a warning only when measured compaction thresholds or consistency checks fail;
  • bound Agent count for the current tenant; only platform administrators can see cross-tenant binding totals for a global Knowledge Base;
  • warning and failed-object counts.

For a tenant-owned row, the primary actions are open, synchronize, deactivate, and delete. For a global row, tenant administrators can open, bind, and unbind; only platform administrators can synchronize, deactivate, or delete. Delete is gated by active bindings, retention, legal hold, and purge policy.

Knowledge Base detail

Use these tabs:

TabPurpose
OverviewState, freshness, active generation, counts, policy, and recent warnings.
SourcesConfigure uploads, Confluence, and SharePoint sources; test connections; request sync.
DocumentsSearch document metadata, inspect versions, ACL status, parse state, and citations.
Sync RunsProgress, cursor type, ADD/MODIFY/DELETE/ACL_ONLY/METADATA_ONLY counts, artifact reuse, retries, rate limiting, and redacted errors.
Index GenerationsActive and candidate logical generations, ordered BASE/DELTA manifests, segment counts/digests, artifact and embedding reuse, compaction evidence, embedding migrations, watermarks, promotion evidence, rollback deadline, and retention state.
Agent BindingsBind or unbind Agents and choose retrieval profile and priority.
Access PolicyPer-source ACL mode, immutable ACL revision/freshness, claim mappings, revocation SLO, authorization-projection lag, trust/change-review policy, and fail-closed diagnostics.
Retrieval PlaygroundRun as the current authorized principal, inspect ranks and citations, and compare profiles.
QualityCurated questions, expected documents, citation and retrieval metrics, strategy comparisons, latency, cost, and release gates.
SettingsName, description, environment, retention, desired embedding profile for a first generation or future migration, deactivation, and purge. Runtime profile is read-only and derived from the active generation.

For a global Knowledge Base:

  • Sources, Documents, Sync Runs, Access Policy, Quality, and Settings are read-only for tenant administrators;
  • Agent Bindings lists and changes only bindings owned by the current tenant;
  • Retrieval Playground requires an Agent binding from the current tenant and runs with the current authorized principal;
  • platform administrators receive the mutation controls and an aggregate view of consuming tenants without exposing one tenant’s Agent details to another.

Incremental update and compaction visibility

Index Generations expands a logical generation into its ordered BASE/DELTA manifest without exposing physical database names or storage credentials. For each segment, show kind, state, watermark range, compatible contract versions, document/chunk/vector/ACL/tombstone counts, reuse ratios, bounded digest status, build duration, and whether another active, candidate, or rollback generation still references it.

Sync Runs explains why work was or was not performed. An unchanged source object shows the matching provider version/content hash. A changed object shows its classification and the invalidated pipeline stages. Embedding reuse reports counts and cost avoided without displaying reusable text, raw input digests, or cross-document identities.

Portal recommends compaction only when measured segment count, delta ratio, suppressed-candidate rate, retrieval fan-out latency, or storage/maintenance cost crosses policy. An authorized owner administrator may request compaction, but cannot choose namespaces, force incompatible segments together, skip validation, or purge referenced predecessors. Global compaction controls remain platform-only; tenant consumers receive redacted read-only health.

Quality shows a lightweight impacted-query validation result for routine DELTA promotion and the broader curated evaluation required for a new BASE, incompatible contract, or embedding migration. Structural, authorization, tombstone, citation, and manifest consistency gates always run regardless of query-evaluation tier.

Embedding upgrade workflow

From Index Generations or Settings, an authorized administrator can start a guided embedding upgrade:

  1. select a qualified target profile visible to the Knowledge Base owner scope;
  2. review whether the change reuses existing chunks or also requires parsing or rechunking;
  3. review document/chunk/token counts, cost and time ranges, temporary storage, provider capacity, candidate freshness, and rollback-window cost;
  4. submit an explicit budget ceiling and rollback window;
  5. monitor backfill, retries, failed chunks, delta lag, source/ACL watermark, index build, and validation without exposing text or vectors;
  6. compare active and candidate retrieval metrics and citations in Quality;
  7. promote only after every gate passes and release notes are supplied; and
  8. monitor the soak period, roll back while eligible, or allow retirement and purge after the deadline.

The UI states plainly that a different or fine-tuned embedding model normally requires every eligible chunk to be embedded again. Reusable chunks avoid connector, parsing, and chunking work; they do not make different vector spaces compatible. It also shows that old/new vector storage and, during the rollback window, dual embedding of changed chunks are temporary migration costs.

Candidate evaluation is visually distinct from live retrieval. The Portal does not offer a percentage-traffic slider in the first release. If shadow evaluation is enabled by policy, the UI displays its query-handling, retention, budget, and capacity consequences and never exposes shadow results to ordinary callers.

For a global Knowledge Base, tenant administrators see the active profile, candidate status, release notes, planned promotion time, and rollback outcome as read-only information. Only platform administrators can choose the target, start, pause, cancel, promote, roll back, retire, or purge the shared generation.

Source wizard

The source wizard is available to the owner tenant for a tenant Knowledge Base and to platform administrators for a global Knowledge Base. It should:

  1. select Git/Markdown repository, upload, Confluence, or SharePoint;
  2. select or create an external credential reference;
  3. test provider connectivity without exposing secret values;
  4. browse and select allowed spaces, sites, libraries, or folders;
  5. choose file, content, size, and language filters;
  6. choose UNIFORM_SCOPE or MIRROR_SOURCE_ACL with a clear consequence;
  7. choose a source trust tier, global change-review policy when applicable, and ACL reconciliation/freshness ceiling;
  8. choose a schedule and ingestion policy;
  9. review estimated documents, bytes, chunks, embedding tokens/spend, duration, and hard ceilings before saving;
  10. enqueue the first bounded synchronization.

Portal never displays connector tokens or embedding values. Error messages are redacted before persistence and display.

Retrieval playground

The playground calls the same runtime retrieval path used by Agents. It shows:

  • the effective principal and Agent binding used for the test;
  • the active generation and source freshness;
  • returned text, citation, and source authorization evidence;
  • lexical, vector, and fused positions when diagnostic permission is present;
  • the selected retrieval strategy and any graph expansion, with optional ordered evidence groups, path retrieval score, relation origins, and contributing canonical chunks rather than opaque graph claims;
  • graph seed, pair, visited-node/edge, retained-path, pruned-path, token, timeout, and fallback counts when diagnostic permission is present;
  • total latency and stage timings;
  • warnings for stale ACLs or partial sources; add reranker health only when the canonical operation exists.

Add an “Explain exclusion” diagnostic that traces, without exposing hidden content, the effective catalog, binding, KB/source state, source approval, ACL revision/freshness, principal/group match, document supersession, generation, candidate, and budget decision for a known authorized document reference. It records the actor, reason, policy/contract versions, and correlation ID.

An administrator may not impersonate another user merely by entering a user ID. Before MIRROR_SOURCE_ACL is operationally supported, Portal must add an explicit audited authorization-simulation capability. It resolves a target subject and current claims server-side, requires a fine-grained capability, reason or ticket, short expiry, and complete actor/target audit, and limits a tenant administrator to that tenant. Simulation uses the same authorization implementation but cannot mint a delegated token, call tools, or expose content that the simulator is not separately permitted to inspect.

GraphRAG Decision

GraphRAG is not required to create a useful shared Knowledge Base. Its indexing pipeline normally adds entity and relationship extraction, deduplication, community detection, and summary generation. Those steps add model cost, latency, evaluation complexity, and new provenance questions.

The entity and co-occurrence tables in Hindsight are not by themselves GraphRAG. They describe relationships in Agent memory and do not provide a source-governed document graph, community summaries, or a GraphRAG query planner.

External graph-RAG papers and implementations are research inputs, not product dependencies. We can independently adopt generally useful ideas such as separating precise and thematic query signals, combining graph expansion with raw chunk retrieval, pruning redundant graph context with relational paths, preserving useful evidence structure, and rebuilding derived descriptions from reverse provenance. We do not adopt another project’s server, workspace model, schema, API, prompts, fixed parameters, or tenant boundary as the Light Portal contract.

Deterministic structure before extracted relationships

The Light Platform Documentation pilot should begin with relationships that can be reproduced directly from Git and Markdown structure:

  • repository contains document and document contains heading;
  • a Markdown link or explicit cross-reference points to another document or heading;
  • a document or section documents an API operation, configuration key, service, component, implementation plan, or design decision when the source contains a deterministic identifier;
  • repository path, commit, heading anchor, and link target provide exact contribution provenance.

Each entity and relation records its origin as explicit, structural, or extracted plus the creating contract version. Semantic similarity can discover query seeds but is not persisted as a factual relation. LLM-extracted entities and relationships may be added later as lower-trust derived artifacts with complete contributing chunks, extractor/prompt identity, diagnostic confidence, and incremental invalidation. A generated description never outranks explicit source structure merely because it is fluent.

An optional Light Portal graph-assisted strategy should:

  1. ingest deterministic structural entities and typed relationships, then add qualified extracted entities, aliases, and relationships from immutable chunks where they provide measured value;
  2. retain each chunk-level contribution instead of storing only one merged description;
  3. index entity and relationship identifiers and descriptions for bounded seed discovery;
  4. combine precise entity candidates, broader relationship candidates, and ordinary lexical and vector chunk candidates;
  5. construct traversal state only from the request’s authorized contribution set;
  6. use a bounded path-pruned planner when it reduces redundant neighborhoods or community context, while retaining disconnected evidence when a supported path does not exist;
  7. map every selected path back to canonical chunks, deduplicate repeated text without discarding path membership, and identify graph expansion only as a ranking method;
  8. optionally return provenance-backed evidence groups so an Agent can preserve relationship order without adopting a Light-owned answer prompt; and
  9. rebuild or remove affected artifacts after document, content, relationship, parser, extractor, or permission changes.

Consider a GraphRAG pilot only when all of the following are true:

  • the hybrid RAG baseline is deployed and measured;
  • a representative evaluation set contains relationship-heavy or corpus-wide questions that hybrid retrieval misses;
  • the expected accuracy gain justifies extraction and reindexing cost;
  • every entity, relation, and summary can retain document and chunk provenance;
  • source deletions and ACL changes can remove or suppress derived graph data;
  • every pilot source uses UNIFORM_SCOPE, or every MIRROR_SOURCE_ACL source has passed the graph-specific non-disclosure tests;
  • the tenant explicitly enables the feature and accepts its cost and data-use policy.

The first pilot should be an optional per-Knowledge-Base index strategy behind the same retrieval API. Use relational tables for entities, aliases, relationships, communities, summaries, and provenance first. A client must not need a different API merely because the server selects a graph-assisted retrieval plan. Relational paths are normally computed at query time over the pinned generation rather than precomputed as an all-pairs path index. No graph database is required unless measured traversal scale makes PostgreSQL or a bounded in-memory adjacency projection the bottleneck.

Compare strategies using the same normalized documents, chunks, embedding profile, authorization inputs, answer model, and question set. The evaluation must include exact facts, rare identifiers, cross-document relationships, corpus-wide themes, time or version-sensitive questions, and questions with no supported answer. Measure retrieval recall and ranking, citation precision, answer faithfulness, forbidden-document rate, latency, indexing cost, and incremental add, edit, delete, and ACL-change behavior. Pairwise LLM preference can supplement these measurements but cannot replace them.

The answer model used for faithfulness and no-answer scoring is an external evaluation-harness dependency, not a knowledge-service operation. Its provider, model/version, prompt digest, decoding settings, and data-use policy are pinned for the complete comparison; changing any of them starts a new evaluation series rather than silently moving the baseline.

Within GRAPH_ASSISTED, compare path-pruned retrieval with bounded immediate neighborhood and community-summary baselines. Measure unique canonical chunks, duplicate passage tokens, path overlap, supported evidence per token, marginal gain after each added path, graph-planner latency, and fallback rate. Corrupt the evaluation graph with missing, stale, incorrectly directed, unsupported, and permission-revoked edges plus erroneous entity merges and splits. The planner must degrade to supported chunk evidence instead of fabricating a connection. Prompt-order experiments belong to the Agent evaluation layer and must be repeated for each qualified answer-model family; the Knowledge Base service does not standardize the paper’s prompt ordering heuristic.

Security And Data Governance

  • Treat every source document as potentially malicious prompt content.
  • Treat extracted entity/relation descriptions and evidence-group ordering as derived untrusted content. A typed path or high path retrieval score cannot authorize tools, elevate a source claim, or override Agent policy.
  • Clearly delimit retrieved passages and instruct Agents that evidence cannot authorize tools, reveal secrets, or change policy.
  • Treat a platform-global source as a cross-tenant content supply chain. Enforce its configured approval/change-review gate before promotion, include source trust tier and approved source version in citations, and permit Agent policy to exclude lower-trust tiers. Delimiting text alone is not the prompt-injection control.
  • Apply file-type allowlists, malware scanning, decompression limits, parser timeouts, and content-size limits before indexing.
  • Encrypt provider secrets externally, objects at rest, database connections, and service traffic.
  • Use explicit global or tenant object paths and scope-specific encryption keys when required by policy.
  • Treat the portability manifest and any canonical source-artifact bundle as controlled egress. Separately authorize export, record it as a durable KnowledgeBasePortabilityManifestIssuedEvent with a content-minimized export-audit projection rather than log lines alone, write artifacts only to an approved encrypted destination, apply explicit retention/expiry and deletion rules, and require stronger content-export authorization plus independent object ACLs for the separately encrypted source bundle. Keep KMS audit authoritative for key use and Portal history authoritative for the administrative issuance.
  • Redact tokens, document bodies, query text, and personal identifiers from ordinary logs.
  • Never log embedding input text, vectors, workload credentials, or provider response bodies. Qualification evidence stores hashes, bounds, request IDs, space identity, and aggregate measurements only.
  • Define retention separately for originals, normalized content, chunks, query audit, and failed-job artifacts.
  • Make purge asynchronous, auditable, idempotent, and visible until all database, object-store, cache, and derived-index records are gone.
  • Require explicit policy for sending document content to an external embedding or reranking provider.
  • Preserve source classification and sensitivity labels where the connector supplies them, and allow policy to exclude unsupported classifications.

Developer Test Strategy

Release qualification is not the developer feedback loop. CI uses a deterministic fake embedding provider with declared space ID test-kb-deterministic-v1, revision 1, fixed dimension, normalization, and distance contract. It derives finite repeatable vectors from a versioned test algorithm and deliberately supports mismatch, throttling, malformed vector, and timeout modes. No CI assertion depends on a live external model or raw similarity from a different fake-space revision.

Seeded corpora cover tenant/global scope, identical names, stable document edits, removed passages without replacement anchors, ACL-only revisions, lexical identifiers/punctuation, several KBs in different spaces, source trust tiers, object-store orphans, and no-answer queries. Fast tests include:

  • pure contract tests for chunking, lexical normalization, ACL normalization, provider precedence, document supersession, per-KB budgeting, local rank and cross-KB RRF, warning/error mapping, and deadline propagation;
  • PostgreSQL/pgvector integration tests for effective-catalog and binding joins, one-snapshot authorization, exact versus ANN retrieval, BASE resolution, lexical/trigram candidates, atomic pointer changes, and stale-projection failure;
  • connector fixtures for replayed/out-of-order pages, SharePoint inheritance and link scopes, Confluence restriction/space/product layers, incomplete sweeps, ACL staleness, and first-crawl quota exhaustion;
  • portability fixtures proving stable payload digests across changed envelope metadata, signature and deny-unknown verification, same-ID/same-digest retry, same-ID/different-digest rejection, terminal abandonment without ID reuse, new-ID publication, the same manifest imported into two distinct target scopes yielding two independent imports while each scope stays idempotent, GLOBAL capability/change-review enforcement, generated target identities, target dependency rebinding, exactly one issuance event per released artifact with no signature bytes or payload content and no artifact released when signing or the append fails, and a tombstone that survives target deletion and purge with its identity binding and content minimization intact; and
  • crash/retry tests around object staging, database commit, segment publication, outbox acknowledgement, orphan collection, and consistent restore manifests.

The clean target fixture is defined with the publication modes above. It rebuilds with the deterministic fake provider and asserts both the replay-suppressed-effect counter and dispatcher spy: expected replay effects are counted as suppressed and no connector, embedding, indexing, pointer, acknowledgement, delete, or purge call escapes replay mode.

The authorization matrix crosses owner scope, consumer host, environment, bound/unbound/inactive binding, active/inactive KB/source, UNIFORM/MIRROR source, user/group/organization/link subject, allow/deny/unresolved mapping, fresh/stale ACL revision, control-plane projection lease, and complete/partial multi-KB policy. Every deny case asserts both zero returned content and content-free logs/errors. A small deterministic suite runs on each change; the larger qualification and load gates run before phase promotion.

Reliability And Observability

At minimum, expose metrics and diagnostics for:

  • synchronization lag and last successful reconciliation by source;
  • ACL discovery age, reconciliation duration/coverage, revocation-visibility lag, stale-source fail-closed count, and unsupported provider-permission states;
  • runtime authorization projection event/heartbeat lag, pending deny fences, five-second revocation-SLO violations, and stale-lease request failures;
  • discovered, unchanged, updated, deleted, parsed, embedded, skipped, and failed object counts;
  • ADD, MODIFY, DELETE, ACL_ONLY, and METADATA_ONLY classifications; invalidated parser/chunker/metadata/lexical/ACL/embedding stages; exact artifact-reuse and security-scoped embedding-reuse counts;
  • provider throttling and retry-after behavior;
  • ingestion-policy reservations and object/chunk/byte/token/spend/time ceiling utilization, pauses, and rejected resumes;
  • queue depth, attempt count, terminal failures, and dead-letter age;
  • active and candidate generation state;
  • active BASE/DELTA segment count, delta-to-base ratio, per-segment candidate fan-out and latency, replacement/tombstone suppression rate, manifest digest failures, compaction reason/progress, and unreferenced-segment retention age;
  • embedding-migration state, snapshot/final watermarks, reusable-chunk coverage, chunks and tokens completed/remaining, retry and bisection counts, effective throughput, budget consumed/remaining, estimated completion range, delta lag, temporary storage, and rollback deadline;
  • active-versus-candidate quality deltas, shadow-evaluation volume when enabled, promotion-gate failures, rollback eligibility, and predecessor catch-up lag;
  • retrieval request count, latency by stage, empty-result rate, and errors;
  • lexical, vector, and fused contribution, plus reranker contribution only when that future operation is enabled;
  • retrieval strategy, graph-expansion contribution, and strategy fallback;
  • graph seed/pair counts, visited nodes/edges, fan-out and hop distributions, retained/pruned paths, path-planner latency/timeouts, evidence-group count, unique contributing chunks, duplicate-token ratio, path overlap, supported evidence per token, and fallback reason;
  • ACL-denied candidate count and stale-ACL failures;
  • embedding token or cost usage, plus reranking usage only when that future operation is enabled;
  • embedding workload (index or query), space ID/revision, expected-space rejection count, route-drift quarantine count, single-query latency, and batch throughput without input text or vector values;
  • per-consumer-host global-KB quota usage, throttling, fair-queue delay, and evaluation-budget consumption without query text;
  • citation-resolution failures;
  • candidate HNSW build CPU/I/O/WAL/memory/parallelism, serving-query p95 impact, and capacity-envelope headroom;
  • staged-object age, orphan collection, missing referenced objects, checkpoint manifest completeness, and restore validation failures;
  • scheduled anti-entropy lag and canonical/manifest/lexical/vector/ACL/tombstone mismatch or repair counts;
  • owner scope, consumer tenant, Knowledge Base, Agent, generation, and query correlation identifiers.

Retrieval readiness covers the query database, required runtime dependencies, and a fresh authorization projection lease. Whether a particular Knowledge Base has a usable promoted generation is a scoped request result, not a reason to remove every retrieval replica from service. Projection failure or a stale deny fence fails readiness and retrieval closed. A connector or individual job failure is reported through component health, metrics, and job state without taking healthy retrieval for unrelated sources offline.

Job claiming uses bounded leases, attempts, backoff, and idempotency. Multiple embedded or explicitly requested external executors may run concurrently, but only one promotion may advance a Knowledge Base from a specific prior active generation. The supervisor is considered healthy only while it can observe and reconcile the durable queue; it need not have an active build task.

Lifecycle

Knowledge Base lifecycle states should include:

DRAFT -> ACTIVE -> DEPRECATED -> INACTIVE -> DELETING -> DELETED

Index generations should include:

BUILDING -> CATCHING_UP -> VALIDATING -> READY -> PROMOTED
                                 \-> FAILED
PROMOTED -> SUPERSEDED -> PURGED
SUPERSEDED -> PROMOTED  (rollback only while eligible)

Index segments should include:

BUILDING -> VALIDATING -> READY -> REFERENCED
                    \-> FAILED
REFERENCED -> UNREFERENCED -> PURGED

Segment state never determines runtime visibility by itself. Only an active, request-pinned generation manifest makes a READY/REFERENCED segment queryable, and reference accounting includes active, candidate, rollback, backup, and retention manifests.

A DRAFT Knowledge Base can ingest and use the retrieval playground but cannot serve ordinary Agent bindings. Activation requires a valid generation and policy. Deactivation immediately removes it from new runtime retrieval while retaining content according to policy.

For a tenant-owned Knowledge Base, the owner tenant controls this lifecycle. For a global Knowledge Base, only a platform administrator can advance it. Deprecating a global Knowledge Base blocks new bindings, identifies an optional replacement Knowledge Base, and gives existing consumer tenants a visible migration deadline. Before deactivation, Portal shows the number of affected tenant bindings. Deletion is rejected while bindings remain unless an explicit platform emergency policy authorizes forced removal.

Tenant administrators can unbind their Agents from a global Knowledge Base at any time. They cannot deactivate the global root for other tenants.

Source deletion or deactivation immediately installs a deny/tombstone fence and removes eligibility without waiting for parsing, embedding, compaction, or a model-migration backfill. Phase 1a follows with a replacement full BASE; Phase 1b publishes the fence in a highest-priority tombstone DELTA. Physical purge follows the erasure/retention/legal-hold precedence above. A later recreated source receives a new source identity unless an explicit restore operation proves continuity.

Routine document, metadata, and permission changes create a compatible DELTA and new logical generation manifest. Parser, chunker, metadata, or ACL contract changes may rebuild only affected artifacts when compatibility is proven; otherwise they create a complete BASE candidate. Compaction changes physical layout but not the resolved logical corpus.

Embedding-space changes always create a complete candidate BASE, a new embedding profile, and a separate physical vector index. Parser, chunker, metadata, or ACL-normalization contract changes also create a candidate generation but may use scoped replacement DELTAs when the compatibility and resolved-corpus gates pass. No contract change rewrites the promoted generation in place. The embedding upgrade workflow separates reusable canonical chunks from generation membership, backfills an isolated target index, catches up through a final content watermark, and keeps the active pointer on the predecessor until promotion. An Alias replacement cannot silently move the existing generation to the replacement space. Promoting a generation for a global Knowledge Base changes the shared corpus for every active tenant binding in that environment. Promotion therefore requires platform authorization, evaluation evidence, an audit record, and visible release notes. Bindings follow the active generation between requests, but every request pins the resolved generation for its complete lexical, vector, citation, and audit lifecycle.

Delivery Plan

The source implementation through the one-container R6 cutover is complete. The following sequence remains the rollout and rollback-evidence procedure; predecessor identity/configuration retirement occurs only after its qualified rollback window.

Before adding further Knowledge phases, consolidate the runtime topology without changing persisted domain, event, job, generation, retrieval, or authorization contracts:

  • extract projection and job execution from the standalone worker binary into reusable library components;
  • embed those components in light-knowledge behind mutually exclusive external and embedded execution modes;
  • use the light-runtime merged Config Server path for Knowledge configuration and deployment secrets instead of a local bootstrap container;
  • cut over projection at a recorded durable offset and heartbeat lease, then cut over job claiming after all old claims drain or expire;
  • keep the old worker image deployable for one rollback window while proving embedded/external parity, then remove it from the default release and Compose/Kubernetes topology; and
  • retain one-shot CLI/Kubernetes-Job execution for approved heavy work.

Phase 0: contract and evaluation baseline

  • Approve terminology, global and tenant trust boundaries, per-source ACL modes, strategy qualification, warning/error taxonomy, and API schemas.
  • Build a representative tenant-isolation, global-sharing, and retrieval evaluation corpus.
  • Define and publish the immutable embedding-space contract and choose the initial qualified profile through the LLM control-plane read model.
  • Require same-space fallback/canary routing and expected-space request and response headers before any durable vector is written.
  • Establish the platform model-authority host plus separately admitted indexing/query gateway lanes, Aliases, and provider capacity with protected query resources.
  • Define the reference capacity/concurrency envelope, enforce the proposed p95 ceilings, and validate Recall@10 >= 0.90 at the 100%, 25%, 5%, and 1% authorization strata against exact authorized-neighbor baselines.
  • Define question categories and deterministic Recall@k, nDCG or MRR, citation precision, answer-faithfulness, no-answer, and authorization measurements.
  • Freeze full-BASE generation, document-supersede, tombstone, metadata, chunker, lexical, citation, ACL, and contract-digest semantics needed by Phase 1a.
  • Publish capacity/storage math, initial ingestion ceilings, object-store backup checkpoint, shared embedding-registry ownership, and candidate-build isolation criteria.
  • Threat-model connector credentials, source ACLs, prompt injection, and document download.
  • Freeze the cross-environment portability manifest schema, the canonical payload and signed-envelope boundary, the export-generated publication ID and payload-digest idempotency identity, the source-side manifest-export and target-side publication aggregates with their event families and standalone export-audit and ledger/tombstone projections, new-identity mapping, target dependency binding, replay side-effect suppression, logical publication lineage, and the exact physical-restore marker. Raw event replay must not execute historical jobs or import effective generation state.

Phase 1a: documentation pilot with full BASE generations

  • Add event-backed global and tenant Knowledge Base, Source, retrieval profile, and host-local Agent binding configuration plus the acknowledged runtime authorization projection in the knowledge database.
  • Implement nullable owner scope, partial uniqueness, effective-catalog reads, immutable scope, platform-only global mutation, strategy qualification, and one-KB runtime retrieval.
  • Add the minimum PostgreSQL operational tables, consistent object storage, the deterministic fake embedding provider, and CI authorization matrix.
  • Implement bounded Git/Markdown repository ingestion, parsing, chunking, embedding, lexical search, vector search, fusion, citations, and generation promotion.
  • Build exactly one immutable BASE per candidate generation and perform a full rebuild when the pilot source changes. Do not implement DELTA segments, compaction, passage anchors, context expansion, cross-generation embedding reuse, upload ingestion, or multi-KB fusion in Phase 1a.
  • Implement the minimal Portal list/detail, Git source status, Agent binding, retrieval playground, Quality view, source trust/review, and quota evidence.
  • Expose the retrieval REST contract. Reject more than one selected KB with the stable over-limit error until Phase 1b.
  • Build the global Light Platform Documentation pilot and evaluate hybrid retrieval, lexical identifiers, no-answer behavior, capacity, and full-rebuild cost before authorizing incremental scope.
  • Publish the pilot’s portability manifest into the clean target fixture with new identities, rebind its target dependencies, rebuild one complete BASE, and prove that no source-environment job, pointer, binding, or promotion acknowledgement became target effective state through replay.

Phase 1b: incremental, upload, and multi-KB retrieval

  • Add upload ingestion for qualified text-bearing media types and the object staging/orphan-collection lifecycle.
  • Add content/permission change classification, dependency-scoped invalidation, document-level supersede records, routine DELTA promotion, stable passage anchors, exact-input embedding reuse within one Knowledge Base, and BASE-plus-DELTA retrieval.
  • Add per-segment lexical/vector budgets, compaction, anti-entropy, optional small-to-big context expansion, and deterministic cross-KB rank fusion with one query embedding per distinct compatible space.
  • Add the remaining Portal operational tabs, warning/partial-result diagnostics, exclusion explanation, and the MCP adapter.
  • Promote Phase 1b only when pilot measurements show that full rebuild cost, freshness, or corpus growth justifies the added state machinery and every incremental qualification test passes.

Phase 2: enterprise connectors and ACLs

  • Add SharePoint delta synchronization and permission normalization.
  • Add Confluence cursor synchronization, reconciliation, and permissions.
  • Add provider notifications where useful, with reconciliation as the correctness path.
  • Preserve connector-proven containment/reference relationships and qualify explicit cascade policies without using semantic graph edges for lifecycle.
  • Qualify MIRROR_SOURCE_ACL, immutable ACL revisions, SharePoint link scopes, Confluence effective-access layers, group mapping, fifteen-minute ACL discovery/refresh ceilings, stale-source fail-closed behavior, and audited authorization simulation/exclusion explanation.

Phase 3: production quality and operations

  • Add curated query evaluations, operational dashboards, rate limiting, cost controls, backup and restore, and purge evidence. Add reranker profiles only after the gateway’s canonical rerank contract is implemented and qualified.
  • Add operator automation, status, audit, and recovery controls for logical environment publication and exact physical restore while retaining their distinct identity and side-effect semantics.
  • Before enabling that automation, publish into a separately deployed clean target environment and verify authorization, artifact egress/retention, dependency rebinding, abandonment, target-local build/promotion, and replay suppression with the same contracts used by the Phase 1a fixture.
  • Implement the embedding-migration preflight, resumable backfill, ordered delta catch-up, final promotion fence, candidate evaluation, atomic promotion, bounded soak, rollback eligibility, Alias retirement, and segment-set purge workflow.
  • Add freshness-priority scheduling, per-segment manifests, scheduled anti-entropy, measured compaction, embedding-reference purge evidence, and segmented exact-versus-ANN recall qualification.
  • Qualify large-corpus partitioning and horizontal worker scale.
  • Record generation evidence in Agent and workflow execution audit.

KB Embedding Qualification Gate

Durable indexing is blocked until the separate KB embedding-stability checkpoint passes. Qualification evidence must:

  • embed a fixed multi-probe corpus through every eligible fallback deployment and bind the result to the declared space ID and revision;
  • detect silent model/provider drift with a versioned multi-probe fingerprint;
  • prove expected-space mismatches fail before provider dispatch;
  • prove the dimension is injected and pinned for every required-space Alias;
  • verify index and query requests across gateway replicas, publication changes, route expiry, quarantine, and fallback without crossing spaces;
  • measure single-query p95 latency separately from batch throughput and prove indexing cannot starve protected query capacity;
  • build a candidate HNSW under declared maintenance/WAL/I/O/parallel-worker caps while the reference query load remains within the single-KB p95 ceiling;
  • prove one consumer host cannot exhaust another host’s global-KB query budget or concurrency;
  • verify batch index/order, finite values, dimensions, retry-after handling, bounded backoff, request-ID capture, and input-hash idempotency; and
  • prove input text, vectors, secrets, and physical provider details do not appear in logs, errors, metrics, or stored qualification evidence.

Before routine incremental indexing is promoted, an end-to-end update qualification additionally must:

  • exercise ADD, MODIFY, DELETE, ACL_ONLY, and METADATA_ONLY changes separately and in one reordered/replayed connector page;
  • prove unchanged parser, chunker, and embedding artifacts are reused while changed contract descendants are rebuilt exactly once;
  • crash workers before and after segment validation and pointer publication, proving there is neither a partial visible manifest nor a duplicate DELTA operation;
  • compare segmented lexical/vector/fused retrieval with an exact evaluation over the resolved logical corpus at representative segment counts, tombstone rates, and ACL selectivity;
  • prove a newer tombstone, replacement, or ACL revocation always suppresses an older BASE hit and is scheduled ahead of concurrent migration work;
  • edit a document so one old passage disappears without a replacement anchor and prove the document-level supersede record removes that BASE hit;
  • validate stable passage-anchor continuity and rejection of ambiguous or unproven matches while exact historical chunk citations remain resolvable;
  • prove embedding reuse uses exact transformed-input and space identity, cannot cross disallowed tenant/global or policy scopes, and still produces complete purge evidence; and
  • compact a multi-DELTA manifest into a replacement BASE, demonstrate logical and retrieval equivalence, atomically promote it, and retain/purge predecessor segments according to references and policy.

Before multi-KB retrieval is enabled, qualification also selects four bound KBs across at least two incompatible spaces and proves per-space embedding grouping, per-KB minimum/priority budgets, local-rank-only RRF, partial/strict failure, deadline cancellation, deterministic tie-breaks, and the p95 ceiling without raw cross-space score comparison.

Before the first production model upgrade, an end-to-end Knowledge Base migration qualification additionally must:

  • switch a representative corpus to a different space while source synchronization, edits, deletions, and ACL changes continue;
  • prove unchanged chunks are reused without connector fetch, parse, or chunker execution while every eligible chunk receives a target-space embedding;
  • interrupt and resume workers at several backfill and catch-up boundaries without duplicate vectors, skipped deltas, or budget-accounting drift;
  • enforce the accepted cost ceiling and leave the active generation unchanged when the migration pauses, fails, exceeds policy, or is canceled;
  • prove candidate quality is evaluated without cross-space score comparison and without candidate selection through the runtime API;
  • promote while queries and synchronization are concurrent and prove each query observes exactly one pinned generation; and
  • apply post-promotion content and permission deltas, roll back within the soak window, and prove the restored generation is current and authorized before the predecessor is later retired and purged.

The existing OpenAI-compatible /v1/embeddings body remains unchanged. This gate adds Light-specific request and response headers and control-plane invariants for durable vector consumers; ordinary OpenAI SDK calls may omit the expected-space headers, but such calls are not qualified to write or query a Knowledge Base index.

Phase 4: optional GraphRAG pilot

  • Select the global Light Platform Documentation corpus or another global or tenant corpus whose included sources all use UNIFORM_SCOPE and which has relationship-heavy and corpus-wide questions. Add a MIRROR_SOURCE_ACL corpus only after the uniform pilot passes.
  • Build deterministic repository, document, heading, link, API-operation, configuration-key, service, and design-reference relationships before adding LLM-extracted graph artifacts.
  • Add provenance-preserving graph extraction, bounded authorized path retrieval, and optional structured evidence groups behind the existing API.
  • Compare HYBRID and GRAPH_ASSISTED using identical source versions, chunks, embeddings, authorization inputs, budgets, and answer models.
  • Within GRAPH_ASSISTED, compare path pruning with bounded immediate-neighborhood and community-summary baselines; do not expose those planners as runtime API choices.
  • Compare quality, citation accuracy, faithfulness, unique evidence per token, redundancy, latency, indexing and query cost, update behavior, and ACL correctness with the hybrid baseline.
  • Exercise incremental add, edit, delete, and permission-removal scenarios and prove that no stale graph contribution remains retrievable.
  • Exercise missing, stale, unsupported, incorrectly directed, and permission-revoked edges, entity merge/split errors, high-degree hubs, disconnected seeds, graph-budget exhaustion, and planner timeout.
  • Promote GraphRAG only for tenants and Knowledge Bases where the evidence supports it.

Acceptance Criteria

Each criterion is tagged with the earliest delivery gate that must enforce it. Phase 1a is complete when every P1a criterion passes; later phases add their own criteria without postponing the core trust boundary:

  • [P1a] The default deployment has exactly one long-running Knowledge container. No bootstrap, projector, or idle builder container is required for startup, event convergence, ingestion, maintenance, or retrieval.

  • [P1a] light-knowledge starts from Config Server merged configuration plus deployment-secret references. A Config Server outage follows the declared last-known-good/startup policy, while a missing required secret fails startup without logging its value.

  • [P1a] The embedded event consumer establishes notification before catch-up, resumes from its durable offset, advances across uninterested events, preserves per-aggregate gap/idempotency evidence, and converges after notification loss, process restart, and projection-leader failover.

  • [P1a] Controller delivery loss cannot lose administrative intent. A direct controller work request is idempotent, returns a durable job ID, and either names an already committed Portal event or is limited to a non-authoritative runtime operation.

  • [P1a] With no eligible job, no build task remains active and the supervisor performs no sub-second polling. On notification or fallback reconciliation it claims at most the configured concurrency, and every job task exits after a terminal, paused, or retryable transition.

  • [P1a] Concurrent indexing at the maximum admitted embedded capacity keeps single-KB retrieval p95 at or below 1,000 ms and cannot consume query-reserved database, gateway, memory, or blocking-thread capacity.

  • [P1a] Shutdown stops new controller commands, event claims, and job claims; drains or safely releases active work within the shared deadline; preserves recoverable leases and idempotency; and closes all API, projection, and job database pools without force-killing the container.

  • [P1a] The optional CLI/Kubernetes-Job path runs the same engine and produces byte- and row-equivalent job, artifact, generation, acknowledgement, audit, and failure results as embedded execution for the same frozen input.

  • [P1a] Every retrieval authorization join uses one transactionally consistent runtime projection in the knowledge database. An unacknowledged deny remains visibly PENDING, accepted-to-effective deny meets five seconds, and a projection stale beyond thirty seconds returns no content.

  • [P1a] Authoritative Knowledge administration and events remain in the Config Server database, while runtime projections and high-volume operational state reside behind the logical knowledge database boundary. Colocated and isolated deployments produce equivalent projected rows, jobs, generations, retrieval results, and audit without cross-database joins, foreign keys, foreign data wrappers, or distributed transactions.

  • [P1a] A crash before or after applying a Config Server event to knowledge causes neither loss nor duplicate side effects. A source cursor older than retained event history fails closed and can be restored only through the qualified signed snapshot/reseed procedure.

  • [P1a] Compatibility mode uses the same explicit control-event and Knowledge commit boundary as isolated mode and passes both redelivery crash windows even when both pools connect to one physical PostgreSQL database.

  • [P1a] Every data-plane foreign key, trigger, function, and cascade-policy registry row resolves only Knowledge-local tables after the split. validate_knowledge_index_generation_profile() and promote_knowledge_base_generation() preserve their validation and atomic promotion contracts through local projection roots, and fresh/upgraded Config Server cascade-policy validation remains clean.

  • [P1a] Portal acknowledgement authorization admits the consolidated light-knowledge workload principal before cutover, admits both old and new principals during the rollback window, and removes the worker principal only when rollback support is retired. Development and installer deny-fence smoke tests exercise the real configured principal rather than a local shared ID.

  • [P1a] Production uses a generally available PostgreSQL plus qualified pgvector build. PostgreSQL 19 remains non-production until GA and the complete compatibility/rollback matrix passes; Turso is not an initial supported backend.

  • [P1a] The documentation pilot builds one complete BASE, accepts one KB per request, and rejects unimplemented DELTA, passage-anchor, reuse, upload, context-expansion, MCP, and multi-KB behavior rather than simulating it.

  • [P1a] Punctuation-heavy identifiers and exact phrases pass the lexical quality set under a recorded lexical contract; a lexical contract change cannot silently compose incompatible projections.

  • [P1a] The initial crawl respects document/chunk/byte/token/spend/time ceilings and leaves the active generation unchanged on budget exhaustion.

  • [P1a] A global source cannot promote outside its approved change-review policy, and every citation exposes its trust tier and approved source version.

  • [P1a] Responses expose a versioned retrieval disposition and uncalibrated rankScore, plus stable warnings/errors; clients are not required to infer no-answer from a universal numeric threshold.

  • [P1a] Object staging, orphan collection, checkpoint backup, and restore validation prove PostgreSQL and object-store consistency.

  • [P1a] Publishing the documentation pilot into the clean target fixture creates new target identities and ordinary target creation events, imports no secret or active binding, starts with no active generation, and becomes retrievable only after a target-local rebuild and promotion passes every applicable gate.

  • [P1a] Projection replay and portability import execute no historical sync, reindex, migration, promotion, rollback, delete, or purge side effect. The replay-suppressed-effect counter and dispatcher spy provide fixture evidence; a source promotion acknowledgement cannot establish target effective state.

  • [P1b] Multi-KB retrieval computes one query embedding per compatible space/transform group, ranks within each KB, applies cross-KB RRF only to local ranks, and obeys qualified per-KB/fan-out budgets.

  • [P2] Every ACL-only change creates an independent immutable ACL revision, and every segment/document eligibility decision names the exact revision.

  • [P2] SharePoint/Confluence MIRROR_SOURCE_ACL discovery and full reconciliation meet the fifteen-minute ceiling; stale, partial, unresolvable link/restriction, or incomplete effective-access state excludes the source.

  • [P3] Revocation or approved erasure immediately invalidates rollback for affected evidence; rollback retention never restores it to retrieval.

  • [P1a] Tenant A cannot list, bind, retrieve, resolve a citation from, or infer the existence of Tenant B’s Knowledge Bases.

  • [P1a] Tenant A and Tenant B can both list the same active global Knowledge Base without receiving copies with tenant host IDs.

  • [P1a] A tenant administrator can bind a global Knowledge Base to Agents in that tenant but cannot mutate its sources, credentials, content, policy, generation, or lifecycle.

  • [P1a] A tenant Agent cannot retrieve from a visible global Knowledge Base until an active binding exists for that Agent and consumer host.

  • [P1a] By-ID reads reject Tenant B’s tenant-owned Knowledge Base but accept a visible global Knowledge Base subject to normal operation authorization.

  • [P1a] Global retrieval audit records contain the non-null consumer host and a null owner host without exposing one tenant’s binding details to another.

  • [P1a] An unbound Agent receives no results even if it knows a Knowledge Base UUID.

  • [P1a] Request filters cannot widen trusted tenant, Agent, source, or principal scope.

  • [P1a] Changing the retrieval strategy does not change the public retrieval, citation, authorization, or audit contract.

  • [P1a] Runtime clients cannot select a graph workspace, engine namespace, or unqualified retrieval strategy.

  • [P2] MIRROR_SOURCE_ACL returns no content when a mapping or permission refresh is incomplete beyond policy.

  • [P1a] Synchronizing the same provider cursor page twice is idempotent.

  • [P1b] A routine ADD, MODIFY, DELETE, ACL_ONLY, or METADATA_ONLY update publishes a bounded DELTA and new immutable logical manifest without copying every unchanged vector or rebuilding the complete BASE.

  • [P1b] ACL_ONLY and DELETE changes require no embedding, take priority over bulk indexing, and suppress older BASE/DELTA hits in the next promoted manifest.

  • [P1b] Artifact dependency and contract digests cause only changed descendants to be rebuilt; exact compatible artifacts are reused and incompatible contracts cannot be composed in one generation.

  • [P1a] A document edit creates a new immutable version and citations identify the version returned.

  • [P1b] A passage anchor resolves across versions only with proven continuity, while every result and audit retains the exact document-version and chunk identity.

  • [P1a] A source deletion disappears from the replacement generation without corrupting the last valid generation.

  • [P2] A permission removal disappears from eligibility within its revocation-visibility SLO without corrupting the last valid content version.

  • [P4] An optional graph-derived entity, relationship, description, or summary is rebuilt or removed when any contributing document is edited, deleted, or loses authorization.

  • [P1b] Deleting a parent cascades only through a connector-proven lifecycle relation and explicit source policy; a hyperlink, semantic similarity edge, or graph-extracted relationship never authorizes cascade deletion.

  • [P4] A graph-assisted result resolves to authorized canonical chunks; an opaque generated graph description is never the sole citation.

  • [P4] When graph-assisted path retrieval is enabled, every seed, visited node, relation, path member, and evidence-group member is derived from the request-pinned generation and authorized contribution set before traversal.

  • [P4] A pathRetrievalScore is exposed only as diagnostic ranking evidence. It never appears as factual confidence, permission evidence, or a replacement for exact chunk and relation-contribution citations.

  • [P4] Every evidence-group member already appears as an authorized result chunk, every typed relation has complete contribution provenance, and clients may ignore the optional structure without changing the underlying evidence.

  • [P4] Seed, pair, fan-out, hop, visited-node/edge, path, token, latency, and memory limits are enforced. Disconnected evidence, pruning exhaustion, timeout, or planner failure produces bounded authorized hybrid results or a scoped error, never an invented path or a wider corpus.

  • [P4] Missing, stale, unsupported, incorrectly directed, or permission-revoked graph edges and erroneous entity merges/splits cannot leave an unauthorized or uncited claim retrievable.

  • [P4] A relational-path cache, if implemented, cannot cross generation, authorization, tenant, principal, policy, planner-version, retrieval-profile, or normalized query-signal boundaries and participates in normal purge evidence.

  • [P1a] A failed candidate generation leaves retrieval on the previous promoted generation and displays the failure in Portal.

  • [P1a] An embedding Alias with missing or mixed space contracts cannot publish, and an expected-space mismatch cannot reach a provider.

  • [P1a] Every stored vector records the exact creating profile/revision and space ID/revision, while every promoted generation records the exact query profile and vector space it selected.

  • [P3] An embedding-only migration reuses unchanged canonical chunks and does not refetch source content, rerun parsing, or rerun chunking; a changed parser or chunker contract invalidates that reuse explicitly.

  • [P3] Old-space and new-space vectors never occupy the same ANN index, and evaluation never compares or merges their raw similarity scores.

  • [P3] A model migration records a starting content watermark, applies every later content, ACL, and tombstone delta, and cannot become READY or PROMOTED until a final reconciliation proves it current at the promotion watermark.

  • [P3] Selecting a desired target profile cannot affect live retrieval. The active generation remains authoritative until one atomic pointer transition changes the generation and its derived runtime profile together.

  • [P3] A failed, paused, canceled, or incomplete migration leaves the previous generation serving without changing its profile, segment set, or citations.

  • [P3] Candidate retrieval is available only to authorized evaluation commands in the first release. Ordinary Agent requests cannot choose a candidate or be randomly routed between active and candidate generations.

  • [P3] Promotion retains a rollback-eligible predecessor through the configured deadline. Rollback is atomic and succeeds only when that predecessor has received all required deltas or passed a fresh reconciliation gate.

  • [P3] Portal shows migration scope, token/cost/time and temporary-storage estimates, progress and delta lag, quality evidence, promotion authorization, rollback deadline, and retirement state without exposing document text or vectors.

  • [P1a] Index and query workload Aliases use the same space while ingestion load cannot starve protected live-query capacity.

  • [P1a] One consumer tenant’s global-KB retrieval volume cannot exhaust another tenant’s query budget or concurrency.

  • [P1a] Filtered ANN meets Recall@10 >= 0.90 against exact authorized neighbors at the 100%, 25%, 5%, and 1% strata and meets the declared p95 ceiling.

  • [P1a] All retrieval stages use one request-pinned index generation even when a promotion occurs concurrently.

  • [P1b] Every retrieval stage also uses that generation’s one ordered segment-manifest digest; a newer replacement, tombstone, metadata, or ACL delta cannot be omitted for an older BASE candidate.

  • [P1b] Segmented ANN/fusion meets the approved recall floor against the exact resolved logical corpus at representative segment counts and tombstone rates.

  • [P1b] Compaction produces a logically and retrieval-equivalent BASE before atomic promotion, and a failed compaction leaves the prior manifest active.

  • [P1b] Exact-input embedding reuse cannot cross a disallowed tenant, global, data-use, encryption, residency, retention, or legal-hold boundary, and last-reference deletion produces complete physical purge evidence.

  • [P3] A model, dimension, normalization, distance, or document-transform change creates a new space/profile candidate index instead of mutating a promoted generation. A query-transform-only revision reuses document vectors only after the explicit equivalence gate passes.

  • [P2] SharePoint and Confluence throttling is retried according to provider guidance without busy looping.

  • [P1a] Every retrieval result has a resolvable, authorized citation.

  • [P1a] Retrieval and Portal APIs never expose embedding values or connector credentials.

  • [P1a] Hindsight memory and Knowledge Base retrieval can both participate in one Agent turn without sharing lifecycle or authorization state.

  • [P1a] Purge evidence covers PostgreSQL, object storage, caches, and every applicable derived index/artifact. P1b adds DELTA, embedding-reference, and passage-anchor evidence; P4 adds graph projections.

Open Implementation Decisions

The architecture above does not depend on these choices, but implementation planning must settle them:

  • the approved object-store implementation and encryption-key model;
  • initial parsers and media-type limits, plus the post-Phase-1a roadmap and authorization/citation contract for OCR, images, scans, and spreadsheets;
  • the initial qualified embedding-space contract, platform model-authority host, and separately admitted LLM gateway indexing/query lanes and Aliases;
  • the physical identity and deduplication rules for canonical chunks, segment operations, immutable embedding artifacts, chunk/artifact references, and materialized ANN rows;
  • canonical serialization and digest algorithms for parser, chunker, metadata, lexical, citation-anchor, ACL-normalization, transform, and projection contracts plus the dependency-invalidation planner;
  • whether evaluation retains or adjusts the Phase 1a 450-token target, 800-token maximum, 64-token overlap, heading prefix, code/table behavior, and later small-to-big context policy;
  • passage-anchor creation and continuity algorithms, provider-anchor precedence, ambiguity behavior, and anchor retention across moves and renames;
  • embedding-artifact reuse scope, reference accounting, keyed-digest and encryption strategy, side-channel controls, and last-reference purge proof;
  • BASE/DELTA segment size/count limits, exact-versus-HNSW crossover, per-segment candidate budgets, compaction triggers, and compaction resource isolation;
  • segment vector projection choice (vector, halfvec, or binary-quantized candidate search with higher-precision rescoring), measured HNSW overhead, and dedicated build-instance/import mechanism;
  • lexical language configurations, identifier field, trigram thresholds, field weights, ranking formula, and whether an operationally qualified BM25 extension adds enough value to replace or supplement core FTS;
  • metadata-schema evolution, required-field validation, backfill policy, and compatibility rules for composing old/new segment metadata;
  • connector-proven document relationship types and the narrow lifecycle rules that permit cascade tombstones without treating semantic graph relations as ownership;
  • the canonical content-log implementation, ordering guarantees, promotion fence, and reconciliation algorithm used while source synchronization remains active during a long embedding migration;
  • embedding-migration estimate tolerances, budget-overrun behavior, pause/resume scheduling, and how provider price revisions affect an accepted estimate;
  • the rollback soak duration, whether predecessor deltas are always dual embedded or reconciled only on demand, and the extra-capacity policy for global Knowledge Bases;
  • the privacy, tenant-consent, sampling, retention, and capacity policy for any optional shadow evaluation; the first release has no live random-traffic canary;
  • the physical KB/segment partition lifecycle, iterative-scan bounds, exact search crossover, and ACL-selectivity recall thresholds beyond the initial all-strata Recall@10 floor;
  • per-consumer-host global-KB quota defaults, chargeback policy, and fair scheduling algorithm;
  • ingestion-policy defaults by tenant/KB/source tier, reservation granularity, budget-increase approval, and restart behavior after PAUSED_BUDGET;
  • object-store versioning implementation, staged-object grace period, checkpoint-manifest format, and restore/orphan scan frequency;
  • portability manifest canonical serialization, signing algorithm and key rotation, logical publication tooling, optional canonical source bundle format, dependency-binding workflow, import-tombstone reporting horizon, export-audit retention horizon and its relationship to platform audit retention, and the operator surface that keeps projection replay, logical publication, and physical restore unambiguous;
  • query-embedding cache size, TTL, keyed-digest rotation, and policy-boundary rules;
  • the exact SharePoint/Confluence sweep sharding needed to meet the fifteen-minute ACL ceiling, group-membership resolution/cache policy, link/restriction edge cases, and stricter thresholds for particular source classifications;
  • per-binding strict/partial operational-failure policy defaults, evidence-gate calibration, and stable warning-code extension process;
  • the platform capability and approval workflow for global creation, source change review, generation promotion, deprecation, and emergency removal;
  • ownership and rollout of the shared embedding-space registry/conformance/lane component across Knowledge Base, Hindsight, and tool-description consumers;
  • deployment reference concurrency and tighter SLOs beyond the proposed launch ceilings, plus the measurements that trigger partitioning or a separate vector engine;
  • the qualification threshold and cost policy for enabling GRAPH_ASSISTED;
  • the deterministic entity/relation taxonomy for Git and Markdown, origin and trust weighting for explicit, structural, and extracted relations, and handling for high-degree index pages and ambiguous identifiers;
  • path-planner activation, seed and pair selection, decay/pruning/scoring version, fan-out/hop/visited/path/token/latency/memory limits, and safe fallback policy;
  • the strategy-independent evidenceGroups schema, relation-type exposure, deduplication that preserves path membership, Agent consumption contract, and whether the first graph pilot returns it outside Portal diagnostics;
  • whether query-derived relational paths need a cache and, if so, its generation/authorization/query key, TTL, reference accounting, invalidation, and purge policy;
  • whether and how MIRROR_SOURCE_ACL can safely qualify for graph-assisted retrieval without partitioning the graph by visibility boundary;
  • query-audit retention and whether raw query text is stored, hashed, or omitted by policy.

References

Current Light Portal design and implementation references:

External implementation references:

Workflow Version and Publication

Status

Accepted design.

The workflow draft, publish, immutable-version, published-version selection, and comparison flows are implemented. Publishing one or more tools to a Gateway instance from Instance Admin is the next control-plane phase and is specified here so that it uses the same version model.

Purpose

Workflow definitions are authored interactively and may change many times before they are safe to use from a tool. A tool, however, must not execute an unreviewed moving target. It needs a stable reference that supports audit, controlled upgrade, and rollback.

This design defines:

  • how workflow identity is preserved across versions;
  • when a version is mutable or immutable;
  • how a tool selects a workflow version without requiring raw JSON or digest entry;
  • how versions are compared and rolled back; and
  • how the selected tool and workflow revisions eventually become Gateway configuration through Config Server snapshots.

Decision Summary

  1. A workflow has one stable wfDefId for its complete version history.
  2. (hostId, wfDefId, version) identifies one workflow version.
  3. A DRAFT version may be saved repeatedly without changing its version.
  4. Publishing freezes the exact saved YAML and changes the version to PUBLISHED.
  5. A published version cannot be edited. Further work starts as a new version under the same wfDefId.
  6. Tools may bind only to published workflow versions.
  7. Namespace and name are searchable display metadata, not identity keys.
  8. Digests remain internal integrity evidence. Users do not enter or manage them on the Tool form.
  9. Workflow publication and Gateway activation are separate operations.
  10. A Gateway change becomes live only after Instance Admin creates a new configuration snapshot and selects it as current.

Goals

  • Let an author iterate frequently without creating duplicate workflow identities.
  • Make every version used by a tool immutable and reproducible.
  • Allow a tool to move forward or roll back by selecting a different published version under the same wfDefId.
  • Prevent draft workflows from entering Gateway runtime configuration.
  • Present workflow selection in business terms such as customer/customer-360 @ 1.2.0.
  • Keep internal hashes, generated IDs, and binding JSON out of the normal user workflow.
  • Preserve optimistic concurrency and event-sourced projection behavior.
  • Reuse Config Server snapshots for deployment activation and configuration drift prevention.

Non-Goals

  • Publishing a workflow does not deploy it to a Gateway.
  • Saving a tool does not change a live Gateway.
  • The workflow version string is not generated automatically by this design.
  • Namespace and name do not provide uniqueness across a workflow history.
  • Digests do not replace human-readable version identity or configuration snapshots.
  • A configuration snapshot does not replace workflow-version immutability.

Terminology

TermMeaning
Workflow identityStable wfDefId shared by all versions of one workflow.
Workflow versionOne YAML definition identified by (hostId, wfDefId, version).
DraftMutable saved version that cannot be selected by a tool.
Published versionImmutable version eligible for tool binding.
Tool bindingInternal contract pinning a tool to wfDefId and workflowVersion.
Tool publicationSelection of completed tool revisions for a Gateway instance.
Configuration snapshotImmutable Config Server artifact for one instance.
Current snapshotSnapshot selected for delivery to the live instance.

Identity Model

wfDefId is the aggregate identity. It never changes merely because the YAML or version changes.

wfDefId: 2695cdee-cb82-4b34-a2d8-f69093c733e3
  1.0.0  PUBLISHED
  1.1.0  PUBLISHED
  1.2.0  DRAFT

The identity rules are:

  • hostId + wfDefId identifies the workflow history;
  • hostId + wfDefId + version identifies a saved revision;
  • hostId + namespace + name + version prevents duplicate labels within a host, but does not replace wfDefId; and
  • a tool stores wfDefId and workflowVersion, never namespace and name as its durable reference.

Keeping wfDefId stable preserves grouping in the editor, avoids ambiguous namespace/name matching, and makes rollback a version-selection operation instead of rebinding to an unrelated workflow.

Lifecycle State Machine

Current stateOperationResultAllowed
NoneCreateNew saved DRAFTYes
DRAFTSaveSame version updatedYes
DRAFTPublish exact saved YAMLSame version becomes PUBLISHEDYes
DRAFTBind from ToolNo changeNo
PUBLISHEDEdit or overwrite YAMLNo changeNo
PUBLISHEDCreate new versionNew DRAFT under same wfDefIdYes
PUBLISHEDBind from ToolTool pins this versionYes

DEPRECATED is reserved in persistence for a future retirement operation. The current command and UI lifecycle has no transition into that state; until one is implemented, saved versions move only from DRAFT to PUBLISHED.

Publication must operate on the exact saved draft. If the editor contains unsaved changes, the user must save them before publishing. This prevents the publish command from freezing content that was never projected as a draft.

The version string is supplied by the author. Portal rejects a new draft when the same (hostId, wfDefId, version) already exists. Semantic-version ordering may be added as a UX policy later, but identity does not depend on parsing the version as SemVer.

Authoring Experience

Create and save

Creating a workflow assigns a new wfDefId and saves version 1.0.0, or the version entered by the author, as DRAFT. The author may import YAML, use the visual editor, ask the authoring assistant, validate, test, and save repeatedly.

Publish

Publish Version performs client and server validation, asks for explicit confirmation, and publishes the exact saved definition. After success:

  • YAML and identity fields become read-only;
  • graph and step-palette mutation actions are disabled;
  • publication status is visible in the editor and workflow table; and
  • the version becomes available in workflow-backed Tool forms.

Create a new version

For a published version, Create New Version asks for a new version string, copies the published YAML, updates its embedded version metadata where present, and creates a draft under the same wfDefId. The clone is not durable until it is saved.

Compare versions

The editor loads the workflow’s complete version history. An author selects a base and comparison version and views them side by side as normalized YAML. Normalization reduces noise from formatting while retaining list order and semantic values.

The initial implementation is a side-by-side viewer. Line-level highlighting can be added later without changing the persistence or API contract.

Persistence Model

Workflow aggregate head

wf_definition_t remains the event-backed aggregate head. It contains the currently selected authoring version and aggregate concurrency state, including:

  • host_id and wf_def_id;
  • namespace, name, version, and definition;
  • lifecycle_status;
  • ownership and taxonomy metadata;
  • aggregate_version; and
  • active/update audit fields.

Version history

wf_definition_version_t stores each saved version:

ColumnPurpose
host_id, wf_def_id, versionComposite version identity.
namespace, nameDisplay metadata captured with the version.
definitionExact saved workflow YAML.
lifecycle_statusDRAFT, PUBLISHED, or DEPRECATED.
published_by, published_tsPublication audit evidence.
aggregate_versionEvent projection ordering.
active, update_user, update_tsProjection lifecycle and audit data.

The primary key is (host_id, wf_def_id, version). A foreign key retains the relationship to wf_definition_t. Published rows may not be overwritten by a later draft or by different YAML.

workflow_tool_binding_t has a composite foreign key to the version table so a binding cannot refer to an unknown workflow version. Projection validation also requires the referenced row to be active and PUBLISHED.

Command Contracts

createWfDefinition

  • Generates wfDefId when absent.
  • Enriches the event payload with lifecycleStatus: DRAFT.
  • Creates the aggregate head and initial version row.

updateWfDefinition

  • Uses the existing aggregateVersion optimistic-concurrency contract.
  • Rejects changes to an already published version.
  • Saves the selected version as DRAFT.
  • Permits repeated changes to the same draft version.

publishWfDefinition

  • Uses the workflow update event type so existing event ordering remains authoritative.
  • Requires the selected version to be a saved draft.
  • Requires submitted YAML to equal the saved draft YAML.
  • Enriches the event payload with lifecycleStatus: PUBLISHED.
  • Records publisher and publication timestamp in the version projection.

Publishing is idempotent only for replay of the same ordered event. An interactive attempt to publish an already published version is rejected rather than presented as a new publication.

Query Contracts

getWfDefinitionById

Returns the aggregate head plus a versions collection containing definition, lifecycle, publication, aggregate-version, and audit fields. The Workflow Editor uses this response rather than relying on a potentially stale list row.

getPublishedWfDefinitionVersionLabel

Returns selector options only for active, published workflow versions. Each option has:

{
  "value": "2695cdee-cb82-4b34-a2d8-f69093c733e3|1.2.0",
  "label": "customer/customer-360 @ 1.2.0"
}

The wfDefId|version value is a form helper, not a persisted domain identity. It avoids maintaining two dependent selectors and is omitted before command submission.

getWorkflowToolBindingOption

Accepts hostId and workflowVersionRef. It verifies that the selected version is published and returns trusted binding inputs:

  • wfDefId;
  • namespace and name for display;
  • workflow version; and
  • canonical definition digest.

Draft, inactive, malformed, and unknown selections fail closed.

Workflow-Backed Tool Form

When executionPlacement is workflow, the form displays Workflow Definition as a published-version selector. It does not display:

  • raw Workflow Binding JSON;
  • schema digest;
  • workflow-definition digest;
  • policy digest; or
  • response-policy digest.

The schema-driven form submits the selected wfDefId|version reference. The command service reloads that published version and constructs the internal binding. Defaults such as invocation mode and deadlines are system-owned. The command service derives definition, schema, and policy digests from trusted server-side content, overriding caller-supplied values.

At projection time, Portal reloads the published version’s YAML, recomputes its canonical digest, and requires it to match the binding. The browser is therefore not the trust boundary. Command-time selection resolution and projection both call the same common-util canonical digest implementation, so changes to YAML parsing or canonicalization cannot drift between repositories.

Why Digests Still Exist

Configuration snapshots remove the need for users to manage digests, but do not eliminate internal integrity requirements.

DigestInternal purpose
Definition digestProves the binding matches the immutable published YAML.
Schema digestPins the request/response contract used by the tool.
Policy digestIdentifies the server-owned workflow execution policy profile.
Response-policy digestIdentifies response handling and disclosure rules.
Snapshot/artifact digestVerifies Config Server content delivered to an instance.

Workflow and binding digests protect semantic admission contracts. Snapshot digests protect a deployable configuration artifact. They operate at different layers and should not be overloaded as version labels or activation switches.

Tool Upgrade and Rollback

Changing a tool from workflow version 1.1.0 to 1.2.0 creates a new tool revision with a new immutable binding. It does not alter either workflow version. Rolling back selects 1.1.0 again and republishes the changed tool to the target Gateway.

Existing Gateway configuration remains unchanged until a new configuration snapshot becomes current. Existing in-flight work remains pinned to the binding and policy admitted at start; changing the current snapshot affects new work.

Gateway Tool Publication

This section specifies the planned Instance Admin phase.

Entry point

Instance Admin provides Publish Tools for one tool or a selected batch. The operator chooses a registered light-gateway instance. Portal resolves the current revision of every selected tool and validates that all workflow-backed tools reference published workflow versions.

Publication flow

Select tools and Gateway instance
              |
              v
Resolve immutable tool and workflow revisions
              |
              v
Validate ownership, compatibility, and references
              |
              v
Stage managed mcp-router.yml entries
              |
              v
Create immutable configuration snapshot
              |
              v
Review snapshot comparison
              |
              v
Move the instance current pointer

The publication record should contain at least:

  • host, environment, and Gateway instance ID;
  • publication ID and operator identity;
  • selected tool IDs and aggregate versions;
  • workflow wfDefId and version for each workflow-backed tool;
  • generated managed-entry keys;
  • projection/event watermark;
  • resulting configuration snapshot ID; and
  • status, failure reason, and timestamps.

Batch semantics

A batch is one intended Gateway configuration generation. Validation is all-or-nothing before staging. If any selected tool is inactive, stale, unauthorized, or bound to a non-published workflow version, no batch entries are activated.

Property projection and snapshot creation may be asynchronous, but the snapshot must not become current until every staged entry is present and the snapshot comparison succeeds. Retrying the same publication ID must be idempotent.

Managed mcp-router.yml entries

Portal owns entries generated by tool publication. Their stable managed key must be based on tool identity, not the mutable display name. Republishing a tool updates its managed desired entry for the next snapshot; it never edits an existing snapshot.

Removing a published tool from an instance is also a publication that removes or disables its managed entry in the next snapshot. It does not delete the tool or workflow definition from Portal.

Configuration Snapshot Boundary

The Instance Admin, not the workflow editor or Tool form, creates configuration snapshots. A snapshot captures the complete effective instance configuration, including the generated mcp-router.yml entries.

The current pointer provides controlled activation:

  • creating a snapshot does not make it live;
  • selecting it as current affects subsequent Config Server reads;
  • an invalid runtime load preserves the Gateway’s last known good content; and
  • rollback selects a previous immutable snapshot rather than reconstructing configuration from current authoring rows.

This boundary prevents configuration drift while preserving independent workflow and tool authoring lifecycles.

Authorization and Ownership

  • Workflow creation, update, and publication require Portal write scope and normal owner/position authorization.
  • Published-version labels are host-scoped and returned only through Portal query authorization.
  • A user must be authorized to view a workflow before using it in a tool.
  • Tool publication additionally requires permission to administer the target Gateway instance.
  • Cross-host workflow selection and publication are never allowed.

The server revalidates host and ownership boundaries; selector filtering alone is not authorization.

Concurrency and Failure Handling

  • Workflow mutations use aggregateVersion to reject stale commands.
  • Version projection applies only newer aggregate events.
  • A stale event must not create a new version-history row before the aggregate head accepts it.
  • Publishing fails if the draft changed after the editor loaded it.
  • Binding creation fails if the workflow version is not active and published.
  • Binding projection fails if its definition digest differs from canonical stored YAML.
  • Snapshot activation fails closed when staging is incomplete or comparison detects unexpected content.

Failures do not modify an immutable published version or an existing configuration snapshot.

Migration and Compatibility

The schema migration:

  1. adds lifecycle status to wf_definition_t;
  2. creates wf_definition_version_t;
  3. backfills each existing workflow head as PUBLISHED with its current version and audit metadata;
  4. marks the corresponding aggregate head as published;
  5. adds the published-version lookup index; and
  6. adds the workflow-tool-binding composite foreign key.

Existing definitions are treated as published because existing tools may already depend on them. This prevents migration from silently turning a runtime dependency into an editable draft.

Legacy workflow events without lifecycle status replay as published to preserve their historical projection semantics. New create and update commands always emit explicit lifecycle status.

The foreign key is introduced as NOT VALID for an existing database so rollout is not blocked by historical binding drift. Operations must audit and repair legacy violations before validating the constraint. Fresh databases create the constraint normally.

Rollback removes the new binding constraint, version table, and lifecycle column. It should be used only before new multi-version data or bindings depend on the model.

Observability and Audit

Audit views should answer:

  • who created, changed, and published a workflow version;
  • which exact YAML digest was published;
  • which tools reference each published version;
  • which workflow and tool revisions were included in a Gateway publication;
  • which configuration snapshot contains that publication; and
  • which snapshot is current or was selected during rollback.

Recommended metrics include publication validation failures, stale aggregate conflicts, rejected draft bindings, digest mismatches, snapshot creation latency, activation failures, and last-known-good fallback events.

Rollout Plan

Phase 1: Workflow lifecycle

  • Apply the database migration.
  • Deploy the updated Portal database provider.
  • Deploy workflow command and query services.
  • Deploy the Workflow Editor lifecycle and comparison UI.
  • Verify existing workflows appear as published and remain readable.

Phase 2: Tool binding UX

  • Deploy published-version label and option queries.
  • Deploy the Tool form without raw binding or digest inputs.
  • Deploy server-derived digest handling and projection verification.
  • Verify draft versions never appear in the selector.

Phase 3: Gateway publication

  • Add Instance Admin single and batch tool selection.
  • Add publication manifest and status persistence.
  • Generate managed mcp-router.yml desired entries.
  • Reuse snapshot creation and YAML comparison.
  • Require explicit current-pointer activation.
  • Add removal, retry, failure recovery, and rollback tests.

Acceptance Criteria

  • Multiple versions share one wfDefId and appear as one workflow history.
  • A draft can be saved repeatedly without changing its version.
  • An unsaved draft cannot be published.
  • Published YAML cannot be changed by the editor, command, or projection.
  • Creating a new version retains the same wfDefId.
  • Two saved versions can be compared as normalized YAML.
  • Only active published versions appear on the Tool form.
  • The Tool form shows no raw binding JSON or digest fields.
  • Direct callers cannot use a forged definition digest.
  • A tool can be rebound to an older published version for rollback.
  • Tool authoring alone does not alter live Gateway configuration.
  • A Gateway change becomes live only through a newly current immutable configuration snapshot.

Endpoint Tools and Workflow Access

Status

Implemented for the REST/HTTP first slice, with qualification still pending. MCP and JSON-RPC palette builders remain later protocol extensions of the same identity and grant model. Authentication metadata must explicitly resolve to type: none; authenticated, unspecified-authentication, or Portal-policy-protected HTTP endpoints remain fail-closed until delegated credential support is implemented.

Purpose

Light Portal imports API versions, derives their endpoints, and projects those endpoints as Tools. A Tool adds the descriptions, schemas, examples, safety metadata, and testing information needed by agents and workflow authors.

This design defines:

  • how endpoint identity works across REST, MCP, and hybrid APIs;
  • how an API endpoint becomes an enriched Tool;
  • where workflow access is granted;
  • how the Workflow Editor lists permitted Tools; and
  • how a Tool is added as a call inside a fork branch.

The related designs are Agent Skill And API Endpoint Discovery, Workflow Editor, and Workflow Version and Publication.

Decision Summary

  1. api_endpoint_t.endpoint_id remains the internal UUID identity used by database relationships.
  2. api_endpoint_t.endpoint is the canonical protocol-native endpoint key.
  3. The endpoint key is unique within a host and API version.
  4. Every workflow-callable API endpoint has one endpoint-backed Tool.
  5. The Tool owns the enriched LightAPI consumption document.
  6. Workflow access is granted from /app/genai/Tool.
  7. API Admin and Service Endpoint may provide bulk or contextual actions, but they use the same Tool workflow grant.
  8. A workflow invoking a Tool is independent from a Tool implemented by a workflow. These are different relationships.
  9. The Workflow Editor lists only Tools granted to the current workflow.
  10. Runtime authorization uses the Tool grant and refuses to bypass endpoint access controls: protected endpoints are not callable in the first slice.

Capability Flow

API Version
  -> API Endpoint
       -> endpoint-backed Tool
            -> LightAPI description, validation, and tests
            -> workflow access grant
                 -> Workflow Editor palette
                      -> call task in a workflow or fork branch

The API version is the contract source. The endpoint is the runtime operation. The Tool is the consumable capability. The workflow grant decides whether a workflow may use that capability.

Endpoint Identity

Internal identity

The Portal database continues to use the existing UUID:

host_id + endpoint_id

Permissions, rules, Tool mappings, and other relational data reference this UUID. It is not typed into workflow YAML.

Natural identity

The endpoint’s natural identity is:

host_id + api_version_id + endpoint

The database must enforce this identity with a unique constraint:

UNIQUE (host_id, api_version_id, endpoint)

endpoint is an opaque, protocol-native key. Consumers do not parse one API type as though it were another.

API typeEndpoint key example
REST/OpenAPI/customers/{customerId}/preferences@get
MCP toolgetCustomerPreferences@call
Hybrid/JSON-RPClightapi.net/customer/getPreferences/0.1.0

Endpoint producers must generate a deterministic canonical value:

  • REST uses the normalized templated path and lowercase HTTP method.
  • MCP uses the exact tool name and call operation.
  • Hybrid uses host/service/action/version.

Changing the canonical endpoint key defines a different operation.

Portable capability identity

LightAPI uses a qualified endpointId for agent, workflow, and cross-document references. It is separate from the database UUID and protocol-native endpoint key.

Examples:

customer-api/customer.preferences.get
customer-mcp/getCustomerPreferences
light-portal/customer.getPreferences

The Tool stores this value as capabilityRef. An API version must not contain two endpoints with the same capabilityRef.

API Endpoint and Tool Responsibilities

API endpoint

api_endpoint_t owns the parsed API contract and runtime identity:

  • API version relationship;
  • protocol-native endpoint key;
  • HTTP method and path where applicable;
  • generated input and response schemas;
  • source protocol and lifecycle; and
  • the internal endpoint UUID used by access control.

Tool

tool_t is the agent- and workflow-facing capability projection. An endpoint-backed Tool keeps its endpoint_id relationship and adds:

  • Tool name and description;
  • portable capabilityRef;
  • enriched LightAPI endpoint document;
  • safety and idempotency metadata;
  • examples and test definitions;
  • semantic discovery metadata;
  • validation status and document digest; and
  • workflow access status.

For an endpoint-backed Tool, the Tool and endpoint have a one-to-one relationship within an API version.

LightAPI Enrichment

The Tool uses the LightAPI Description Specification as its complete consumption contract.

The Portal generates a profile: endpoint document from the API endpoint and allows the Tool administrator to enrich it. The document includes only the sections relevant to the operation, including:

  • info and source provenance;
  • protocol invocation details;
  • authentication and environments;
  • logical input schema and wire request mapping;
  • result schema, success cases, failures, and output extraction;
  • examples and fixtures;
  • safety, confirmation, and idempotency;
  • capability classification and tags; and
  • agent progressive-disclosure metadata.

Generated HTTP operations always carry explicit authentication metadata. OpenAPI security requirements produce a protected custom marker, while an operation that is public after applying operation-level overrides produces type: none. Imported LightAPI operations without an explicit none contract remain protected.

Example:

lightapi: 0.1.0
profile: endpoint
info:
  title: Get Customer Preferences
  namespace: customer-api
  version: 1.0.0
operations:
  getCustomerPreferences:
    endpointId: customer-api/customer.preferences.get
    protocol: http
    method: GET
    endpoint: /customers/{customerId}/preferences
    metadata:
      portalEndpoint: /customers/{customerId}/preferences@get
    safety:
      destructive: false
    idempotency:
      safeToRetry: true
    input:
      schema:
        type: object
        required: [customerId]
        properties:
          customerId:
            type: string

Tool save validates the LightAPI schema and endpoint relationship. A dedicated Validate action and the richer Test workspace remain follow-up work. Testing will let the administrator select an environment and run an example or test sequence. Privileged, destructive, or confirmation-required operations must not run without the required approval.

Workflow Access Grant

Workflow access belongs to the Tool because the Tool is the reviewed, validated, and testable capability.

The grant is stored separately from descriptive LightAPI metadata:

workflow_tool_grant_t
  host_id
  grant_id
  tool_id
  wf_def_id
  workflow_version             optional
  tool_version
  lightapi_digest
  allowed_environments
  aggregate_version
  active
  update_user
  update_ts

wf_def_id identifies the workflow allowed to use the Tool. When workflow_version is present, the grant applies only to that immutable workflow version. The Tool version and LightAPI digest pin the reviewed capability contract.

There is one active grant aggregate for each Tool/workflow pair. workflow_version is mutable scope on that aggregate: null applies to all versions and a value narrows it to one version. Definition-wide and version-specific grants therefore cannot coexist for the same Tool/workflow.

The grant is created and changed through Portal commands and events. Projection tables are never edited directly.

Suggested commands:

  • grantWorkflowTool
  • updateWorkflowToolGrant
  • revokeWorkflowTool

Suggested events:

  • WorkflowToolGrantedEvent
  • WorkflowToolGrantUpdatedEvent
  • WorkflowToolRevokedEvent

Separate Invocation Directions

Two relationships must remain distinct:

RelationshipMeaning
Tool implemented by WorkflowCalling the Tool starts a selected published workflow.
Workflow granted ToolA workflow call task may invoke the selected Tool.

The second relationship does not depend on how the workflow starts. A workflow has the same Tool grant whether it was started by an agent, user, scheduler, API, or another workflow.

Portal User Experience

API Admin

From /app/service/admin, an administrator selects an API version and can:

  • generate or refresh endpoint-backed Tools; and
  • open the Tool catalog filtered to that API version.

Service Endpoint

From /app/serviceEndpoint, an administrator can:

  • inspect the protocol-native endpoint and generated schemas;
  • open its mapped Tool;
  • see the Tool’s persisted validation status; and
  • grant selected endpoint Tools through the same Tool grant command.

This page does not maintain a second workflow-access flag.

Tool

From /app/genai/Tool, an administrator can:

  • review and enrich the LightAPI document;
  • see save-time validation results;
  • select one workflow or workflow version;
  • grant or revoke workflow access; and
  • see which workflows currently use the Tool.

A dedicated Validate action and downstream Test workspace remain a follow-up; this slice does not present save-time validation as execution test evidence.

The normal action is named Workflow Access. It is not part of executionPlacement, because execution placement describes how the Tool itself is implemented.

Workflow Editor Palette

The Workflow Editor queries a workflow-aware catalog:

getWorkflowCallableTool(hostId, wfDefId, workflowVersion, environment)

The query returns only Tools whose grants and contracts are active and valid. Each result contains enough trusted data to create a task:

  • toolId and Tool version;
  • capabilityRef;
  • the grant’s allowed environments and resolved/runtime-selected environment;
  • API name and version;
  • protocol-native endpoint key;
  • protocol, method, and invocation mapping;
  • input and result schemas;
  • safety metadata; and
  • LightAPI digest.

The palette groups results by API and API version. Users search by Tool name, endpoint, capability, tags, or description.

Selecting a Tool creates the protocol-specific call task. The author does not type an endpoint UUID, URL, method, or Tool name manually.

For REST/HTTP, the generated task carries logical path inputs plus query, header, and body mappings. The runtime resolves those templates from workflow context and applies them to the downstream request after resolving the physical endpoint from the pinned LightAPI operation. The resolved URI must retain the Portal API version target’s scheme, host, and effective port. Absolute and protocol-relative LightAPI endpoints that select another authority fail before dispatch.

Fork Branch Editor

When a fork step is selected, the property panel provides:

  • editable fork step ID;
  • editable branch names;
  • Add Branch and Remove Branch actions;
  • branch validation; and
  • an Add Step action for each branch.

Branch names must be unique within the fork. Renaming a branch updates the YAML key without changing the tasks inside that branch.

Choosing Add Step opens the normal step palette. Choosing a call task opens the workflow-callable Tool selector described above. After selection, the editor inserts the generated call beneath that branch and refreshes the YAML, outline, and graph from the same parsed workflow model.

Runtime Authorization

Before invoking the downstream operation, the workflow runtime resolves the Tool and evaluates:

  1. the workflow-to-Tool grant;
  2. the pinned Tool version and LightAPI digest;
  3. Tool and endpoint lifecycle state;
  4. the selected environment;
  5. the endpoint URI from the validated LightAPI operation and selected environment; and
  6. whether authentication explicitly resolves to type: none, and whether the endpoint has active scope/rule configuration or role, group, user, position, or attribute permission rows.

A missing, revoked, inactive, or mismatched grant fails before a downstream request is sent. In the first REST/HTTP slice, non-none or missing LightAPI authentication or active endpoint scope/rule/RBAC/ABAC permission configuration also fails closed. Grant creation performs the same scope, rule, role, group, user, position, and attribute checks, so an operator receives an explicit rejection instead of a grant that later disappears from the callable catalog. Portal and runtime operation selection both match endpoint ID, HTTP protocol, method, lifecycle, and explicit unauthenticated status, then choose the first operation by key. Grant validation sources the method from the linked Portal endpoint rather than the Tool’s optional apiMethod metadata; a missing endpoint method is reported as a distinct validation error. Delegated credential resolution is required before those protected endpoints can be enabled.

The workflow identity authorizes use of an unprotected Tool. A later delegated credential extension must preserve the initiating agent or user in the execution and audit context before protected endpoints are callable.

Pending Qualification Criteria

  • The database enforces one canonical endpoint key per host and API version.
  • REST, MCP, and hybrid endpoint producers generate deterministic keys.
  • Every endpoint-backed Tool has an endpoint UUID and portable capability reference.
  • The Tool LightAPI document validates against the supported specification version.
  • Tool enrichment is preserved when generated endpoint fields are refreshed.
  • Workflow access can be granted and revoked only through Portal events.
  • Tool workflow access is independent from workflow-backed Tool execution.
  • The callable-Tool query returns only Tools granted to the selected workflow.
  • The Workflow Editor creates call tasks from trusted Tool data.
  • Fork branches can be added, removed, and renamed from the property panel.
  • A call can be added to any fork branch through the same Tool palette.
  • Runtime execution rejects unavailable or unauthorized Tool calls before any downstream effect.
  • Agent-, user-, scheduler-, API-, and workflow-started executions enforce the same workflow Tool grant.

API Version Publication To Gateway

Status

Source implementation complete; integration and runtime qualification remain.

The query, compiler, append-only command, locked graph-revision guard, replacement event ordering, snapshot-readiness guard, API Detail dialog, and warning-confirmation workflow are implemented. Source-level unit tests and builds cover the candidate/preview/publish flow, deterministic compilation, stale revision handling (including the event-free path), complete child-first retirement, and the asynchronous success response.

The release is not yet qualified as complete. The PostgreSQL concurrency and source-query integration suites must run against an isolated test database, followed by the end-to-end projection, snapshot activation, Gateway apply, and rollback scenarios in this document.

This document defines the Portal workflow for publishing one API version’s derived configuration to a Light Gateway instance and, when requested, retiring selected versions of the same API on that instance. Publication accepts one ordered event batch into Portal’s event store. The normal asynchronous projection worker later updates Portal desired-state tables. A separate configuration snapshot must then be created, reviewed, selected as current, and loaded by the Gateway before runtime behavior changes.

Purpose

Today, an administrator who adds an API endpoint must move through API Detail, Service Endpoint, Access Overview, Instance Admin, Instance API, and Instance API Config. The final Sync Config From API action pulls access-control data into an already existing Instance API association. When the desired access configuration becomes empty, the current sync path can also leave old rule.endpointRules or rule.ruleBodies values in place.

That workflow separates related decisions across too many pages and does not model an API-version upgrade as one operation. It also makes Instance Admin the starting point even though the API version is the source being published.

This design adds Publish to Gateway to each API-version row on API Detail. The action lets an authorized operator:

  • select a Gateway instance;
  • see versions of the same API already associated with that Gateway;
  • choose whether those versions remain active or are retired;
  • preview the API-derived configuration and warnings; and
  • update the new version and any selected retirements with one command.

The design is related to Control-Plane Policy Publication Through Config Server. This command records a requested desired instance configuration change. The immutable configuration snapshot and current-pointer workflow described there remains the runtime publication boundary.

Decision Summary

  1. Publish to Gateway is a row action for a specific API version on /app/apiDetail.
  2. Access Overview remains the place to edit and review endpoint access. It does not publish Gateway configuration.
  3. After a Gateway is selected, Portal shows active and inactive versions of the same apiId associated with that Gateway.
  4. The operator explicitly selects Keep Existing Versions or Replace Selected Versions. Portal does not infer replacement from a version string.
  5. Portal creates, reuses, or reactivates the selected instance_api_t association and reconciles the complete selected property sections.
  6. The first publication section is access control, which owns rule.endpointRules and rule.ruleBodies. Additional API-derived sections may be added later with explicit property ownership.
  7. A successful command means the complete event batch was atomically appended to event_store_t. Projection is asynchronous; command success does not mean that desired-state tables or a Gateway already contain the change.
  8. Missing rules, missing permission roles, and a completely empty access configuration produce visible warnings. A source query failure or invalid compiled configuration blocks publication.
  9. A confirmed empty access-control section writes canonical active {} values for both owned properties. The command never leaves an older value merely because the new source is empty.
  10. Replacement establishes the selected version before explicitly retiring the complete dependent graph of each selected old Instance API.
  11. No publication status table or immutable publication manifest is added. Existing domain events provide history, mutable projections provide current desired state, and configuration snapshots provide deployable evidence.
  12. Publication-specific Idempotency-Key support is deferred. Stale-preview protection and optimistic concurrency remain required.
  13. Instance Admin retains snapshot creation, comparison, verification, activation, and rollback.
  14. The existing Sync Config From API action may remain temporarily as an administrator repair path, but it is not the normal publication workflow.

Goals

  • Reduce a multi-page operation to one API-version-centered publication flow.
  • Support intentional parallel API versions and explicit replacement.
  • Prevent stale endpointRules, ruleBodies, or other owned properties from surviving an empty publication.
  • Preserve event sourcing, optimistic concurrency, ownership, and replay.
  • Keep server-side API data authoritative; the browser never compiles or submits final Gateway property values.
  • Make warnings and replacement effects visible before confirmation.
  • Keep runtime activation immutable and reversible through configuration snapshots.
  • Leave room for future API-level publication sections without moving the action to another page.

Non-Goals

  • Access Overview does not become a deployment or publication page.
  • Creating or editing an API version does not automatically change a Gateway.
  • Publishing an API version does not create or activate a configuration snapshot.
  • Portal does not assume that version strings use Semantic Versioning.
  • A higher-looking version does not automatically supersede another version.
  • Replacement does not physically delete projection or event history.
  • The first implementation does not automatically migrate application bindings between API versions.
  • The first implementation does not add a publication history table, a publication lifecycle state machine, or exact-publication rollback.
  • The first implementation does not guarantee idempotent retry after an ambiguous HTTP failure.
  • The first implementation does not define proactive event-size limits beyond the existing event append safeguards.
  • Upgrade migrations are not required during early development; canonical fresh-install artifacts remain authoritative.
  • This design does not define traffic draining or application compatibility between API versions.
  • This design does not move Gateway Tool publication back into instance_api_property_t; the Tool catalog remains its authoritative path.

Terminology

TermMeaning
API versionOne registered API contract identified by apiVersionId.
Gateway instanceAn active Portal instance eligible to run Light Gateway.
Instance APIThe association between a Gateway instance and one API version, stored in instance_api_t.
Publication sectionA server-side compiler that owns a declared set of Gateway configuration properties.
KeepUpdate the selected version without retiring another version.
ReplaceUpdate the selected version, then retire explicitly selected existing Instance APIs.
RetireSoft-deactivate an Instance API and the dependent records that must not return if it is reactivated.
Events acceptedThe complete command event batch was stored as canonical history and made available for asynchronous projection.
ProjectedThe asynchronous projection worker applied the complete event transaction to Portal desired-state tables.
Snapshot currentA verified immutable snapshot was selected as the Gateway’s current configuration.
AppliedRuntime evidence shows that the Gateway loaded the current snapshot.

User Experience

API Detail row action

/app/apiDetail lists multiple versions, so Publish to Gateway belongs in the action column of each version row. The action receives the row’s apiVersionId; the server resolves hostId, apiId, and version text from trusted context and current records.

The action is visible to admin, host-admin, and api-admin. Server authorization remains authoritative even when the UI hides or disables the action.

Step 1: Select Gateway

The dialog lists active Gateway instances that the current user may administer. Each option includes:

  • instance name and instanceId;
  • product and product version;
  • environment or environment tag where available;
  • existing association status for the selected apiVersionId; and
  • current configuration snapshot identity and timestamp where available.

The query returns only eligible instances for the trusted host. At minimum, an eligible target is active, belongs to the host, has an active Gateway product version such as productId=gtw, and is not read-only.

Step 2: Review existing versions

After a Gateway is selected, Portal shows every active and inactive Instance API for the same apiId on that Gateway. It does not show unrelated APIs.

FieldPurpose
API versionHuman-readable version currently associated with the Gateway.
instanceApiIdDurable UUID used for events, configuration, and retirement.
StateActive, inactive, or selected for replacement.
Path prefixesIndicates whether versions have distinct routing boundaries.
Application bindingsShows consumers affected by retirement.
Property countShows whether version-specific configuration exists.
Last updateHelps identify stale or recently changed associations.

The operator chooses one mode:

Keep Existing Versions

The selected API version is created, reactivated, or updated while every other version remains unchanged. This supports compatibility windows, canary rollout, and blue/green operation when routing remains unambiguous.

Replace Selected Versions

The operator selects one or more existing Instance APIs to retire after the new version’s create/update actions in the same ordered event batch. The dialog requires explicit confirmation and shows dependent path prefixes, application bindings, and properties that will be retired.

The first implementation does not automatically migrate application bindings. Each dependency is classified as:

  • Retire with the old Instance API;
  • Keep old version, which removes that Instance API from the retirement selection; or
  • Block, when the command cannot safely retire the dependency.

The initial implementation must not provide “replace all older versions.” “Older” is ambiguous without an explicit lifecycle policy.

Step 3: Preview compiled configuration

The first publication section is:

Access control
  rule.endpointRules
  rule.ruleBodies

The server preview reports source counts, validation results, warnings, and a normalized diff against the selected Instance API properties. Values may be shown to an authorized administrator but are not editable in this dialog.

Warnings include:

  • an endpoint without any configured rule;
  • an endpoint without permission roles;
  • a permission with no effective principal selection; and
  • a completely empty access-control section.

Warnings require acknowledgement but do not by themselves block publication. Failed source queries, unresolved rule references, invalid compiled values, and routing conflicts are blocking errors.

Future sections may be added only with an explicit property-ownership contract. Two sections must not silently write the same property.

Step 4: Confirm and publish

The confirmation identifies:

  • the selected API version and Gateway;
  • whether the association will be created, reactivated, or updated;
  • the selected sections and acknowledged warnings;
  • existing versions that remain active;
  • existing versions and dependent records that will be retired; and
  • the fact that a configuration snapshot must still be created and activated.

On success, the dialog says Gateway publication events accepted and shows the event transaction or command correlation ID. It explains that projection is asynchronous and that a snapshot can be created only after projection has caught up. The dialog does not poll or wait for projection and does not use a publication status named STAGED.

Routing And Path Prefixes

Keeping multiple versions is valid only when the resulting Gateway routing is unambiguous. Preview blocks conflicts such as identical path prefixes with incompatible upstream targets or duplicate effective endpoint routes.

Access-control publication does not infer a path prefix from API version text. For the first implementation:

  • an existing or reactivated Instance API retains its active path prefixes;
  • creating a missing Instance API does not silently copy prefixes from another version;
  • the preview clearly warns when the target association has no active path prefix and links to the Instance API Path Prefix workflow; and
  • Replace Selected Versions is blocked until the target version has the path-prefix configuration required to remain routable after retirement.

An explicit path-prefix copy or migration workflow may be added later.

Data Model And Audit

The design uses the existing desired-state projections:

instance_t
  -> instance_api_t
       -> instance_api_property_t
       -> instance_api_path_prefix_t
       -> instance_app_api_t
            -> instance_app_api_property_t

instance_api_t enforces one association for (host_id, instance_id, api_version_id). instance_api_property_t identifies one override by (host_id, instance_api_id, property_id).

Foreign-key ON DELETE CASCADE protects physical deletion, but normal Portal deletion is soft deactivation. Retiring an instance_api_t row therefore does not automatically deactivate its child rows.

No api_gateway_publication_t table is added. Audit and replay use:

  • the immutable domain events containing the exact IDs and property values;
  • the event append transaction identity and a shared command correlation UUID;
  • mutable projection rows for current desired state; and
  • immutable configuration snapshots for deployable and historical rendered configurations.

Every event generated by one command carries the same correlation UUID. Replay uses the values captured in those events and never recompiles old events from current access-control tables.

Query Contracts

getApiGatewayPublicationCandidate

The candidate query accepts apiVersionId and returns only server-authorized, eligible Gateway instances. Each candidate contains target metadata, association state for the selected version, versions of the same API already on that instance, dependent record counts, path-prefix readiness, and current snapshot metadata. It also returns the target graph’s accepted and projected revisions and whether an unresolved projection failure exists.

The server derives the trusted host and resolves apiId and version text from apiVersionId. A target whose accepted revision is ahead of its projected revision, or whose graph has an unresolved projection failure, is shown as not ready for another publication. This avoids compiling a new event batch from a stale target projection.

previewApiVersionGatewayPublication

The preview accepts:

{
  "apiVersionId": "uuid",
  "instanceId": "uuid",
  "publicationMode": "REPLACE_SELECTED",
  "retireInstanceApiIds": ["uuid"],
  "sections": ["ACCESS_CONTROL"]
}

It returns:

  • resolved source identity and revisions;
  • association action: CREATE, REACTIVATE, or UPDATE;
  • normalized desired properties, including canonical empty values;
  • create, update, reactivate, deactivate, and unchanged counts;
  • routing and dependency validation results;
  • non-blocking access warnings;
  • retirement effects; and
  • previewDigest, calculated over the normalized request, compiled property values, relevant source revisions, and target desired-state baseline.

The preview is advisory. It does not reserve records or mutate desired state.

Command Contract

publishApiVersionToGateway

The command accepts the operator decision, not browser-generated configuration:

{
  "apiVersionId": "uuid",
  "instanceId": "uuid",
  "publicationMode": "REPLACE_SELECTED",
  "retireInstanceApiIds": ["uuid"],
  "sections": ["ACCESS_CONTROL"],
  "acknowledgedWarningCodes": ["ENDPOINT_WITHOUT_PERMISSION_ROLES"],
  "expectedTargetAcceptedRevision": 12,
  "expectedPreviewDigest": "sha256:..."
}

The command must not accept apiId, API version text, endpointRules, ruleBodies, instanceApiId, property IDs, or compiled values as authoritative client input.

Immediately before building events, the command reloads and recompiles all source and target records. It rejects the request with a conflict when the new digest differs from expectedPreviewDigest. The operator must preview and confirm the changed result. It also rejects the command when the target graph’s accepted revision is ahead of its projected revision or it has an unresolved projection failure. The UI need not wait after a successful command, but a later publication must not compile from projections that have not caught up.

The preview returns expectedTargetAcceptedRevision. Event persistence checks that value while holding the target graph’s transaction lock and before advancing accepted_revision or inserting any event. A mismatch returns a conflict. Checking only in the handler before append is insufficient because two commands can compile concurrently from the same projected baseline.

Publication-specific Idempotency-Key behavior is not part of the first release. A retry after an ambiguous response refreshes the candidate and preview before submitting another command.

Result

{
  "commandCorrelationId": "uuid",
  "eventTransactionId": "uuid",
  "eventsAccepted": true,
  "acceptedEventCount": 4,
  "projectionMode": "ASYNCHRONOUS",
  "instanceId": "uuid",
  "instanceApiId": "uuid",
  "associationAction": "CREATE",
  "properties": {
    "created": 2,
    "updated": 0,
    "reactivated": 0,
    "deactivated": 0,
    "unchanged": 0
  },
  "retiredInstanceApiIds": ["uuid"],
  "snapshotActivationRequired": true
}

The command returns success immediately after the complete event transaction is accepted. instanceApiId is the durable server-generated or existing UUID captured in the events; for CREATE, its projection row might not be queryable yet. Property and retirement counts describe planned event actions, not confirmed projection outcomes. The result does not claim partial property or retirement success.

Server-Side Compilation

The compiler loads the selected active API version and its current endpoint, permission, filter, and rule projections. It also resolves the registered property IDs for rule.endpointRules and rule.ruleBodies.

A failed source query blocks publication. It must not be treated as a successful empty result.

Each selected section owns a complete property set. Access-control publication always addresses both owned properties:

non-empty desired map -> canonical serialized object
empty desired map     -> active canonical {}

An empty result is allowed after explicit warning acknowledgement. Writing {} clears the API-specific desired value and prevents a previous value from surviving accidentally. Deactivation is not used for this case because it could allow a lower-scope value to become effective.

For each owned property, the compiler compares desired and current state:

Current stateDesired stateEvent action
MissingPresent, including {}Create
InactivePresent, including {}Reactivate/create above stored version
Active and differentPresentUpdate
Active and identicalPresentNo property event

Event versions use the greater of current projection and event-store versions, plus one.

Association Creation And Concurrency

Portal resolves the Instance API by (hostId, instanceId, apiVersionId):

  • active association: reuse its instanceApiId;
  • inactive association: reuse and reactivate its instanceApiId; or
  • no association: generate a server-side UUID and create it.

The database uniqueness constraint remains the final identity guard. Concurrent first-publication attempts must not leave a canonical event that can only fail during projection. Event persistence serializes commands for the target graph and atomically compares expectedTargetAcceptedRevision before accepting the batch. If another command won, the loser returns 409 Conflict; the client refreshes and uses the winning instanceApiId. The natural-key check remains a defense in depth.

Event Append And Asynchronous Projection

Portal commands use two separate processing stages:

  1. Command acceptance validates the request, builds the complete ordered event batch, and atomically appends the canonical events to event_store_t through the existing persistence path. This is the only stage the command handler waits for.
  2. Asynchronous projection consumes that event transaction and applies its payloads to relational current-state tables such as instance_api_t and instance_api_property_t.

All projection updates for one event transaction run in one database transaction and in event ordinal order. The UI returns after command acceptance and does not wait for this work. A newly created Instance API and its properties therefore become visible only after the projection worker processes the batch.

If asynchronous projection fails:

  • the already successful command remains successful because its canonical events were accepted;
  • the projection transaction rolls back, so no partial desired-state graph is visible;
  • the failed transaction is visible through the established projection DLQ;
  • the canonical events remain available in the event store for repair and replay; and
  • later publication and snapshot commands reject the target graph until replay advances its projected revision to the accepted revision.

There is no publication lifecycle record and no STAGED state. The relevant facts already exist at their owning boundaries: event acceptance in the event store, projection progress and failures in the projection subsystem and DLQ, snapshot identity in the snapshot workflow, and runtime application evidence from the Gateway.

Replacement And Retirement

A replacement builds one ordered event batch:

  1. create or reactivate the selected Instance API when required;
  2. create, update, or reactivate its selected properties;
  3. retire dependent state for every selected old Instance API; and
  4. retire each selected old instance_api_t association.

The preview validates the resulting post-command graph, including routing and dependency decisions, before append. The asynchronous worker later applies the ordered event transaction atomically to desired-state tables, so readers never observe a partially projected replacement.

A complete retirement explicitly handles:

  • every active instance_api_property_t row;
  • every active instance_api_path_prefix_t row;
  • every active instance_app_api_t relationship;
  • every active instance_app_api_property_t row; and
  • any other active binding whose domain identity is tied to the retiring instanceApiId.

Gateway Tool publications are instance-level and remain a separate workflow. Retiring an Instance API does not mutate the Gateway Tool publication. An operator uses the existing REPLACE_API_SCOPE Gateway Tool publication when the runtime Tool set must also stop exposing endpoints from the retired API version; Tools belonging to other API versions or workflows remain untouched.

Automatic binding migration is deferred. When a required dependency cannot be retired safely, replacement is blocked.

Authorization

Publication is permitted for authenticated users with one of these Portal administrative roles:

  • admin;
  • host-admin; or
  • api-admin.

The server also verifies that:

  • trusted request host matches every selected record;
  • the API version belongs to that host and is active;
  • the Gateway belongs to that host, is active, is eligible, and is not read-only;
  • every retirement target belongs to the selected Gateway and the same API; and
  • every dependent record being retired belongs to the selected old Instance API.

Candidate, preview, and command paths use the same authorization and eligibility helper. The command repeats all checks to reject forged IDs and stale browser state.

Snapshot And Runtime Activation

The command records the requested Portal desired-state change as events. It does not prove that projections are current or that a running Light Gateway loaded the configuration.

Before creating a snapshot, Instance Admin verifies for the target graph that:

  • instance_graph_revision_t.accepted_revision equals projected_revision; and
  • there is no unresolved projection failure for that graph.

If projection is still pending, snapshot creation returns a retryable conflict. If projection failed, the UI links to the DLQ/repair workflow. This check belongs to snapshot creation; the publication dialog does not wait or poll for it.

After the projection is current, Instance Admin performs the established flow:

  1. create an immutable configuration snapshot from projected desired state;
  2. compare it with the current snapshot, including access warnings;
  3. validate the rendered Gateway configuration;
  4. select the verified snapshot as current; and
  5. observe Gateway reload or restart acknowledgement.

Only the final acknowledgement supports an APPLIED runtime claim. A successful command or snapshot-pointer change alone is insufficient.

Failure Handling

ConditionResult
API version or Gateway is inactiveReject before preview/publication.
Caller is not admin, host-admin, or api-adminReject without revealing unauthorized details.
Required source query failsReject; do not treat it as empty configuration.
Endpoint lacks rules or permission rolesWarn and require acknowledgement.
Preview digest changedConflict; require a new preview and confirmation.
Concurrent command created the association firstConflict; refresh the winning association.
Target has no required path prefixWarn; block replacement until routing is ready.
Routing conflict remains unresolvedReject.
Dependent binding cannot be safely retiredReject replacement.
Event append failsNo event batch is accepted.
Asynchronous projection fails after acceptancePreserve command success; roll back the projection transaction, expose the failure in the DLQ, and recover through replay.
Accepted revision is ahead of projected revisionReject another publication and snapshot creation until projection catches up.
Snapshot validation failsKeep the current snapshot unchanged.
Gateway apply failsKeep or restore the last known good runtime configuration.

Observability And Audit

Every preview and command exposes or logs:

  • command correlation UUID and event transaction identity;
  • trusted host, instanceId, apiId, apiVersionId, and instanceApiId;
  • publication mode and selected retirement IDs;
  • selected sections and acknowledged warnings;
  • preview digest and source revisions;
  • property and dependency action counts;
  • requesting user and timestamp;
  • event-append outcome; and
  • separately, projection revision and DLQ outcome; and
  • later snapshot/runtime identities where those systems expose them.

Audit views distinguish an operator decision to keep an old version from an accepted replacement whose asynchronous projection is pending or failed.

Implementation Readiness

This design has been implemented across Portal query, command, persistence, and UI source. The command boundary, authorization roles, warning behavior, UUID identity, event history, projection failure handling, and snapshot/runtime boundary are settled.

The following are required implementation gates, not deferred enhancements:

  • use the normal append-only command-handler behavior; do not enable synchronous projection for this command;
  • validate expectedTargetAcceptedRevision under the target graph transaction lock so concurrent commands cannot append from the same stale baseline;
  • register every emitted event for ordered transactional projection and DLQ replay, with all IDs and compiled values carried in the event payloads;
  • block preview/command compilation when the target graph is not fully projected;
  • block snapshot creation when accepted and projected graph revisions differ or an unresolved projection failure exists; and
  • prove target routing readiness before accepting replacement events.

No publication status table, synchronous projection response, migration plan, mandatory idempotency key, event-size redesign, or immutable publication manifest is required to begin implementation.

Implementation Plan

Phase 1: Candidate, compiler, and preview

  • Add the API Detail row action and dialog.
  • Add the authorized Gateway candidate query.
  • Show versions of the same API and dependency/path-prefix readiness.
  • Show accepted/projected revision readiness and unresolved projection failures.
  • Compile both access-control properties, warnings, diff, and preview digest without writing state.

Phase 2: Append-only publication without replacement

  • Add the compound command using the normal append-only command-handler path.
  • Create, reuse, or reactivate the selected Instance API UUID.
  • Reconcile endpointRules and ruleBodies, including active {} values.
  • Return the accepted event transaction identity without waiting for projection.
  • Reject compilation when the target graph projection is behind or failed.
  • Keep the existing sync action temporarily as a repair fallback.

Phase 3: Explicit replacement

  • Add keep/replace selection and dependency review.
  • Require target path-prefix readiness.
  • Order the selected-version events before retirement events and project the complete transaction atomically.
  • Keep Gateway Tool publication in its existing instance-level workflow; use REPLACE_API_SCOPE separately when API-version Tool bindings must change.

Phase 4: Snapshot handoff

  • Link successful commands to snapshot creation and comparison.
  • Enforce accepted/projected revision equality and no unresolved projection failure before snapshot creation.
  • Display snapshot and runtime-apply evidence through the existing snapshot workflow.
  • Deprecate the manual sync path when publication has equivalent repair and recovery tooling.

Validation Strategy

Unit tests

  • Compile permissions, filters, rules, and rule bodies deterministically.
  • Distinguish source-query failure from a successful empty result.
  • Generate and require acknowledgement for missing-rule and missing-role warnings.
  • Create, update, reactivate, and unchanged reconciliation.
  • Write canonical active {} for both empty access-control properties.
  • Reject unauthorized Gateway and retirement IDs.
  • Reject a stale preview digest.
  • Reject a stale expectedTargetAcceptedRevision.

Database and projection tests

  • Create the first association with a server-generated UUID.
  • Reuse an active association without a duplicate create.
  • Reactivate an inactive association above projection and event-store versions.
  • Return a clean conflict for concurrent first-publication attempts.
  • Prove two commands compiled at the same graph revision cannot both append.
  • Reject preview/command execution while the target graph projection is behind or failed.
  • Project association creation before property creation.
  • Roll back the whole projection when a later event fails.
  • Replay appended events without querying current authoring tables.
  • Deactivate the complete dependent graph during replacement.
  • Prove that reactivating an old association does not revive stale children.

UI tests

  • Render Publish to Gateway on each API-version row, not Access Overview.
  • Filter candidates to authorized eligible Gateway instances.
  • Show only versions of the same API after Gateway selection.
  • Display and require acknowledgement for access warnings.
  • Prevent submission when preview validation fails or becomes stale.
  • Report event acceptance without waiting or polling for projection.
  • Distinguish event acceptance, projection, snapshot, and runtime application.

End-to-end tests

  • Publish a version to a Gateway with no existing association.
  • Republish after adding an endpoint and verify updated endpointRules.
  • Publish empty access data and prove stale values are replaced by {}.
  • Keep two versions with non-conflicting path prefixes.
  • Replace an old version and verify its complete dependent graph is inactive.
  • Build, compare, validate, and activate a snapshot, then verify the Gateway loaded the expected configuration.
  • Delay asynchronous projection and verify the command still returns accepted.
  • Force asynchronous projection failure, verify the DLQ entry and no partial graph, block publication/snapshot creation, and recover the accepted batch through replay.

Acceptance Criteria

The first complete release is accepted when:

  • an authorized admin, host-admin, or api-admin can publish from the selected API-version row;
  • the target dialog shows eligible Gateways and existing versions of the same API;
  • preview distinguishes blocking errors from acknowledged access warnings;
  • a missing Instance API is created with a server-generated UUID;
  • active, inactive, missing, changed, unchanged, and empty properties reconcile correctly;
  • empty access data writes active canonical {} values for both owned properties;
  • stale previews and concurrent first creation return clean conflicts;
  • stale target graph revisions are rejected atomically by event persistence;
  • replacement requires target routing readiness and deactivates the complete selected old graph;
  • the command reports success after the complete event transaction is accepted and does not wait for projection;
  • publication and snapshot creation are rejected while the target graph’s projection is behind or has an unresolved failure;
  • unauthorized or cross-host identifiers are rejected server-side; and
  • snapshot comparison, validation, activation, runtime evidence, and rollback remain separate from the publication command.

Light Gateway

Local Model Provider Transport For LLM Gateway

Status

Proposed design for implementation and qualification.

This design extends the completed OpenAI-compatible LLM gateway and the Embedding Space Contract. It does not change the public /v1 client contract.

Decision Summary

The LLM gateway must support self-hosted model servers running on the same machine, in the same Kubernetes cluster, or elsewhere on an organization’s private network. Self-hosted models are a first-class production deployment, not a development-fixture exception.

Keep HTTPS mandatory for public provider destinations. Add explicit private network profiles for operator-approved model endpoints:

  • public_tls for public HTTPS providers;
  • private_tls for a private endpoint protected by server TLS; and
  • private_plaintext for an explicitly approved private HTTP endpoint when the operator accepts the transport risk.

mTLS remains a possible future strengthening of private_tls, but it is not mandatory and is not part of the first implementation. Service-mesh-specific transport is also deferred.

When a model runtime does not provide HTTPS, deploy light-gateway beside the runtime as its TLS sidecar. The central llm-gateway connects to the sidecar over HTTPS; the sidecar forwards over loopback to the model runtime:

Application or knowledge service
              |
              | OpenAI-compatible HTTPS
              v
        logical llm-gateway
              |
              | private TLS
              v
   light-gateway model sidecar
              |
              | HTTP over loopback
              v
       Ollama / llama.cpp / vLLM

The sidecar is a transport-security boundary. It does not become another LLM configuration authority, select Public Aliases, hold cloud provider credentials, translate model semantics, or bypass LLM gateway policy.

Motivation

Enterprise knowledge bases frequently contain material that cannot leave the customer’s controlled network. Customers therefore need to run embedding and generation models on their own hosts or clusters. Many self-hosted runtimes expose OpenAI-compatible APIs but listen on HTTP by default.

The current production contract rejects HTTP, loopback destinations, and private-network destinations. That is a useful default for operator-configured public providers because it prevents plaintext credential disclosure and server-side request forgery. It is too broad for a reviewed, network-isolated model-serving deployment.

The underlying LLM provider transport already accepts HTTP and HTTPS. The production compiler and Portal control plane impose the stronger policy. This design replaces that single policy with explicit trust profiles rather than using developmentFixtures or adding an unrestricted allowHttp flag.

Terminology

TermMeaning
llm-gatewayThe logical inference gateway that authenticates callers, resolves Public Aliases, enforces policy and budgets, routes operations, validates provider responses, and writes audit records.
light-gatewayThe general LightAPI reverse proxy. In this design, a restricted instance runs beside a model server to terminate TLS.
Model runtimeA self-hosted inference server such as Ollama, llama-server, or vLLM.
Provider endpointOne materialized provider configuration: protocol, base URL, endpoint authentication, headers, and Network Profile. One or more Provider Deployments may reference it.
Model sidecarA light-gateway instance colocated with one model runtime or one tightly coupled model-serving unit.
Network profileThe declared transport and destination trust contract for a Provider endpoint.
Network zoneAn operator-managed set of DNS names, addresses, ports, workload identities, and enforcement controls that may host private model endpoints.
Trust bundleOne or more CA certificates used to authenticate a private TLS server.
Client identityA client certificate and private key used by llm-gateway for mTLS.
Physical runtime identityA stable, control-plane-issued identity for the model-server instance reached through an endpoint, independent of the Provider Deployment record and bound to live evidence.
Capacity domainThe smallest scheduler, accelerator, or host resource whose saturation can affect every deployment assigned to it.
Sidecar identityThe supported profile version, non-secret configuration digest, and certificate identity reported by a model sidecar and captured by live qualification.

Goals

  • Make Ollama, llama.cpp, vLLM, and other OpenAI-compatible self-hosted servers production-capable Provider Deployments.
  • Encrypt prompts, retrieved knowledge, tool input, and vectors whenever traffic crosses a host or an unencrypted application namespace.
  • Reuse light-gateway as the supported TLS sidecar for runtimes without native TLS.
  • Preserve the existing Public Alias, routing, pricing, audit, conformance, and Embedding Space Contract behavior.
  • Retain SSRF, DNS-rebinding, redirect, credential, and private-network protections for public providers.
  • Make every relaxation explicit, scoped, publication-validated, observable, and reversible.
  • Support private CAs and certificate rotation without disabling hostname verification. Leave mTLS as an optional later enhancement.
  • Bound gateway concurrency by the local runtime’s effective parallelism and keep protected query, indexing, and standard traffic out of shared capacity domains when isolation is required.

Non-Goals

  • This design does not let callers submit provider URLs.
  • It does not make light-gateway an Alias router or LLM protocol translator.
  • It does not treat a private IP address as sufficient proof of trust.
  • It does not permit arbitrary public HTTP provider URLs.
  • It does not weaken provider conformance or embedding-space qualification for local models.
  • It does not expose model-management, model-download, debug, runtime metrics, Web UI, or administrative runtime endpoints through the sidecar’s inference listener. The first release serves only exact, authenticated sidecar health and identity paths on that listener. A separate operations listener and sidecar metrics endpoint are deferred.
  • It does not require every self-hosted runtime to implement native TLS.

Existing Foundation

The existing light-gateway implementation already supplies most data-plane mechanics needed by the sidecar profile:

  • server.enableHttps, server.httpsPort, server.tlsCertPath, and server.tlsKeyPath configure an HTTPS listener;
  • the listener validates that the certificate and private key parse and match;
  • proxy.hosts accepts a fixed http:// or https:// upstream;
  • an HTTP upstream can be bound to 127.0.0.1 inside the model host or pod;
  • upstream redirects are not part of LLM provider routing;
  • client.caCertPath supplies a private CA bundle when light-gateway itself calls a TLS upstream; and
  • client.clientCertPath plus client.clientKeyPath support outbound mTLS.

The current HTTPS listener authenticates the server to its clients. Requiring a client certificate on that listener is a separate capability. This design does not require that capability in the first release. A sidecar may use server-authenticated TLS plus a rotated workload credential and network policy.

The current llm-gateway provider client disables redirects, deliberately ignores ambient HTTP_PROXY and HTTPS_PROXY settings with no_proxy(), and uses a custom DNS resolver. It does not yet accept a per-endpoint private CA or client identity, and its production compiler rejects private destinations. Those are implementation gaps addressed by this design. An explicit reviewed egress-proxy contract is separate future work; process environment variables must not silently change provider routing.

Supported Topologies

Native Private TLS

Use this when the model runtime supports HTTPS directly.

llm-gateway -- HTTPS --> model runtime

Examples include vLLM with its SSL options and an OpenSSL-enabled llama-server. The model endpoint presents a certificate whose SAN contains the configured service name. The LLM gateway validates the chain against the declared private trust bundle.

Light Gateway TLS Sidecar

Use this when the model runtime exposes HTTP only, including the normal Ollama deployment pattern.

model host or pod
+-------------------------------------------------------+
| light-gateway :8443                                   |
|   - HTTPS listener                                    |
|   - workload authentication                           |
|   - exact method/path allowlist                       |
|   - bounded request/response handling                 |
|   - fixed upstream http://127.0.0.1:11434             |
|                         |                             |
|                         +--> Ollama :11434            |
+-------------------------------------------------------+

Only the sidecar port is exposed to the private network. The raw runtime port is bound to loopback in the sidecar’s network namespace and is blocked from other workloads. This prevents bypassing sidecar authentication and limits.

The sidecar preserves OpenAI-compatible request paths, bodies, streaming, and responses. For example, /v1/embeddings remains /v1/embeddings upstream. When a runtime exposes only a proprietary endpoint such as /api/embed, a protocol adapter or an additional provider codec is required; the TLS sidecar must not silently reinterpret the API.

Private Plaintext

Application HTTP is allowed for an explicitly selected private destination, including another computer on a home network. It is never the default. The UI and command path must explain that prompts, retrieved knowledge, vectors, and responses are not encrypted by the application transport. Credentials are forbidden on this profile in the first release.

Enterprise policy may prohibit remote plaintext or restrict it to loopback, an encrypted overlay, or an isolated model-serving network. A home or lab operator may accept HTTP to an approved RFC 1918 or unique-local address. The gateway still blocks public, metadata, link-local, multicast, unspecified, and other unsafe destinations. A light-gateway TLS sidecar remains the recommended option for a remote HTTP-only runtime, but it is not mandatory for an operator whose effective policy permits private HTTP.

Network Profile Contract

The current values-backed configuration materializes one ProviderConfig for each providerId and rejects conflicting materialization. Keep that ownership rule: each Provider endpoint declares exactly one immutable Network Profile, and each Provider Deployment references one Provider endpoint. Deployments that share a providerId therefore share protocol, base URL, endpoint authentication, headers, and Network Profile. Use a different providerId when any of those values differ.

The conceptual compiled shape is:

providers:
  ollama-embedding-sidecar:
    providerProtocol: openai_embeddings
    baseUrl: https://embedding-01.models.corp.example:8443/v1
    endpointAuth:
      mode: bearer
      credentialRef: credential://ollama-sidecar/workload
    networkProfile:
      mode: private_tls
      termination: light_gateway_sidecar
      networkZoneId: kb-model-serving
      tls:
        trustBundleRef: config://corp-model-ca-v3
        trustBundleSha256: 42b8...f10c
      connection:
        poolIdleTimeoutMs: 30000
        clientRefreshIntervalMs: 300000

deployments:
  tenant-embedding-local-v1:
    provider: ollama-embedding-sidecar
    physicalRuntimeId: models-host-01/ollama-embedding
    capacityDomainId: models-host-01/gpu-0
    sidecar:
      profileVersion: model-provider-sidecar/v1
      configSha256: a910...7d2e

The exact storage representation may use normalized columns and referenced resources rather than one JSON object. The compiled runtime contract must be strongly typed and reject unknown fields.

Profile Semantics

ModeDestinationRequired protectionCredentials
public_tlsPublic DNS or IPHTTPS with public or explicitly approved CAOptional typed endpoint credential allowed
private_tlsApproved private DNS or IPServer-authenticated HTTPSOptional endpoint credential allowed
private_plaintextExplicitly approved private DNS or IPOperator accepts plaintext riskCredential forbidden in the first release

termination is an expected topology declaration and may be native or light_gateway_sidecar. It is not evidence by itself and does not change client protocol semantics. A sidecar declaration becomes trusted only when signed live qualification matches the expected sidecar identity.

What A Network Zone Means

A Network Zone is a named outbound allowlist, not a claim that every private address is trustworthy. It owns the permitted DNS names, CIDRs, and ports for one or more Provider endpoints and states which transport modes the operator permits there. allowedPorts and reusable address constraints belong here, not on a single-URL Network Profile. For example:

networkZone:
  id: home-model-lan
  dnsNames: [ollama.home.arpa]
  cidrs: [192.168.1.0/24]
  ports: [11434]
  allowPrivateTls: true
  allowPrivatePlaintext: true

An enterprise zone might allow only *.models.corp.example, a model-serving subnet, and port 8443, with plaintext disabled. A home installation may allow HTTP to a specific RFC 1918 address and port. The zone is enforced in addition to firewalls or Kubernetes NetworkPolicy; it does not replace them.

Use a reusable Portal resource owned by the host or platform administrator. Tenant application users cannot create arbitrary zones. Publication compiles the selected DNS names, CIDRs, ports, and allowed transport modes into the LLM gateway snapshot so enforcement does not depend on a caller-supplied label.

How Private CA Bundles Are Delivered

A CA certificate is public material, but it is security-sensitive trust configuration. Project only a versioned reference and SHA-256 digest. Resolve the PEM bundle at runtime from a managed configuration mount or configuration service. Do not place the PEM repeatedly in every Provider Deployment payload, and never put a client private key in values.yml.

This keeps publications small, makes rotation explicit, and lets every replica verify that the resolved bundle matches the published digest. A standalone or home installation may resolve the reference to a local file. An enterprise may resolve it through its normal certificate-management integration.

Changing bytes behind a stable reference without publishing a new version and digest is forbidden. During compilation, the gateway hashes the resolved PEM, compares it with trustBundleSha256, and includes that resolved digest in the provider-client reuse identity. A trust-bundle change therefore rebuilds the TLS client and discards its connection pool even when the reference string did not change.

Immutability

The following values are part of the published provider/deployment contract:

  • network profile mode;
  • network zone;
  • endpoint-authentication mode and credential-reference identity;
  • trust-bundle identity, version, and digest;
  • provider protocol;
  • normalized base URL;
  • physical runtime and capacity-domain identities; and
  • expected sidecar profile version and configuration digest when applicable.

A change creates a new deployment revision and new conformance evidence. It must not silently mutate an active physical destination. Certificate material may rotate under a versioned reference using the staged procedure below, but a change of trust authority is still a reviewed publication change.

Credential Contract

The current ProviderConfig.secret_ref and SecretResolver require a non-empty secret, and the provider client always emits Authorization: Bearer <secret> for OpenAI protocols or x-api-key for Anthropic. That cannot represent normal credential-free Ollama or llama-server deployments. Phase L1 must replace the implicit protocol behavior with a typed endpoint-authentication contract:

  • none, which performs no secret resolution and emits no credential header;
  • bearer, which resolves one approved credentialRef and emits one Bearer credential; or
  • api_key, which resolves one approved credentialRef and emits it only in a contract-approved header.

An empty string is not the representation of none. private_plaintext requires endpointAuth.mode: none in the first release. An endpoint that needs a credential must use TLS, normally by adding the supported sidecar.

When a sidecar authenticates llm-gateway and the loopback runtime also needs an API key, those are separate credentials on separate hops. The endpoint credential belongs to the Provider endpoint and is removed before proxying. The runtime credential belongs to the sidecar publication and is injected from a sidecar-owned secret reference after authentication; it is never carried in ProviderConfig.headers and is never forwarded from the caller. Phase L1 must define both slots even if most local runtimes use runtimeAuth.mode: none. The current provider extra-header allowlist and credential-like-value rejection are retained; they are not a second secret channel.

Required Phase L1 Contract Changes

The first contract phase must land the non-transport fields that later phases depend on:

  • optional typed endpoint authentication and the distinct sidecar-to-runtime authentication slot;
  • Network Profile, Network Zone, and resolved trust-bundle digests in provider client identity;
  • physical-runtime identity, capacity-domain identity, effective parallelism, readiness/warmup policy, and local timeout bounds;
  • endpoint-, deployment-revision-, transport-, and sidecar-bound live qualification evidence with a distinct evidence kind;
  • runner key identity and signature fields for qualification authenticity; and
  • an explicit local pricing basis so zero marginal price and amortized internal price are not ambiguous.

Deferring these shapes until the TLS client or live runner is implemented would reopen the coordinated values-backed configuration contract.

Validation Rules

Common URL Rules

  • The base URL must have an http or https scheme and a host.
  • User information, query strings, and fragments are forbidden.
  • Redirects are disabled.
  • The effective port must be permitted by the applicable profile policy and, for private profiles, declared by the Network Zone.
  • The resolver filters answers to the applicable compiled address policy and fails when no permitted address remains. For private profiles that policy is the Network Zone; for public TLS it retains the globally-routable-address rules. An unrelated IPv4 or IPv6 answer is never used.
  • The connector validates the selected socket peer address against the same compiled policy immediately after connection; DNS filtering alone is insufficient.
  • Callers cannot override the authority, SNI, destination, or path prefix.
  • Ambient HTTP proxy variables are ignored.

The current ProviderDnsResolver receives one allow_non_public_networks: bool, rejects a public profile when any DNS answer is forbidden, and classifies every non-globally-routable IPv6 address, including fc00::/7, as forbidden. Do not turn that boolean into a broad production escape hatch. The Network-Zone-aware resolver positively selects only compiled CIDRs, including explicitly approved unique-local ranges, and fails when the filtered set is empty.

clientRefreshIntervalMs is not a certificate- or trust-rotation mechanism. It is the maximum age of the active provider client before a same-material client and empty connection pool replace it, bounding how long DNS can remain hidden behind connection reuse without interrupting active requests. The compiler requires finite, policy-bounded poolIdleTimeoutMs and clientRefreshIntervalMs. A material endpoint, zone, credential, or trust change rebuilds immediately and never waits for this interval.

Public TLS

  • The URL scheme is https.
  • Loopback, private, link-local, multicast, unspecified, broadcast, and cloud metadata destinations are forbidden.
  • Every new connection uses checked DNS results. A pooled connection is not re-resolved while it remains open, so the client has an explicit finite idle timeout and is rebuilt on material profile, zone, endpoint, or trust changes.
  • Provider credentials come only from approved secret references.

Private TLS

  • The URL scheme is https.
  • The Provider endpoint references an approved Network Zone.
  • The URL host and port must be members of that zone, and only resolved addresses in the zone may be connected.
  • Certificate-chain and hostname verification are mandatory.
  • The URL host is the TLS verification name and SNI value. The first release does not expose an independent tls.serverName override; address pinning is done in the resolver while the DNS host remains in the URL.
  • An IP-literal URL is permitted only when zone policy allows it and the server certificate contains the matching IP SAN. DNS names are recommended for private CAs.
  • verifyHostname: false and accept-invalid-certificate behavior are forbidden in production.
  • mTLS may be added later as an optional strengthening without changing the meaning of private_tls.

Private Plaintext

  • The scheme is http.
  • Public destinations are forbidden.
  • The destination and port must be present in a Network Zone whose allowPrivatePlaintext policy is true.
  • The operator receives a clear warning and explicitly accepts the lack of transport encryption. Enterprise policy may disable this choice.
  • endpointAuth.mode must be none; credentials over private HTTP are not part of the first release.
  • Fallback cannot escape to a less protected zone.

Model Sidecar Security Profile

A general-purpose gateway configuration exposes more functionality than a model sidecar needs. Provide a supported model-provider-sidecar profile with these invariants:

  1. HTTPS is enabled and the cleartext listener is disabled on the network interface.
  2. The only upstream is a fixed loopback address and port.
  3. Dynamic routing, service_url, controller discovery, and caller-selected upstreams are disabled.
  4. Only required methods and paths are allowed, initially:
    • POST /v1/chat/completions;
    • POST /v1/responses when the runtime is conformant;
    • POST /v1/embeddings;
    • exact authenticated /sidecar/health and /sidecar/identity paths on the inference listener.
  5. Runtime model-management, download, debug, metrics, Web UI, and native admin endpoints are denied.
  6. The external Host header is not trusted to select an upstream.
  7. Proxy authentication headers are validated and then removed or replaced before the loopback hop unless the model runtime needs a distinct local credential.
  8. Hop-by-hop headers and caller-supplied forwarding headers are normalized.
  9. Request, response, idle, connect, body-size, and streaming limits match or are tighter than the compiled LLM Deployment capabilities.
  10. Prompt text, vectors, authorization data, and full model responses are not written to logs or metrics.

The profile is deny-by-default. handler.paths performs the exact method-and-path selection; unified-security authenticates only after a path has selected the inference chain. Prefix-based authentication is not a path allowlist. A copy-safe core configuration therefore includes the allowlist and a non-proxy default chain. The following is the Phase L3 target and is not available until its two new terminal handlers land. Current handler collection rejects an unknown handler or chain ID during configuration load, so this profile fails to start on a pre-L3 Light Gateway binary rather than silently degrading. Phase L3 registers both IDs in GATEWAY_HANDLER_DESCRIPTORS and adds their request-dispatch behavior in the same binary release.

server.ip: 0.0.0.0
server.enableHttp: false
server.enableHttps: true
server.httpsPort: 8443
server.tlsCertPath: /config/tls/server-chain.pem
server.tlsKeyPath: /config/tls/server-key.pem

handler.handlers:
  - sidecar-deny
  - sidecar-identity
  - correlation
  - unified-security
  - headers
  - proxy
  - health
handler.chains:
  model-inference:
    exec:
      - correlation
      - unified-security
      - headers
      - proxy
  sidecar-health:
    exec:
      - correlation
      - unified-security
      - health
  identity:
    exec:
      - correlation
      - unified-security
      - sidecar-identity
  deny:
    exec:
      - sidecar-deny
handler.paths:
  - path: /v1/chat/completions
    method: POST
    exec: [model-inference]
  - path: /v1/responses
    method: POST
    exec: [model-inference]
  - path: /v1/embeddings
    method: POST
    exec: [model-inference]
  - path: /sidecar/health
    method: GET
    exec: [sidecar-health]
  - path: /sidecar/identity
    method: GET
    exec: [identity]
handler.defaultHandlers:
  - deny

unified-security.enabled: true
unified-security.anonymousPrefixes: []
unified-security.pathPrefixAuths:
  - prefix: /v1/chat/completions
    jwt: true
  - prefix: /v1/responses
    jwt: true
  - prefix: /v1/embeddings
    jwt: true
  - prefix: /sidecar/health
    jwt: true
  - prefix: /sidecar/identity
    jwt: true

proxy.hosts: http://127.0.0.1:11434
proxy.rewriteHostHeader: true

This maximal example shows all three inference operations. The generator emits only the exact entries declared and qualified for that sidecar; an embedding-only runtime receives only POST /v1/embeddings. It derives the pathPrefixAuths entries from the same operation set instead of installing one broad /v1 prefix. handler.paths remains the method/path security boundary, because unified-security prefix matching is not exact by itself.

The target profile requires two new terminal handlers in Phase L3. sidecar-deny returns a local 404 and cannot select an upstream. sidecar-identity returns the non-secret identity contract described below. The current exception handler ID is not a deny implementation: it has no request-dispatch arm, and relying on that no-op behavior would make an unwritten fallthrough invariant load-bearing.

The deny chain must be non-empty and contain no upstream- or content-selecting handler, including proxy, router, virtual, resource, or path-resource. The model-provider-sidecar profile generator and validator enforce this rule; generic handler.yml validation does not, because an empty default chain is valid fixed-upstream reverse-proxy behavior in ordinary Light Gateway deployments. The generated profile also supplies the JWK or API-key configuration, credential removal and optional runtime-credential injection, request and streaming limits, and registry-disabled settings. Customers should consume the generated profile rather than assemble those security-sensitive settings from unrelated examples.

Generated-profile integration tests must assert a local 404 and no upstream connection for at least POST /api/tags, GET /v1/embeddings, and, on an embedding-only sidecar, POST /v1/chat/completions. They also assert that an empty default chain is rejected by the model-sidecar profile generator or profile-specific validator without changing generic handler semantics.

The existing health handler remains the fixed local 200 ok liveness check; it does not carry identity. /sidecar/identity uses the new handler. Both paths are exact, authenticated, locally served, and never proxied in the first release. A separate operations listener and sidecar metrics endpoint are deferred; native runtime metrics remain unreachable through the sidecar.

Authentication

The first release uses server-authenticated TLS. A scoped, rotated endpoint credential may authenticate llm-gateway to the model sidecar. The sidecar validates and removes it before proxying. If the model runtime also requires an API key, the sidecar resolves the separate runtimeAuth reference and injects that value only on the loopback hop. The central gateway does not hold both secrets in one untyped header map. Network policy remains mandatory in managed environments.

mTLS is optional, not mandatory, and is deferred from the first release. It may later be added by extending the light-gateway HTTPS listener with a client-CA bundle and a requireClientCertificate setting. That future extension must not change the public LLM API or make existing server-authenticated TLS deployments invalid.

Authentication is defense in depth, not a substitute for provider visibility and Alias authorization in llm-gateway.

Sidecar Identity And Registration Invariants

termination: light_gateway_sidecar is only an expected declaration. The new sidecar-identity handler serves /sidecar/identity and exposes, without secret material:

  • sidecar profile name and version;
  • canonical non-secret configuration SHA-256;
  • certificate identity and expiry information;
  • physical runtime identity; and
  • the inference paths and methods enabled by that profile.

The signed live runner captures these values through the published endpoint and binds them to its qualification result. The compiler accepts a sidecar profile only when that evidence matches the deployment revision, Provider endpoint, Network Profile digest, expected profile version, and expected configuration digest.

All active Provider Deployments that claim the same physicalRuntimeId must use the same approved endpoint boundary and Network Profile. Multiple models may be served by one live-qualified runtime, but registering the same model runtime once through its sidecar and again through a raw or weaker endpoint is rejected. Live qualification also probes from outside the colocated host or pod and fails when the declared raw runtime address is reachable.

Raw-port reachability evidence is valid only from a recorded external vantage. The evidence carries a vantage kind, environment or cluster identity, source workload identity, source Network Zone, and network-namespace identity. For a Kubernetes sidecar the runner namespace must differ from the model Pod’s shared network namespace; a runner inside that Pod cannot prove external isolation. A bare-metal probe similarly originates outside the model host.

An unreachable result caused by NetworkPolicy is useful but does not by itself prove loopback binding. Signed qualification binds the live probe to admission or manifest evidence showing the runtime loopback bind, hostNetwork: false, absence of a raw-port Service, and the allowed container set. The runner records a digest of that isolation evidence and the raw probe target rather than publishing unnecessary internal topology.

Private CA And Certificate Lifecycle

Trust

The LLM gateway must resolve a versioned trustBundleRef and build a TLS client for that deployment. It must not depend on modifying a process-global CA file or disabling certificate checks. A public CA may be used for an internal DNS name when organizational policy permits it; otherwise use the enterprise CA, cert-manager issuer, SPIRE authority, or equivalent.

Rotation

Use overlap rather than a flag day:

  1. Publish a new trust-bundle version and digest containing the current and next CA when the authority changes.
  2. Request llm-router reload for each selected LLM gateway instance and verify that it resolved the advertised digest, rebuilt the affected provider client, and discarded the old pool.
  3. Rotate sidecar server certificates.
  4. Prove conformance and health through every eligible endpoint.
  5. Remove the old CA through another versioned publication and repeat the explicit reload and verification check.

Expose certificate expiry and rotation status without logging private-key material.

Routing, Fallback, Conformance, And Qualification

Local deployments participate in normal operation-aware routing. Transport profiles do not create new client operations.

  • A chat deployment uses an approved Chat or Responses provider protocol.
  • An embedding deployment uses the OpenAI Embeddings provider protocol.
  • Every physical production deployment completes automated live qualification through its actual published URL, including the TLS sidecar when present.
  • Fallback candidates must satisfy the requested operation, capability, network policy, and data-residency policy.
  • An embedding Alias continues to require one compatible Embedding Space Contract across all eligible deployments.
  • A local model change, quantization change that affects outputs, normalization change, prompt transform, or embedding weights change may require a new embedding-space revision even when dimensions remain unchanged.

Local Runtime Capacity, Readiness, And Isolation

Local runtimes have physical constraints that a cloud-provider account does not describe. Ollama may serialize work, load only a bounded number of models, and evict an idle model. A vLLM instance reserves a fixed accelerator budget and has bounded admission. The generic contract must describe the effective limits without making provider-specific environment variables part of the public API:

runtimeCapacity:
  physicalRuntimeId: models-host-01/ollama-embedding
  capacityDomainId: models-host-01/gpu-0
  maxParallelRequests: 2
  maxQueuedRequests: 4
  readinessPolicy: warm_before_eligible
  coldStartTimeoutMs: 180000
  streamSetupTimeoutMs: 30000
  requestTimeoutMs: 120000

The values are immutable deployment inputs, are included in live qualification, and are bounded by host policy. Runtime-specific controls such as model keep-alive, loaded-model count, queue length, and accelerator-memory fraction remain in the deployment manifest, but qualification verifies that the claimed effective capacity and timeouts are credible.

The compiler enforces DeploymentConfig.concurrency <= maxParallelRequests. Gateway admission must also bound queued work rather than hiding an unbounded runtime queue behind requestTimeoutMs. Local deployments use explicit connect, stream-setup, request, and cold-start limits rather than inheriting cloud defaults.

A runtime using warm_before_eligible is not routable until the configured model is loaded or pinned and a warmup probe succeeds within coldStartTimeoutMs. Cold loading is therefore a readiness transition, not a normal user’s first request. If the model is evicted or the runtime restarts, health removes the deployment from eligibility, warmup runs again, and only then is it returned to service. A cold-load miss must not be misclassified as congestion and repeatedly trip paid fallback or quarantine.

The existing compiler separates Knowledge Base query, indexing, and standard embedding lanes by deployment and provider-account quota group. Extend that check to capacityDomainId. Distinct deployment records or server processes do not prove isolation when they share one GPU or runtime scheduler. A policy that requires protected query capacity must use disjoint capacity domains, and the production exercise must load both lanes concurrently to prove the boundary.

Local Pricing And Budget Semantics

Every local deployment still publishes an operation price and price version. The owner explicitly selects one pricing basis:

  • amortized_internal uses a non-zero chargeback rate derived from the organization’s hardware, energy, license, or allocation convention; or
  • zero_marginal records that the operator assigns no monetary charge to an additional request.

The basis is audit metadata, not an inference capability. A zero price does not mean unlimited accelerator capacity; concurrency, queue, and capacity-domain policy enforce the physical limit. Conversely, a monetary budget must not be repurposed as the capacity control.

The current ambiguous-usage branches compute ambiguous_charge_micros.min(reserved).max(1). With a zero-price deployment, reserved is zero, so the ledger charges one micro-unit without a matching reservation. Phase L1 makes the minimum-charge floor conditional on a non-zero reserved envelope: when reserved == 0, both generation and embedding reconciliation charge zero while recording usageComplete: false. This is a ledger invariant, not only a pricing-display correction.

Fallback preserves the current worst-case reservation principle. Before the first attempt, the gateway reserves the summed price envelope for every attempt the Alias may make. A route that starts on a zero-price local deployment and may fall back to a paid cloud deployment is rejected before dispatch when the paid envelope exceeds maxCostMicros; fallback is never an unbudgeted cost event.

What Conformance Means

Conformance is machine-generated evidence that a claimed provider contract actually behaves as the gateway expects. It is not a model-version allowlist and it is not a field an administrator should type into a form.

Three related checks must remain distinct:

CheckScopeWhat it provesWhen it runs
Codec conformanceGateway provider codec and versioned fixture corpusRequest encoding, response decoding, errors, streaming, tools, usage, and embedding formats behave consistentlyBuild and gateway release
Live deployment qualificationExact Provider Deployment URL, physical model, transport profile, and declared operationsThe reachable server behaves compatibly through its real TLS sidecar or native endpointRegistration, material configuration change, and scheduled refresh
KB embedding qualificationEvery physical embedding deployment under one Embedding Space ContractDimensions, normalization, ordering, finite vectors, known-probe fingerprints, and space compatibility are stableBefore index promotion and after any model or transform change

The “conformance matrix” is simply the coverage table across provider protocol, operation, transport mode, and declared capability. It does not mean that the Portal maintains a list of allowed Ollama, llama.cpp, or vLLM release numbers. The runtime version may be recorded as diagnostic evidence when available, but compatibility is established by tests against the endpoint rather than by matching a version string.

For example, one deployment can pass OpenAI-compatible Chat but fail Responses, or pass scalar embeddings but fail batch ordering or base64 output. Testing one operation must not qualify all operations automatically.

Current Implementation Status

Conformance has been removed from the editable Provider Deployment form and from the Portal action panel. That is intentional: ordinary Portal administrators must not submit or edit PASS evidence.

It has not been removed from the system. The current database still stores state, digest, validity, and result evidence; command handlers still support pending, completion, and due-refresh transitions; and the production gateway configuration still requires a complete ConformanceResult. Routing checks its digest, provider protocol, physical model, tested operation, expiry, state, and capability evidence.

The current type cannot prove the transport claims in this design. ConformanceResult has no Provider endpoint, normalized URL, deployment revision, Network Profile digest, Network Zone, physical runtime, or sidecar identity. The compiler can therefore attach evidence from one endpoint to a different endpoint when protocol and model happen to match. Its FixtureProvenance values describe synthetic or captured codec fixtures; they do not distinguish a codec result from a live network probe.

There is also an authenticity gap. verify_digest() recomputes a digest stored inside the result. The implementation correctly describes this as integrity binding, not authenticity. Anyone with direct database write access can compute another internally consistent PASS. Removing the editable form protects the normal Portal workflow but does not make database evidence authentic.

Finally, the checked-in Rust provider-conformance runner executes the versioned JSON fixture corpus through the gateway codecs. It does not make live network calls to the configured model deployment. The command contract anticipates a trusted external runner, but no complete worker that consumes PENDING, probes the endpoint, and records the canonical result exists in this checkout. Until that worker is implemented, the production workflow is incomplete even though the enforcement structures remain.

Live Evidence Contract

Phase L1 extends the evidence type before the worker is implemented. Keep FixtureProvenance for fixture origin and add a result-level evidence kind such as codec_corpus, live_endpoint, or kb_embedding. A routable production deployment requires signed live_endpoint evidence containing at least:

evidenceKind: live_endpoint
deploymentRevisionId: tenant-embedding-local-v1/r4
providerEndpointSha256: 3b82...119e
networkProfileSha256: 0f43...d201
physicalRuntimeId: models-host-01/ollama-embedding
capacityDeclarationSha256: be71...820a
sidecar:
  profileVersion: model-provider-sidecar/v1
  configSha256: a910...7d2e
  certificateIdentitySha256: 38d0...f4c1
  isolationEvidenceSha256: c552...08fa
  rawPortReachable: false
runnerVantage:
  kind: external_workload
  environmentId: production-ca-central-1
  sourceWorkloadId: provider-qualification-runner
  sourceNetworkZoneId: qualification-probes
  sourceNetworkNamespaceId: runner/qualification-7f94
  targetNetworkNamespaceId: models/embedding-01
  rawProbeTargetSha256: 0cb1...63ed
testedOperations: [embed]
testedAt: 2026-08-08T16:00:00Z
validUntil: 2026-08-15T16:00:00Z
signerKeyId: provider-qualification-2026-q3
signature: base64:...

providerEndpointSha256 covers the normalized scheme, URL host, effective port, path prefix, protocol, endpoint-authentication mode, and relevant header names, but not secret values. networkProfileSha256 covers the mode, Network Zone revision, trust-bundle digest, expected termination, and pool/connection policy. The sidecar and external-vantage fields are mandatory only for sidecar termination. Evidence is rejected when the runner and target network namespaces are the same or when the isolation-manifest digest is absent.

The trusted runner signs the canonical result with a runner-held key. The gateway verifies the signature against public keys loaded from a protected runner trust store that is independent of the evidence database; the values snapshot may select an approved key ID but cannot introduce a new trusted key. Signature verification happens before state, expiry, operations, model, capabilities, endpoint identity, and transport identity checks. The existing self-digest remains useful for canonical integrity but is not accepted as proof of producer identity. Key rotation and revocation update the protected trust store and the versioned publication together.

The correct workflow is to retain machine-owned evidence, implement the live runner, and expose at most read-only status and diagnostics in Portal. A home operator should not edit conformance JSON, sign results, or choose a supported runtime version; registration triggers the automated checks.

Live transport and protocol qualification must cover:

  • certificate chain and hostname validation for private_tls;
  • an explicit private-HTTP connection when private_plaintext is selected;
  • exact method/path exposure;
  • the expected sidecar profile, configuration digest, certificate identity, and locally served identity response;
  • failure to reach the raw runtime port from a recorded external vantage, together with loopback/admission isolation evidence;
  • streaming and disconnect behavior;
  • body and response bounds;
  • float/base64 embedding behavior;
  • usage reporting and ambiguous accounting;
  • warmup, cold restart, declared concurrency, queue saturation, and timeout behavior;
  • sidecar restart and certificate rotation; and
  • rejection before provider dispatch when the expected embedding space does not match.

Control-Plane Changes

Portal Resources

Add or extend strongly typed resources for:

  • Provider Network Profile;
  • Network Zone;
  • TLS Trust Bundle reference;
  • typed endpoint and sidecar-runtime authentication;
  • physical runtime and capacity domain;
  • local runtime capacity, warmup, and timeout declaration;
  • local pricing basis; and
  • expected sidecar profile identity.

Provider Deployment creation must offer only profiles and zones authorized for the current host and environment. Global platform model authorities may publish platform-owned profiles for global knowledge-base workloads.

The UI must not offer verify hostname = false. It should display the resolved security posture, certificate owner, network zone, and any plaintext risk accepted by the operator.

Publication

The immutable values.yml snapshot carries identifiers, versions, policy, DNS names, ports, address constraints, material digests, and approved runner key IDs. It cannot introduce runner public keys, and it never carries private keys. Trust bundles may be delivered through the existing secret/config mechanism or resolved from a protected runtime mount, but their digest and version are bound to the publication and to provider-client reuse.

The compiler rejects:

  • unknown network-profile modes;
  • a scheme inconsistent with its profile;
  • public HTTP;
  • a private destination without an approved zone;
  • plaintext plus a credential;
  • TLS whose URL host, zone policy, and trust policy cannot perform normal hostname or IP-SAN verification;
  • address or port constraints that do not contain the endpoint;
  • missing, zero, or policy-exceeding pool idle and client refresh intervals;
  • deployment concurrency above declared runtime parallelism;
  • protected lanes that share a capacity domain;
  • a fallback route that violates the Alias data-residency requirement;
  • unsigned, expired, or endpoint/profile/runtime-mismatched live evidence;
  • a sidecar profile without matching signed identity, isolation-manifest, and distinct external-vantage evidence; and
  • duplicate registration of one physical runtime through a weaker endpoint.

Clean Cutover

Because the compiled configuration structures are deny-unknown-fields and digest-bound, this is a coordinated control-plane and gateway contract change. Reuse the established publication cutover procedure:

  1. retain the complete pre-cutover artifact set;
  2. stop publication while mixed schemas could be emitted;
  3. deploy readers and writers that understand the new contract;
  4. publish one complete root with regenerated conformance evidence;
  5. explicitly reload llm-router on each selected instance and assert the expected generation and digest; and
  6. pair binary rollback with restoration of the previous records.

Runtime Failure Semantics

ConditionClassificationRetry behavior
Unknown or invisible AliasClient-visible not foundDo not retry another provider
Profile or expected-space mismatchClient-visible unsupported featureDo not dispatch
No permitted DNS answer or connected peer outside the network zoneConfiguration/security invariantQuarantine deployment; do not retry it
Invalid, expired, or hostname-mismatched certificateConfiguration/security invariantQuarantine deployment and alert
Sidecar or physical-runtime identity mismatchConfiguration/security invariantQuarantine deployment and require new signed qualification
Model cold, evicted, or warmingDeployment not readyDo not send user traffic; run bounded warmup before eligibility
Declared local queue or concurrency exhaustedCapacity exhaustedApply normal bounded eligible fallback; do not extend the request deadline
Sidecar temporarily unavailableProvider unavailableNormal bounded fallback to an eligible compatible deployment
Sidecar returns a protocol-invalid bodyProvider conformance failureFail safely and quarantine according to policy

Certificate failures must not be hidden as ordinary congestion. Audit and metrics identify the public deployment and network profile without exposing private keys, prompts, vectors, or unnecessary internal topology.

Observability

Record or expose:

  • network profile mode and termination type;
  • publication generation and deployment identifier;
  • TLS protocol and certificate expiry bucket;
  • resolved trust-bundle digest and endpoint-authentication version, not their contents;
  • handshake, hostname, peer-address, network-zone, and workload-authentication failures;
  • sidecar profile version and non-secret configuration digest;
  • sidecar availability, latency, response-size, timeout, and retry metrics;
  • conformance state for every physical endpoint; and
  • warmup state, queue saturation, declared versus observed parallelism, and capacity-domain health, without placing provider-specific operational details in the OpenAI-compatible response.

Never log request bodies, embedding vectors, provider credentials, certificate private keys, or full authorization headers.

Deployment Examples

Ollama With Light Gateway

Kubernetes Pod
├── light-gateway
│   ├── listens on 0.0.0.0:8443 with enterprise TLS
│   └── proxies approved /v1 paths to 127.0.0.1:11434
└── Ollama
    └── listens only on 127.0.0.1:11434

The Provider Deployment uses an OpenAI-compatible provider protocol and a URL such as:

https://ollama-embedding.models.svc.corp.example:8443/v1

Ollama’s native /api administration and model-management surface is not published through the sidecar.

Containers in one Kubernetes Pod share a network namespace. Kubernetes NetworkPolicy cannot mediate the sidecar-to-runtime loopback hop. The boundary therefore depends on Ollama binding only to 127.0.0.1 (for example through OLLAMA_HOST), the Pod containing no unrelated or untrusted container, and no Service publishing the raw runtime port. hostNetwork: true is forbidden for this profile because it defeats the intended loopback boundary. Admission policy validates these conditions, and live qualification proves the raw port is unreachable from another workload.

llama.cpp Native TLS

An OpenSSL-enabled llama-server may terminate TLS itself. The Provider Deployment uses termination: native, its private CA, and the service DNS name. Use the Light Gateway sidecar instead when the organization wants centralized certificate rotation, consistent authentication, or a smaller exposed runtime surface.

vLLM Native TLS Or Sidecar

vLLM may use its SSL certificate, key, and CA settings directly. A sidecar remains valid where the organization wants centralized certificate rotation, consistent authorization, or a smaller exposed runtime surface.

Runtime-Specific Qualification Checks

Runtime releases and OpenAI-compatible shims evolve, so these are probes rather than a supported-version allowlist:

RuntimeLive qualification must not assume
OllamaRequested embedding dimensions, encoding_format: base64, usage fields, batch ordering, model keep-alive, or parallel request behavior work merely because the route exists.
llama-serverTLS build options, chat-template behavior, streaming framing, embedding batching, and usage reporting are identical across builds.
vLLMGPU admission, queue behavior, streaming setup, embedding dimensions/encoding, and usage reporting match cloud OpenAI behavior without probing.

Failed probes produce operation- or capability-specific diagnostics so an operator can correct the runtime configuration without manually editing evidence.

Security Analysis

Threats Addressed

  • Passive or active interception of prompts, knowledge text, and vectors across the organizational network.
  • Accidental provider credential disclosure over plaintext by requiring endpointAuth.mode: none for private_plaintext.
  • SSRF to internal services or cloud metadata endpoints.
  • DNS rebinding from an approved name to an unapproved address.
  • Bypassing the sidecar through the raw model port.
  • Exposure of model-management or debug endpoints.
  • Caller-controlled provider selection or forwarding headers.
  • Silent fallback from an approved local model to an incompatible or disallowed provider.

Residual Risks

  • A compromised model host can observe plaintext after TLS termination.
  • A compromised sidecar can observe prompts and vectors in memory.
  • Private CA compromise affects every endpoint using that trust authority.
  • Local model drift can violate output or embedding semantics without strong conformance and model-artifact evidence.
  • A shared GPU or provider account can still cause workload starvation unless query, indexing, and standard traffic use qualified capacity isolation.

These risks require host hardening, workload identity, least-privilege network policy, model-artifact provenance, and the existing KB embedding-stability gate.

Alternatives Considered

Allow All HTTP Provider URLs

Rejected. It exposes credentials and data and turns a provider configuration surface into unrestricted internal-network access.

Use developmentFixtures In Production

Rejected. It is a broad testing bypass rather than a reviewed production trust contract and weakens unrelated validation.

Require Native TLS From Every Runtime

Rejected. It excludes widely used local runtimes and duplicates certificate operations across model-serving products. The Light Gateway sidecar provides a consistent supported boundary.

Put TLS Only At A Distant Shared Ingress

Rejected as the default. If the ingress-to-model hop crosses the network in plaintext, the sensitive portion remains exposed. Terminate TLS on the model host or in the model pod when the applicable policy requires encryption.

Treat Network Isolation As Equivalent To TLS

Rejected as the general rule. Private networks reduce exposure but do not authenticate the model endpoint or protect against every internal observer.

Delivery Plan

Phase L1: Contract And Compiler

  • Define the Network Profile, Network Zone, resolved trust-bundle, endpoint-authentication, sidecar-runtime-authentication, sidecar identity, physical-runtime, capacity-domain, readiness, timeout, and pricing-basis contracts.
  • Extend ConformanceResult with evidence kind, deployment revision, endpoint, transport, physical-runtime, capacity, sidecar, signer, and signature binding.
  • Thread the complete shapes through Portal DB, commands, values-backed publication, runtime configuration, client-reuse digest generation, signature verification, and audit.
  • Preserve public_tls as the default and current behavior.
  • Add publication-time scheme, address, port, credential, identity, capacity, pricing, conformance, and fallback checks.
  • Preserve worst-case paid-fallback reservation and make the ambiguous-usage minimum-charge floor conditional on a non-zero reserved envelope.

Phase L2: Private TLS Client

  • Add per-endpoint private CA loading to llm-gateway and bind the resolved PEM digest to provider-client reuse.
  • Derive verification name and SNI from the URL host; keep hostname verification and redirects fixed to safe values.
  • Replace the public/private boolean with a Network-Zone-aware resolver and connector that filter DNS answers and validate the connected peer.
  • Set explicit pool idle behavior, recycle same-material clients on the bounded DNS refresh interval, and rebuild immediately on material changes.
  • Add certificate rotation and deployment quarantine behavior.

Phase L3: Model Provider Sidecar

  • Add a generated and documented model-provider-sidecar Light Gateway profile.
  • Register terminal sidecar-deny and sidecar-identity IDs in GATEWAY_HANDLER_DESCRIPTORS and implement both dispatch arms. Make the exact path/method allowlist, mirrored authentication paths, and profile-validated terminal deny chain mandatory.
  • Add endpoint-credential removal, separate runtime-credential injection, fixed health, and identity attestation. Keep both exact authenticated paths on the inference listener; the separate operations listener is deferred.
  • Add Kubernetes admission guidance for loopback binding, no hostNetwork, no raw-port Service, and no unrelated container in the Pod.
  • Add generated-profile integration tests proving administrative paths, wrong methods, and undeclared operations return a local 404 without an upstream connection.
  • Qualify streaming, body limits, timeouts, disconnects, and header handling.

Phase L4: Provider Qualification

  • Qualify Ollama behind the sidecar.
  • Qualify native-TLS and sidecar modes for llama.cpp and vLLM.
  • Implement the signed live deployment runner, runner-key rotation, gateway-side signature verification, and read-only Portal status.
  • Bind results to the exact endpoint, Network Profile, deployment revision, physical runtime, capacity declaration, and sidecar identity.
  • Record the runner’s external vantage and bind a failed raw-port probe to loopback/admission isolation evidence. Exercise warmup, cold start, concurrency, queue saturation, and local timeout behavior.
  • Record runtime versions when available for diagnostics, but do not enforce a supported-version allowlist.
  • Validate Chat, Responses, and Embeddings independently; do not infer support for one operation from another.

Phase L5: Production Exercise

  • Rotate CA and server certificates without downtime.
  • Exercise DNS changes, expired certificates, invalid SANs, sidecar restart, model restart/eviction, trust-bundle client rebuild, fallback, and quarantine.
  • Verify no prompt, KB text, vector, or credential appears in logs or evidence.
  • Measure embedding query latency separately from indexing throughput.
  • Prove indexing cannot starve protected query capacity by concurrently loading disjoint capacity domains.
  • Exercise zero-price local routing followed by paid fallback and prove the paid worst-case envelope is reserved before dispatch.
  • Assert an ambiguous accepted result with zero_marginal pricing has reserved == 0, charged == 0, and usageComplete: false for generation and embedding.
  • Promote a KB index only after the Embedding Space Contract is verified through every eligible local deployment.

Acceptance Criteria

  1. An Ollama deployment may use an HTTPS Light Gateway sidecar or an explicitly approved, credential-free private_plaintext connection.
  2. When a sidecar is selected, the raw Ollama or model-runtime port is loopback-bound and unpublished. Signed live qualification verifies it from a recorded, distinct external network namespace and binds the result to admission or manifest isolation evidence.
  3. Public HTTP, unapproved private destinations, metadata addresses, redirects, DNS rebinding, and an out-of-zone connected peer are rejected before provider dispatch. Same-material clients and pools are recycled within the published DNS refresh interval; material changes rebuild them immediately.
  4. Private TLS validates the configured CA and URL host; an independent SNI or verification-name override and invalid-certificate bypass are unavailable.
  5. Private HTTP is available only by explicit operator choice within a Network Zone that permits it, requires endpointAuth.mode: none, and can be disabled by enterprise policy.
  6. Only approved OpenAI-compatible inference methods and paths reach the sidecar proxy; unmatched paths, wrong methods, runtime administration, and runtime metrics are denied locally by a dedicated terminal handler and generated-profile tests.
  7. The public /v1/chat/completions, /v1/responses, and /v1/embeddings contracts do not change.
  8. Local pricing declares zero_marginal or amortized_internal; paid fallback is included in the pre-dispatch budget envelope, while physical limits are enforced independently by capacity admission. Ambiguous zero-price usage records zero reserved and charged micro-units with incomplete usage.
  9. Every local physical deployment has runner-signed, current protocol and transport evidence bound to its published endpoint, deployment revision, Network Profile, physical runtime, capacity declaration, and sidecar identity when applicable. Sidecar raw-port evidence records a distinct external runner vantage and an isolation-manifest digest.
  10. Every embedding fallback has the same immutable Embedding Space Contract.
  11. Gateway concurrency does not exceed qualified runtime parallelism, cold models are warmed before eligibility, and protected lanes do not share a capacity domain.
  12. A trust-bundle digest change rebuilds every affected client and drops its old connection pool when the selected instance reload succeeds.
  13. Certificate rotation, runner-key rotation, and paired control-plane rollback are rehearsed.
  14. Logs, metrics, errors, and conformance evidence contain no prompt text, vectors, credentials, or private keys.

Resolved Design Decisions

  • mTLS is optional and deferred; it is not required for the first release.
  • Service-mesh-specific transport is not part of the first release.
  • A separate sidecar operations listener and sidecar metrics endpoint are deferred. Exact authenticated health and identity paths remain on the inference listener in the first release.
  • Network Zone is a reusable, administrator-owned Portal resource compiled into the gateway snapshot.
  • Network Profile belongs to the materialized Provider endpoint. Deployments sharing a providerId share that complete endpoint configuration.
  • A private CA bundle is resolved from managed configuration by versioned reference and verified against a published digest; PEM and private keys are not repeated in deployment configuration values. The resolved digest is part of provider-client reuse identity.
  • An operator may select private HTTP to another computer when the selected Network Zone permits it. It is credential-free in the first release, and enterprise policy may prohibit it.
  • The URL host is the TLS verification name and SNI value. Private-TLS IP literals require an IP SAN; no independent tls.serverName is exposed.
  • Sidecar endpoint credentials and sidecar-to-runtime credentials are separate typed slots and are never carried together in provider extra headers.
  • The generated sidecar uses dedicated terminal sidecar-deny and sidecar-identity handlers. The existing fixed health handler remains a liveness response and is not identity evidence.
  • Physical runtime and capacity-domain identities are immutable, live-qualified routing inputs. Protected workload lanes must not share a capacity domain.
  • Local prices explicitly distinguish zero marginal cost from amortized internal chargeback, and any paid fallback is reserved before the first attempt.
  • Provider runtime versions are diagnostic evidence, not an allowlist. Actual protocol and capability behavior determines qualification.
  • Conformance evidence is machine-owned. Portal may show read-only status but does not allow administrators to edit results; the runner signs live evidence and the gateway verifies producer authenticity and endpoint binding.

References

Light Workflow Tool Access Approval

Status

Proposed.

The design reuses the Portal Workflow application, ask tasks, role-based worklists, and the existing workflow Tool grant events. It changes the authoring and approval experience but does not weaken runtime Tool grant enforcement.

Purpose

A workflow author currently sees only Tools that are already granted to the workflow definition. This creates a circular authoring flow:

  1. The workflow must exist before a Tool can be granted to it.
  2. The author needs the Tool while constructing the workflow.
  3. Granting the Tool is performed outside the Workflow Editor.
  4. The author might not have Tool administration permission.
  5. A Tool administrator must be given the workflow definition ID, version, Tool version, digest, and environments through an out-of-band conversation.

The result is a manually saved, incomplete workflow and an approval process with poor context and weak usability.

This design introduces a built-in Light Portal workflow named Grant Tools to Workflow. A workflow author starts it from the Workflow Editor. The workflow assigns an approval task to the configured Tool approver role, applies the approved grants through a constrained internal command, and sends an acknowledgement task to the original author.

The related designs are:

Decision Summary

  1. Runtime authorization remains an explicit Tool grant scoped to one workflow definition. Version-scoped grants are deferred because the current active uniqueness key and deterministic grant identity are definition-wide.
  2. Tools are not granted automatically to every workflow on a host.
  3. Creating a workflow immediately persists a minimal draft and allocates its stable wfDefId.
  4. The editor distinguishes requestable and pending Tools from callable Tools.
  5. A pending Tool can be inserted into a draft, but it cannot be tested, published, or executed.
  6. Access is requested by starting the built-in Grant Tools to Workflow workflow from the editor.
  7. The approval ask task is assigned to an approver role such as genai-admin. One role assignment is visible to all active members and one member claims it.
  8. Approval alone does not bypass command authorization. A typed completion action on the generic Human Task page verifies the approval and applies the grants in the same transaction as task completion.
  9. All Tool grants in one approved request are applied atomically.
  10. The editor enables testing when it observes committed grants. The author’s acknowledgement task is informational and is not an authorization gate.

Goals

  • Keep the workflow author inside the Workflow Editor.
  • Allow an author to compose a complete draft while access is pending.
  • Preserve separation of duties between workflow authors and Tool approvers.
  • Give approvers names, schemas, environments, safety metadata, usage locations, and justification instead of requiring raw ID exchange.
  • Reuse the Portal Workflow Worklist and Human Task pages.
  • Record the requester, approver, decision, exact Tool pins, and grant result.
  • Keep the existing workflow_tool_grant_t projection as the runtime authorization authority.
  • Fail closed when a Tool changes between request and approval.

Non-Goals

  • Do not grant a Tool to every workflow on the host by default.
  • Do not let a pending request satisfy runtime authorization.
  • Do not let the workflow service credential grant arbitrary Tool access.
  • Do not treat the original author’s JWT as an administrator credential after another user approves a task.
  • Do not create a workflow-specific approval page. The generic Human Task page renders the approval task and dispatches its typed completion action to the workflow Tool access decision command.
  • Do not duplicate the durable Tool grant in the access-request read model.

Existing Platform Capabilities

The implementation can build on existing behavior:

  • An ask task can assign work to a roleId or an assigneeId.
  • A role assignment is visible to active members of that role.
  • A user claims a role task before completing it.
  • Completion rechecks assignment ownership and active role membership.
  • The parent task records the completing user and submitted result.
  • Other users cannot complete a role task after one user has completed it.
  • The Portal Worklist and Human Task pages already query, claim, release, and complete these assignments.

The built-in workflow should use these generic facilities rather than introducing a separate approval inbox.

Identity Bootstrap

An access request requires a target wfDefId. The editor must therefore create the workflow identity before the author needs the first Tool.

When the user chooses New Workflow, Portal creates a minimal valid draft containing workflow metadata and an empty task list, then opens the editor with the returned wfDefId. This is an intentional draft lifecycle operation, not a manual save of a half-built workflow.

The wfDefId remains stable across versions. The initial implementation grants access definition-wide. It removes workflow_version from the final workflow_tool_grant_t contract and keys one active grant by (host_id, tool_id, wf_def_id). Adding version scope later requires a new identity design, active uniqueness key, deterministic grant ID, query contract, and runtime predicate; it is not a nullable option in this release.

This removal is limited to workflow_tool_grant_t.workflow_version and its grant command/query/event contracts. It does not change workflow_tool_binding_t.workflow_version, which pins a workflow-backed MCP Tool to its implementation version and remains required.

Authoring Flow

Create or open workflow draft
  -> search granted and requestable Tools
  -> select one or more requestable Tools
  -> insert pending references into the draft
  -> start Grant Tools to Workflow
  -> continue editing while approval is pending
  -> observe grant status in the editor
  -> test and publish after every required grant is active

Selecting Tools

The Reference dropdown presents Tool availability explicitly:

StatusMeaningSelectableTestable
GRANTEDAn active grant matches the Tool pin and environment.YesYes
REQUESTABLEThe Tool is discoverable and eligible for a request.YesNo
REQUEST_REQUIREDThe draft references the eligible Tool but no nonterminal request exists.YesNo
PENDING_APPROVALA REQUESTED request is waiting for an approver.YesNo
REJECTEDThe approver rejected the request.Existing reference onlyNo
STALETool identity, version, digest, policy, or environment changed.Existing reference onlyNo
INELIGIBLEThe Tool cannot currently be granted.NoNo

Pending references must have a visible badge in the dropdown, workflow graph, outline, and task property panel. The editor must not describe them as callable.

GRANTED is derived only from a current active workflow_tool_grant_t row, not from the terminal request status. Revoking a grant therefore immediately returns the reference to a blocked request-required state even though the old request remains GRANTED for audit.

Discovery Versus Authorization

Showing a Tool before approval reveals its name, description, and schemas. Discovery must therefore be authorized independently from execution.

The initial requestable catalog requires both permission to edit the target workflow and the explicit tool.catalog.read permission. It returns only eligible endpoint-backed Tools within that authorized host. This permission allows metadata/schema discovery but grants no execution authority. A future owner-controlled requestable flag may narrow discovery further, but the first release does not add one.

The current callable query remains strict and returns only granted Tools. A new workflow-reference catalog combines callable, requestable, and request-state information for authoring.

Starting the Access Workflow

The editor provides Request Tool Access after the author selects one or more Tools. Starting the built-in workflow derives requester and tenant identity from the authenticated request rather than trusting client-supplied values.

The workflow input contains:

{
  "requestId": "UUID",
  "hostId": "UUID",
  "targetWorkflow": {
    "wfDefId": "UUID",
    "namespace": "customer",
    "name": "customer-360",
    "version": "1.0.0"
  },
  "requester": {
    "userId": "UUID"
  },
  "justification": "Read customer context for the customer-360 workflow.",
  "tools": [
    {
      "toolId": "UUID",
      "name": "getCustomerProfile",
      "version": "1.0.0",
      "lightapiDigest": "sha256:...",
      "capabilityRef": "API0004/getCustomerProfile",
      "allowedEnvironments": ["loc", "dev"],
      "usageLocations": ["loadCustomer/profile"]
    }
  ]
}

Before starting the approval workflow, the server validates that:

  • the target workflow draft exists and the requester may edit it;
  • each Tool is discoverable and currently requestable;
  • every item carries both toolId and capabilityRef, and that exact pair identifies the same active Tool row;
  • Tool versions, capability references, and digests match current projections;
  • requested environments are explicit and allowed; and
  • there is no equivalent active grant or pending request.

The current workflow version is review context, not grant scope. Each metadata.workflowTool pin is extended to contain toolId, capabilityRef, Tool version, lightapiDigest, and nonempty allowedEnvironments. The request must reproduce that exact identity pair and environment set. A capability reference alone is never sufficient to choose a Tool aggregate.

The server computes and stores a canonical request digest over the immutable approval fields. Display labels may be refreshed for presentation, but the approved IDs, pins, environments, target workflow, and digest cannot change inside a nonterminal request.

Approval Workflow

The published Grant Tools to Workflow definition contains these logical steps:

  1. Validate and record the request.
  2. Create a required approval ask task.
  3. Assign the task to the configured Tool approver role.
  4. On rejection, record the reason and notify the requester.
  5. On approval, submit the typed human-task decision, which completes the task and applies the grant set atomically under the approver identity.
  6. Record either GRANTED or STALE/FAILED from the decision result.
  7. Create an informational acknowledgement task for the requester.

The approval task uses the generic assignment model:

ask:
  prompt: Review the requested workflow Tool access.
  mode: approval
  assignment:
    roleId: genai-admin
    categoryCode: workflow-tool-access
    reasonCode: grant-tools-to-workflow
  options:
    - label: Approve
      value: APPROVED
    - label: Reject
      value: REJECTED
  commentRequired: true
  required: true

The concrete role ID is configuration. Authorization at completion is based on an approval permission such as genai.workflowTool.approve, not solely on a hard-coded display role name.

Role Assignment Semantics

The workflow creates one role assignment, not one independent approval task per role member. Every active member sees the assignment in the Worklist. The first member claims it; other members then see it as claimed. Completion records the actual approving user and makes the task unavailable to the other members.

The generic Human Task page’s typed approval renderer shows:

  • requester and justification;
  • target workflow name, namespace, definition ID, and current draft version for review context;
  • each Tool name, capability, version, and digest;
  • requested environments;
  • safety, sensitivity, lifecycle, and endpoint policy metadata;
  • workflow task or branch locations using each Tool; and
  • any differences from an existing or previous grant.

Secure Grant Application

The generic Human Task page recognizes the trusted task’s typed action and calls workflow/decideWorkflowToolAccess/0.1.0 instead of generic completeTask. This is not a workflow-specific page; it is a specialized completion contract rendered by the existing Human Task UI.

CompleteTask.additionalAction(...) cannot be used for grant application because it runs after completeHumanTask has committed. The database provider must factor the existing lock, assignment, role-membership, and sibling-cancel logic into reusable internals and expose one atomic decision operation. That operation locks and verifies:

  1. The workflow instance uses the configured published Grant Tools to Workflow definition, version, and definition digest.
  2. The referenced approval task and taskAsstId belong to that instance and are active and claimed by the authenticated approver.
  3. The approver still has active role membership and genai.workflowTool.approve permission.
  4. The submitted answer is APPROVE or REJECT.
  5. The approved request digest matches the immutable request payload.
  6. The approval has not already been consumed for a different payload.
  7. Each requested toolId and capabilityRef pair, Tool pin, endpoint policy, lifecycle, and environment set still matches current data.

For approval it completes the task, cancels sibling assignments, emits WorkflowToolGrantedEvent for new or inactive deterministic grants and WorkflowToolGrantUpdatedEvent for approved changes to an active grant, emits the request decision event, projects all changes, and commits once. An already equivalent active grant is rejected before request creation. Rejection completes the task and request decision without grant events. Every event is attributed to the authenticated human approver. The workflow service receives no general grant permission.

Atomicity And Idempotency

One approval decision covers the exact Tool set represented by the request digest. Applying the request is all-or-nothing:

  • revalidate every requested Tool first;
  • insert all grant events in one transaction; and
  • create no grants if any item is stale or invalid.

The operation is idempotent by hostId + requestId + requestDigest. Retrying after an uncertain response returns the existing result and does not create a second grant aggregate or advance versions twice.

If partial approval is needed, the approver rejects the original set and the author submits a smaller request. A later design may add line-item decisions, but partial mutation is not part of the initial implementation.

Request State Projection

The workflow instance and human tasks are the source of orchestration truth. The editor needs an efficient read model keyed by the target workflow and Tool. That projection does not replace workflow_tool_grant_t.

Recommended request fields include:

workflow_tool_access_request_t
  host_id
  request_id
  approval_wf_instance_id
  target_wf_def_id
  requester_user_id
  request_digest
  status
  decision_user_id              optional
  decision_comment              optional
  requested_ts
  decided_ts                    optional
  error_code                    optional
  error_message                 optional

workflow_tool_access_request_item_t
  host_id
  request_id
  tool_id
  capability_ref
  tool_version
  lightapi_digest
  allowed_environments
  usage_locations
  status

The projection is rebuildable from workflow/request events and grant results. The actual authorization decision continues to come from an active matching row in workflow_tool_grant_t.

There is no separate applied_ts: a GRANTED decision and its grant events commit atomically, so decided_ts is also the application time. Generic update_user/update_ts fields are intentionally omitted; requester and decision actors are captured by requester_user_id/decision_user_id, while requested_ts/decided_ts capture their corresponding transitions. A STALE or FAILED decision also records error_code and error_message.

Recommended request states are:

REQUESTED -> GRANTED
REQUESTED -> REJECTED
REQUESTED -> CANCELLED
REQUESTED -> STALE
REQUESTED -> FAILED

Human ask tasks currently have no waiting-task deadline sweeper. The first release therefore has no automatic rejection, expiry, or acknowledgement auto-completion promise. Those states require a separately designed runtime deadline primitive and are deferred to hardening.

Editor Validation And Gates

Validation depends on the operation being performed:

OperationPending reference behavior
EditAllowed and visibly marked.
Save draftAllowed with warnings.
Static definition validationStructure and schema pass; access warning remains.
TestBlocked until every referenced Tool is granted.
PublishBlocked until every referenced Tool is granted and current.
ExecuteFails closed if a grant is absent, inactive, stale, or out of environment.

Every metadata.workflowTool pin declares a nonempty allowedEnvironments set. An active grant satisfies the pin only when it contains the same toolId/capabilityRef pair and covers every declared environment. Draft save validates the set structurally, Publish validates all declared environments, editor Test additionally requires the selected test environment, and Start/runtime require the actual service environment. An undeclared deployment environment fails closed rather than inheriting access.

The editor should explain the blocking state instead of returning only a list of unresolved capability references. Example:

Testing is blocked while Tool access is pending:
- API0004/getCustomerProfile: waiting for genai-admin approval
- API0004/getCustomerPolicies: request became stale after Tool update

The Test button may remain visible but disabled, with a link to the request status and approval workflow instance.

Acknowledgement And Notification

After grants commit, the workflow creates an acknowledgement ask task assigned directly to the original requester:

assignment:
  assigneeId: "${ .requester.userId }"
  categoryCode: workflow-tool-access
  reasonCode: access-request-completed

The task summarizes granted Tools and links back to the target workflow. It is informational:

  • grants are effective before acknowledgement;
  • the editor unlocks testing by querying committed grant state;
  • failure to acknowledge never revokes or delays a grant; and
  • an unacknowledged informational task may remain open in the first release.

The existing Portal notification channel may additionally notify the author and approver, but notification delivery is not part of authorization.

Rejection, Cancellation, And Staleness

Rejection

Rejection requires a comment. Pending references remain in the draft so the author can see exactly what is blocked, but the editor offers Remove Reference and Submit New Request actions.

Cancellation

The requester may cancel only before an approval decision. Deleting the draft or removing all requested references should offer to cancel the pending request. Cancellation does not affect existing grants.

Staleness

The request becomes stale when any approved security-relevant value changes, including:

  • Tool version or LightAPI digest;
  • endpoint identity or lifecycle;
  • allowed environments; or
  • endpoint authorization or policy configuration.

A stale request creates no grants. The author refreshes the Tool pins and submits a new request. Existing active grants continue to follow their normal digest and lifecycle validation rules.

Authorization Model

Recommended permissions are:

PermissionPurpose
workflow.writeEdit the target workflow draft.
workflow.toolAccess.requestStart or cancel an access request.
tool.catalog.readDiscover Tool metadata and schemas.
genai.workflowTool.approveClaim and decide the typed task and atomically apply grants.

The request endpoint checks workflow.write, workflow.toolAccess.request, and tool.catalog.read. The typed Human Task completion path checks current assignment and approval permission, then applies the grants in that same transaction. Runtime continues checking the resulting workflow Tool grant.

Passing the original user JWT through workflow execution remains necessary for downstream endpoint authorization. It does not replace the workflow-level Tool grant:

user authorization      -> may this user access the endpoint?
workflow Tool grant     -> may this workflow invoke the capability?
approval evidence       -> did an authorized approver permit this exact grant?

Audit Requirements

The audit trail must answer:

  • Who requested access?
  • Which workflow definition and current draft version was reviewed?
  • Which exact Tool versions, digests, and environments were reviewed?
  • Where were the Tools referenced in the workflow draft?
  • Who claimed and completed the approval task?
  • What decision and comment were submitted?
  • Which grants were created and by which request?
  • Did any item become stale before application?
  • Was the author notified and did the author acknowledge the result?

The request digest connects the editor selection, approval task, decision command, and emitted grant events.

Deployment And Promotion

The built-in workflow is a versioned, published control-plane artifact. Each environment must configure its expected workflow definition identity, version, and digest for the typed decision allowlist.

Deployment order is:

  1. Deploy schema, projection, query, and disabled decision support with an empty trusted-workflow identity.
  2. Publish the built-in approval workflow.
  3. Capture and configure its exact definition ID, version, and digest.
  4. Enable the request/decision feature and its authorization policy.
  5. Deploy editor support for draft identity creation, requestable Tools, pending-reference save behavior, and test/publication gates.

The request and decision commands must remain disabled until the trusted workflow definition and its expected digest are configured. Promotion carries the built-in workflow and ordinary Tool grant events through the established control-plane promotion process; transient approval tasks are environment-local operational state and are not promoted. Add task_asst_t and the two request tables to the global snapshot export/conversion skip sets; process_info_t and task_info_t are already skipped.

workflow_tool_approval_evidence_t is unrelated: it records approval evidence for a workflow-backed MCP Tool binding keyed by binding_id. This design’s request aggregate is keyed by request_id and must not reuse that table.

Implementation Phases

Phase 1: Authoring And Request

  • Create a minimal draft when New Workflow opens.
  • Add the workflow-reference Tool catalog and status badges.
  • Allow pending Tool references in drafts.
  • Start the built-in access workflow from the editor.
  • Project request and item status for editor queries.

Phase 2: Approval And Grant Application

  • Publish the built-in approval workflow.
  • Assign and display the role-based approval task.
  • Implement durable request digests and approval evidence.
  • Implement atomic, idempotent grant application.
  • Add requester acknowledgement and editor status refresh.

Phase 3: Hardening

  • Add cancellation, expiry, retry, and stale-request flows.
  • Add notifications and deep links.
  • Add audit reports and operational dashboards.
  • Add configurable definition-wide versus version-specific approval policy.
  • Consider preapproved Tool bundles for low-risk workflow namespaces while still materializing explicit per-workflow grants.

Acceptance Criteria

The design is complete when:

  1. A new workflow receives a stable wfDefId before the author selects Tools.
  2. An authorized author can discover and insert a requestable Tool without Tool administration access.
  3. The draft saves while the Tool is pending, with an explicit warning.
  4. The author cannot test, publish, or execute the pending reference.
  5. Starting the request creates one claimable role task visible to eligible approvers.
  6. A non-member cannot claim or complete the approval task.
  7. Approval of an unchanged request atomically creates all expected existing workflow Tool grants.
  8. Rejection or staleness creates no grants.
  9. Retrying grant application is idempotent.
  10. The editor unlocks testing from committed grant state without waiting for acknowledgement.
  11. The author receives a user-assigned acknowledgement containing a link to the workflow.
  12. No generic task or arbitrary workflow can invoke the typed decision action.

Claim Org Role Bootstrap

Problem

The Claim Org action lets a signed-in user create an organization and its default host from the profile menu. The createOrg form captures the organization owner, default subdomain, host description, and host owner. The backend create-org flow then creates the organization, the default host, and user-host membership rows in one transaction.

That transaction is not sufficient for a usable tenant. A new host membership without role assignments can leave the owner unable to authorize after switching to the newly claimed host. The user profile/login query joins roles through role_user_t by the current user_host_t.host_id; if the current host has no active role rows for that user, role-dependent reads can return no user context.

The Claim Org bootstrap must create the minimum administration roles and assignments for the default host at the same time as the organization and host.

Current Flow

The current UI entry point is the Claim Org menu in portal-view/src/components/Header/ProfileMenu.tsx, which routes to /app/form/createOrg through portal-view/src/contexts/UserContext.tsx.

The createOrg form is defined in portal-view/src/data/Forms.json. It posts the host/createOrg action and includes:

  • domain
  • orgName
  • orgDesc
  • orgOwner
  • subDomain
  • hostDesc
  • hostOwner

The form help text already says that creating the default host assigns the host owner the host-admin role. Existing UI comments also assume that the organization owner can update and delete the organization because the user has the org-admin role.

On the projection side, HostOrgPersistenceImpl has separate handlers for:

  • createOrg, which writes org_t
  • createHost, which writes host_t
  • createUserHost, which writes user_host_t

Access control data is projected by AccessControlPersistenceImpl into:

  • role_t
  • role_user_t
  • role_permission_t

The role_user_t table has a foreign key to (host_id, role_id) in role_t, so role rows must exist before the user-role assignments are inserted.

Goals

  1. A claimed organization must be immediately usable by the selected organization owner and host owner.
  2. The default host must receive deterministic administrative roles.
  3. The role assignments must be created in the same command transaction as the organization, host, and user-host membership.
  4. The event stream and projections must remain replayable and idempotent.
  5. The implementation must use the canonical role IDs already used by the portal data: org-admin and host-admin.

Non-Goals

This design does not introduce a new global organization-role table. Existing roles are host-scoped through role_t.host_id, so the organization administrator role for a claimed organization is represented as a role on the default host.

This design also does not merge organization and host administration into one broad role. Organization ownership and host ownership are separate responsibilities, and the system should grant both roles only when the same user is selected for both owner fields.

Role Model

For the default host created during Claim Org:

Role IDAssigned ToPurpose
org-adminorgOwnerManage organization metadata, billing, and owner transfer for the claimed domain.
host-adminhostOwnerManage the default host, membership, infrastructure, and host-level API deployment setup.

If orgOwner and hostOwner are the same user, that user receives both roles.

The two roles should stay separate. org-admin should not implicitly include all host administration permissions. If an organization owner also needs to administer the default host, the command should grant that user both org-admin and host-admin explicitly.

The implementation should not use the current Java constant value HOST_ADMIN_ROLE = "hostAdmin" for this bootstrap. The canonical role ID in portal role data and UI task IDs is host-admin. The constant should be corrected or a new canonical constant should be introduced before it is used by bootstrap code.

Command Transaction

The Claim Org command should validate and persist these facts atomically:

  1. Create org_t for domain.
  2. Create the default host_t for (domain, subDomain).
  3. Create user_host_t rows for the selected owners on the default host.
  4. Switch the selected hostOwner to the new host by emitting UserHostSwitchedEvent.
  5. Create or reactivate role_t rows for org-admin and host-admin on the default host.
  6. Assign org-admin to orgOwner in role_user_t.
  7. Assign host-admin to hostOwner in role_user_t.
  8. Seed the required role_permission_t rows for these roles when endpoint-based authorization is enforced for the target admin APIs.

All rows should share the command’s audit fields where possible: update_user, update_ts, and the event aggregate version metadata. Inserts should use the same idempotent create/reactivate pattern already used by role and role-user projections.

Event Shape

The preferred event-sourcing shape is a single command producing multiple atomic events in one transaction:

  1. OrgCreatedEvent
  2. HostCreatedEvent
  3. UserHostCreatedEvent for orgOwner, if needed
  4. UserHostCreatedEvent for hostOwner, if different
  5. UserHostSwitchedEvent for hostOwner
  6. RoleCreatedEvent for org-admin
  7. RoleCreatedEvent for host-admin
  8. RoleUserCreatedEvent for orgOwner and org-admin
  9. RoleUserCreatedEvent for hostOwner and host-admin
  10. RolePermissionCreatedEvent events for the required endpoint permissions, if endpoint permission seeding is part of the command

The events must be written atomically by the command side. Each emitted event must reserve and carry its own user nonce because event_store_t enforces uniqueness on (user_id, nonce). Projection replay can then use the existing individual projection handlers. This matches the existing atomic-event design direction while keeping the Claim Org user gesture transactional.

Claim Org emits UserHostSwitchedEvent for the host owner after creating the selected host owner’s user_host_t membership. The master OAuth host tenant login boundary allows this safely: light-oauth validates the portal client under the configured OAuth host, then stores auth_session_t, auth_code_t, and auth_refresh_token_t rows with tenant host_id plus master auth_host_id.

The target login/session design is documented in Master OAuth Host Tenant Login. It keeps OAuth provider/client rows on the master host while storing tenant-host claims and sessions for the user’s current host.

If the current command service still emits one composite createOrg event, the projection may temporarily perform the role bootstrap as part of that composite handler. That should be treated as a compatibility step, not the long-term event model.

Permission Bootstrap

Creating role_t and role_user_t rows gives the user role identity on the new host. It does not automatically grant endpoint access if the request path is protected by role_permission_t.

The authoritative role-permission catalog should live with the command service as static, versioned metadata, for example default-role-permissions.yml. The Claim Org command reads that catalog and emits the required RolePermissionCreatedEvent events. This keeps the authorization bootstrap in the event stream, so projection replay produces the same state without depending on seed SQL.

The chosen source must be deterministic and replayable. It must also account for the fact that role_permission_t references api_endpoint_t through (host_id, endpoint_id). Permission rows can only be inserted after the target host has the corresponding endpoint rows.

Event importer assets such as events.json can mirror the same catalog for environment bootstrap and repair, but they should not be the only source of truth for permissions created by an interactive Claim Org command.

Initial SQL seed files should not own the final role-permission state. Seed SQL is useful for bootstrapping a local database, but event-sourced permission state must be represented by events so replay and promotion remain deterministic.

If endpoint rows are not available during Claim Org, the command should still create the roles and role-user assignments, then schedule or trigger a follow-up permission bootstrap once the endpoint catalog exists. That follow-up must emit the same RolePermissionCreatedEvent facts that would have been emitted synchronously.

UI Contract

The createOrg form should require both owners:

  • orgOwner
  • hostOwner

The current form requires hostOwner but not orgOwner. Since the backend persistence expects orgOwner, the form schema and command service request schema should mark both owner fields required and reject blank values through static schema validation.

The Claim Org command creates the selected host owner’s membership for the new default host and switches that owner’s current host in the same transaction. The portal must not bootstrap duplicate OAuth provider/client rows on every tenant host.

When automatic switching is enabled, the UI success path should tell the host owner to log out and log in again so the browser session receives the new tenant-host and role claims.

Owner Transfer

Owner transfer role behavior is intentionally deferred. Claim Org bootstrap grants the initial org-admin and host-admin assignments, but later changes to org_t.org_owner or host_t.host_owner should not automatically remove or transfer those roles until the access policy is defined.

There are valid cases where more than one user should keep the same administrative role. For example, a new organization owner may need org-admin while the previous owner remains an administrator during handoff, support, or shared ownership. Automatically deleting the old owner’s RoleUser assignment can remove access that was granted intentionally through another path.

When this policy is revisited, the implementation should decide separately:

  1. Whether changing orgOwner should grant org-admin to the new owner.
  2. Whether changing hostOwner should grant host-admin to the new owner.
  3. Whether the old owner should retain the role, lose it, or require an explicit UI choice.
  4. How to distinguish a bootstrap-created role assignment from an independently granted role assignment.

Until then, UpdateOrg and UpdateHost should remain metadata updates only. Any role changes after Claim Org should use the existing role-user administration flow.

Backfill

Existing claimed organizations may already have a default host and user_host_t rows without the corresponding admin role bootstrap.

A one-time repair should:

  1. Find active hosts whose organization and host owner users exist.
  2. Ensure org-admin and host-admin exist in role_t for each host.
  3. Ensure the organization owner has org-admin.
  4. Ensure the host owner has host-admin.
  5. Seed required role permissions if the endpoint catalog is present.

The repair must be idempotent and should only activate missing or soft-deleted bootstrap rows. It should not remove custom roles or overwrite existing role assignments.

Validation

A focused validation set should cover:

  • Claim Org creates org_t, host_t, and user_host_t rows.
  • Claim Org creates org-admin and host-admin rows in role_t for the default host.
  • Claim Org assigns org-admin to orgOwner.
  • Claim Org assigns host-admin to hostOwner.
  • The same user can receive both roles when orgOwner == hostOwner.
  • After Claim Org switches the host owner to the claimed host, the current-host user query returns the claimed user with active roles on the next login.
  • Replaying the events does not duplicate rows or downgrade active rows.
  • Permission bootstrap either creates the expected role_permission_t rows or records a deterministic follow-up when endpoint rows are not present.
  • Claim Org switches the selected host owner’s current host during creation after the master OAuth host login boundary is implemented.
  • The UI tells the host owner to log out and log in again after Claim Org switches the current host.

Resolved Decisions

  1. Claim Org creates the selected host owner’s user-host membership and switches that owner to the new host during the same command transaction.
  2. org-admin and host-admin should remain separate roles. A user who needs both capabilities should receive both roles explicitly.
  3. The authoritative role-permission catalog should live as command-side static metadata, with importer assets kept in sync for bootstrap and repair.

Remaining Follow-up

The implementation still needs to define the exact org-admin and host-admin endpoint permission sets. That catalog should be reviewed with the host and organization command/query API surface before implementation starts.

The owner-transfer role policy also remains open. The system should decide whether owner changes imply role grants, role revokes, both, or neither before adding role side effects to UpdateOrg or UpdateHost.

Master OAuth Host Tenant Login

Problem

In a deployed portal instance, dev.lightapi.net is the master host for the instance. Its host ID is:

01964b05-552a-7c4b-9184-6857e7f3dc5f

The master host owns the OAuth provider and portal client configuration:

  • auth_provider_t
  • auth_client_t
  • auth_provider_client_t

Tenant hosts own user membership, roles, groups, positions, attributes, and host-scoped portal data. A user can belong to many hosts, and user_host_t.current = TRUE identifies which tenant host should be used for the user’s login roles and JWT host claim.

The current light-oauth authorization code flow mixes these two meanings of host:

  1. It validates the portal client against the configured master host.
  2. It loads the user by the current tenant host.
  3. It writes auth_session_t and auth_code_t using the user’s current tenant host.

That fails after Claim Org switches the user to the newly created tenant host, because auth_session_t, auth_code_t, and auth_refresh_token_t currently enforce this foreign key:

FOREIGN KEY (host_id, client_id, provider_id)
REFERENCES auth_provider_client_t(host_id, client_id, provider_id)

The new tenant host does not and should not have duplicate OAuth provider/client rows. The FK therefore rejects login with:

auth_session_t_host_id_client_id_provider_id_fkey

Goals

  1. Keep dev.lightapi.net as the single master OAuth host for the instance.
  2. Allow users whose current host is a tenant host to log in through the master host’s provider/client.
  3. Preserve tenant-scoped JWT claims, especially the host claim and role claims.
  4. Avoid duplicating auth_provider_t, auth_client_t, or auth_provider_client_t rows per tenant host.
  5. Allow Claim Org to switch the host owner to the new host and require logout/login for fresh claims.
  6. Keep session, auth-code, refresh-token, and audit lifecycle behavior deterministic and queryable.

Non-Goals

This design does not introduce tenant-specific OAuth provider IDs, client IDs, redirect URIs, or BFF configuration.

This design does not change the portal UI or BFF to select a different OAuth provider per tenant host.

This design does not remove database referential integrity. The provider-client relationship should remain enforced, but it should be enforced against the master OAuth host instead of the tenant host.

Terminology

TermMeaning
Master OAuth hostThe host that owns OAuth provider/client configuration for the portal instance. In local/dev this is 01964b05-552a-7c4b-9184-6857e7f3dc5f.
Tenant hostThe user’s current business host from user_host_t.current; this drives roles and tenant data access.
auth_host_idThe host ID used to validate OAuth provider/client configuration.
host_idThe tenant host ID used for session ownership, user roles, and JWT host claim.

Decision

Separate OAuth configuration host from tenant host in the OAuth runtime tables.

Keep host_id in auth_session_t, auth_code_t, and auth_refresh_token_t as the tenant/current host. Add auth_host_id to those tables to point to the master OAuth host that owns the provider-client mapping.

The provider-client foreign key should move from host_id to auth_host_id:

FOREIGN KEY (auth_host_id, client_id, provider_id)
REFERENCES auth_provider_client_t(host_id, client_id, provider_id)

Session and token lifecycle keys should remain tenant-host scoped:

auth_session_t.host_id
auth_code_t.host_id
auth_refresh_token_t.host_id

This preserves the current meaning of host_id for tenant access while allowing all OAuth configuration to live on the master host.

Data Model

auth_session_t

Add:

auth_host_id UUID NOT NULL

Keep:

PRIMARY KEY (host_id, session_id)
FOREIGN KEY (host_id) REFERENCES host_t(host_id)

Replace:

FOREIGN KEY (host_id, client_id, provider_id)
REFERENCES auth_provider_client_t(host_id, client_id, provider_id)

With:

FOREIGN KEY (auth_host_id, client_id, provider_id)
REFERENCES auth_provider_client_t(host_id, client_id, provider_id)

auth_code_t

Add:

auth_host_id UUID NOT NULL

Keep:

PRIMARY KEY (host_id, auth_code)
FOREIGN KEY (host_id, session_id)
REFERENCES auth_session_t(host_id, session_id)

Replace the provider-client FK with:

FOREIGN KEY (auth_host_id, client_id, provider_id)
REFERENCES auth_provider_client_t(host_id, client_id, provider_id)

auth_refresh_token_t

Add:

auth_host_id UUID NOT NULL

Keep:

PRIMARY KEY (host_id, refresh_token)
FOREIGN KEY (host_id, session_id)
REFERENCES auth_session_t(host_id, session_id)

Replace the provider-client FK with:

FOREIGN KEY (auth_host_id, client_id, provider_id)
REFERENCES auth_provider_client_t(host_id, client_id, provider_id)

auth_session_audit_t

auth_session_audit_t.host_id should remain the tenant host for session and user queries.

Add:

auth_host_id UUID NOT NULL

Audit rows must distinguish the authorization server host from the tenant host from the first migration. This is required for security and compliance trails because a single database can contain multiple master hosts, and operators need to answer both questions:

  • Which OAuth host authenticated the user?
  • Which tenant host did the user access?

auth_host_id should be populated from the same value used by the session, auth code, or refresh token involved in the audit event.

Token Endpoint Lookup Indexes

The token endpoint receives an authorization code or refresh token string. It does not receive tenant host_id in the standard OAuth request, so it cannot use the (host_id, auth_code) or (host_id, refresh_token) primary keys as the first lookup.

Add unique secondary indexes:

CREATE UNIQUE INDEX idx_auth_code_t_auth_code
    ON auth_code_t(auth_code);

CREATE UNIQUE INDEX idx_auth_refresh_token_t_refresh_token
    ON auth_refresh_token_t(refresh_token);

The token endpoint should load the row by the code or refresh token string, then validate the tenant and OAuth boundaries with the row values. This keeps the external OAuth token format unchanged and avoids embedding tenant host IDs into authorization code or refresh token strings.

Migration

The migration should be backward compatible for existing rows.

  1. Add nullable auth_host_id columns.
ALTER TABLE auth_session_t ADD COLUMN auth_host_id UUID;
ALTER TABLE auth_code_t ADD COLUMN auth_host_id UUID;
ALTER TABLE auth_refresh_token_t ADD COLUMN auth_host_id UUID;
ALTER TABLE auth_session_audit_t ADD COLUMN auth_host_id UUID;
  1. Backfill existing rows. Existing valid rows used host_id for both meanings, so the safe default is:
UPDATE auth_session_t SET auth_host_id = host_id WHERE auth_host_id IS NULL;
UPDATE auth_code_t SET auth_host_id = host_id WHERE auth_host_id IS NULL;
UPDATE auth_refresh_token_t SET auth_host_id = host_id WHERE auth_host_id IS NULL;
UPDATE auth_session_audit_t SET auth_host_id = host_id WHERE auth_host_id IS NULL;
  1. Set the new columns to not null.
ALTER TABLE auth_session_t ALTER COLUMN auth_host_id SET NOT NULL;
ALTER TABLE auth_code_t ALTER COLUMN auth_host_id SET NOT NULL;
ALTER TABLE auth_refresh_token_t ALTER COLUMN auth_host_id SET NOT NULL;
ALTER TABLE auth_session_audit_t ALTER COLUMN auth_host_id SET NOT NULL;
  1. Drop the current provider-client FKs.

The exact constraint names vary by schema version. The migration should drop the existing provider-client constraints on:

  • auth_session_t
  • auth_code_t
  • auth_refresh_token_t
  1. Add new provider-client FKs through auth_host_id.
ALTER TABLE auth_session_t
    ADD CONSTRAINT auth_session_t_auth_provider_client_fk
    FOREIGN KEY (auth_host_id, client_id, provider_id)
    REFERENCES auth_provider_client_t(host_id, client_id, provider_id)
    ON DELETE CASCADE;

ALTER TABLE auth_code_t
    ADD CONSTRAINT auth_code_t_auth_provider_client_fk
    FOREIGN KEY (auth_host_id, client_id, provider_id)
    REFERENCES auth_provider_client_t(host_id, client_id, provider_id)
    ON DELETE CASCADE;

ALTER TABLE auth_refresh_token_t
    ADD CONSTRAINT auth_refresh_token_t_auth_provider_client_fk
    FOREIGN KEY (auth_host_id, client_id, provider_id)
    REFERENCES auth_provider_client_t(host_id, client_id, provider_id)
    ON DELETE CASCADE;
  1. Add supporting indexes.
CREATE INDEX idx_auth_session_t_auth_host_client_provider
    ON auth_session_t(auth_host_id, client_id, provider_id);

CREATE INDEX idx_auth_code_t_auth_host_client_provider
    ON auth_code_t(auth_host_id, client_id, provider_id);

CREATE INDEX idx_auth_refresh_token_t_auth_host_client_provider
    ON auth_refresh_token_t(auth_host_id, client_id, provider_id);

CREATE UNIQUE INDEX idx_auth_code_t_auth_code
    ON auth_code_t(auth_code);

CREATE UNIQUE INDEX idx_auth_refresh_token_t_refresh_token
    ON auth_refresh_token_t(refresh_token);

CREATE INDEX idx_auth_session_audit_t_auth_refresh_rotation
    ON auth_session_audit_t(auth_host_id, old_refresh_token_id, client_id, provider_id, event_type, event_ts DESC);

light-oauth Changes

Authorization Code Login

In post_code, keep using state.host_id to validate the configured portal client:

#![allow(unused)]
fn main() {
let client = get_client_by_provider_client_id(state.host_id, provider_id, client_id);
}

After password verification, use two host IDs:

#![allow(unused)]
fn main() {
let auth_host_id = client.host_id; // master OAuth host
let tenant_host_id = user.host_id; // current user host
}

Persist:

#![allow(unused)]
fn main() {
AuthCode {
    host_id: tenant_host_id,
    auth_host_id,
    ...
}

AuthSession {
    host_id: tenant_host_id,
    auth_host_id,
    ...
}
}

Authorization Code Token Exchange

When exchanging the code:

  1. Load the auth code by the unique auth_code value.
  2. Authenticate the client against the master OAuth host.
  3. Verify:
#![allow(unused)]
fn main() {
code.provider_id == provider_id
code.client_id == client.client_id
code.auth_host_id == client.host_id
}

The lookup can remain by authorization code only because auth_code_t(auth_code) is unique. The endpoint must still validate the row after retrieval so a code issued to one client or master host cannot be exchanged by another client.

Generate access token claims from tenant data:

#![allow(unused)]
fn main() {
("host", Some(code.host_id.to_string()))
}

Create refresh tokens with:

#![allow(unused)]
fn main() {
AuthRefreshToken {
    host_id: code.host_id,
    auth_host_id: code.auth_host_id,
    ...
}
}

Refresh Token Flow

The token endpoint should load refresh tokens by the unique refresh_token value. After the row is loaded, all mutation and session lifecycle operations should use the tenant host_id from the row.

The refresh flow must also verify that the authenticated client belongs to the same master OAuth host stored on the refresh token:

#![allow(unused)]
fn main() {
token.auth_host_id == client.host_id
token.client_id == client.client_id
token.provider_id == provider_id
}

Rotated refresh tokens must carry forward auth_host_id.

The JWT host claim must continue to come from token.host_id, not token.auth_host_id.

Refresh-token deletion and rotation should use:

#![allow(unused)]
fn main() {
host_id = token.host_id
refresh_token = token.refresh_token
}

This preserves tenant-host session ownership while allowing the token endpoint to find the row without the caller providing tenant host_id.

Logout And Revocation

Logout and administrative revocation should use the tenant host from the provided token or loaded refresh-token row.

For refresh-token based logout:

  1. Load the refresh token by the unique refresh_token value.
  2. Validate token.auth_host_id == client.host_id when client context is present.
  3. Revoke the session with token.host_id and token.session_id.
  4. Delete refresh tokens and outstanding auth codes with the same tenant host_id and session_id.
  5. Write audit rows with both tenant host_id and master auth_host_id.

For access-token based logout, the host claim represents the tenant host. The logout handler should use that tenant host to locate the session or refresh token state, and should not treat the master OAuth host as the tenant context.

Password Grant

The password grant has the same host split:

#![allow(unused)]
fn main() {
let auth_host_id = client.host_id;
let tenant_host_id = user.host_id;
}

Sessions and refresh tokens should store both values.

Client Authenticated User Grant

This grant already accepts an optional tenant host in the request. That host should remain the tenant host_id.

The authenticated client’s host should become auth_host_id.

Client Authentication

authenticate_client should become host-aware. The token endpoint should not load a client only by client_id, because auth_client_t is keyed by (host_id, client_id).

Preferred behavior:

#![allow(unused)]
fn main() {
get_client_by_provider_client_id(state.host_id, provider_id, client_id)
}

This keeps token endpoint client authentication aligned with the authorization endpoint.

Provider And Key Lookup

Provider and signing-key lookup should also be scoped by the configured master OAuth host.

Current provider IDs are short and not globally guaranteed across every possible master host in a shared database. Therefore the light-oauth lookup shape should be:

#![allow(unused)]
fn main() {
query_provider_by_id(state.host_id, provider_id)
query_current_provider_key(state.host_id, provider_id)
query_long_live_provider_key(state.host_id, provider_id)
}

The SQL should include host_id = $1 as well as provider_id = $2. This prevents accidental cross-master-host key or provider resolution if another portal instance later stores the same provider ID in the same database cluster.

JWT Claims

The access token must continue to identify the tenant host:

{
  "host": "<tenant-host-id>",
  "role": "host-admin org-admin"
}

The master OAuth host should not replace the JWT host claim. It is an implementation detail for OAuth provider/client validation.

If operational diagnostics need visibility into the authorization host, a separate claim could be introduced later, but this is not required for the current flow and should not be added unless there is a clear consumer.

Claim Org Behavior

With this design implemented, Claim Org can safely emit UserHostSwitchedEvent for the selected host owner during the same command transaction that creates:

  1. OrgCreatedEvent
  2. HostCreatedEvent
  3. UserHostCreatedEvent
  4. UserHostSwitchedEvent
  5. RoleCreatedEvent for org-admin
  6. RoleCreatedEvent for host-admin
  7. RoleUserCreatedEvent for orgOwner and org-admin
  8. RoleUserCreatedEvent for hostOwner and host-admin

The user’s current browser session still has the old host claim. The UI should tell the host owner to log out and log in again after Claim Org. The next login will:

  1. Authenticate through the master OAuth host.
  2. Load roles from the new current tenant host.
  3. Store session/code/refresh rows with tenant host_id and master auth_host_id.
  4. Issue a token whose host claim is the new tenant host.

Backfill And Repair

For existing databases, the schema migration backfills auth_host_id = host_id for existing valid OAuth rows.

For users already switched to a tenant host by an earlier Claim Org deployment, no OAuth provider/client rows should be created on the tenant host. After this design is deployed, those users should be able to log in because new session rows will reference:

host_id      = tenant host
auth_host_id = master OAuth host

If an earlier failed login left partial session artifacts, they should be removed through existing session cleanup paths or targeted SQL cleanup before retesting.

Validation

A focused validation set should cover:

  • Existing master-host login still succeeds after migration.
  • Claim Org switches the selected host owner to the new tenant host.
  • The host owner can log out and log in again after Claim Org.
  • New auth_session_t rows use tenant host_id and master auth_host_id.
  • New auth_code_t rows use tenant host_id and master auth_host_id.
  • New auth_refresh_token_t rows use tenant host_id and master auth_host_id.
  • The JWT host claim is the tenant host, not the master OAuth host.
  • Role claims come from the tenant host after user_host_t.current is switched.
  • No auth_provider_t, auth_client_t, or auth_provider_client_t rows are created for the tenant host.
  • Refresh token rotation preserves auth_host_id.
  • Revoking a session or refresh token still works with tenant-host keys.
  • Logout uses the tenant host from the token/session row and writes audit rows with auth_host_id.
  • Existing rows migrated with auth_host_id = host_id still support token refresh and audit queries.
  • Auth code lookup uses auth_code_t(auth_code) and still rejects mismatched client/provider/auth host.
  • Refresh token lookup uses auth_refresh_token_t(refresh_token) and still rejects mismatched client/provider/auth host.
  • Provider and provider-key lookup is scoped by the configured master OAuth host.

Resolved Decisions

  1. auth_session_audit_t must add auth_host_id in the first migration.
  2. Provider and provider-key lookup must require the configured master OAuth host ID.
  3. auth_code_t lookup remains by unique auth_code, followed by strict client, provider, and auth_host_id validation.
  4. auth_refresh_token_t lookup remains by unique refresh_token, followed by strict client, provider, and auth_host_id validation.
  5. Authorization code and refresh token string formats should not embed tenant host IDs in this design.

Schema Registry

The schema registry is the portal-owned catalog for reusable schema contracts. The first release should focus on JSON Schema documents for UI form generation, backend validation, external schema discovery, and operational auditability. The model should remain extensible enough to add Protobuf later if gRPC-over-WebSocket contract discovery becomes a real requirement, but Protobuf support is not required for the initial hardening pass. This design focuses on hardening the current schema-query, schema-command, and schema_t implementation so it can safely validate configuration property values and support future schema reuse across portal features.

Current State

The portal already has the core pieces of a schema registry:

  • schema_t stores schema metadata and the schema body.
  • schema-query exposes read actions such as getSchema, getSchemaLabel, getSchemaById, and getFreshSchema.
  • schema-command exposes create, update, and delete actions.
  • schema_t.host_id supports tenant-specific rows, with NULL representing a global schema.
  • schema_t.schema_status tracks draft, published, and retired states.
  • schema_t.spec_version records the schema language version, such as a JSON Schema draft.

The implementation is not ready to be treated as an authoritative validation service yet. The main gaps are:

  • schema lookup is not consistently tenant-aware
  • version lookup semantics are not explicit enough for config validation
  • schema bodies are not clearly validated before being stored
  • published schema immutability is not defined
  • schema type and body validation rules are not explicit
  • schema rows do not have a stable URL-friendly public alias
  • config properties do not currently reference schemas
  • backend config command handlers do not validate values against schemas
  • tests for schema CRUD, tenant/global lookup, versioning, and config validation are incomplete

Goals

  • Store JSON Schema documents with clear tenant/global ownership.
  • Support immutable published schema versions.
  • Let config properties reference an exact schema id and version.
  • Validate structured config property values on both frontend and backend.
  • Preserve existing schema registry CRUD pages and generated forms.
  • Add a Marketplace Schema Catalog entry for browse-first schema discovery.
  • Add URL-friendly schema aliases so external applications can retrieve published schemas through portal-service.
  • Keep schema lookup cheap for list pages by returning schema metadata first and loading schema bodies lazily.
  • Support schema status transitions: draft, published, retired.
  • Make validation errors specific enough for editors to highlight the failing JSON path.
  • Categorize and tag schemas for easier discovery and filtering.

Non-Goals

  • Do not build a full schema compatibility engine in the first release.
  • Do not require every config property to have a schema.
  • Do not replace OpenAPI schemas or the existing API spec registry.
  • Do not implement Protobuf parsing, compatibility, config form generation, or runtime validation in the first release.
  • Do not make the config update page depend on schema registry completion for basic scalar and raw JSON/YAML editing.
  • Do not allow unpublished schemas to validate production config overrides.

Data Model

The existing schema_t table is a reasonable starting point. It already has:

  • schema_id
  • host_id
  • schema_version
  • schema_type
  • spec_version
  • schema_body
  • schema_status
  • ownership, active, audit, and aggregate-version fields

Before production validation depends on this table, the versioning model should be made explicit. The recommended model is:

  • schema_id is the stable, lower-case, URL-friendly logical schema id.
  • schema_version identifies an immutable schema version.
  • host_id IS NULL means a global schema.
  • host_id IS NOT NULL means a tenant-specific schema.
  • a published schema body is immutable
  • changing a published schema creates a new version
  • retiring a schema version marks it unavailable for new bindings but keeps it readable for historical audit and existing references

The current table uses schema_id as the primary key while also defining unique indexes on (schema_id, schema_version) and (host_id, schema_id, schema_version). That conflicts with a true immutable version-row model. The preferred correction is to introduce a surrogate row key such as schema_uid UUID and keep uniqueness on the logical reference:

schema_uid       UUID primary key
schema_lineage_id UUID not null
host_id          UUID nullable
schema_id        VARCHAR(126)
schema_alias     VARCHAR(126) nullable
schema_version   VARCHAR(12)
schema_type      VARCHAR(16)
spec_version     VARCHAR(12)
schema_body      TEXT
schema_status    CHAR(1)
external_visible BOOLEAN
aggregate_version BIGINT
...

The registry should keep unique constraints for:

  • global schema versions: schema_id + schema_version where host_id IS NULL
  • tenant schema versions: host_id + schema_id + schema_version where host_id IS NOT NULL
  • version rows within one logical lineage: schema_lineage_id + schema_version

schema_lineage_id is the stable identity for a logical schema within a scope. All immutable versions of the same global schema share one lineage id. All immutable versions of the same tenant schema share a different lineage id. This prevents category and tag assignments from colliding when a global schema and a tenant schema use the same schema_id.

schema_alias is an optional URL-friendly external identifier for a schema lineage. It should use the same lower-case, URL-friendly character policy as schema_id, and it should be stable across immutable versions of the same lineage. schema_alias is allowed to differ from schema_id so operators can rename an external contract URL without changing internal schema ids.

Because alias and taxonomy are lineage-level metadata, the clean target is a small lineage table:

schema_lineage_t
  schema_lineage_id UUID primary key
  host_id UUID nullable
  schema_id VARCHAR(126)
  schema_alias VARCHAR(126) nullable
  external_visible BOOLEAN not null default false
  ...

schema_t
  schema_uid UUID primary key
  schema_lineage_id UUID references schema_lineage_t(schema_lineage_id)
  schema_version VARCHAR(12)
  schema_body TEXT
  ...

If a separate lineage table is too large for the first pass, schema_alias and external_visible can be stored on schema_t with command-side enforcement that all versions in one lineage share the same alias and visibility. The migration should still move them to schema_lineage_t when the immutable version-row model is introduced.

Alias uniqueness should be scoped the same way as schemas:

  • global aliases: unique schema_alias where host_id IS NULL
  • tenant aliases: unique host_id + schema_alias where host_id IS NOT NULL

If a surrogate key migration is too disruptive for the first hardening pass, the minimum acceptable interim model is to keep the current row shape but document that schema_id represents the current mutable aggregate. That is weaker for config validation because a schema body can drift under an existing config property reference. The immutable version-row model should be the target.

Schema Types

schema_type should be treated as a controlled value. The first supported value is:

schema_typeschema_body meaningspec_version examplesFirst-release use
jsonJSON Schema documentdraft-07, 2019-09, 2020-12Config form generation, frontend validation, backend config command validation, catalog discovery

For json schemas, schema-command must parse schema_body as JSON and validate it as a JSON Schema document before the schema can be published.

protobuf should remain a reserved future schema_type, not an MVP requirement. If future gRPC-over-WebSocket support needs Protobuf contracts, add Protobuf parsing and either a schema artifact table or a schema bundle table for multi-file imports and compiled descriptors. Do not overload the JSON Schema validation path to make Protobuf fit.

Classification and Discovery

Schemas must support categorization and tagging using the portal’s common category_t, tag_t, entity_category_t, and entity_tag_t infrastructure, similar to APIs, workflows, agents, and skills.

  • entity_type will be 'schema'.
  • entity_id should be schema_lineage_id::text, not raw schema_id. This lets tags and categories apply to the logical schema lineage rather than a specific immutable version, while still separating global and tenant schemas that use the same schema_id.
  • entity_category_t connects schemas to categories.
  • entity_tag_t connects schemas to tags.
  • schema-command create/update payloads should use categoryIds and tagIds to match the existing taxonomy contract used by API, workflow, and skill forms.
  • When categoryIds or tagIds are present on update, the command should replace that assignment set. An empty array clears assignments. An omitted field leaves the current assignment set unchanged.

These mappings enable discovery across the portal using category and tag filters. Query paths must join through category_t and tag_t, enforce active = TRUE on mapping rows and taxonomy rows, and resolve global plus host-specific taxonomy labels for the selected host.

Marketplace Schema Catalog

Add a Schema Catalog entry under Marketplace alongside API Catalog and Workflow Catalog. If the navigation uses short labels, the menu label can be Schema, but the page title should be Schema Catalog.

Recommended route:

/app/marketplace/schema

Visible records should include:

  • published global schemas visible to the caller
  • published tenant schemas for the selected host
  • draft or retired schemas only when the caller owns or administers the schema
  • json schemas in the first release

Common filters:

  • search text for schema id, name, description, source, and owner metadata
  • schema type, starting with json
  • schema status, such as draft, published, and retired
  • categories from getCategoryLabelByType(entityType = "schema")
  • grouped tags from getTagLabelByType(entityType = "schema")
  • active or inactive state
  • sort and card/list view options

Catalog cards should show a compact contract summary:

  • schema id, name, latest published version, and type
  • spec version, source, status, and scope provenance
  • schema alias and external URL when external access is enabled
  • categories and grouped tags
  • whether a schema body is available for preview
  • whether a JSON Schema can be used for config-backed form generation

Common actions:

  • open a read-only schema details drawer
  • preview JSON Schema source
  • copy a schema reference, including schemaId, schemaVersion, and schemaType
  • copy an external schema URL when schema_alias and external_visible are set
  • create a new version when the user has schema write permission
  • edit draft metadata and taxonomy assignments when permitted
  • open the schema administration page for table-based management

External Schema Access

External applications should be able to retrieve published schemas through portal-service/apps/portal-service, similar to the existing /r/data reference-data endpoint. The recommended route is:

GET /r/schema/{schemaAlias}

Query parameters:

  • host is optional. When present, the service first resolves a tenant schema for host + schemaAlias, then falls back to a global schema with the same alias. When omitted, only global schemas are considered.
  • version is optional. When omitted, the service returns the latest published active version for the resolved alias. When present, the service returns that exact published or retired active version if it is still externally visible.
  • envelope is optional. The default should return the schema body directly for external validators. envelope=true should return metadata plus schemaBody.

Default response for schema_type = "json" should be the JSON Schema document itself with Content-Type: application/schema+json where possible. The response should include headers such as:

X-Schema-Id: security-jwt-claim-mapping
X-Schema-Alias: jwt-claim-mapping
X-Schema-Version: 1.0.0
X-Schema-Type: json
X-Schema-Source: global|tenant

Envelope response:

{
  "schemaAlias": "jwt-claim-mapping",
  "schemaId": "security-jwt-claim-mapping",
  "schemaVersion": "1.0.0",
  "schemaType": "json",
  "specVersion": "2020-12",
  "schemaStatus": "P",
  "source": "global",
  "schemaBody": { }
}

The external route must only serve schemas that are:

  • active
  • published, or retired when an exact version is requested
  • external_visible = TRUE
  • visible in the requested host scope

Draft schemas must never be returned by /r/schema/{schemaAlias}. A missing, inactive, private, or unauthorized alias should return 404 instead of leaking that the schema exists.

portal-service should add a lightweight schema lookup service and cache, separate from the /r/data reference cache. Suggested cache key:

host + schemaAlias + version + envelope

The cache should be invalidated when a schema is published, retired, deleted, or when alias/external visibility changes.

Config Property Binding

Config property validation needs an explicit link from a config property to a schema. The simplest useful binding is to add these nullable fields to the base config property definition:

config_property_t.schema_id
config_property_t.schema_version

This works because a config property has at most one schema for its value shape. The selected hostId is still used during lookup so tenants can override the global schema with the same schemaId + schemaVersion when needed.

The binding should be optional:

  • scalar properties can continue to use valueType validation only
  • map and list properties can attach JSON Schema for structured validation
  • File and Cert properties should keep using their existing generated forms until file-specific schema handling is designed

The registry lookup for config validation should resolve in this order:

  1. tenant-specific schema for hostId + schemaId + schemaVersion
  2. global schema for schemaId + schemaVersion
  3. no schema found, which disables schema-backed validation for that row

Only published schemas should be used to validate active config override commands.

API Changes

The existing schema-query actions can remain, but config validation needs a tenant-aware versioned lookup. Add or evolve an action such as getSchemaByRef:

{
  "hostId": "host uuid",
  "schemaId": "security-jwt-claim-mapping",
  "schemaVersion": "1.0.0",
  "active": true
}

Response:

{
  "schemaId": "security-jwt-claim-mapping",
  "schemaAlias": "jwt-claim-mapping",
  "schemaVersion": "1.0.0",
  "schemaType": "json",
  "specVersion": "v2020-12",
  "schemaStatus": "P",
  "schemaBody": "{...}",
  "source": "tenant"
}

getSchema should remain a metadata list query. It should not return schemaBody by default because schema bodies can be large and are usually not needed for table rendering.

querySchemaCatalog or an evolved getSchema should support server-side catalog filtering:

{
  "hostId": "host uuid",
  "offset": 0,
  "limit": 20,
  "active": true,
  "schemaTypes": ["json"],
  "schemaStatus": "P",
  "categoryIds": ["..."],
  "tagIds": ["..."],
  "tagMatch": "all",
  "globalFilter": "jwt"
}

Category filters should use OR semantics. Tag filters should support tagMatch = "all" and tagMatch = "any". The response should return categoryIds, categories, tagIds, and tags, but omit schemaBody unless a details action explicitly asks for it. It should also return schemaAlias, externalVisible, and the derived external URL when alias-based access is enabled.

schema-command should validate schemaBody before create or update. It should reject invalid JSON Schema documents for schema_type = "json". It should also enforce the status rules:

  • draft schemas can be edited
  • publishing validates the schema body and makes that version available
  • published schema bodies are immutable
  • retired schemas remain readable but cannot be newly bound to config properties

schema-command should also support schemaAlias and externalVisible. schemaAlias must be lower-case and URL-friendly, unique in the selected global/host scope, and stable across versions of the same lineage. externalVisible controls whether the alias can be served by portal-service /r/schema/{schemaAlias}. A draft schema may carry an alias, but the external route must not serve it until a published version exists.

Schema delete should remain a soft delete or retire operation for schemas that may be referenced by config properties or historical overrides.

schema-command should support linking categoryIds and tagIds during schema creation and update. schema-query already has getSchemaByCategoryId and getSchemaByTagId; those actions should be hardened rather than reintroduced. They must honor hostId, offset, limit, active, active taxonomy mapping rows, active taxonomy labels, and active schema rows. They should return schema metadata for catalog browsing and filtering, not full schema bodies by default.

Config Update Page Integration

getConfigUpdateProperties should include schema metadata but not schema body:

{
  "configId": "config uuid",
  "propertyId": "property uuid",
  "propertyName": "jwt.claimMapping",
  "valueType": "map",
  "schemaId": "security-jwt-claim-mapping",
  "schemaVersion": "1.0.0",
  "schemaType": "json",
  "schemaStatus": "P",
  "hasSchema": true
}

When the user opens a map or list editor, the frontend calls the tenant-aware schema lookup and caches the result by:

hostId + schemaId + schemaVersion

The structured editor should always provide raw JSON and YAML tabs. The Form tab is enabled only when a published compatible schema is available. YAML input is normalized to compact JSON before it is sent to the existing config command API, because config property values are stored as strings.

Validation Flow

Validation runs in two layers.

Frontend validation:

  • parse scalar values according to valueType
  • parse list values as JSON arrays
  • parse map values as JSON objects
  • run JSON Schema validation when a schema is available
  • show validation errors next to the row or field that failed
  • keep the draft dirty until the value is valid

Backend validation:

  • load the config property metadata by configId + propertyId
  • parse propertyValue according to valueType
  • resolve the published schema for hostId + schemaId + schemaVersion
  • validate the parsed value against the schema
  • reject the command before event persistence when validation fails

Backend validation is authoritative. Frontend validation improves usability but cannot replace command-side enforcement.

Validation errors should include enough detail for row-level UI feedback:

{
  "code": "CONFIG_PROPERTY_SCHEMA_VALIDATION_FAILED",
  "configId": "config uuid",
  "propertyId": "property uuid",
  "schemaId": "security-jwt-claim-mapping",
  "schemaVersion": "1.0.0",
  "errors": [
    {
      "path": "$.issuer",
      "keyword": "required",
      "message": "issuer is required"
    }
  ]
}

Security And RBAC

Schema registry access should follow the same tenant ownership model used by other portal resources:

  • global schemas are readable by authorized portal users
  • tenant schemas are readable only within the selected host context
  • schema create/update/delete requires write permission
  • config command validation may read a schema internally even when the end user only has config update permission
  • command authorization remains separate from schema validation

The frontend should not expose tenant-specific schema bodies from another host. The backend lookup must enforce this even if the UI sends a forged hostId.

External schema access has a stricter rule: /r/schema/{schemaAlias} should only return active schemas that are explicitly marked external_visible = TRUE. It should return 404 for missing, private, draft, inactive, or unauthorized aliases so external callers cannot enumerate private schema names.

Testing

The first hardening pass should include tests for:

  • create draft schema
  • reject invalid schemaBody
  • publish schema
  • reject edits to published schema body
  • retire schema
  • tenant-specific lookup
  • global fallback lookup
  • schema metadata list excluding body
  • schema body lookup by hostId + schemaId + schemaVersion
  • schema alias validation and global/tenant uniqueness
  • external visibility enforcement
  • /r/schema/{schemaAlias} latest published lookup
  • /r/schema/{schemaAlias}?version=... exact version lookup
  • /r/schema/{schemaAlias} host-specific lookup with global fallback
  • /r/schema/{schemaAlias} direct body and envelope response shapes
  • create/update schema with categoryIds and tagIds
  • replace and clear schema taxonomy assignments on update
  • schema category and tag catalog filters, including active mapping rows
  • tenant/global taxonomy collision prevention through schema_lineage_id
  • JSON Schema schema_type validation
  • Schema Catalog visibility, filters, and body-lazy result shape
  • config property binding
  • valid map/list config property override
  • invalid map/list config property override
  • scalar validation still works when no schema is attached
  • version mismatch and getFreshSchema

Implementation Order

Implement the schema registry foundation before enabling schema-backed validation in the config update page. The registry work does not need to block the entire config update page, but it must block the Form tab and backend schema enforcement.

Recommended order:

  1. Harden schema registry data model, lookup, and command validation.
  2. Add schema alias and external visibility support.
  3. Add taxonomy linkage through categoryIds, tagIds, and schema_lineage_id.
  4. Add the Marketplace Schema Catalog entry and body-lazy catalog query.
  5. Add /r/schema/{schemaAlias} in portal-service/apps/portal-service.
  6. Add config-property-to-schema binding.
  7. Add backend config property value validation in config command handlers.
  8. Extend getConfigUpdateProperties to return schema metadata.
  9. Add lazy schema lookup and typed Form tab in portal-view.
  10. Add end-to-end tests for schema-backed config updates and catalog discovery.

The config update page can still ship a useful MVP with scalar validation and raw JSON/YAML editors while the registry is being hardened. Once the registry foundation is complete, the same page can enable schema-backed forms and command validation without changing the operator workflow.

Recommendation

Use the schema registry as the authoritative source for structured config property schemas. Do not implement a separate local schema convention in portal-view. Stabilize the registry enough for versioned, tenant-aware, published-schema lookup, then use it to validate map and list config property values in both the frontend editor and the backend command path.

JSON Schema Registry

JSON Schema is a declarative language that provides a standardized way to describe and validate JSON data.

What it does

JSON Schema defines the structure, content, data types, and constraints of JSON documents. It’s an IETF standard that helps ensure the consistency and integrity of JSON data across applications.

How it works

JSON Schema uses keywords to define data properties. A JSON Schema validator checks if JSON documents conform to the schema.

What it’s useful for

  • Describing existing data formats
  • Validating data as part of automated testing
  • Submitting client data
  • Defining how a record should be organized

What is a JSON Schema Registry

The JSON Schema Registry provides a centralized service for your JSON schemas with RESTful endpoints for storing and retrieving JSON schemas.

When using data in a distributed application with many RESTful APIs, it is important to ensure that it is well-formed and structured. If data is sent without prior validation, errors may occur on the services. A schema registry provides a way to ensure that the data is validated before it is sent and validated after it is received.

A schema registry is a service used to define and confirm the structure of data that is sent between consumers and providers. In a schema registry, developers can define what the data should look like and how it should be validated. The schemas can be utilized in the OpenAPI specifications to ensure that schemas can be externalized.

Schema records can also help ensure forward and backward compatibility when changes are made to the data structure. When a schema record is used, the data transfered with more schema information that can be used to ensure that applications reading the data can interpret it.

Given the API consumers and providers can belong to different groups or organizations, it is necessary to have a centralized service to manage the schemas so that they can be shared between them. This is why we have implemented this service as part of the light-portal.

Schema Specification Version

The registry is heterogeneous registry as it can store schemas of different schema draft versions. By default the registry is configured to store schemas of Draft 2020-12. When a schema is added, the version which is currently is set, is what the schema is saved as.

The following list contains all supported specification versions.

  • Draft 4
  • Draft 6
  • Draft 7
  • 2019-09
  • 2020-12

Schema Version

Once a schema is registed into the registry, it will be assigned as version 1. Each time it is updated, the version number will increase 1. When the schema is retrieve, the version number can be part of the URL to indicate that exact version will be retrieved. If version number is not in the URL, the latest version will be retrieved.

Access Endpoint

Table Structure

YAML Rule Registry

React Schema Form

React Schema Form is a form generator based on JSON Schema and form definitions from Light Portal. It renders UI forms to manipulate database entities, and form submissions are automatically hooked into an API endpoint.

Debugging a Component

Encountering a bug in a react-schema-form component can be challenging since the source code may not be directly visible. To debug:

  1. Set up the Light Portal server if dropdowns are loaded from the server.
  2. Use the example app in the same project to debug.

Use a Local Alias with Vite

Vite allows creating an alias to point to your library’s src folder. Update the vite.config.ts in your example app:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      'react-schema-form': path.resolve(__dirname, '../src'), // Adjust the path to point to the library's `src` folder
    },
  },
});

Update the example app’s package.json file. In the dependencies section, replace the library’s version with a local path:

{
  "dependencies": {
    "react-schema-form": "file:../src"
  }
}

Library Entry Point

Vite requires an entry point file, typically named index.js or index.ts, in your library’s src folder. Ensure that your library’s src folder includes a properly configured index.js file, like this:

export { default as SchemaForm } from './SchemaForm'
export { default as ComposedComponent } from './ComposedComponent'
export { default as utils } from './utils'
export { default as Array } from './Array'

Without a correctly named and configured entry file, components like SchemaForm may not be imported properly.

Update index.html

If you change the entry point file from main.js to index.js, ensure you update the reference in the index.html file located in the root folder. For example:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.js"></script>
  </body>
</html>

Sync devDependencies from peerDependencies

When the source code in src is used directly by the example app, the peerDependencies in the example app won’t work for react-schema-form components. To address this, copy the peerDependencies into the devDependencies section of react-schema-form’s package.json. For example:

  "devDependencies": {
    "@babel/runtime": "^7.26.0",
    "@codemirror/autocomplete": "^6.18.2",
    "@codemirror/language": "^6.10.6",
    "@codemirror/lint": "^6.8.2",
    "@codemirror/search": "^6.5.7",
    "@codemirror/state": "^6.4.1",
    "@codemirror/theme-one-dark": "^6.1.2",
    "@codemirror/view": "^6.34.2",
    "@emotion/react": "^11.13.5",
    "@emotion/styled": "^11.13.5",
    "@eslint/js": "^9.13.0",
    "@lezer/common": "^1.2.3",
    "@mui/icons-material": "^6.1.6",
    "@mui/material": "^6.1.6",
    "@mui/styles": "^6.1.6",
    "@types/react": "^18.3.1",
    "@uiw/react-markdown-editor": "^6.1.2",
    "@vitejs/plugin-react": "^4.3.3",
    "codemirror": "^6.0.1",
    "eslint": "^9.13.0",
    "eslint-plugin-react": "^7.37.2",
    "eslint-plugin-react-hooks": "^5.0.0",
    "eslint-plugin-react-refresh": "^0.4.14",
    "gh-pages": "^6.2.0",
    "globals": "^15.11.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "vite": "^6.0.3"
  },
  "peerDependencies": {
    "@babel/runtime": "^7.26.0",
    "@codemirror/autocomplete": "^6.18.2",
    "@codemirror/language": "^6.10.6",
    "@codemirror/lint": "^6.8.2",
    "@codemirror/search": "^6.5.7",
    "@codemirror/state": "^6.4.1",
    "@codemirror/theme-one-dark": "^6.1.2",
    "@codemirror/view": "^6.34.2",
    "@emotion/react": "^11.13.5",
    "@emotion/styled": "^11.13.5",
    "@lezer/common": "^1.2.3",
    "@mui/icons-material": "^6.1.6",
    "@mui/material": "^6.1.6",
    "@mui/styles": "^6.1.6",
    "@types/react": "^18.3.1",
    "@uiw/react-markdown-editor": "^6.1.2",
    "codemirror": "^6.0.1",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },

Additionally, ensure the peerDependencies are also synced with the dependencies section of the example app’s package.json. This step allows react-schema-form components to load independently and work seamlessly during development.

Update Source Code

After completing all the updates, perform a clean install for both react-schema-form and the example app. Then, start the server from the example folder using the following command:

yarn dev

Whenever you modify a react-schema-form component, simply refresh the browser to reload the example application and see the updated component in action.

Debug with Visual Studio Code

You can debug the component using Visual Studio Code. There are many tutorials available online that explain how to debug React applications built with Vite, which can help you set up breakpoints, inspect components, and track down issues effectively.

Component dynaselect

dynaselect is a component that renders a dropdown select, either from static options or options loaded dynamically from a server via an API endpoint. It is a wrapper of material ui Autocomplete component. Below is an example form from the example app that demonstrates how to use this component.

{
  "schema": {
    "type": "object",
    "title": "React Component Autocomplete Demo Static Single",
    "properties": {
      "name": {
        "title": "Name",
        "type": "string",
        "default": "Steve"
      },
      "host": {
        "title": "Host",
        "type": "string"
      },
      "environment": {
        "type": "string",
        "title": "Environment",
        "default": "LOCAL",
        "enum": [
          "LOCAL",
          "SIT1",
          "SIT2",
          "SIT3",
          "UAT1",
          "UAT2"
        ]
      },
      "stringarraysingle": {
        "type": "array",
        "title": "Single String Array",
        "items": {
          "type": "string"
        }
      },
      "stringcat": {
        "type": "string",
        "title": "Joined Strings"
      },
      "stringarraymultiple": {
        "type": "array",
        "title": "Multiple String Array",
        "items": {
          "type": "string"
        }
      }
    },
    "required": [
      "name",
      "environment"
    ]
  },
  "form": [
    "name",
    {
      "key": "host",
      "type": "dynaselect",
      "multiple": false,
      "action": {
        "url": "https://localhost/portal/query?cmd=%7B%22host%22%3A%22lightapi.net%22%2C%22service%22%3A%22user%22%2C%22action%22%3A%22listHost%22%2C%22version%22%3A%220.1.0%22%7D"
      }
    },
    {
      "key": "environment",
      "type": "dynaselect",
      "multiple": false,
      "options": [
        {
          "id": "LOCAL",
          "label": "Local"
        },
        {
          "id": "SIT1",
          "label": "SIT1"
        },
        {
          "id": "SIT2",
          "label": "SIT2"
        },
        {
          "id": "SIT3",
          "label": "SIT3"
        },
        {
          "id": "UAT1",
          "label": "UAT1"
        },
        {
          "id": "UAT2",
          "label": "UAT2"
        }
      ]
    },
    {
      "key": "stringarraysingle",
      "type": "dynaselect",
      "multiple": false,
      "options": [
        {
          "id": "id1",
          "label": "label1"
        },
        {
          "id": "id2",
          "label": "label2"
        },
        {
          "id": "id3",
          "label": "label3"
        },
        {
          "id": "id4",
          "label": "label4"
        },
        {
          "id": "id5",
          "label": "label5"
        },
        {
          "id": "id6",
          "label": "label6"
        }
      ]
    },
    {
      "key": "stringcat",
      "type": "dynaselect",
      "multiple": true,
      "options": [
        {
          "id": "id1",
          "label": "label1"
        },
        {
          "id": "id2",
          "label": "label2"
        },
        {
          "id": "id3",
          "label": "label3"
        },
        {
          "id": "id4",
          "label": "label4"
        },
        {
          "id": "id5",
          "label": "label5"
        },
        {
          "id": "id6",
          "label": "label6"
        }
      ]
    },
    {
      "key": "stringarraymultiple",
      "type": "dynaselect",
      "multiple": true,
      "options": [
        {
          "id": "id1",
          "label": "label1"
        },
        {
          "id": "id2",
          "label": "label2"
        },
        {
          "id": "id3",
          "label": "label3"
        },
        {
          "id": "id4",
          "label": "label4"
        },
        {
          "id": "id5",
          "label": "label5"
        },
        {
          "id": "id6",
          "label": "label6"
        }
      ]
    }
  ]
}

Dynamic Options from APIs

The host is a string type field rendered as a dynaselect with multiple set to false. The options for the select are loaded via an API endpoint, with the action URL provided. Note that the cmd query parameter value is encoded because it contains curly brackets {}.

To encode and decode the query parameter value, you can use the following tool:

Encoder/Decoder Tool

Encoded:

%7B%22host%22%3A%22lightapi.net%22%2C%22service%22%3A%22user%22%2C%22action%22%3A%22listHost%22%2C%22version%22%3A%220.1.0%22%7D

Decoded:

{"host":"lightapi.net","service":"user","action":"listHost","version":"0.1.0"}

When using the example app to test the react-schema-form with APIs, you need to configure CORS on the light-gateway. Ensure that CORS is enabled only on the light-gateway and not on the backend API, such as hybrid-query.

Here is the example in values.yml for the light-gateway.

# cors.yml
cors.enabled: true
cors.allowedOrigins:
  - https://devsignin.lightapi.net
  - https://dev.lightapi.net
  - https://localhost:3000
  - http://localhost:5173
cors.allowedMethods:
  - GET
  - POST
  - PUT
  - DELETE

Single string type

For the environment field, the schema defines the type as string, and the form definition specifies multiple: false to indicate it is a single select.

The select result in the model looks like the following:

{
  "environment": "SIT1",
}

Single string array type

For the stringarraysingle field, the schema defines the type as a string array, and the form definition specifies multiple: false to indicate it is a single select.

The select result in the model looks like the following:

{
  "stringarraysingle": [
    "id3"
  ],	
}

Multiple string type

For the stringcat field, the schema defines the type as a string, and the form definition specifies multiple: true to indicate it is a multiple select.

The select result in the model looks like the following:

{
	"stringcat": "id2,id4"
}

Multiple string array type

For the stringarraymultiple field, the schema defines the type as a string array, and the form definition specifies multiple: true to indicate it is a multiple select.

The select result in the model looks like the following:

{
  "stringarraymultiple": [
    "id2",
    "id5",
    "id3"
  ],	
}

User Management

User Type

The user_type field is a critical part of the user security profile in the JWT token and can be leveraged for fine-grained authorization. In a multi-tenant environment, user_type is presented as a dropdown populated from the reference table configured for the organization. It can be dynamically selected based on the host chosen during the user registration process.

Supported Standard Dropdown Models

  1. Employee and Customer

    • Dropdown values: E (Employee), C (Customer)
    • Default model for lightapi.net host.
    • Suitable for most organizations.
  2. Employee, Personal, and Business

    • Dropdown values:
      • E (Employee)
      • P (Personal)
      • B (Business)
    • Commonly used for banks where personal and business banking are separated.

Database Configuration

  • The user_type field is nullable in the user_t table by default.
  • However, you can enforce this field as mandatory in your application via the schema and UI configuration.

On-Prem Deployment

In on-premise environments, the user_type can determine the authentication method:

  • Employees: Authenticated via Active Directory.
  • Customers: Authenticated via a customer database.

This flexibility allows organizations to tailor the authentication process based on their specific needs and user classifications.

Handling Users with Multi-Host Access

There are two primary ways to handle users who belong to multiple hosts:

  1. User-Host Mapping Table:

user_t: This table would not have a host_id and would store core user information that is host-independent. The user_id would be unique across all hosts.

user_host_t (or user_tenant_t): This would be a mapping table to represent the many-to-many relationship between users and hosts.

-- user_t (no host_id, globally unique user_id)
CREATE TABLE user_t (
    user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), -- UUID is recommended
    -- ... other user attributes (e.g., name, email) 
);

-- user_host_t (mapping table)
CREATE TABLE user_host_t (
    user_id UUID NOT NULL,
    host_id UUID NOT NULL,
    -- ... other relationship-specific attributes (e.g., roles within the host)
    PRIMARY KEY (user_id, host_id),
    FOREIGN KEY (user_id) REFERENCES user_t (user_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id) REFERENCES host_t (host_id) ON DELETE CASCADE -- Assuming you have a hosts_t
);
  1. Duplicating User Records (Less Recommended):

user_t: You would keep host_id in this table, and the primary key would be (host_id, user_id).

User Duplication: If a user needs access to multiple hosts, you would duplicate their user record in users_t for each host they belong to, each with a different host_id.

Why User-Host Mapping is Generally Preferred:

  • Data Integrity: Avoids data duplication and the potential for inconsistencies that come with it. If a user’s core information (e.g., name, email) changes, you only need to update it in one place in user_t.

  • Flexibility: Easier to add or remove a user’s access to hosts without affecting their core user data.

  • Querying: While you’ll need joins to get a user’s hosts or a host’s users, these joins are straightforward using the mapping table.

  • Scalability: Better scalability as your user base and the number of hosts they can access grow.

Distributing Tables in a Multi-Host User Scenario:

With the user-host mapping approach:

  • user_t: This table would likely be a reference table in Citus (replicated to all nodes) since it does not have a host_id for distribution.

  • user_host_t: This table would be distributed by host_id.

  • Other tables (e.g., employees_t, api_endpoints_t, etc.): These would be distributed by host_id as before.

When querying, you would typically:

  • Start with the user_hosts_t table to find the hosts a user has access to.

  • Join with other tables (distributed by host_id) based on the host_id to retrieve tenant-specific data.

Choosing the Right user_id Primary Key:

Here’s a comparison of the options for the user_id primary key in user_t:

1. UUID (user_id)

  • Pros:
    • Globally Unique: Avoids collisions across hosts or when scaling beyond the current setup.
    • Security: Difficult to guess or enumerate.
    • Scalability: Well-suited for distributed environments like Citus.
  • Cons:
    • Storage: Slightly larger storage size compared to integers.
    • Readability: Not human-readable, which can be inconvenient for debugging.
  • Recommendation:
    This is generally the best option for a user_id in a multi-tenant, distributed environment.

2. Email (email)

  • Pros:
    • Human-Readable: Easy to identify and manage.
    • Login Identifier: Often used as a natural login credential.
  • Cons:
    • Uniqueness Challenges: Enforcing global uniqueness across all hosts may require complex constraints or application logic.
    • Changeability: If emails change, cascading updates can complicate the database.
    • Security: Using emails as primary keys can expose sensitive user data if not handled securely.
    • Performance: String comparisons are slower than those for integers or UUIDs.
  • Recommendation:
    Not recommended as a primary key, especially in a multi-tenant or distributed setup.

3. User-Chosen Unique ID (e.g., username)

  • Pros:
    • Human-Readable: Intuitive and user-friendly.
  • Cons:
    • Uniqueness Challenges: Enforcing global uniqueness is challenging and may require complex constraints.
    • Changeability: Users may request username changes, causing cascading update issues.
    • Security: Usernames are easier to guess or enumerate compared to UUIDs.
  • Recommendation:
    Not recommended as a primary key in a multi-tenant, distributed environment.

In Conclusion:

  • Use a User-Host Mapping Table:
    This is the best approach to handle users who belong to multiple hosts in a multi-tenant Citus environment.

  • Use UUID for user_id:
    UUIDs are the most suitable option for the user_id primary key in user_t due to their global uniqueness, security, and scalability.

  • Distribute by host_id:
    Distribute tables that need sharding by host_id, and ensure that foreign keys to distributed tables include host_id.

  • Use Reference Tables:
    For tables like user_t that don’t have a host_id, designate them as reference tables in Citus.

This approach provides a flexible and scalable foundation for managing users with multi-host access in your Citus-based multi-tenant application.

User Tables

Using a single user_t table with a user_type discriminator is a good approach for managing both employees and customers in a unified way. Adding optional referral relationships for customers adds a nice dimension as well. Here’s a suggested table schema in PostgreSQL, along with explanations and some considerations:

user_t (User Table): This table will store basic information common to both employees and customers.

CREATE TABLE user_t (
    user_id                   VARCHAR(24) NOT NULL,
    email                     VARCHAR(255) NOT NULL,
    password                  VARCHAR(1024) NOT NULL,
    language                  CHAR(2) NOT NULL,
    first_name                VARCHAR(32) NULL,
    last_name                 VARCHAR(32) NULL,
    user_type                 CHAR(1) NULL, -- E employee C customer or E employee P personal B business
    phone_number              VARCHAR(20) NULL,
    gender                    CHAR(1) NULL,
    birthday                  DATE NULL,
    country                   VARCHAR(3) NULL,
    province                  VARCHAR(32) NULL,
    city                      VARCHAR(32) NULL,
    address                   VARCHAR(128) NULL,
    post_code                 VARCHAR(16) NULL,
    verified                  BOOLEAN NOT NULL DEFAULT false,
    token                     VARCHAR(64) NULL,
    locked                    BOOLEAN NOT NULL DEFAULT false,
    nonce                     BIGINT NOT NULL DEFAULT 0,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

ALTER TABLE user_t ADD CONSTRAINT user_pk PRIMARY KEY ( user_id );

ALTER TABLE user_t ADD CONSTRAINT user_email_uk UNIQUE ( email );

user_host_t (User to host relationship or mapping):

CREATE TABLE user_host_t (
    host_id                   VARCHAR(24) NOT NULL,
    user_id                   VARCHAR(24) NOT NULL,
    -- other relationship-specific attributes (e.g., roles within the host)
    PRIMARY KEY (host_id, user_id),
    FOREIGN KEY (user_id) REFERENCES user_t (user_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id) REFERENCES host_t (host_id) ON DELETE CASCADE
);

employee_t (Employee Table): This table will store employee-specific attributes.

CREATE TABLE employee_t (
    host_id                   VARCHAR(22) NOT NULL,
    employee_id               VARCHAR(50) NOT NULL,  -- Employee ID or number or ACF2 ID. Unique within the host. 
    user_id                   VARCHAR(22) NOT NULL,
    title                     VARCHAR(255) NOT NULL,
    manager_id                VARCHAR(50), -- manager's employee_id if there is one.
    hire_date                 DATE,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, employee_id),
    FOREIGN KEY (host_id, user_id) REFERENCES user_host_t(host_id, user_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, manager_id) REFERENCES employee_t(host_id, employee_id) ON DELETE CASCADE
);

customer_t (Customer Table): This table will store customer-specific attributes.

CREATE TABLE customer_t (
    host_id                   VARCHAR(24) NOT NULL,
    customer_id               VARCHAR(50) NOT NULL,
    user_id                   VARCHAR(24) NOT NULL,
    -- Other customer-specific attributes
    referral_id               VARCHAR(22), -- the customer_id who refers this customer. 
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, customer_id),
    FOREIGN KEY (host_id, user_id) REFERENCES user_host_t(host_id, user_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, referral_id) REFERENCES customer_t(host_id, customer_id) ON DELETE CASCADE
);

position_t (Position Table): Defines different positions within the organization for employees.

CREATE TABLE position_t (
    host_id                   VARCHAR(22) NOT NULL,
    position_id               VARCHAR(22) NOT NULL,
    position_name             VARCHAR(255) UNIQUE NOT NULL,
    description               TEXT,
    inherit_to_ancestor       BOOLEAN DEFAULT FALSE,
    inherit_to_sibling        BOOLEAN DEFAULT FALSE,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, position_id)
);

user_position_t (Employee Position Table): Links employees to their positions with effective dates.

CREATE TABLE employee_position_t (
    host_id                   VARCHAR(22) NOT NULL,
    employee_id               VARCHAR(50) NOT NULL,
    position_id               VARCHAR(22) NOT NULL,
    position_type             CHAR(1) NOT NULL, -- P position of own, D inherited from a decendant, S inherited from a sibling.
    start_date                DATE NOT NULL,
    end_date                  DATE,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, employee_id, position_id),
    FOREIGN KEY (host_id, position_id) REFERENCES position_t(host_id, position_id) ON DELETE CASCADE
);

Authorization Strategies

In order to link users to API endpoints for authorization, we will adpot the following approaches with a rule engine to enforce the policies in the sidecar of the API with access-control middleware handler.

A. Role-Based Access Control (RBAC)

This is a common and relatively simple approach. You define roles (e.g., “admin,” “editor,” “viewer”) and assign permissions to those roles. Users are then assigned to one or more roles.

Role Table:

CREATE TABLE role_t (
    host_id                   VARCHAR(22) NOT NULL,
    role_id                   VARCHAR(22) NOT NULL,
    role_name                 VARCHAR(255) UNIQUE NOT NULL,
    description               TEXT,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, role_id)
);

Role-Endpoint Permission Table:

CREATE TABLE role_permission_t (
    host_id                   VARCHAR(32) NOT NULL,
    role_id                   VARCHAR(32) NOT NULL,
    endpoint_id               VARCHAR(64) NOT NULL,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, role_id, endpoint_id),
    FOREIGN KEY (host_id, role_id) REFERENCES role_t(host_id, role_id) ON DELETE CASCADE,
    FOREIGN KEY (endpoint_id) REFERENCES api_endpoint_t(endpoint_id) ON DELETE CASCADE
);

Role-User Assignment Table:

CREATE TABLE role_user_t (
    host_id                   VARCHAR(22) NOT NULL,
    role_id                   VARCHAR(22) NOT NULL,
    user_id                   VARCHAR(22) NOT NULL,
    start_date DATE NOT NULL DEFAULT CURRENT_DATE,
    end_date DATE,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, role_id, user_id, start_date),
    FOREIGN KEY (user_id) REFERENCES user_t(user_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, role_id) REFERENCES role_t(host_id, role_id) ON DELETE CASCADE
);

B. User-Based Access Control (UBAC)

This approach assigns permissions directly to users, allowing for very fine-grained control. It’s more flexible but can become complex to manage if you have a lot of users and endpoints. It should only be used for temporary access.

User-Endpoint Permissions Table:

CREATE TABLE user_permission_t (
    user_id                   VARCHAR(22) NOT NULL,
    host_id                   VARCHAR(22) NOT NULL,
    endpoint_id               VARCHAR(22) NOT NULL,
    start_date DATE NOT NULL DEFAULT CURRENT_DATE,
    end_date DATE,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (user_id, host_id, endpoint_id),
    FOREIGN KEY (user_id) REFERENCES user_t(user_id) ON DELETE CASCADE,
    FOREIGN KEY (endpoint_id) REFERENCES api_endpoint_t(endpoint_id) ON DELETE CASCADE
);

C. Group-Based Access Control (GBAC)

You can group users into teams or departments and assign permissions to those groups. This is useful when you want to manage permissions for sets of users with similar access needs.

Groups Table:

CREATE TABLE group_t (
    host_id                   VARCHAR(32) NOT NULL,
    group_id                  VARCHAR(32) NOT NULL,
    group_name                VARCHAR(255) UNIQUE NOT NULL,
    description               TEXT,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, group_id)
);

Group-Endpoint Permission Table:

CREATE TABLE group_permission_t (
    host_id                   VARCHAR(32) NOT NULL,
    group_id                  VARCHAR(32) NOT NULL,
    endpoint_id               VARCHAR(32) NOT NULL,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, group_id, endpoint_id),
    FOREIGN KEY (host_id, group_id) REFERENCES group_t(host_id, group_id) ON DELETE CASCADE,
    FOREIGN KEY (endpoint_id) REFERENCES api_endpoint_t(endpoint_id) ON DELETE CASCADE
);

Group-User Membership Table:

CREATE TABLE group_user_t (
    host_id                   VARCHAR(22) NOT NULL,
    group_id                  VARCHAR(22) NOT NULL,
    user_id                   VARCHAR(22) NOT NULL,
    start_date DATE NOT NULL DEFAULT CURRENT_DATE,
    end_date DATE,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, group_id, user_id, start_date),
    FOREIGN KEY (user_id) REFERENCES user_t(user_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, group_id) REFERENCES group_t(host_id, group_id) ON DELETE CASCADE
);

D. Attribute-Based Access Control (ABAC)

Attribute Table:

CREATE TABLE attribute_t (
    host_id                   VARCHAR(22) NOT NULL,
    attribute_id              VARCHAR(22) NOT NULL,
    attribute_name            VARCHAR(255) UNIQUE NOT NULL, -- The name of the attribute (e.g., "department," "job_title," "project," "clearance_level," "location").
    attribute_type            VARCHAR(50) CHECK (attribute_type IN ('string', 'integer', 'boolean', 'date', 'float', 'list')), -- Define allowed data types
    description               TEXT,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, attribute_id)
);

  1. Attribute User Table:
CREATE TABLE attribute_user_t (
    host_id                   VARCHAR(22) NOT NULL,
    attribute_id              VARCHAR(22) NOT NULL,
    user_id                   VARCHAR(22) NOT NULL, -- References users_t
    attribute_value           TEXT, -- Store values as strings; you can cast later
    start_date                DATE NOT NULL DEFAULT CURRENT_DATE,
    end_date                  DATE,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, attribute_id, user_id, start_date),
    FOREIGN KEY (user_id) REFERENCES user_t(user_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, attribute_id) REFERENCES attribute_t(host_id, attribute_id) ON DELETE CASCADE
);


  1. Attribute Permission Table:
CREATE TABLE attribute_permission_t (
    host_id                   VARCHAR(32) NOT NULL,
    attribute_id              VARCHAR(32) NOT NULL,
    endpoint_id               VARCHAR(32) NOT NULL, -- References api_endpoints_t
    attribute_value           TEXT,
    update_user               VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_timestamp          TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (host_id, attribute_id, endpoint_id),
    FOREIGN KEY (endpoint_id) REFERENCES api_endpoint_t(endpoint_id) ON DELETE CASCADE,
    FOREIGN KEY (host_id, attribute_id) REFERENCES attribute_t(host_id, attribute_id) ON DELETE CASCADE
);

How it Works:

  1. Define Attributes: Define all relevant attributes in attribute_t. Think about all the properties of your users, resources, and environment that might be used in access control decisions.

  2. Assign Attributes to Users: Populate attribute_user_t to associate attribute values with users.

  3. Assign Attributes to Endpoints: Populate attribute_permission_t to associate attribute values with API endpoints.

  4. Write Policies: Create policy rules in rule engine. These rules should use the attribute names defined in attribute_t.

  5. Policy Evaluation (at runtime):

  • The policy engine receives the subject (user), resource (API endpoint), and action (HTTP method) of the request.

  • The engine retrieves the relevant attributes from the user_attribute_t and attribute_permission_t tables.

  • The engine evaluates the policy rule from the relevant policies against the attributes.

  • Based on the policy evaluation result, access is either granted or denied.

Key Advantages of ABAC:

  • Fine-Grained Control: Express very specific access rules.

  • Centralized Policy Management: Policies are stored centrally and can be easily updated.

  • Flexibility and Scalability: Adapts easily to changing requirements.

  • Auditing and Compliance: Easier to audit and demonstrate compliance.

Format of attributes in JWT token:

Unlike roles, groups and positions that can be concatanated as a string, an attribut is a key/value pair. We need to format multiple attributes into a string and put it into a token.

Challenges

  • Spaces: The primary issue is that simple key-value pairs like key1:value1 key2:value2 will not work when value contain spaces.

  • Escaping: We need a way to escape characters that may confuse the parser, for example if the value also contains a :.

  • Readability: The format should be reasonably readable for debugging and human consumption.

  • Parsing: The format should be easy to parse on the application side.

Options

  1. Comma-Separated Key-Value Pairs with Escaping:
  • Format: key1=value1,key2=value2_with_spaces,key3=value3,with,commas

  • Escaping: Use backslash \ to escape commas and backslashes within the values. You can also escape spaces to make it more clear \

  • Pros: Simple to implement, relatively easy to parse using splitting by comma and then by =.

  • Cons: Can become hard to read with complex values, requires proper escaping, will become unreadable if \ need to be escaped.

  1. Custom Delimiter and Escaping:
  • Format: key1^=^value1~key2^=^value2 with spaces~key3^=^value3~

  • Delimiter: Use ^=^ as delimiter for key and value and use ~ for different attributes.

  • Pros: You can avoid many escaping issues and keep spaces, easier to read than comma separated values.

  • Cons: Need to choose delimiter carefully to make sure it is unique.

  1. URL-Encoded Key-Value Pairs:
  • Format: key1=value1&key2=value+with+spaces&key3=value3%2Cwith%2Ccommas

  • Pros: Well-established standard, handles spaces and special characters well.

  • Cons: Requires URL encoding and decoding, slightly more overhead, can be less readable.

  • Recommended Approach: Custom Delimiter with Simple Escaping

We recommend the Custom Delimiter with Simple Escaping approach for your use case. It’s a good balance between simplicity, readability, and the ability to handle spaces within values. It avoids the need to rely on complex URL encoding and also avoids the unreadability issue of using comma with backslash escaping.

JWT Security Claims

Using the tables defined above, follow these steps to create an authorization code token with user security claims:

  1. uid
    The entity_id (e.g., employee_id for employees and customer_id for customers) should be assigned to the uid claim in the JWT. This uid will be used by the response transformer to filter the response for the user and must represent a business identifier.

    Examples:

    • Employee: Use the ACF2 ID as the uid.
    • Customer: Use the CIF ID as the uid (e.g., in a banking context).
  2. role
    Include a list of roles associated with the user.

  3. grp
    Add a list of groups the user belongs to.

  4. att
    Include a list of key-value pairs representing user attributes.

  5. pos Include a list of positions for the user.

  6. host The host of the user.

Example Token

eyJraWQiOiJUal9sX3RJQlRnaW5PdFFiTDBQdjV3IiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJ1cm46Y29tOm5ldHdvcmtudDpvYXV0aDI6djEiLCJhdWQiOiJ1cm46Y29tLm5ldHdvcmtudCIsImV4cCI6MTczNDA2NDU5NSwianRpIjoicEs4WEtDZkU1aVFSdWdlQThJWXBwZyIsImlhdCI6MTczNDA2Mzk5NSwibmJmIjoxNzM0MDYzODc1LCJ2ZXIiOiIxLjAiLCJ1aWQiOiJzaDM1IiwidXR5IjoiRSIsImNpZCI6ImY3ZDQyMzQ4LWM2NDctNGVmYi1hNTJkLTRjNTc4NzQyMWU3MiIsImNzcmYiOiItTUN4OGhZRlF1bVZ3NFZkRDVHbEd3Iiwic2NwIjpbInBvcnRhbC5yIiwicG9ydGFsLnciLCJyZWYuciIsInJlZi53Il0sInJvbGUiOiJhZG1pbiB1c2VyIiwiYzEiOiIzNjEiLCJjMiI6IjY3IiwiZ3JwIjoiZGVsZXRlIGluc2VydCBzZWxlY3QgdXBkYXRlIiwiYXR0IjoiY291bnRyeV49XkNBTn5wZXJhbmVudCBlbXBsb3llZV49XnRydWV-c2VjdXJpdHlfY2xlYXJhbmNlX2xldmVsXj1eMiIsInBvcyI6IkFQSVBsYXRmb3JtRGVsaXZlcnkiLCJob3N0IjoiTjJDTXcwSEdRWGVMdkMxd0JmbG4yQSJ9.Gky_rR9hreP04GZm-0H_HBBAeDIPhQ9tsNuZclUzTdkMrYay40kcNk4jWkPdMcxfIfIbGj2eqSQgNhkBuym2yc6HsRF0nukZhYSGklVNXFe3R-0DdKwxxWyqvXyWDvrQtme0ttT2tYGTRRCZXnHDRMUFeDSz7kVjjIj3WymjFyxWBnWnBOjYqDL34652Fb8c7hWME0nSxbWO0ZvPRDhRM-l0nDGNm2ojq-3sjaU_pRywYahXP-wtnNSLwvctFgONPWSM9Ie6FqwRmYBFVo8OE0VdTRvUfnO4mL1O2UbTfxzbNJFv4HP1mSZG_SSB5j3t_RuZLfUMIajFi105ze2PUg

And the payload:

{
  "iss": "urn:com:networknt:oauth2:v1",
  "aud": "urn:com.networknt",
  "exp": 1734064595,
  "jti": "pK8XKCfE5iQRugeA8IYppg",
  "iat": 1734063995,
  "nbf": 1734063875,
  "ver": "1.0",
  "uid": "sh35",
  "uty": "E",
  "cid": "f7d42348-c647-4efb-a52d-4c5787421e72",
  "csrf": "-MCx8hYFQumVw4VdD5GlGw",
  "scp": [
    "portal.r",
    "portal.w"
  ],
  "role": "admin user",
  "c1": "361",
  "c2": "67",
  "grp": "delete insert select update",
  "att": "country^=^CAN~peranent employee^=^true~security_clearance_level^=^2",
  "pos": "APIPlatformDelivery",
  "host": "N2CMw0HGQXeLvC1wBfln2A"
}

Group and Position Management

You can create groups that align with teams, departments, or other organizational units. These groups are relatively static and reflect the overall organizational structure. Use a separate table, group_t, as described earlier, to store these groups. Groups can be applied to all users regardless of their user type.

Use the Employee Reporting Structure to Manage Positions

Positions are similar to groups in managing user permissions, but they leverage the organizational reporting structure to propagate permissions between team members and their direct manager.

  • Position Flags

    Each position in the position_t table has two flags:

  • inherit_to_ancestor: Determines if the position is inherited by a subordinate.
  • inherit_to_sibling: Determines if the position is inherited by team members (siblings) under the same manager.
  • Responsibilities

    The application is responsible for propagating positions:

  • Between Siblings: Assigning inherited positions to team members under the same manager.
  • To the Manager: Assigning inherited positions to the direct manager.
  • User Interface for Position Management

    A user interface (UI) can be implemented to simplify position management:

  • Feature: List all potential inherited positions for selection when adding a new user or changing a manager.
  • Functionality: Allow administrators to choose specific positions to inherit for users and managers dynamically.

Use Both Groups and Positions

You can choose to use both groups and positions for your organization. However, you need to ensure that groups and positions categorize users across different dimensions. In general, groups should be used for customers, while positions should be used for employees.

User Login Query

Here is the query to run against the database tables upon a user login request:

SELECT
    u.user_id,
    u.user_type,
    CASE
        WHEN u.user_type = 'E' THEN e.employee_id
        WHEN u.user_type = 'C' THEN c.customer_id
        ELSE NULL
    END AS entity_id,
    CASE WHEN u.user_type = 'E' THEN string_agg(DISTINCT p.position_name, ' ' ORDER BY p.position_name) ELSE NULL END AS positions,
    string_agg(DISTINCT r.role_name, ' ' ORDER BY r.role_name) AS roles,
    string_agg(DISTINCT g.group_name, ' ' ORDER BY g.group_name) AS groups,
     CASE
        WHEN COUNT(DISTINCT at.attribute_name || '^=^' || aut.attribute_value) > 0 THEN string_agg(DISTINCT at.attribute_name || '^=^' || aut.attribute_value, '~' ORDER BY at.attribute_name || '^=^' || aut.attribute_value)
        ELSE NULL
    END AS attributes
FROM
    user_t AS u
LEFT JOIN
    user_host_t AS uh ON u.user_id = uh.user_id
LEFT JOIN
    role_user_t AS ru ON u.user_id = ru.user_id
LEFT JOIN
    role_t AS r ON ru.host_id = r.host_id AND ru.role_id = r.role_id
LEFT JOIN
    attribute_user_t AS aut ON u.user_id = aut.user_id
LEFT JOIN
    attribute_t AS at ON aut.host_id = at.host_id AND aut.attribute_id = at.attribute_id
LEFT JOIN
    group_user_t AS gu ON u.user_id = gu.user_id
LEFT JOIN
    group_t AS g ON gu.host_id = g.host_id AND gu.group_id = g.group_id
LEFT JOIN
    employee_t AS e ON uh.host_id = e.host_id AND u.user_id = e.user_id
LEFT JOIN
    customer_t AS c ON uh.host_id = c.host_id AND u.user_id = c.user_id
LEFT JOIN
    employee_position_t AS ep ON e.host_id = ep.host_id AND e.employee_id = ep.employee_id
LEFT JOIN
    position_t AS p ON ep.host_id = p.host_id AND ep.position_id = p.position_id
WHERE
    u.email = '[email protected]'
GROUP BY
    u.user_id, u.user_type, e.employee_id, c.customer_id;

And here is an example result from the test database:

utgdG50vRVOX3mL1Kf83aA  E   sh35    APIPlatformDelivery admin user  delete insert select update country^=^CAN~peranent employee^=^true~security_clearance_level^=^2

Parse Attribute String

The query above returns attributes in a customized format. These attributes can be parsed using the Util.parseAttributes method available in the light-4j utility module

Portal View and Default Role

Given the flexibility of fine-grained authorization approaches, users can choose one or more methods to suit their business requirements. However, in scenarios where RBAC (Role-Based Access Control) is not utilized, the role claim may not exist in the custom claims of the JWT token.

Handling Missing role in JWT

For the portal-view application, at least one role is required to filter menu items. To address cases where no roles are present in the JWT:

  1. Default Role Assignment:
    If the role claim is absent in the JWT, the system will:

    • Assign a default role, "user", to ensure compatibility.
    • Include this role in a roles field in the browser cookie.
  2. Cookie Roles Field:

    • The roles field in the cookie will contain a single role: "user".
    • This ensures the portal-view can still function as expected by displaying the appropriate menu items for users.

Example Workflow

  1. A user authenticates, and their JWT is generated without a role claim.
  2. During authentication handling:
    • The StatelessAuthHandler checks for the presence of the role claim.
    • If no roles are found, the "user" role is added to the roles field in the cookie.
  3. The portal-view reads the roles field from the cookie to filter menu items appropriately.

This approach provides a seamless experience while maintaining compatibility with applications requiring roles for authorization or UI customization.

Private Messages

Problem

Portal users need a way to exchange private messages from the user profile without exposing email addresses to each other. The sender should only need a recipient user id or a display-safe user label. The backend can resolve email internally when it needs to send an external notification, but email must not be part of the user-facing message contract.

Current State

The current codebase already has a partial private-message skeleton:

  • user-command exposes lightapi.net/user/sendMessage/0.1.0.
  • The sendMessage request contains userId, subject, and content.
  • light-portal defines PrivateMessageSentEvent.
  • portal-db defines message_t.
  • portal-view has a mail menu, a private messages page, and a privateMessage form.
  • user-query exposes lightapi.net/user/getPrivateMessage/0.1.0.

The current implementation is not complete enough to support production use:

  • GetPrivateMessage has its real implementation commented out and currently returns null.
  • SendMessage resolves the recipient through queryUserById, then stores the whole response as toEmail. That lookup currently returns too much user data, including email and sensitive fields that should not be exposed through a peer messaging flow.
  • SendMessage does not put fromId into event data, but the projection code reads fromId from event data.
  • The message_t table now has host_id NOT NULL, but the projection insert does not write host_id.
  • The table is inbox-style storage, keyed by sender and nonce, and does not model conversations, read state, participant visibility, or per-user delete.
  • The UI mostly relies on the mail menu response and navigation state. The messages page should load its own data from the query API.
  • The existing private-message tests are disabled stubs.

Goals

  • Let one logged-in user send a message to another portal user without knowing or seeing the recipient email.
  • Keep the message model host-scoped so tenant boundaries are explicit.
  • Derive sender identity from the authorization token, not from form input.
  • Store user ids in message records and events. Do not store recipient email in the message projection unless a short migration bridge requires it.
  • Support an inbox page, unread badge, conversation view, reply, read state, and per-user hide/delete.
  • Keep email notification as an optional side effect that resolves the recipient email internally.
  • Provide a path from the existing message_t skeleton to a conversation-based model without breaking existing UI routes immediately.

Non-Goals

  • Do not build group chat in the first phase.
  • Do not expose email addresses in message APIs, events, UI state, or task context.
  • Do not use private messages as an audit or support-ticket system.
  • Do not implement WebSocket or SSE push in the first phase. Polling is enough until the read/write model is stable.
  • Do not make public user lookup broader as part of this feature.

Privacy Rules

Private messages should be user-id based at every external boundary.

The UI may show:

  • Display name.
  • Avatar or initials.
  • User id when no better label exists.
  • Message subject, preview, content, and timestamps.

The UI must not show:

  • Sender email.
  • Recipient email.
  • Password, token, nonce, or other profile internals from user_t.

The backend may resolve recipient email only inside trusted server code for external email notification. That internal lookup should return the minimum fields required, ideally user_id, email, current host membership, and a display label.

For a chat-like experience, introduce conversation identity instead of treating each message as an isolated inbox row.

CREATE TABLE private_conversation_t (
    host_id              UUID NOT NULL,
    conversation_id      UUID NOT NULL,
    participant_low_id   UUID NOT NULL,
    participant_high_id  UUID NOT NULL,
    created_ts           TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_message_id      UUID NULL,
    last_message_ts      TIMESTAMP WITH TIME ZONE NULL,
    PRIMARY KEY (host_id, conversation_id),
    UNIQUE (host_id, participant_low_id, participant_high_id),
    FOREIGN KEY (host_id) REFERENCES host_t(host_id) ON DELETE CASCADE
);

participant_low_id and participant_high_id are the two sorted user ids. This gives each pair of users one stable conversation per host without relying on email.

CREATE TABLE private_message_t (
    host_id          UUID NOT NULL,
    message_id       UUID NOT NULL,
    conversation_id  UUID NOT NULL,
    from_user_id     UUID NOT NULL,
    to_user_id       UUID NOT NULL,
    subject          VARCHAR(256) NULL,
    content          TEXT NOT NULL,
    send_ts          TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY (host_id, message_id),
    FOREIGN KEY (host_id, conversation_id)
        REFERENCES private_conversation_t(host_id, conversation_id)
        ON DELETE CASCADE
);
CREATE TABLE private_message_state_t (
    host_id      UUID NOT NULL,
    message_id   UUID NOT NULL,
    user_id      UUID NOT NULL,
    read_ts      TIMESTAMP WITH TIME ZONE NULL,
    deleted_ts   TIMESTAMP WITH TIME ZONE NULL,
    PRIMARY KEY (host_id, message_id, user_id),
    FOREIGN KEY (host_id, message_id)
        REFERENCES private_message_t(host_id, message_id)
        ON DELETE CASCADE
);

The state table keeps read and delete behavior per participant. A user deleting a message should hide it from that user only. It should not erase the other participant’s copy.

Recommended indexes:

CREATE INDEX idx_private_conversation_last_message
    ON private_conversation_t (host_id, participant_low_id, participant_high_id, last_message_ts DESC);

CREATE INDEX idx_private_message_conversation_ts
    ON private_message_t (host_id, conversation_id, send_ts DESC);

CREATE INDEX idx_private_message_to_user_ts
    ON private_message_t (host_id, to_user_id, send_ts DESC);

CREATE INDEX idx_private_message_state_unread
    ON private_message_state_t (host_id, user_id)
    WHERE read_ts IS NULL AND deleted_ts IS NULL;

If the first implementation needs to reuse message_t, treat it as a migration bridge only. Add from_user_id, to_user_id, message_id, read_ts, and per-user delete columns, then migrate to the conversation tables once the API contract is stable.

Event Model

Keep the event-driven command/query pattern. A message send should create a CloudEvent and the query-side projection should update the private-message tables.

Recommended event data:

{
  "hostId": "019...",
  "conversationId": "019...",
  "messageId": "019...",
  "fromUserId": "019...",
  "toUserId": "019...",
  "subject": "Question about the API",
  "content": "Can you take a look at this?"
}

fromUserId and hostId are derived from the token. toUserId, subject, and content come from validated request data. conversationId can be generated by the command side after looking up or creating the pair conversation, or it can be derived during projection from the participant pair.

Do not put toEmail into PrivateMessageSentEvent. Email notification should be a separate trusted server-side action.

API Contracts

Send Message

Keep the existing sendMessage action name for compatibility, but change the contract to be user-id based.

{
  "toUserId": "019...",
  "conversationId": "019...",
  "subject": "Question about the API",
  "content": "Can you take a look at this?"
}

conversationId is optional. If absent, the backend resolves or creates the conversation for the current user and toUserId.

Server responsibilities:

  • Require an authorization-code token.
  • Derive fromUserId from the token.
  • Derive hostId from the active user host.
  • Validate that toUserId belongs to the same host.
  • Reject empty content and enforce size limits.
  • Optionally reject self-messages unless a product decision allows notes to self.
  • Write the event through the existing command event-store path.
  • Send optional external email notification after the command is accepted.

Conversation List

Add or evolve a query endpoint for the inbox list.

{
  "offset": 0,
  "limit": 25
}

The backend derives hostId and userId from the token. The response should include only conversations involving the current user.

{
  "total": 1,
  "conversations": [
    {
      "conversationId": "019...",
      "otherUserId": "019...",
      "otherUserLabel": "Jane Smith",
      "lastMessageTs": "2026-05-08T13:30:00Z",
      "lastMessagePreview": "Can you take a look at this?",
      "unreadCount": 2
    }
  ]
}

Conversation Messages

{
  "conversationId": "019...",
  "offset": 0,
  "limit": 50
}

The backend validates that the current user is one of the participants.

{
  "conversationId": "019...",
  "messages": [
    {
      "messageId": "019...",
      "fromUserId": "019...",
      "fromUserLabel": "Jane Smith",
      "subject": "Question about the API",
      "content": "Can you take a look at this?",
      "sendTs": "2026-05-08T13:30:00Z",
      "read": false
    }
  ]
}

Unread Count

The mail badge should call a count endpoint instead of loading all messages.

{
  "count": 3
}

Mark Read and Delete

markPrivateConversationRead should mark unread rows in private_message_state_t for the current user and conversation.

deletePrivateMessage or hidePrivateConversation should set deleted_ts for the current user only.

Operational Cleanup

Private messages are user content, not operational status rows. They should not be hard-deleted only because they are old while either participant can still see them.

The operational cleanup job may purge active private-message rows only when all participant state rows for the message have deleted_ts set and the latest deleted_ts is older than privateMessageRetentionDays.

Cleanup responsibilities:

  • Select purge candidates from private_message_t joined to private_message_state_t.
  • Require every participant state row for the message to have deleted_ts set.
  • Use MAX(deleted_ts) as the retention clock so the grace period starts after the last participant deletes the message.
  • Delete private_message_state_t rows first, then delete the private_message_t row in the same transaction.
  • Leave private_conversation_t rows in place so the participant pair keeps a stable conversation identity if a new message is sent later.
  • Skip private-message cleanup when privateMessageRetentionDays is less than or equal to zero.

The cleanup job should not purge visible messages, partially deleted messages, or recently deleted-by-all messages. A separate maximum retention policy for undeleted private messages would need an explicit product/security decision.

Authorization

The command and query handlers must not trust user ids supplied by the client for the current user. The current user is always the token subject.

Rules:

  • A sender can send only as themself.
  • A user can read only conversations where they are a participant.
  • A user can mark read or delete only their own state rows.
  • Admin visibility should be a separate explicit support/admin endpoint if it is needed later.
  • Cross-host messaging should be rejected in the first phase. If cross-host messaging is later needed, the contract must model the recipient host explicitly and pass a product/security review.

Portal View

Use the current profile surfaces but make them data-driven:

  • MailMenu should poll unread count and show a small list of recent conversations only after the menu opens.
  • /app/messages should fetch conversation data directly. It should not depend on location.state from MailMenu.
  • The privateMessage form should use toUserId, not userId, to avoid confusing recipient identity with the current user.
  • Reply should prefill toUserId and optionally conversationId.
  • User-facing labels should come from a display-safe user label endpoint.
  • Empty inbox, loading, and error states should be explicit.

The first UI can be an inbox plus conversation thread. Real-time typing, presence, attachments, and rich-text editing are later enhancements.

Migration Plan

Phase 0: Stop the Broken Behavior

  • Make GetPrivateMessage return valid JSON even before the new model is complete.
  • Fix the existing projection insert to include host_id if message_t remains in use.
  • Ensure SendMessage stores sender identity from the token.
  • Stop using broad queryUserById output as a recipient email value.

Phase 1: User-ID Based Backend

  • Add the conversation/message/state tables.
  • Update PrivateMessageSentEvent to use fromUserId and toUserId.
  • Add a trusted recipient resolver that returns only internal fields needed for validation and optional email notification.
  • Implement conversation list, conversation messages, unread count, mark-read, and hide/delete APIs.

Phase 2: Portal View

  • Update the mail badge to use unread count.
  • Update /app/messages to load data directly.
  • Update the privateMessage form and reply paths to use toUserId.
  • Remove email assumptions from task context and UI state.

Phase 3: Cleanup

  • Remove to_email from the active private-message path.
  • Remove disabled private-message tests and replace them with focused coverage.
  • Ensure operational cleanup targets the active private-message tables and purges only messages deleted by all participants after the retention window.
  • Add optional push delivery later if polling becomes insufficient.

Testing

Backend tests should cover:

  • Sender is derived from token and cannot be spoofed.
  • Recipient must belong to the current host.
  • Message event contains user ids, not emails.
  • Projection writes host-scoped conversation and message rows.
  • Inbox query returns only conversations for the current user.
  • Conversation query rejects non-participants.
  • Unread count increments for the recipient and clears after mark-read.
  • Delete/hide affects only the current user’s state.
  • Operational cleanup purges only messages deleted by all participants after retention and keeps visible, partially deleted, and recently deleted messages.

Frontend tests should cover:

  • Mail menu shows unread count without loading full inbox.
  • Messages page fetches its own data.
  • Reply pre-populates recipient context without email.
  • Empty and error states do not produce JSON parse failures.

Open Questions

  • Should users be able to send messages to themselves as private notes?
  • Should profile pages expose a “Message” action only for users in the same host, or should some cross-host flows be allowed?
  • Should email notification include the sender display label, or only say that a portal message was received?
  • Should any maximum retention policy apply to undeleted private messages?
  • Should administrators have a separate support/audit view, and under what permission?

Config Server

Default Config Properties

For each config class in light-4j modules, we use annotations to generate schemas for the config files with default values, comments and validation rules.

As one time step, we also generate events to input all the properties into the light-portal. These events will create a base-line of the config properties with default values. All events in this first time population doesn’t have a version.

For each version release, we will create and attach an event.json file with the change to the properties. Most likely, we will add some properties with default values for each release. All events in the is file will have a version associated. Once played on the portal, updates for the version will be populated.

On the portal ui, we load all properties and default values from database with a union of the base-line properties and all versions below and equal to the current version.

Instance Config Snapshot

Once a logical instance is created on the light-portal, we need to provide the product_version_id which will map to a specific product version. We also need to provide runtime configuration and deployment configuration for the instance to start the server and deploy it to a target environment. During the configuration updates, it might be a process of discovery and may take several revisit to complete. If a user makes a mistake, he/she might want to rollback the previous changes to a snapshot version to start it over again. During the deployment, we also need to save and tag the snapshot version so that we can rollback to the previous deployment configuration snapshot in case of deployment failure.

The above requirements force us to create a table that is record all the commit for the config updates at instance level. It is like a GitHub commit to group several updates together. The user needs to explicitly click the commit button on the UI to allow the server to run the query to populate the snapshot table to create a new snapshot id.

Durng the deployment, the deployment serivce will invoke the config server to force a commit and also link that commit to a deployment id just like a tag in GitHub.

To meet the requirement above, we need to design tables to store immutable snapshots associated with a commitId/snapshotId to proivde reliable rollback points.

Snapshot tables

CREATE TABLE config_snapshot_t (
    snapshot_id                 UUID NOT NULL, -- Primary Key, maybe UUIDv7 for time ordering
    snapshot_ts                 TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    snapshot_type               VARCHAR(32) NOT NULL, -- e.g., 'DEPLOYMENT', 'USER_SAVE', 'SCHEDULED_BACKUP'
    description                 TEXT,                 -- User-provided description or system-generated info
    user_id                     UUID,                 -- User who triggered it (if applicable)
    deployment_id               UUID,                 -- FK to deployment_t if snapshot_type is 'DEPLOYMENT'
    -- Scope columns define WHAT this snapshot represents:
    scope_host_id               UUID NOT NULL,      -- Host context (always needed)
    scope_config_phase          CHAR(1) NOT NULL,   -- config phase context(required)
    scope_environment           VARCHAR(16),        -- Environment context (if snapshot is env-specific)
    scope_product_id            VARCHAR(8)          -- Product id context
    scope_product_version       VARCHAR(12)         -- Product version context
    scope_service_id            VARCHAR(512)        -- Service id context
    scope_api_id                VARCHAR(16)         -- Api id context
    scope_api_version           VARCHAR(16)         -- Api version context
    PRIMARY KEY(snapshot_id),
    FOREIGN KEY(deployment_id) REFERENCES deployment_t(deployment_id) ON DELETE SET NULL,
    FOREIGN KEY(user_id) REFERENCES user_t(user_id) ON DELETE SET NULL,
    FOREIGN KEY(scope_host_id) REFERENCES host_t(host_id) ON DELETE CASCADE
);

-- Index for finding snapshots by type or scope
CREATE INDEX idx_config_snapshot_scope ON config_snapshot_t (scope_host_id, scope_config_phase, scope_environment, 
    scope_product_id, scope_product_version, scope_service_id, scope_api_id, scope_api_version, snapshot_type, snapshot_ts);
CREATE INDEX idx_config_snapshot_deployment ON config_snapshot_t (deployment_id);


CREATE TABLE config_snapshot_property_t (
    snapshot_property_id        UUID NOT NULL,         -- Surrogate primary key for easier referencing/updates if needed
    snapshot_id                 UUID NOT NULL,         -- FK to config_snapshot_t
    config_id                   UUID NOT NULL,         -- The config id
    property_id                 UUID NOT NULL,         -- The final property id 
    property_name               VARCHAR(64) NOT NULL,  -- The final property name
    property_type               VARCHAR(32) NOT NULL,  -- The property type
    property_value              TEXT,                  -- The effective property value at snapshot time
    value_type                  VARCHAR(32),           -- Optional: Store the type (string, int, bool...) for easier parsing later
    source_level                VARCHAR(32),           -- e.g., 'instance', 'product_version', 'environment', 'default'
    PRIMARY KEY(snapshot_property_id),
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);

-- Unique constraint to ensure one value per key within a snapshot
ALTER TABLE config_snapshot_property_t
    ADD CONSTRAINT config_snapshot_property_uk UNIQUE (snapshot_id, config_id, property_id);

-- Index for quickly retrieving all properties for a snapshot
CREATE INDEX idx_config_snapshot_property_snapid ON config_snapshot_property_t (snapshot_id);


-- Snapshot of Instance API Overrides
CREATE TABLE snapshot_instance_api_property_t (
    snapshot_id         UUID NOT NULL,
    host_id             UUID NOT NULL,
    instance_api_id     UUID NOT NULL,
    property_id         UUID NOT NULL,
    property_value      TEXT,
    update_user         VARCHAR (255) NOT NULL,
    update_ts           TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY(snapshot_id, host_id, instance_api_id, property_id), -- Composite PK matches original structure + snapshot_id
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);
CREATE INDEX idx_snap_iapi_prop ON snapshot_instance_api_property_t (snapshot_id);


-- Snapshot of Instance App Overrides
CREATE TABLE snapshot_instance_app_property_t (
    snapshot_id         UUID NOT NULL,
    host_id             UUID NOT NULL,
    instance_app_id     UUID NOT NULL,
    property_id         UUID NOT NULL,
    property_value      TEXT,
    update_user         VARCHAR (255) NOT NULL,
    update_ts           TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY(snapshot_id, host_id, instance_app_id, property_id),
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);
CREATE INDEX idx_snap_iapp_prop ON snapshot_instance_app_property_t (snapshot_id);

-- Snapshot of Instance App API Overrides
CREATE TABLE snapshot_instance_app_api_property_t (
    snapshot_id         UUID NOT NULL,
    host_id             UUID NOT NULL,
    instance_app_id     UUID NOT NULL,
    instance_api_id     UUID NOT NULL,
    property_id         UUID NOT NULL,
    property_value      TEXT,
    update_user         VARCHAR (255) NOT NULL,
    update_ts           TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY(snapshot_id, host_id, instance_app_id, instance_api_id, property_id),
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);
CREATE INDEX idx_snap_iaappi_prop ON snapshot_instance_app_api_property_t (snapshot_id);


-- Snapshot of Instance Overrides
CREATE TABLE snapshot_instance_property_t (
    snapshot_id         UUID NOT NULL,
    host_id             UUID NOT NULL,
    instance_id         UUID NOT NULL,
    property_id         UUID NOT NULL,
    property_value      TEXT,
    update_user         VARCHAR (255) NOT NULL,
    update_ts           TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY(snapshot_id, host_id, instance_id, property_id),
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);
CREATE INDEX idx_snap_inst_prop ON snapshot_instance_property_t (snapshot_id);


-- Snapshot of Environment Overrides (If needed for rollback)
CREATE TABLE snapshot_environment_property_t (
    snapshot_id         UUID NOT NULL,
    host_id             UUID NOT NULL,
    environment         VARCHAR(16) NOT NULL,
    property_id         UUID NOT NULL,
    property_value      TEXT,
    update_user         VARCHAR (255) NOT NULL,
    update_ts           TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY(snapshot_id, host_id, environment, property_id),
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);
CREATE INDEX idx_snap_env_prop ON snapshot_environment_property_t (snapshot_id);

CREATE TABLE snapshot_product_property_t (
    snapshot_id         UUID NOT NULL,
    product_id          VARCHAR(8) NOT NULL,
    property_id         UUID NOT NULL,
    property_value      TEXT,
    update_user         VARCHAR (255) NOT NULL,
    update_ts           TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY(snapshot_id, product_id, property_id),
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);
CREATE INDEX idx_snap_prd_prop ON snapshot_product_property_t (snapshot_id);

CREATE TABLE snapshot_product_version_property_t (
    snapshot_id         UUID NOT NULL,
    host_id             UUID NOT NULL,
    product_version_id  UUID NOT NULL,
    property_id         UUID NOT NULL,
    property_value      TEXT,
    update_user         VARCHAR (255) NOT NULL,
    update_ts           TIMESTAMP WITH TIME ZONE NOT NULL,
    PRIMARY KEY(snapshot_id, host_id, product_version_id, property_id),
    FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
);
CREATE INDEX idx_snap_pv_prop ON snapshot_product_version_property_t (snapshot_id);

How to generate rollback events

There are two options to generate rollback events or compensate events.

Option 1. With historical events.

  1. Identify Target State: You have a snapshot_id representing the desired historical state.
  2. Find Snapshot Timestamp: Get the snapshot_ts from config_snapshot_t for the target snapshot_id.
  3. Query Events: Find all configuration events in your event store that:
    • Occurred after the snapshot_ts.
    • Relate to the specific scope (host, instance, environment, etc.) being rolled back.
  4. Generate Compensating Events: For each event found in step 3, create its logical inverse (a “compensating event”). For example:
    • InstancePropertyUpdated { propertyId: X, newValue: B, oldValue: A } -> InstancePropertyUpdated { propertyId: X, newValue: A, oldValue: B } (Requires storing oldValue in the original event).
    • InstancePropertyCreated { propertyId: X, value: A } -> InstancePropertyDeleted { propertyId: X, value: A } (Requires storing the value in the delete event for potential future rollback).
    • InstancePropertyDeleted { propertyId: X, value: A } -> InstancePropertyCreated { propertyId: X, value: A } (Requires storing the value in the delete event).
  5. Order Compensating Events: Sort the generated compensating events in the reverse chronological order of the original events they are compensating for.
  6. Replay Compensating Events: Apply these ordered compensating events through your event handling system.

Conceptually, this is a valid approach often used in event sourcing patterns (related to compensating transactions). However, it comes with significant challenges and complexities:

Challenges & Considerations:

  1. Generating Perfect Inverse Events: This is the hardest part.
    • Requires Rich Events: Your original events must contain enough information to construct their inverse. For updates, you need the oldValue. For creations, the delete needs the key. For deletions, the create needs the deleted value. If your current events don’t store this, you cannot reliably generate compensating events this way.
    • Complexity: For multi-step or complex operations, determining the exact inverse sequence can be non-trivial.
  2. Order of Operations: Compensating events MUST be applied in strict reverse order. Getting this wrong can lead to incorrect states.
  3. State Dependencies: Event handlers sometimes make assumptions about the state before the event is applied. Replaying compensating events might encounter unexpected states if other unrelated changes have occurred or if the reverse logic isn’t perfect, potentially causing handler errors.
  4. Performance: Querying potentially thousands of events, generating inverses, and replaying them might be slow, especially if the time gap between the snapshot and the present is large.
  5. Snapshot Data Not Used: This approach doesn’t directly leverage the known good state stored in config_snapshot_property_t. It relies solely on the ability to perfectly reverse subsequent events.
  6. Idempotency: Compensating event handlers should ideally be idempotent (applying them multiple times has the same effect as applying them once), although this is hard to guarantee for inverse operations.

Option 2: Diff-based event generation.

  1. Get Target State: Fetch key-values from config_snapshot_property_t for snapshot_id. (TargetState)
  2. Get Current State: Run aggregation query for the current configuration. (CurrentState)
  3. Calculate Diff: Find differences between TargetState and CurrentState.
  4. Generate Corrective Events: Create events to transform CurrentState into TargetState.
    • If key is in TargetState but different/missing in CurrentState -> Generate Upsert[Level]Property event with the value from TargetState (applied at the highest relevant override level for the scope).
    • If key is in CurrentState but missing in TargetState -> Generate Delete[Level]Property event for the override that’s currently providing the value (likely the highest relevant override level).
  5. Apply Events: Apply these corrective events.

Why the Diff-Based Approach is Often Preferred for Snapshot Rollback:

  • Uses Known Good State: It directly uses the guaranteed state from the snapshot table.
  • Less Reliant on Event Reversibility: It doesn’t matter if the original events are perfectly reversible or store old values. It focuses on achieving the target state from the current state.
  • Potentially Fewer Events: Might generate fewer events than reversing a long history, focusing only on the net changes needed.
  • More Direct: The generated events directly aim to establish the target state, which can feel less fragile than relying on reversing history.

Conclusion:

While method of reversing events since the snapshot is a recognized event sourcing pattern, it’s often more complex and potentially fragile for the specific task of rolling back to a known snapshot state compared to the diff-based corrective event generation method.

The diff-based method leverages the snapshot data directly and focuses on achieving the target state, making it generally more robust and often easier to implement correctly, as it doesn’t require perfectly reversible events.

How to create the snapshot

Let’s clarify how the scope_* columns in config_snapshot_t relate to the query that generates the snapshot and the override tables (*_property_t).

The Purpose of scope_* Columns:

The scope_* columns in config_snapshot_t serve one primary purpose: To record the specific context for which the snapshot was generated. They define what set of effective configuration values are stored in the associated config_snapshot_property_t rows.

Think of them as the input parameters that were used to run the aggregation query when the snapshot was created.

How They Are Used in the Snapshot Generation Query:

You do not need one scope_* column for every *_property_t table. Instead, the values you store in the scope_* columns are the parameters you pass into your aggregation query’s WHERE clauses to filter the rows from the relevant override tables according to the desired context.

Let’s refine the query strategy using the scope_* concept and aim for a more efficient query than repeated NOT EXISTS clauses (using ROW_NUMBER() or DISTINCT ON).

Example Scenario: Snapshotting for a specific Instance

Let’s say you want to create a snapshot for a specific instance_id on a specific host_id.

  1. Input Parameters:

    • p_host_id (UUID)
    • p_instance_id (UUID)
  2. Derive Related IDs (Inside your snapshot creation logic/service):

    • You’ll need to query instance_t to get the associated product_version_id, environment, etc., for this instance.
    • Query product_version_t to get product_id.
    • Let’s call these derived values v_product_version_id, v_environment, v_product_id.
  3. config_snapshot_t Record:

    • Generate a snapshot_id (e.g., UUIDv7).
    • snapshot_ts: CURRENT_TIMESTAMP
    • snapshot_type: e.g., ‘DEPLOYMENT’
    • scope_host_id: p_host_id
    • scope_instance_id: p_instance_id
    • scope_environment: v_environment (Store the derived environment for clarity, even though it came from the instance)
    • scope_product_version_id: v_product_version_id (Store for clarity)
    • scope_product_id: v_product_id (Store for clarity)
    • (Other scope_* columns like scope_instance_api_id would be NULL for this instance-level snapshot)
  4. Aggregation Query (Using ROW_NUMBER()): This query uses the input parameters (p_host_id, p_instance_id) and the derived values (v_product_version_id, v_environment, v_product_id) to find the highest priority value for each property_id.

WITH – Parameters derived before running this query: – p_host_id UUID – p_instance_id UUID – v_product_version_id UUID (derived from p_instance_id) – v_environment VARCHAR(16) (derived from p_instance_id) – v_product_id VARCHAR(8) (derived from v_product_version_id)

– Find relevant instance_api_ids and instance_app_ids for the target instance RelevantInstanceApis AS ( SELECT instance_api_id FROM instance_api_t WHERE host_id = ? – p_host_id AND instance_id = ? – p_instance_id ), RelevantInstanceApps AS ( SELECT instance_app_id FROM instance_app_t WHERE host_id = ? – p_host_id AND instance_id = ? – p_instance_id ),

– Pre-process Instance App API properties with merging logic Merged_Instance_App_Api_Properties AS ( SELECT iaap.property_id, CASE cp.value_type WHEN ‘map’ THEN COALESCE(jsonb_merge_agg(iaap.property_value::jsonb), ‘{}’::jsonb)::text WHEN ‘list’ THEN COALESCE((SELECT jsonb_agg(elem ORDER BY iaa.update_ts) – Order elements based on when they were added via the link table? Or property update_ts? Assuming property update_ts. Check data model if linking time matters more. FROM jsonb_array_elements(sub.property_value::jsonb) elem WHERE jsonb_typeof(sub.property_value::jsonb) = ‘array’ ), ‘[]’::jsonb)::text – Requires subquery if ordering elements – Subquery approach for ordering list elements by property timestamp: /* COALESCE( (SELECT jsonb_agg(elem ORDER BY prop.update_ts) FROM instance_app_api_property_t prop, jsonb_array_elements(prop.property_value::jsonb) elem WHERE prop.host_id = iaap.host_id AND prop.instance_app_id = iaap.instance_app_id AND prop.instance_api_id = iaap.instance_api_id AND prop.property_id = iaap.property_id AND jsonb_typeof(prop.property_value::jsonb) = ‘array’ ), ‘[]’::jsonb )::text / ELSE MAX(iaap.property_value) – For simple types, MAX can work if only one entry expected, otherwise need timestamp logic – More robust for simple types: Pick latest based on timestamp / (SELECT property_value FROM instance_app_api_property_t latest WHERE latest.host_id = iaap.host_id AND latest.instance_app_id = iaap.instance_app_id AND latest.instance_api_id = iaap.instance_api_id AND latest.property_id = iaap.property_id ORDER BY latest.update_ts DESC LIMIT 1) */ END AS effective_value FROM instance_app_api_property_t iaap JOIN config_property_t cp ON iaap.property_id = cp.property_id JOIN instance_app_api_t iaa ON iaa.host_id = iaap.host_id AND iaa.instance_app_id = iaap.instance_app_id AND iaa.instance_api_id = iaap.instance_api_id – Join to potentially use its timestamp for ordering lists WHERE iaap.host_id = ? – p_host_id AND iaap.instance_app_id IN (SELECT instance_app_id FROM RelevantInstanceApps) AND iaap.instance_api_id IN (SELECT instance_api_id FROM RelevantInstanceApis) GROUP BY iaap.host_id, iaap.instance_app_id, iaap.instance_api_id, iaap.property_id, cp.value_type – Group to aggregate/merge ),

– Pre-process Instance API properties Merged_Instance_Api_Properties AS ( SELECT iap.property_id, CASE cp.value_type WHEN ‘map’ THEN COALESCE(jsonb_merge_agg(iap.property_value::jsonb), ‘{}’::jsonb)::text WHEN ‘list’ THEN COALESCE((SELECT jsonb_agg(elem ORDER BY prop.update_ts) FROM instance_api_property_t prop, jsonb_array_elements(prop.property_value::jsonb) elem WHERE prop.host_id = iap.host_id AND prop.instance_api_id = iap.instance_api_id AND prop.property_id = iap.property_id AND jsonb_typeof(prop.property_value::jsonb) = ‘array’), ‘[]’::jsonb)::text ELSE (SELECT property_value FROM instance_api_property_t latest WHERE latest.host_id = iap.host_id AND latest.instance_api_id = iap.instance_api_id AND latest.property_id = iap.property_id ORDER BY latest.update_ts DESC LIMIT 1) END AS effective_value FROM instance_api_property_t iap JOIN config_property_t cp ON iap.property_id = cp.property_id WHERE iap.host_id = ? – p_host_id AND iap.instance_api_id IN (SELECT instance_api_id FROM RelevantInstanceApis) GROUP BY iap.host_id, iap.instance_api_id, iap.property_id, cp.value_type ),

– Pre-process Instance App properties Merged_Instance_App_Properties AS ( SELECT iapp.property_id, CASE cp.value_type WHEN ‘map’ THEN COALESCE(jsonb_merge_agg(iapp.property_value::jsonb), ‘{}’::jsonb)::text WHEN ‘list’ THEN COALESCE((SELECT jsonb_agg(elem ORDER BY prop.update_ts) FROM instance_app_property_t prop, jsonb_array_elements(prop.property_value::jsonb) elem WHERE prop.host_id = iapp.host_id AND prop.instance_app_id = iapp.instance_app_id AND prop.property_id = iapp.property_id AND jsonb_typeof(prop.property_value::jsonb) = ‘array’), ‘[]’::jsonb)::text ELSE (SELECT property_value FROM instance_app_property_t latest WHERE latest.host_id = iapp.host_id AND latest.instance_app_id = iapp.instance_app_id AND latest.property_id = iapp.property_id ORDER BY latest.update_ts DESC LIMIT 1) END AS effective_value FROM instance_app_property_t iapp JOIN config_property_t cp ON iapp.property_id = cp.property_id WHERE iapp.host_id = ? – p_host_id AND iapp.instance_app_id IN (SELECT instance_app_id FROM RelevantInstanceApps) GROUP BY iapp.host_id, iapp.instance_app_id, iapp.property_id, cp.value_type ),

– Combine all levels with priority AllOverrides AS ( – Priority 10: Instance App API (highest) - Requires aggregating the merged results if multiple app/api combos apply to the instance SELECT m_iaap.property_id, – Need final merge/latest logic here if multiple app/api combos apply to the SAME instance_id and define the SAME property_id – Assuming for now we take the first one found or need more complex logic if merge is needed again at this stage – For simplicity, let’s assume we just take MAX effective value if multiple rows exist per property_id for the instance MAX(m_iaap.effective_value) as property_value, – This MAX might not be right for JSON, need specific logic if merging across app/api combos is needed here 10 AS priority_level FROM Merged_Instance_App_Api_Properties m_iaap – No additional instance filter needed if CTEs were already filtered by RelevantInstanceApps/Apis linked to p_instance_id GROUP BY m_iaap.property_id – Group to handle multiple app/api links potentially setting the same property for the instance

UNION ALL

-- Priority 20: Instance API
SELECT
    m_iap.property_id,
    MAX(m_iap.effective_value) as property_value, -- Similar merge concern as above
    20 AS priority_level
FROM Merged_Instance_Api_Properties m_iap
GROUP BY m_iap.property_id

UNION ALL

-- Priority 30: Instance App
SELECT
    m_iapp.property_id,
    MAX(m_iapp.effective_value) as property_value, -- Similar merge concern
    30 AS priority_level
FROM Merged_Instance_App_Properties m_iapp
GROUP BY m_iapp.property_id

UNION ALL

-- Priority 40: Instance
SELECT
    ip.property_id,
    ip.property_value,
    40 AS priority_level
FROM instance_property_t ip
WHERE ip.host_id = ? -- p_host_id
  AND ip.instance_id = ? -- p_instance_id

UNION ALL

-- Priority 50: Product Version
SELECT
    pvp.property_id,
    pvp.property_value,
    50 AS priority_level
FROM product_version_property_t pvp
WHERE pvp.host_id = ? -- p_host_id
  AND pvp.product_version_id = ? -- v_product_version_id

UNION ALL

-- Priority 60: Environment
SELECT
    ep.property_id,
    ep.property_value,
    60 AS priority_level
FROM environment_property_t ep
WHERE ep.host_id = ? -- p_host_id
  AND ep.environment = ? -- v_environment

UNION ALL

-- Priority 70: Product (Host independent)
SELECT
    pp.property_id,
    pp.property_value,
    70 AS priority_level
FROM product_property_t pp
WHERE pp.product_id = ? -- v_product_id

UNION ALL

-- Priority 100: Default values
SELECT
    cp.property_id,
    cp.property_value, -- Default value
    100 AS priority_level
FROM config_property_t cp
-- Optimization: Filter defaults to only those applicable to the product version?
-- JOIN product_version_config_property_t pvcp ON cp.property_id = pvcp.property_id
-- WHERE pvcp.host_id = ? AND pvcp.product_version_id = ?

), RankedOverrides AS ( SELECT ao.property_id, ao.property_value, ao.priority_level, ROW_NUMBER() OVER (PARTITION BY ao.property_id ORDER BY ao.priority_level ASC) as rn FROM AllOverrides ao WHERE ao.property_value IS NOT NULL – Exclude levels where the value was NULL (unless NULL is a valid override) ) – Final Selection for Snapshot Table SELECT – snapshot_id needs to be added here or during INSERT cfg.config_name || ‘.’ || cp.property_name AS property_key, ro.property_value, cp.property_type, cp.value_type – Include ro.priority_level AS source_priority if storing provenance FROM RankedOverrides ro JOIN config_property_t cp ON ro.property_id = cp.property_id JOIN config_t cfg ON cp.config_id = cfg.config_id WHERE ro.rn = 1;


5.  **Populate `config_snapshot_property_t`:** Insert the results of this query into `config_snapshot_property_t`, using the `snapshot_id` generated in step 3.

**Key Takeaways:**

*   The `scope_*` columns define the *context* of the snapshot.
*   The values for these `scope_*` columns are used as *parameters* within the `WHERE` clauses of the aggregation query that *generates* the snapshot data.
*   You don't need a `scope_*` column per override table. You need columns representing the different *dimensions* or *levels* by which you might want to define a snapshot's context (host, instance, environment, product version, etc.).
*   The aggregation query uses these parameters to filter the relevant rows from each override table and then determines the highest priority value using `UNION ALL` and a ranking mechanism (`ROW_NUMBER()` or `DISTINCT ON`).

This approach keeps the `config_snapshot_t` table focused on metadata and context, while the query handles the complex logic of applying that context to the various override tables to produce the effective configuration for `config_snapshot_property_t`.

### Config Phase

In the config_t table, there is a config_phase column to separate different stages of api/app life cycles. For example, config for codegen, config for runtime, config for deployment. 

Given your two main use cases:

1.  **Service Startup:** Needs the *runtime* (`'R'`) configuration.
2.  **Deployment Rollback:** Needs to potentially restore the state required for *deployment* (`'D'`) and the resulting *runtime* (`'R'`) configuration from that point in time. (Generator `'G'` configs are usually less relevant for deployment/runtime rollbacks).

Here are the options and the recommended approach:

**Option 1: Phase-Specific Snapshots (Separate Records)**

*   **How:** Add `scope_config_phase CHAR(1)` to `config_snapshot_t`.
*   **Snapshot Creation:** When a snapshot event occurs (e.g., pre-deployment):
    *   Generate a `snapshot_id_D` (e.g., using UUIDv7).
    *   Run the aggregation query with `config_phase = 'D'`.
    *   Store results in `config_snapshot_property_t` linked to `snapshot_id_D`.
    *   Create metadata in `config_snapshot_t` for `snapshot_id_D` with `scope_config_phase = 'D'`.
    *   Generate *another* `snapshot_id_R`.
    *   Run the aggregation query with `config_phase = 'R'`.
    *   Store results in `config_snapshot_property_t` linked to `snapshot_id_R`.
    *   Create metadata in `config_snapshot_t` for `snapshot_id_R` with `scope_config_phase = 'R'`.
    *   You'd need a way to link `snapshot_id_D` and `snapshot_id_R` to the same logical event (e.g., same `related_deployment_id`).
*   **Pros:** Very explicit separation. Querying for a specific phase's snapshot is straightforward.
*   **Cons:** Requires multiple runs of the aggregation query. Doubles the metadata rows in `config_snapshot_t`. Complicates linking phases related to the same event. Less efficient.

**Option 2: Single Snapshot, Phase Included in Properties (Recommended)**

*   **How:** Do **not** add `scope_config_phase` to `config_snapshot_t`. Instead, add `config_phase CHAR(1)` to `config_snapshot_property_t`.
*   **Snapshot Creation:**
    *   Generate a single `snapshot_id`.
    *   Create one metadata row in `config_snapshot_t` representing the overall scope and time (without phase).
    *   **Modify the Aggregation Query:**
        *   **Remove** the `WHERE c.config_phase = ?` filter entirely.
        *   **SELECT** the `c.config_phase` value in the final `SELECT` statement.
    *   Run this modified query *once*. It will calculate the effective properties across *all* phases applicable to the scope.
    *   Store the results in `config_snapshot_property_t`, populating the new `config_phase` column for each property based on the phase of the `config_t` record from which it originated.
*   **`config_snapshot_property_t` Structure:**
    ```sql
    CREATE TABLE config_snapshot_property_t (
        -- ... other columns ...
        config_phase        CHAR(1) NOT NULL, -- Phase this property belongs to
        property_key        TEXT NOT NULL,
        property_value      TEXT,
        property_type       VARCHAR(32),
        value_type          VARCHAR(32),
        -- ...
        PRIMARY KEY(snapshot_property_id), -- Or PK(snapshot_id, config_phase, property_key)? Needs thought.
        FOREIGN KEY(snapshot_id) REFERENCES config_snapshot_t(snapshot_id) ON DELETE CASCADE
    );
    -- Ensure uniqueness within a snapshot for a given key *and phase*
    ALTER TABLE config_snapshot_property_t
        ADD CONSTRAINT config_snapshot_property_uk UNIQUE (snapshot_id, config_phase, property_key);
    -- Index for lookup by snapshot and phase
    CREATE INDEX idx_config_snapshot_property_snap_phase ON config_snapshot_property_t (snapshot_id, config_phase);
    ```
*   **Pros:**### commitConfigInstance

Let's outline the structure of your `commitConfigInstance` service method and the necessary SQL INSERT statements using JDBC.

This involves several steps within a single database transaction:

1.  **Generate Snapshot ID:** Create a new UUID for the snapshot.
2.  **Derive Scope IDs:** Query live tables (`instance_t`, `product_version_t`, etc.) based on the input `hostId` and `instanceId` to get other relevant scope identifiers (`environment`, `productId`, `productVersionId`, `serviceId`, etc.).
3.  **Insert Metadata:** Insert a record into `config_snapshot_t`.
4.  **Aggregate Effective Config:** Run the complex aggregation query (using `ROW_NUMBER()` or similar) to get the final effective properties.
5.  **Insert Effective Config:** Insert the results from step 4 into `config_snapshot_property_t`.
6.  **Snapshot Override Tables:** For each relevant live override table (`instance_property_t`, `instance_api_property_t`, etc.), select its current state (filtered by scope) and insert it into the corresponding `snapshot_*_property_t` table.
7.  **Commit/Rollback:** Commit the transaction if all steps succeed, otherwise roll back.

**Java Service Method Structure (Conceptual)**

```java
import com.github.f4b6a3.uuid.UuidCreator; // For UUIDv7 generation
import javax.sql.DataSource; // Assuming you have a DataSource injected
import java.sql.*;
import java.time.OffsetDateTime;
import java.util.*;

public class ConfigSnapshotService {

    private final DataSource ds;
    // Inject DataSource via constructor

    // Pre-compile your complex aggregation query (modify based on previous examples)
    private static final String AGGREGATE_EFFECTIVE_CONFIG_SQL = """
        WITH AllOverrides AS (
            -- Priority 10: Instance App API (merged) ...
            -- Priority 20: Instance API (merged) ...
            -- Priority 30: Instance App (merged) ...
            -- Priority 40: Instance ...
            -- Priority 50: Product Version ...
            -- Priority 60: Environment ...
            -- Priority 70: Product ...
            -- Priority 100: Default ...
        ),
        RankedOverrides AS (
           SELECT ..., ROW_NUMBER() OVER (PARTITION BY ao.property_id ORDER BY ao.priority_level ASC) as rn
           FROM AllOverrides ao WHERE ao.property_value IS NOT NULL
        )
        SELECT
            c.config_phase,   -- Phase from config_t
            cfg.config_id,    -- Added config_id
            cp.property_id,   -- Added property_id
            cp.property_name, -- Added property_name
            cp.property_type,
            cp.value_type,
            cfg.config_name || '.' || cp.property_name AS property_key, -- Keep for logging/debug? Not needed in snapshot table itself
            ro.property_value,
            ro.priority_level -- To determine source_level
        FROM RankedOverrides ro
        JOIN config_property_t cp ON ro.property_id = cp.property_id
        JOIN config_t cfg ON cp.config_id = cfg.config_id
        WHERE ro.rn = 1;
    """; // NOTE: Add parameters (?) for host_id, instance_id, derived IDs etc.

    public Result<String> commitConfigInstance(Map<String, Object> event) {
        // 1. Extract Input Parameters
        UUID hostId = (UUID) event.get("hostId");
        UUID instanceId = (UUID) event.get("instanceId");
        String snapshotType = (String) event.getOrDefault("snapshotType", "USER_SAVE"); // Default type
        String description = (String) event.get("description");
        UUID userId = (UUID) event.get("userId"); // May be null
        UUID deploymentId = (UUID) event.get("deploymentId"); // May be null

        if (hostId == null || instanceId == null) {
            return Failure.of(new Status(INVALID_PARAMETER, "hostId and instanceId are required."));
        }

        UUID snapshotId = UuidCreator.getTimeOrderedEpoch(); // Generate Snapshot ID (e.g., V7)

        Connection connection = null;
        try {
            connection = ds.getConnection();
            connection.setAutoCommit(false); // Start Transaction

            // 2. Derive Scope IDs
            // Query instance_t and potentially product_version_t based on hostId, instanceId
            DerivedScope scope = deriveScopeInfo(connection, hostId, instanceId);
            if (scope == null) {
                connection.rollback(); // Rollback if instance not found
                return Failure.of(new Status(OBJECT_NOT_FOUND, "Instance not found for hostId/instanceId."));
            }

            // 3. Insert Snapshot Metadata
            insertSnapshotMetadata(connection, snapshotId, snapshotType, description, userId, deploymentId, hostId, scope);

            // 4 & 5. Aggregate and Insert Effective Config
            insertEffectiveConfigSnapshot(connection, snapshotId, hostId, instanceId, scope);

            // 6. Snapshot Individual Override Tables
            // Use INSERT ... SELECT ... for efficiency
            snapshotInstanceProperties(connection, snapshotId, hostId, instanceId);
            snapshotInstanceApiProperties(connection, snapshotId, hostId, instanceId);
            snapshotInstanceAppProperties(connection, snapshotId, hostId, instanceId);
            snapshotInstanceAppApiProperties(connection, snapshotId, hostId, instanceId); // Requires finding relevant App/API IDs first
            snapshotEnvironmentProperties(connection, snapshotId, hostId, scope.environment());
            snapshotProductVersionProperties(connection, snapshotId, hostId, scope.productVersionId());
            snapshotProductProperties(connection, snapshotId, scope.productId());
            // Add others as needed

            // 7. Commit Transaction
            connection.commit();
            logger.info("Successfully created config snapshot: {}", snapshotId);
            return Success.of(snapshotId.toString());

        } catch (SQLException e) {
            logger.error("SQLException during snapshot creation for instance {}: {}", instanceId, e.getMessage(), e);
            if (connection != null) {
                try {
                    connection.rollback();
                } catch (SQLException ex) {
                    logger.error("Error rolling back transaction:", ex);
                }
            }
            return Failure.of(new Status(SQL_EXCEPTION, "Database error during snapshot creation."));
        } catch (Exception e) { // Catch other potential errors (e.g., during scope derivation)
             logger.error("Exception during snapshot creation for instance {}: {}", instanceId, e.getMessage(), e);
             if (connection != null) {
                 try { connection.rollback(); } catch (SQLException ex) { logger.error("Error rolling back transaction:", ex); }
             }
            return Failure.of(new Status(GENERIC_EXCEPTION, "Unexpected error during snapshot creation."));
        } finally {
            if (connection != null) {
                try {
                    connection.setAutoCommit(true); // Restore default behavior
                    connection.close();
                } catch (SQLException e) {
                    logger.error("Error closing connection:", e);
                }
            }
        }
    }

    // --- Helper Methods ---

    // Placeholder for derived scope data structure
    private record DerivedScope(String environment, String productId, String productVersion, UUID productVersionId, String serviceId /*, add API details if needed */) {}

    private DerivedScope deriveScopeInfo(Connection conn, UUID hostId, UUID instanceId) throws SQLException {
        // Query instance_t LEFT JOIN product_version_t ... WHERE i.host_id = ? AND i.instance_id = ?
        // Extract environment, service_id from instance_t
        // Extract product_id, product_version from product_version_t (via product_version_id in instance_t)
        // Return new DerivedScope(...) or null if not found
        String sql = """
            SELECT i.environment, i.service_id, pv.product_id, pv.product_version, i.product_version_id
            FROM instance_t i
            LEFT JOIN product_version_t pv ON i.host_id = pv.host_id AND i.product_version_id = pv.product_version_id
            WHERE i.host_id = ? AND i.instance_id = ?
        """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, hostId);
            ps.setObject(2, instanceId);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return new DerivedScope(
                        rs.getString("environment"),
                        rs.getString("product_id"),
                        rs.getString("product_version"),
                        rs.getObject("product_version_id", UUID.class),
                        rs.getString("service_id")
                    );
                } else {
                    return null; // Instance not found
                }
            }
        }
    }

    private void insertSnapshotMetadata(Connection conn, UUID snapshotId, String snapshotType, String description,
                                        UUID userId, UUID deploymentId, UUID hostId, DerivedScope scope) throws SQLException {
        String sql = """
            INSERT INTO config_snapshot_t
            (snapshot_id, snapshot_ts, snapshot_type, description, user_id, deployment_id,
             scope_host_id, scope_environment, scope_product_id, scope_product_version_id, -- Changed col name
             scope_service_id /*, scope_api_id, scope_api_version - Add if applicable */)
            VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?, ?, ?, ? /*, ?, ? */)
            """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, snapshotId);
            ps.setString(2, snapshotType);
            ps.setString(3, description);
            ps.setObject(4, userId);         // setObject handles null correctly
            ps.setObject(5, deploymentId);   // setObject handles null correctly
            ps.setObject(6, hostId);
            ps.setString(7, scope.environment());
            ps.setString(8, scope.productId());
            ps.setObject(9, scope.productVersionId()); // Store the ID
            ps.setString(10, scope.serviceId());
            // Set API scope if needed ps.setObject(11, ...); ps.setString(12, ...);
            ps.executeUpdate();
        }
    }


    private void insertEffectiveConfigSnapshot(Connection conn, UUID snapshotId, UUID hostId, UUID instanceId, DerivedScope scope) throws SQLException {
         String insertSql = """
            INSERT INTO config_snapshot_property_t
            (snapshot_property_id, snapshot_id, config_phase, config_id, property_id, property_name,
             property_type, property_value, value_type, source_level)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """;
        // Prepare the aggregation query
        try (PreparedStatement selectStmt = conn.prepareStatement(AGGREGATE_EFFECTIVE_CONFIG_SQL);
             PreparedStatement insertStmt = conn.prepareStatement(insertSql)) {

             // Set ALL parameters for the AGGREGATE_EFFECTIVE_CONFIG_SQL query
             int paramIndex = 1;
             // Example: set parameters based on how AGGREGATE_EFFECTIVE_CONFIG_SQL is structured
             // selectStmt.setObject(paramIndex++, hostId);
             // selectStmt.setObject(paramIndex++, instanceId);
             // ... set derived scope IDs (productVersionId, environment, productId) ...
             // ... set parameters for all UNION branches and potential subqueries ...

             try (ResultSet rs = selectStmt.executeQuery()) {
                int batchCount = 0;
                while (rs.next()) {
                    insertStmt.setObject(1, UuidCreator.getTimeOrderedEpoch()); // snapshot_property_id
                    insertStmt.setObject(2, snapshotId);
                    insertStmt.setString(3, rs.getString("config_phase"));
                    insertStmt.setObject(4, rs.getObject("config_id", UUID.class));
                    insertStmt.setObject(5, rs.getObject("property_id", UUID.class));
                    insertStmt.setString(6, rs.getString("property_name"));
                    insertStmt.setString(7, rs.getString("property_type"));
                    insertStmt.setString(8, rs.getString("property_value"));
                    insertStmt.setString(9, rs.getString("value_type"));
                    insertStmt.setString(10, mapPriorityToSourceLevel(rs.getInt("priority_level"))); // Map numeric priority back to level name

                    insertStmt.addBatch();
                    batchCount++;

                    if (batchCount % 100 == 0) { // Execute batch periodically
                        insertStmt.executeBatch();
                    }
                }
                 if (batchCount % 100 != 0) { // Execute remaining batch
                     insertStmt.executeBatch();
                 }
             }
         }
    }

    // Helper to map priority back to source level name
    private String mapPriorityToSourceLevel(int priority) {
        return switch (priority) {
            case 10 -> "instance_app_api"; // Adjust priorities as used in your query
            case 20 -> "instance_api";
            case 30 -> "instance_app";
            case 40 -> "instance";
            case 50 -> "product_version";
            case 60 -> "environment";
            case 70 -> "product";
            case 100 -> "default";
            default -> "unknown";
        };
    }


    // --- Methods for Snapshotting Individual Override Tables ---

    private void snapshotInstanceProperties(Connection conn, UUID snapshotId, UUID hostId, UUID instanceId) throws SQLException {
        String sql = """
            INSERT INTO snapshot_instance_property_t
            (snapshot_id, host_id, instance_id, property_id, property_value, update_user, update_ts)
            SELECT ?, host_id, instance_id, property_id, property_value, update_user, update_ts
            FROM instance_property_t
            WHERE host_id = ? AND instance_id = ?
            """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, snapshotId);
            ps.setObject(2, hostId);
            ps.setObject(3, instanceId);
            ps.executeUpdate();
        }
    }

    private void snapshotInstanceApiProperties(Connection conn, UUID snapshotId, UUID hostId, UUID instanceId) throws SQLException {
         // Find relevant instance_api_ids first
        List<UUID> apiIds = findRelevantInstanceApiIds(conn, hostId, instanceId);
        if (apiIds.isEmpty()) return; // No API overrides for this instance

        String sql = """
            INSERT INTO snapshot_instance_api_property_t
            (snapshot_id, host_id, instance_api_id, property_id, property_value, update_user, update_ts)
            SELECT ?, host_id, instance_api_id, property_id, property_value, update_user, update_ts
            FROM instance_api_property_t
            WHERE host_id = ? AND instance_api_id = ANY(?) -- Use ANY with array for multiple IDs
            """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, snapshotId);
            ps.setObject(2, hostId);
            // Create a SQL Array from the List of UUIDs
            Array sqlArray = conn.createArrayOf("UUID", apiIds.toArray());
            ps.setArray(3, sqlArray);
            ps.executeUpdate();
            sqlArray.free(); // Release array resources
        }
    }

    // Similar methods for snapshotInstanceAppProperties, snapshotInstanceAppApiProperties...
    // These will need helper methods like findRelevantInstanceApiIds/findRelevantInstanceAppIds

    private void snapshotEnvironmentProperties(Connection conn, UUID snapshotId, UUID hostId, String environment) throws SQLException {
        if (environment == null || environment.isEmpty()) return; // No environment scope
        String sql = """
             INSERT INTO snapshot_environment_property_t
             (snapshot_id, host_id, environment, property_id, property_value, update_user, update_ts)
             SELECT ?, host_id, environment, property_id, property_value, update_user, update_ts
             FROM environment_property_t
             WHERE host_id = ? AND environment = ?
             """;
         try (PreparedStatement ps = conn.prepareStatement(sql)) {
             ps.setObject(1, snapshotId);
             ps.setObject(2, hostId);
             ps.setString(3, environment);
             ps.executeUpdate();
         }
    }

     private void snapshotProductVersionProperties(Connection conn, UUID snapshotId, UUID hostId, UUID productVersionId) throws SQLException {
         if (productVersionId == null) return;
         String sql = """
              INSERT INTO snapshot_product_version_property_t
              (snapshot_id, host_id, product_version_id, property_id, property_value, update_user, update_ts)
              SELECT ?, host_id, product_version_id, property_id, property_value, update_user, update_ts
              FROM product_version_property_t
              WHERE host_id = ? AND product_version_id = ?
              """;
          try (PreparedStatement ps = conn.prepareStatement(sql)) {
              ps.setObject(1, snapshotId);
              ps.setObject(2, hostId);
              ps.setObject(3, productVersionId);
              ps.executeUpdate();
          }
     }

     private void snapshotProductProperties(Connection conn, UUID snapshotId, String productId) throws SQLException {
         if (productId == null || productId.isEmpty()) return;
         String sql = """
               INSERT INTO snapshot_product_property_t
               (snapshot_id, product_id, property_id, property_value, update_user, update_ts)
               SELECT ?, product_id, property_id, property_value, update_user, update_ts
               FROM product_property_t
               WHERE product_id = ?
               """;
           try (PreparedStatement ps = conn.prepareStatement(sql)) {
               ps.setObject(1, snapshotId);
               ps.setString(2, productId);
               ps.executeUpdate();
           }
     }

    // --- Helper method to find associated instance_api_ids ---
    private List<UUID> findRelevantInstanceApiIds(Connection conn, UUID hostId, UUID instanceId) throws SQLException {
        List<UUID> ids = new ArrayList<>();
        String sql = "SELECT instance_api_id FROM instance_api_t WHERE host_id = ? AND instance_id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, hostId);
            ps.setObject(2, instanceId);
            try (ResultSet rs = ps.executeQuery()) {
                while(rs.next()) {
                    ids.add(rs.getObject("instance_api_id", UUID.class));
                }
            }
        }
        return ids;
    }
     // --- Add similar helper for findRelevantInstanceAppIds ---
     // --- Add similar helper for findRelevantInstanceAppApiIds (if needed) ---

}

SQL INSERT Statements:

  1. config_snapshot_t:

    INSERT INTO config_snapshot_t
    (snapshot_id, snapshot_ts, snapshot_type, description, user_id, deployment_id,
     scope_host_id, scope_environment, scope_product_id, scope_product_version_id, scope_service_id /*, ... other scope cols */)
    VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?, ?, ?, ? /*, ... */)
    

    (Parameters: snapshotId, snapshotType, description, userId, deploymentId, hostId, environment, productId, productVersionId, serviceId, …)

  2. config_snapshot_property_t: (Executed in a loop/batch)

    INSERT INTO config_snapshot_property_t
    (snapshot_property_id, snapshot_id, config_phase, config_id, property_id, property_name,
     property_type, property_value, value_type, source_level)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    

    (Parameters: new UUID, snapshotId, phase, configId, propertyId, propName, propType, propValue, valType, sourceLevelString)

  3. snapshot_instance_property_t:

    INSERT INTO snapshot_instance_property_t
    (snapshot_id, host_id, instance_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_id, property_id, property_value, update_user, update_ts
    FROM instance_property_t
    WHERE host_id = ? AND instance_id = ?
    

    (Parameters: snapshotId, hostId, instanceId)

  4. snapshot_instance_api_property_t:

    INSERT INTO snapshot_instance_api_property_t
    (snapshot_id, host_id, instance_api_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_api_id, property_id, property_value, update_user, update_ts
    FROM instance_api_property_t
    WHERE host_id = ? AND instance_api_id = ANY(?) -- Parameter is a SQL Array of relevant instance_api_ids
    

    (Parameters: snapshotId, hostId, SQL Array of instance_api_ids)

  5. snapshot_instance_app_property_t:

    INSERT INTO snapshot_instance_app_property_t
    (snapshot_id, host_id, instance_app_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_app_id, property_id, property_value, update_user, update_ts
    FROM instance_app_property_t
    WHERE host_id = ? AND instance_app_id = ANY(?) -- Parameter is a SQL Array of relevant instance_app_ids
    

    (Parameters: snapshotId, hostId, SQL Array of instance_app_ids)

  6. snapshot_instance_app_api_property_t:

    INSERT INTO snapshot_instance_app_api_property_t
    (snapshot_id, host_id, instance_app_id, instance_api_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_app_id, instance_api_id, property_id, property_value, update_user, update_ts
    FROM instance_app_api_property_t
    WHERE host_id = ?
      AND instance_app_id = ANY(?) -- SQL Array of relevant instance_app_ids
      AND instance_api_id = ANY(?) -- SQL Array of relevant instance_api_ids
    

    (Parameters: snapshotId, hostId, SQL Array of instance_app_ids, SQL Array of instance_api_ids)

  7. snapshot_environment_property_t:

    INSERT INTO snapshot_environment_property_t
    (snapshot_id, host_id, environment, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, environment, property_id, property_value, update_user, update_ts
    FROM environment_property_t
    WHERE host_id = ? AND environment = ?
    

    (Parameters: snapshotId, hostId, environment)

  8. snapshot_product_version_property_t:

    INSERT INTO snapshot_product_version_property_t
    (snapshot_id, host_id, product_version_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, product_version_id, property_id, property_value, update_user, update_ts
    FROM product_version_property_t
    WHERE host_id = ? AND product_version_id = ?
    

    (Parameters: snapshotId, hostId, productVersionId)

  9. snapshot_product_property_t:

    INSERT INTO snapshot_product_property_t
    (snapshot_id, product_id, property_id, property_value, update_user, update_ts)
    SELECT ?, product_id, property_id, property_value, update_user, update_ts
    FROM product_property_t
    WHERE product_id = ?
    

    (Parameters: snapshotId, productId)

Important Considerations:

  • Transaction Management: Ensure all these inserts happen within a single atomic transaction.
  • Error Handling: Robust error handling and rollback are critical.
  • Performance: The aggregation query can be heavy. Consider its performance impact. The INSERT ... SELECT statements should be reasonably efficient if the source tables are properly indexed on their scope columns.
  • Parameterization: Use PreparedStatement for all queries to prevent SQL injection and improve performance.
  • Scope Derivation Logic: Ensure the logic in deriveScopeInfo and the helpers like findRelevantInstanceApiIds is correct and handles cases where relationships might not exist (e.g., an instance with no associated APIs).
  • UUID Generation: Use a suitable UUID generator (like UUIDv7 via uuid-creator).
  • Logging: Add clear logging for snapshot creation success/failure.
  • Refinement: You’ll need to adapt the AGGREGATE_EFFECTIVE_CONFIG_SQL query based on the final version using ROW_NUMBER and correctly incorporate parameters.
    • Only one aggregation query run per snapshot event.
    • Captures the complete multi-phase state at that point under one snapshot_id.
    • Simpler config_snapshot_t metadata.
    • Facilitates rollbacks that might need to restore both ‘D’ and ‘R’ phase configurations.
  • Cons:
    • Requires modifying the aggregation query.
    • Consuming services (like startup) need to add AND config_phase = 'R' when querying config_snapshot_property_t.

Conclusion:

Option 2 is the better approach. Create a single snapshot representing the point-in-time state for the defined scope, but modify your aggregation query to:

  1. Remove the config_phase = ? filter.
  2. Select c.config_phase in the final output.
  3. Store this config_phase along with the effective property_key and property_value in the config_snapshot_property_t table.

This is more efficient for snapshot creation and provides a complete view for rollbacks. Consumers simply need to add a phase filter when reading the specific configuration they need (e.g., 'R' for runtime).

Modified Aggregation Query Snippet (Final SELECT):

-- (Inside the final SELECT statement of the modified aggregation query)
SELECT
    -- snapshot_id needs to be added here or during INSERT
    c.config_phase,   -- <-- Select the phase
    cfg.config_name || '.' || cp.property_name AS property_key,
    ro.property_value,
    cp.property_type,
    cp.value_type
    -- Include ro.priority_level AS source_priority if storing provenance
FROM RankedOverrides ro
JOIN config_property_t cp ON ro.property_id = cp.property_id
JOIN config_t cfg ON cp.config_id = cfg.config_id
-- Make sure the join to config_t (aliased as c or cfg) is available here to get the phase
-- This join likely happened earlier to get config_name anyway.
WHERE ro.rn = 1;

commitConfigInstance

Okay, let’s outline the structure of your commitConfigInstance service method and the necessary SQL INSERT statements using JDBC.

This involves several steps within a single database transaction:

  1. Generate Snapshot ID: Create a new UUID for the snapshot.
  2. Derive Scope IDs: Query live tables (instance_t, product_version_t, etc.) based on the input hostId and instanceId to get other relevant scope identifiers (environment, productId, productVersionId, serviceId, etc.).
  3. Insert Metadata: Insert a record into config_snapshot_t.
  4. Aggregate Effective Config: Run the complex aggregation query (using ROW_NUMBER() or similar) to get the final effective properties.
  5. Insert Effective Config: Insert the results from step 4 into config_snapshot_property_t.
  6. Snapshot Override Tables: For each relevant live override table (instance_property_t, instance_api_property_t, etc.), select its current state (filtered by scope) and insert it into the corresponding snapshot_*_property_t table.
  7. Commit/Rollback: Commit the transaction if all steps succeed, otherwise roll back.

Java Service Method Structure (Conceptual)

import com.github.f4b6a3.uuid.UuidCreator; // For UUIDv7 generation
import javax.sql.DataSource; // Assuming you have a DataSource injected
import java.sql.*;
import java.time.OffsetDateTime;
import java.util.*;

public class ConfigSnapshotService {

    private final DataSource ds;
    // Inject DataSource via constructor

    // Pre-compile your complex aggregation query (modify based on previous examples)
    private static final String AGGREGATE_EFFECTIVE_CONFIG_SQL = """
        WITH AllOverrides AS (
            -- Priority 10: Instance App API (merged) ...
            -- Priority 20: Instance API (merged) ...
            -- Priority 30: Instance App (merged) ...
            -- Priority 40: Instance ...
            -- Priority 50: Product Version ...
            -- Priority 60: Environment ...
            -- Priority 70: Product ...
            -- Priority 100: Default ...
        ),
        RankedOverrides AS (
           SELECT ..., ROW_NUMBER() OVER (PARTITION BY ao.property_id ORDER BY ao.priority_level ASC) as rn
           FROM AllOverrides ao WHERE ao.property_value IS NOT NULL
        )
        SELECT
            c.config_phase,   -- Phase from config_t
            cfg.config_id,    -- Added config_id
            cp.property_id,   -- Added property_id
            cp.property_name, -- Added property_name
            cp.property_type,
            cp.value_type,
            cfg.config_name || '.' || cp.property_name AS property_key, -- Keep for logging/debug? Not needed in snapshot table itself
            ro.property_value,
            ro.priority_level -- To determine source_level
        FROM RankedOverrides ro
        JOIN config_property_t cp ON ro.property_id = cp.property_id
        JOIN config_t cfg ON cp.config_id = cfg.config_id
        WHERE ro.rn = 1;
    """; // NOTE: Add parameters (?) for host_id, instance_id, derived IDs etc.

    public Result<String> commitConfigInstance(Map<String, Object> event) {
        // 1. Extract Input Parameters
        UUID hostId = (UUID) event.get("hostId");
        UUID instanceId = (UUID) event.get("instanceId");
        String snapshotType = (String) event.getOrDefault("snapshotType", "USER_SAVE"); // Default type
        String description = (String) event.get("description");
        UUID userId = (UUID) event.get("userId"); // May be null
        UUID deploymentId = (UUID) event.get("deploymentId"); // May be null

        if (hostId == null || instanceId == null) {
            return Failure.of(new Status(INVALID_PARAMETER, "hostId and instanceId are required."));
        }

        UUID snapshotId = UuidCreator.getTimeOrderedEpoch(); // Generate Snapshot ID (e.g., V7)

        Connection connection = null;
        try {
            connection = ds.getConnection();
            connection.setAutoCommit(false); // Start Transaction

            // 2. Derive Scope IDs
            // Query instance_t and potentially product_version_t based on hostId, instanceId
            DerivedScope scope = deriveScopeInfo(connection, hostId, instanceId);
            if (scope == null) {
                connection.rollback(); // Rollback if instance not found
                return Failure.of(new Status(OBJECT_NOT_FOUND, "Instance not found for hostId/instanceId."));
            }

            // 3. Insert Snapshot Metadata
            insertSnapshotMetadata(connection, snapshotId, snapshotType, description, userId, deploymentId, hostId, scope);

            // 4 & 5. Aggregate and Insert Effective Config
            insertEffectiveConfigSnapshot(connection, snapshotId, hostId, instanceId, scope);

            // 6. Snapshot Individual Override Tables
            // Use INSERT ... SELECT ... for efficiency
            snapshotInstanceProperties(connection, snapshotId, hostId, instanceId);
            snapshotInstanceApiProperties(connection, snapshotId, hostId, instanceId);
            snapshotInstanceAppProperties(connection, snapshotId, hostId, instanceId);
            snapshotInstanceAppApiProperties(connection, snapshotId, hostId, instanceId); // Requires finding relevant App/API IDs first
            snapshotEnvironmentProperties(connection, snapshotId, hostId, scope.environment());
            snapshotProductVersionProperties(connection, snapshotId, hostId, scope.productVersionId());
            snapshotProductProperties(connection, snapshotId, scope.productId());
            // Add others as needed

            // 7. Commit Transaction
            connection.commit();
            logger.info("Successfully created config snapshot: {}", snapshotId);
            return Success.of(snapshotId.toString());

        } catch (SQLException e) {
            logger.error("SQLException during snapshot creation for instance {}: {}", instanceId, e.getMessage(), e);
            if (connection != null) {
                try {
                    connection.rollback();
                } catch (SQLException ex) {
                    logger.error("Error rolling back transaction:", ex);
                }
            }
            return Failure.of(new Status(SQL_EXCEPTION, "Database error during snapshot creation."));
        } catch (Exception e) { // Catch other potential errors (e.g., during scope derivation)
             logger.error("Exception during snapshot creation for instance {}: {}", instanceId, e.getMessage(), e);
             if (connection != null) {
                 try { connection.rollback(); } catch (SQLException ex) { logger.error("Error rolling back transaction:", ex); }
             }
            return Failure.of(new Status(GENERIC_EXCEPTION, "Unexpected error during snapshot creation."));
        } finally {
            if (connection != null) {
                try {
                    connection.setAutoCommit(true); // Restore default behavior
                    connection.close();
                } catch (SQLException e) {
                    logger.error("Error closing connection:", e);
                }
            }
        }
    }

    // --- Helper Methods ---

    // Placeholder for derived scope data structure
    private record DerivedScope(String environment, String productId, String productVersion, UUID productVersionId, String serviceId /*, add API details if needed */) {}

    private DerivedScope deriveScopeInfo(Connection conn, UUID hostId, UUID instanceId) throws SQLException {
        // Query instance_t LEFT JOIN product_version_t ... WHERE i.host_id = ? AND i.instance_id = ?
        // Extract environment, service_id from instance_t
        // Extract product_id, product_version from product_version_t (via product_version_id in instance_t)
        // Return new DerivedScope(...) or null if not found
        String sql = """
            SELECT i.environment, i.service_id, pv.product_id, pv.product_version, i.product_version_id
            FROM instance_t i
            LEFT JOIN product_version_t pv ON i.host_id = pv.host_id AND i.product_version_id = pv.product_version_id
            WHERE i.host_id = ? AND i.instance_id = ?
        """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, hostId);
            ps.setObject(2, instanceId);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return new DerivedScope(
                        rs.getString("environment"),
                        rs.getString("product_id"),
                        rs.getString("product_version"),
                        rs.getObject("product_version_id", UUID.class),
                        rs.getString("service_id")
                    );
                } else {
                    return null; // Instance not found
                }
            }
        }
    }

    private void insertSnapshotMetadata(Connection conn, UUID snapshotId, String snapshotType, String description,
                                        UUID userId, UUID deploymentId, UUID hostId, DerivedScope scope) throws SQLException {
        String sql = """
            INSERT INTO config_snapshot_t
            (snapshot_id, snapshot_ts, snapshot_type, description, user_id, deployment_id,
             scope_host_id, scope_environment, scope_product_id, scope_product_version_id, -- Changed col name
             scope_service_id /*, scope_api_id, scope_api_version - Add if applicable */)
            VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?, ?, ?, ? /*, ?, ? */)
            """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, snapshotId);
            ps.setString(2, snapshotType);
            ps.setString(3, description);
            ps.setObject(4, userId);         // setObject handles null correctly
            ps.setObject(5, deploymentId);   // setObject handles null correctly
            ps.setObject(6, hostId);
            ps.setString(7, scope.environment());
            ps.setString(8, scope.productId());
            ps.setObject(9, scope.productVersionId()); // Store the ID
            ps.setString(10, scope.serviceId());
            // Set API scope if needed ps.setObject(11, ...); ps.setString(12, ...);
            ps.executeUpdate();
        }
    }


    private void insertEffectiveConfigSnapshot(Connection conn, UUID snapshotId, UUID hostId, UUID instanceId, DerivedScope scope) throws SQLException {
         String insertSql = """
            INSERT INTO config_snapshot_property_t
            (snapshot_property_id, snapshot_id, config_phase, config_id, property_id, property_name,
             property_type, property_value, value_type, source_level)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """;
        // Prepare the aggregation query
        try (PreparedStatement selectStmt = conn.prepareStatement(AGGREGATE_EFFECTIVE_CONFIG_SQL);
             PreparedStatement insertStmt = conn.prepareStatement(insertSql)) {

             // Set ALL parameters for the AGGREGATE_EFFECTIVE_CONFIG_SQL query
             int paramIndex = 1;
             // Example: set parameters based on how AGGREGATE_EFFECTIVE_CONFIG_SQL is structured
             // selectStmt.setObject(paramIndex++, hostId);
             // selectStmt.setObject(paramIndex++, instanceId);
             // ... set derived scope IDs (productVersionId, environment, productId) ...
             // ... set parameters for all UNION branches and potential subqueries ...

             try (ResultSet rs = selectStmt.executeQuery()) {
                int batchCount = 0;
                while (rs.next()) {
                    insertStmt.setObject(1, UuidCreator.getTimeOrderedEpoch()); // snapshot_property_id
                    insertStmt.setObject(2, snapshotId);
                    insertStmt.setString(3, rs.getString("config_phase"));
                    insertStmt.setObject(4, rs.getObject("config_id", UUID.class));
                    insertStmt.setObject(5, rs.getObject("property_id", UUID.class));
                    insertStmt.setString(6, rs.getString("property_name"));
                    insertStmt.setString(7, rs.getString("property_type"));
                    insertStmt.setString(8, rs.getString("property_value"));
                    insertStmt.setString(9, rs.getString("value_type"));
                    insertStmt.setString(10, mapPriorityToSourceLevel(rs.getInt("priority_level"))); // Map numeric priority back to level name

                    insertStmt.addBatch();
                    batchCount++;

                    if (batchCount % 100 == 0) { // Execute batch periodically
                        insertStmt.executeBatch();
                    }
                }
                 if (batchCount % 100 != 0) { // Execute remaining batch
                     insertStmt.executeBatch();
                 }
             }
         }
    }

    // Helper to map priority back to source level name
    private String mapPriorityToSourceLevel(int priority) {
        return switch (priority) {
            case 10 -> "instance_app_api"; // Adjust priorities as used in your query
            case 20 -> "instance_api";
            case 30 -> "instance_app";
            case 40 -> "instance";
            case 50 -> "product_version";
            case 60 -> "environment";
            case 70 -> "product";
            case 100 -> "default";
            default -> "unknown";
        };
    }


    // --- Methods for Snapshotting Individual Override Tables ---

    private void snapshotInstanceProperties(Connection conn, UUID snapshotId, UUID hostId, UUID instanceId) throws SQLException {
        String sql = """
            INSERT INTO snapshot_instance_property_t
            (snapshot_id, host_id, instance_id, property_id, property_value, update_user, update_ts)
            SELECT ?, host_id, instance_id, property_id, property_value, update_user, update_ts
            FROM instance_property_t
            WHERE host_id = ? AND instance_id = ?
            """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, snapshotId);
            ps.setObject(2, hostId);
            ps.setObject(3, instanceId);
            ps.executeUpdate();
        }
    }

    private void snapshotInstanceApiProperties(Connection conn, UUID snapshotId, UUID hostId, UUID instanceId) throws SQLException {
         // Find relevant instance_api_ids first
        List<UUID> apiIds = findRelevantInstanceApiIds(conn, hostId, instanceId);
        if (apiIds.isEmpty()) return; // No API overrides for this instance

        String sql = """
            INSERT INTO snapshot_instance_api_property_t
            (snapshot_id, host_id, instance_api_id, property_id, property_value, update_user, update_ts)
            SELECT ?, host_id, instance_api_id, property_id, property_value, update_user, update_ts
            FROM instance_api_property_t
            WHERE host_id = ? AND instance_api_id = ANY(?) -- Use ANY with array for multiple IDs
            """;
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, snapshotId);
            ps.setObject(2, hostId);
            // Create a SQL Array from the List of UUIDs
            Array sqlArray = conn.createArrayOf("UUID", apiIds.toArray());
            ps.setArray(3, sqlArray);
            ps.executeUpdate();
            sqlArray.free(); // Release array resources
        }
    }

    // Similar methods for snapshotInstanceAppProperties, snapshotInstanceAppApiProperties...
    // These will need helper methods like findRelevantInstanceApiIds/findRelevantInstanceAppIds

    private void snapshotEnvironmentProperties(Connection conn, UUID snapshotId, UUID hostId, String environment) throws SQLException {
        if (environment == null || environment.isEmpty()) return; // No environment scope
        String sql = """
             INSERT INTO snapshot_environment_property_t
             (snapshot_id, host_id, environment, property_id, property_value, update_user, update_ts)
             SELECT ?, host_id, environment, property_id, property_value, update_user, update_ts
             FROM environment_property_t
             WHERE host_id = ? AND environment = ?
             """;
         try (PreparedStatement ps = conn.prepareStatement(sql)) {
             ps.setObject(1, snapshotId);
             ps.setObject(2, hostId);
             ps.setString(3, environment);
             ps.executeUpdate();
         }
    }

     private void snapshotProductVersionProperties(Connection conn, UUID snapshotId, UUID hostId, UUID productVersionId) throws SQLException {
         if (productVersionId == null) return;
         String sql = """
              INSERT INTO snapshot_product_version_property_t
              (snapshot_id, host_id, product_version_id, property_id, property_value, update_user, update_ts)
              SELECT ?, host_id, product_version_id, property_id, property_value, update_user, update_ts
              FROM product_version_property_t
              WHERE host_id = ? AND product_version_id = ?
              """;
          try (PreparedStatement ps = conn.prepareStatement(sql)) {
              ps.setObject(1, snapshotId);
              ps.setObject(2, hostId);
              ps.setObject(3, productVersionId);
              ps.executeUpdate();
          }
     }

     private void snapshotProductProperties(Connection conn, UUID snapshotId, String productId) throws SQLException {
         if (productId == null || productId.isEmpty()) return;
         String sql = """
               INSERT INTO snapshot_product_property_t
               (snapshot_id, product_id, property_id, property_value, update_user, update_ts)
               SELECT ?, product_id, property_id, property_value, update_user, update_ts
               FROM product_property_t
               WHERE product_id = ?
               """;
           try (PreparedStatement ps = conn.prepareStatement(sql)) {
               ps.setObject(1, snapshotId);
               ps.setString(2, productId);
               ps.executeUpdate();
           }
     }

    // --- Helper method to find associated instance_api_ids ---
    private List<UUID> findRelevantInstanceApiIds(Connection conn, UUID hostId, UUID instanceId) throws SQLException {
        List<UUID> ids = new ArrayList<>();
        String sql = "SELECT instance_api_id FROM instance_api_t WHERE host_id = ? AND instance_id = ?";
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, hostId);
            ps.setObject(2, instanceId);
            try (ResultSet rs = ps.executeQuery()) {
                while(rs.next()) {
                    ids.add(rs.getObject("instance_api_id", UUID.class));
                }
            }
        }
        return ids;
    }
     // --- Add similar helper for findRelevantInstanceAppIds ---
     // --- Add similar helper for findRelevantInstanceAppApiIds (if needed) ---

}

SQL INSERT Statements:

  1. config_snapshot_t:

    INSERT INTO config_snapshot_t
    (snapshot_id, snapshot_ts, snapshot_type, description, user_id, deployment_id,
     scope_host_id, scope_environment, scope_product_id, scope_product_version_id, scope_service_id /*, ... other scope cols */)
    VALUES (?, CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?, ?, ?, ? /*, ... */)
    

    (Parameters: snapshotId, snapshotType, description, userId, deploymentId, hostId, environment, productId, productVersionId, serviceId, …)

  2. config_snapshot_property_t: (Executed in a loop/batch)

    INSERT INTO config_snapshot_property_t
    (snapshot_property_id, snapshot_id, config_phase, config_id, property_id, property_name,
     property_type, property_value, value_type, source_level)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    

    (Parameters: new UUID, snapshotId, phase, configId, propertyId, propName, propType, propValue, valType, sourceLevelString)

  3. snapshot_instance_property_t:

    INSERT INTO snapshot_instance_property_t
    (snapshot_id, host_id, instance_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_id, property_id, property_value, update_user, update_ts
    FROM instance_property_t
    WHERE host_id = ? AND instance_id = ?
    

    (Parameters: snapshotId, hostId, instanceId)

  4. snapshot_instance_api_property_t:

    INSERT INTO snapshot_instance_api_property_t
    (snapshot_id, host_id, instance_api_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_api_id, property_id, property_value, update_user, update_ts
    FROM instance_api_property_t
    WHERE host_id = ? AND instance_api_id = ANY(?) -- Parameter is a SQL Array of relevant instance_api_ids
    

    (Parameters: snapshotId, hostId, SQL Array of instance_api_ids)

  5. snapshot_instance_app_property_t:

    INSERT INTO snapshot_instance_app_property_t
    (snapshot_id, host_id, instance_app_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_app_id, property_id, property_value, update_user, update_ts
    FROM instance_app_property_t
    WHERE host_id = ? AND instance_app_id = ANY(?) -- Parameter is a SQL Array of relevant instance_app_ids
    

    (Parameters: snapshotId, hostId, SQL Array of instance_app_ids)

  6. snapshot_instance_app_api_property_t:

    INSERT INTO snapshot_instance_app_api_property_t
    (snapshot_id, host_id, instance_app_id, instance_api_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, instance_app_id, instance_api_id, property_id, property_value, update_user, update_ts
    FROM instance_app_api_property_t
    WHERE host_id = ?
      AND instance_app_id = ANY(?) -- SQL Array of relevant instance_app_ids
      AND instance_api_id = ANY(?) -- SQL Array of relevant instance_api_ids
    

    (Parameters: snapshotId, hostId, SQL Array of instance_app_ids, SQL Array of instance_api_ids)

  7. snapshot_environment_property_t:

    INSERT INTO snapshot_environment_property_t
    (snapshot_id, host_id, environment, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, environment, property_id, property_value, update_user, update_ts
    FROM environment_property_t
    WHERE host_id = ? AND environment = ?
    

    (Parameters: snapshotId, hostId, environment)

  8. snapshot_product_version_property_t:

    INSERT INTO snapshot_product_version_property_t
    (snapshot_id, host_id, product_version_id, property_id, property_value, update_user, update_ts)
    SELECT ?, host_id, product_version_id, property_id, property_value, update_user, update_ts
    FROM product_version_property_t
    WHERE host_id = ? AND product_version_id = ?
    

    (Parameters: snapshotId, hostId, productVersionId)

  9. snapshot_product_property_t:

    INSERT INTO snapshot_product_property_t
    (snapshot_id, product_id, property_id, property_value, update_user, update_ts)
    SELECT ?, product_id, property_id, property_value, update_user, update_ts
    FROM product_property_t
    WHERE product_id = ?
    

    (Parameters: snapshotId, productId)

Important Considerations:

  • Transaction Management: Ensure all these inserts happen within a single atomic transaction.
  • Error Handling: Robust error handling and rollback are critical.
  • Performance: The aggregation query can be heavy. Consider its performance impact. The INSERT ... SELECT statements should be reasonably efficient if the source tables are properly indexed on their scope columns.
  • Parameterization: Use PreparedStatement for all queries to prevent SQL injection and improve performance.
  • Scope Derivation Logic: Ensure the logic in deriveScopeInfo and the helpers like findRelevantInstanceApiIds is correct and handles cases where relationships might not exist (e.g., an instance with no associated APIs).
  • UUID Generation: Use a suitable UUID generator (like UUIDv7 via uuid-creator).
  • Logging: Add clear logging for snapshot creation success/failure.
  • Refinement: You’ll need to adapt the AGGREGATE_EFFECTIVE_CONFIG_SQL query based on the final version using ROW_NUMBER and correctly incorporate parameters.

rollbackConfigInstance

Okay, here’s the rollbackConfigInstance method implementing the DELETE/INSERT strategy to restore the state of instance-level and related sub-level overrides from a snapshot.

Assumptions:

  • “Rolling back an instance” means restoring the overrides defined specifically for that instance and its associated APIs, Apps, and App-API combinations. It does not modify higher-level overrides (Environment, Product Version, Product).
  • The snapshot_*_property_t tables accurately store the state of the corresponding live tables at the time the snapshot was taken.
  • The necessary helper methods like findRelevantInstanceApiIds, findRelevantInstanceAppIds exist (examples provided).
import com.github.f4b6a3.uuid.UuidCreator; // If needed for audit logging ID
import javax.sql.DataSource;
import java.sql.*;
import java.util.*;

public class ConfigRollbackService {

    private final DataSource ds;
    // Inject DataSource via constructor

    // --- SQL Templates ---

    // DELETE Statements (Targeting LIVE tables)
    private static final String DELETE_INSTANCE_PROPS_SQL = "DELETE FROM instance_property_t WHERE host_id = ? AND instance_id = ?";
    private static final String DELETE_INSTANCE_API_PROPS_SQL = "DELETE FROM instance_api_property_t WHERE host_id = ? AND instance_api_id = ANY(?)";
    private static final String DELETE_INSTANCE_APP_PROPS_SQL = "DELETE FROM instance_app_property_t WHERE host_id = ? AND instance_app_id = ANY(?)";
    private static final String DELETE_INSTANCE_APP_API_PROPS_SQL = "DELETE FROM instance_app_api_property_t WHERE host_id = ? AND instance_app_id = ANY(?) AND instance_api_id = ANY(?)";

    // INSERT ... SELECT Statements (From SNAPSHOT tables to LIVE tables)
    private static final String INSERT_INSTANCE_PROPS_SQL = """
        INSERT INTO instance_property_t
        (host_id, instance_id, property_id, property_value, update_user, update_ts)
        SELECT host_id, instance_id, property_id, property_value, update_user, update_ts
        FROM snapshot_instance_property_t
        WHERE snapshot_id = ? AND host_id = ? AND instance_id = ?
        """;
    private static final String INSERT_INSTANCE_API_PROPS_SQL = """
        INSERT INTO instance_api_property_t
        (host_id, instance_api_id, property_id, property_value, update_user, update_ts)
        SELECT host_id, instance_api_id, property_id, property_value, update_user, update_ts
        FROM snapshot_instance_api_property_t
        WHERE snapshot_id = ? AND host_id = ? AND instance_api_id = ANY(?)
        """;
     private static final String INSERT_INSTANCE_APP_PROPS_SQL = """
        INSERT INTO instance_app_property_t
        (host_id, instance_app_id, property_id, property_value, update_user, update_ts)
        SELECT host_id, instance_app_id, property_id, property_value, update_user, update_ts
        FROM snapshot_instance_app_property_t
        WHERE snapshot_id = ? AND host_id = ? AND instance_app_id = ANY(?)
        """;
    private static final String INSERT_INSTANCE_APP_API_PROPS_SQL = """
        INSERT INTO instance_app_api_property_t
        (host_id, instance_app_id, instance_api_id, property_id, property_value, update_user, update_ts)
        SELECT host_id, instance_app_id, instance_api_id, property_id, property_value, update_user, update_ts
        FROM snapshot_instance_app_api_property_t
        WHERE snapshot_id = ? AND host_id = ? AND instance_app_id = ANY(?) AND instance_api_id = ANY(?)
        """;

    public Result<String> rollbackConfigInstance(Map<String, Object> event) {
        // 1. Extract Input Parameters
        UUID snapshotId = (UUID) event.get("snapshotId");
        UUID hostId = (UUID) event.get("hostId");
        UUID instanceId = (UUID) event.get("instanceId");
        UUID userId = (UUID) event.get("userId"); // For potential auditing
        String description = (String) event.get("rollbackDescription"); // Optional reason

        if (snapshotId == null || hostId == null || instanceId == null) {
            return Failure.of(new Status(INVALID_PARAMETER, "snapshotId, hostId, and instanceId are required."));
        }

        Connection connection = null;
        List<UUID> currentApiIds = null;
        List<UUID> currentAppIds = null;

        try {
            connection = ds.getConnection();
            connection.setAutoCommit(false); // Start Transaction

            // --- Pre-computation: Find CURRENT associated IDs for DELETE scope ---
            // It's generally safer to delete based on current relationships and then
            // insert based on snapshot relationships if they could have diverged.
            currentApiIds = findRelevantInstanceApiIds(connection, hostId, instanceId);
            currentAppIds = findRelevantInstanceAppIds(connection, hostId, instanceId);
            // Note: InstanceAppApi requires both lists.

            logger.info("Starting rollback for instance {} (host {}) to snapshot {}", instanceId, hostId, snapshotId);

            // --- Execute Deletes from LIVE tables ---
            executeDelete(connection, DELETE_INSTANCE_PROPS_SQL, hostId, instanceId);

            if (!currentApiIds.isEmpty()) {
                executeDeleteWithArray(connection, DELETE_INSTANCE_API_PROPS_SQL, hostId, currentApiIds);
                // Also delete AppApi props related to these APIs if apps also exist
                if (!currentAppIds.isEmpty()) {
                     executeDeleteWithTwoArrays(connection, DELETE_INSTANCE_APP_API_PROPS_SQL, hostId, currentAppIds, currentApiIds);
                }
            }

            if (!currentAppIds.isEmpty()) {
                executeDeleteWithArray(connection, DELETE_INSTANCE_APP_PROPS_SQL, hostId, currentAppIds);
                 // AppApi props deletion might have already happened above if APIs existed.
                 // If only apps existed but no APIs, delete AppApi here (redundant if handled above)
                 // Generally safe to run the AppApi delete again if needed, targeting only appIds.
                 // For simplicity, we assume the AppApi delete targeting both arrays covers necessary cases.
            }


            // --- Execute Inserts from SNAPSHOT tables ---
            executeInsertSelect(connection, INSERT_INSTANCE_PROPS_SQL, snapshotId, hostId, instanceId);

            // For array-based inserts, we need the IDs *from the snapshot time*
            // However, the SELECT inside the INSERT query implicitly filters by snapshot_id AND the array condition,
            // so it should correctly only insert relationships that existed in the snapshot.
            // We still use the *current* IDs to DEFINE the overall scope of instance being affected,
            // but the INSERT...SELECT filters correctly based on snapshot content.
            if (!currentApiIds.isEmpty()) { // Use currentApiIds to decide IF we run the insert query
                executeInsertSelectWithArray(connection, INSERT_INSTANCE_API_PROPS_SQL, snapshotId, hostId, currentApiIds);
                 if (!currentAppIds.isEmpty()) {
                    executeInsertSelectWithTwoArrays(connection, INSERT_INSTANCE_APP_API_PROPS_SQL, snapshotId, hostId, currentAppIds, currentApiIds);
                }
            }
            if (!currentAppIds.isEmpty()) { // Use currentAppIds to decide IF we run the insert query
                executeInsertSelectWithArray(connection, INSERT_INSTANCE_APP_PROPS_SQL, snapshotId, hostId, currentAppIds);
                // Redundant AppApi insert if handled above? No, the INSERT uses the AppId filter.
                // If only apps existed at snapshot time, this covers it.
            }

            // --- Optional: Audit Logging ---
            // logRollbackActivity(connection, snapshotId, hostId, instanceId, userId, description);


            // --- Commit Transaction ---
            connection.commit();
            logger.info("Successfully rolled back instance {} (host {}) to snapshot {}", instanceId, hostId, snapshotId);
            return Success.of("Rollback successful to snapshot " + snapshotId);

        } catch (SQLException e) {
            logger.error("SQLException during rollback for instance {} to snapshot {}: {}", instanceId, snapshotId, e.getMessage(), e);
            if (connection != null) {
                try {
                    connection.rollback();
                     logger.warn("Transaction rolled back for instance {} snapshot {}", instanceId, snapshotId);
                } catch (SQLException ex) {
                    logger.error("Error rolling back transaction:", ex);
                }
            }
            return Failure.of(new Status(SQL_EXCEPTION, "Database error during rollback operation."));
        } catch (Exception e) { // Catch other potential errors
             logger.error("Exception during rollback for instance {} to snapshot {}: {}", instanceId, snapshotId, e.getMessage(), e);
             if (connection != null) {
                 try { connection.rollback(); } catch (SQLException ex) { logger.error("Error rolling back transaction:", ex); }
             }
            return Failure.of(new Status(GENERIC_EXCEPTION, "Unexpected error during rollback operation."));
        } finally {
            if (connection != null) {
                try {
                    connection.setAutoCommit(true); // Restore default behavior
                    connection.close();
                } catch (SQLException e) {
                    logger.error("Error closing connection:", e);
                }
            }
        }
    }

    // --- Helper Methods for Execution ---

    private void executeDelete(Connection conn, String sql, UUID hostId, UUID instanceId) throws SQLException {
         try (PreparedStatement ps = conn.prepareStatement(sql)) {
             ps.setObject(1, hostId);
             ps.setObject(2, instanceId);
             int rowsAffected = ps.executeUpdate();
             logger.debug("Deleted {} rows from {} for instance {}", rowsAffected, getTableNameFromDeleteSql(sql), instanceId);
         }
    }

    private void executeDeleteWithArray(Connection conn, String sql, UUID hostId, List<UUID> idList) throws SQLException {
        if (idList == null || idList.isEmpty()) return; // Nothing to delete if list is empty
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setObject(1, hostId);
            Array sqlArray = conn.createArrayOf("UUID", idList.toArray());
            ps.setArray(2, sqlArray);
            int rowsAffected = ps.executeUpdate();
            logger.debug("Deleted {} rows from {} for {} IDs", rowsAffected, getTableNameFromDeleteSql(sql), idList.size());
            sqlArray.free();
        }
    }

    private void executeDeleteWithTwoArrays(Connection conn, String sql, UUID hostId, List<UUID> idList1, List<UUID> idList2) throws SQLException {
        if (idList1 == null || idList1.isEmpty() || idList2 == null || idList2.isEmpty()) return;
         try (PreparedStatement ps = conn.prepareStatement(sql)) {
             ps.setObject(1, hostId);
             Array sqlArray1 = conn.createArrayOf("UUID", idList1.toArray());
             Array sqlArray2 = conn.createArrayOf("UUID", idList2.toArray());
             ps.setArray(2, sqlArray1);
             ps.setArray(3, sqlArray2);
             int rowsAffected = ps.executeUpdate();
             logger.debug("Deleted {} rows from {} for {}x{} IDs", rowsAffected, getTableNameFromDeleteSql(sql), idList1.size(), idList2.size());
             sqlArray1.free();
             sqlArray2.free();
         }
    }


    private void executeInsertSelect(Connection conn, String sql, UUID snapshotId, UUID hostId, UUID instanceId) throws SQLException {
         try (PreparedStatement ps = conn.prepareStatement(sql)) {
             ps.setObject(1, snapshotId);
             ps.setObject(2, hostId);
             ps.setObject(3, instanceId);
             int rowsAffected = ps.executeUpdate();
              logger.debug("Inserted {} rows into {} from snapshot {}", rowsAffected, getTableNameFromInsertSql(sql), snapshotId);
         }
    }

     private void executeInsertSelectWithArray(Connection conn, String sql, UUID snapshotId, UUID hostId, List<UUID> idList) throws SQLException {
         if (idList == null || idList.isEmpty()) return; // No scope to insert for
         try (PreparedStatement ps = conn.prepareStatement(sql)) {
             ps.setObject(1, snapshotId);
             ps.setObject(2, hostId);
             Array sqlArray = conn.createArrayOf("UUID", idList.toArray());
             ps.setArray(3, sqlArray);
             int rowsAffected = ps.executeUpdate();
             logger.debug("Inserted {} rows into {} from snapshot {} for {} IDs", rowsAffected, getTableNameFromInsertSql(sql), snapshotId, idList.size());
             sqlArray.free();
         }
     }

     private void executeInsertSelectWithTwoArrays(Connection conn, String sql, UUID snapshotId, UUID hostId, List<UUID> idList1, List<UUID> idList2) throws SQLException {
         if (idList1 == null || idList1.isEmpty() || idList2 == null || idList2.isEmpty()) return;
         try (PreparedStatement ps = conn.prepareStatement(sql)) {
             ps.setObject(1, snapshotId);
             ps.setObject(2, hostId);
             Array sqlArray1 = conn.createArrayOf("UUID", idList1.toArray());
             Array sqlArray2 = conn.createArrayOf("UUID", idList2.toArray());
             ps.setArray(3, sqlArray1);
             ps.setArray(4, sqlArray2);
             int rowsAffected = ps.executeUpdate();
              logger.debug("Inserted {} rows into {} from snapshot {} for {}x{} IDs", rowsAffected, getTableNameFromInsertSql(sql), snapshotId, idList1.size(), idList2.size());
             sqlArray1.free();
             sqlArray2.free();
         }
     }


    // --- Helper methods to find associated IDs (same as before) ---
    private List<UUID> findRelevantInstanceApiIds(Connection conn, UUID hostId, UUID instanceId) throws SQLException {
        // ... implementation ...
    }
    private List<UUID> findRelevantInstanceAppIds(Connection conn, UUID hostId, UUID instanceId) throws SQLException {
        // ... implementation ...
    }

    // --- Optional: Helper to get table name from SQL for logging ---
    private String getTableNameFromDeleteSql(String sql) {
        // Simple parsing, might need adjustment
        try { return sql.split("FROM ")[1].split(" ")[0]; } catch (Exception e) { return "[unknown table]"; }
    }
    private String getTableNameFromInsertSql(String sql) {
        try { return sql.split("INTO ")[1].split(" ")[0]; } catch (Exception e) { return "[unknown table]"; }
    }

     // --- Optional: Audit Logging Method ---
    // private void logRollbackActivity(Connection conn, UUID snapshotId, ...) throws SQLException { ... }

}

Explanation:

  1. Parameter Extraction: Gets snapshotId, hostId, instanceId.
  2. Transaction Control: Sets setAutoCommit(false) at the start and uses commit() or rollback().
  3. Find Current Related IDs: Queries instance_api_t and instance_app_t to find the currently associated APIs and Apps for the target instance. This is needed to define the scope for the DELETE operations.
  4. Execute Deletes: Uses helper methods to execute DELETE statements against the live override tables (instance_property_t, instance_api_property_t, etc.), filtering by hostId, instanceId, and the arrays of currentApiIds / currentAppIds where applicable. This clears the current overrides within the specific scope.
  5. Execute Inserts: Uses helper methods to execute INSERT INTO [live_table] SELECT ... FROM [snapshot_table] statements.
    • These statements select data from the snapshot_*_property_t tables.
    • They are filtered by the target snapshotId.
    • Crucially, they are also filtered by hostId, instanceId, and the relevant instance_api_id / instance_app_id arrays. This ensures only the overrides relevant to this instance within that snapshot are inserted.
  6. SQL Arrays: Uses connection.createArrayOf("UUID", ...) to pass lists of IDs efficiently to PostgreSQL’s ANY(?) operator. Remember to free() the array resources.
  7. Helpers: Includes helper methods for executing the different types of DELETE/INSERT statements and for finding the related API/App IDs.
  8. Error Handling: Uses try-catch-finally to manage the transaction and connection state.
  9. Logging: Added basic logging for start, success, errors, and rows affected.
  10. Scope Focus: This implementation focuses the rollback on the instance and its direct sub-levels (API, App, App-API). It assumes rolling back an instance doesn’t automatically mean rolling back shared Environment or Product Version overrides.

This approach provides a clean and relatively robust way to restore the specific override state from your snapshots using the DELETE/INSERT strategy. Remember to test thoroughly!

SID and Host Verification

Problem

GitHub issue https://github.com/lightapi/portal-service/issues/39 reports that config-server accepted a valid JWT whose service identity did not match the requested service configuration.

The reported token contains:

{
  "iss": "urn:com:networknt:oauth2:v1",
  "aud": "urn:com.networknt",
  "cid": "019e2825-146d-7a00-b0e8-3671158bb32a",
  "scp": ["portal.r", "portal.w"],
  "host": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "sid": "com.networknt.light-gateway-1.0.0"
}

The request used a different service id:

serviceId=com.networknt.ai.gateway-1.0.0

The token is cryptographically valid, but it must not authorize access to a different service’s configuration. Signature, issuer, audience, and scope validation prove that the token is valid; they do not prove that the token is valid for the requested host and serviceId.

The security contract for runtime service tokens is now:

token.host == requested host context
token.sid == requested service context
token.env == requested environment context, when envTag is present

For config-server, the requested host context is the host query parameter. For controller registry registration, the requested host context is the controller’s configured hostId, because service/register does not carry a separate hostId.

Original Gaps

Rust portal-service/apps/config-server

The Rust config server verifies the bearer token through JwtVerifier and binds the decoded claims into each handler:

#![allow(unused)]
fn main() {
async fn get_configs(
    State(state): State<AppState>,
    _claims: Claims,
    Query(query): Query<ConfigQuery>,
) -> Response
}

The handlers then read host and service_id from the query and call the read model. They did not compare token host with query.host, and did not compare token sid with query.service_id.

Affected endpoints:

GET /config-server/configs
GET /config-server/certs
GET /config-server/files

Java light-config-server

The Java config server routes the same three endpoints through the default chain, which includes JwtVerifyHandler. The Light-4j security handler stores the verified JwtClaims in AUDIT_INFO under Constants.SUBJECT_CLAIMS.

The business handlers then read host and serviceId from query parameters and call the database helpers. They did not compare token host with request host, and did not compare token sid with request serviceId.

Affected handlers:

ConfigsGetHandler
CertsGetHandler
FilesGetHandler

The controller registry paths already perform service identity binding during runtime registration, but they must use the same strict sid and host contract as config-server.

The registry request carries serviceId in the service/register payload. It does not carry a separate hostId; registry writes are scoped to the controller’s configured host id. The integration token must carry both sid and host. The registration must be rejected before the runtime instance is stored when the token has no sid, a blank sid, or a sid that differs from the requested serviceId. It must also be rejected when the token has no host, a blank host, or a host that differs from the configured controller host id.

sub is not an acceptable fallback for registry authorization. It can still be used by other OAuth flows as the subject, but the registry authorization check must bind the explicit service authorization claim:

token.sid == register.params.serviceId
token.host == controller.config.hostId

In controller-rs, this rule belongs in ServiceJwtVerifier::validate, before handle_socket persists the runtime instance. In Java light-controller, it belongs in ServiceJwtValidator.validateServiceToken, called by MicroserviceEndpoint.register with the requested serviceId.

The controller implementations should check env when the request provides envTag, but that check is an additional constraint. It does not replace the mandatory sid to serviceId comparison or the mandatory host to controller hostId comparison.

Security Requirement

For any controller registry request or config-server request that asks for a service-scoped resource with serviceId, the token must contain a sid claim equal to that requested serviceId.

For any config-server request with a host query parameter, the token must contain a host claim equal to that requested host.

For any controller registry service/register request, the token must contain a host claim equal to the controller’s configured hostId.

For any config-server request or controller registry service/register request with a non-blank envTag, the token must contain an env claim equal to that requested envTag.

The request should be rejected when:

  • serviceId is present and non-blank, but token sid is missing.
  • serviceId is present and non-blank, but token sid is blank.
  • serviceId is present and non-blank, but token sid differs from it.
  • host is present and non-blank, but token host is missing.
  • host is present and non-blank, but token host is blank.
  • host is present and non-blank, but token host differs from it.
  • envTag is present and non-blank, but token env is missing.
  • envTag is present and non-blank, but token env is blank.
  • envTag is present and non-blank, but token env differs from it.

The request may continue to use the existing product-level path when no serviceId is supplied. Product-level requests are not service-scoped and should not be forced to match sid until the product-level authorization model is explicitly designed.

The same exception does not apply to controller registry registration because service/register always carries a requested serviceId.

Goals

  • Prevent one service token from downloading another service’s config, certs, or files.
  • Prevent one service token from registering a runtime instance for another service id.
  • Implement the same authorization rule in Rust config-server and Java light-config-server.
  • Implement the same authorization rule in Rust controller-rs and Java light-controller.
  • Keep existing JWT signature, issuer, audience, and scope checks unchanged.
  • Keep the rule local to config-server handlers, because only they know the requested host and serviceId.
  • Return a clear authorization failure before any database lookup is executed.

Implemented Behavior

The implementation applies the same authorization contract in all four runtime paths:

token.host == requested host context
token.sid == requested service context

The implemented paths are:

controller-rs/src/auth.rs
light-controller/src/main/java/com/networknt/controller/auth/ServiceJwtValidator.java
portal-service/crates/internal-auth/src/lib.rs
portal-service/apps/config-server/src/main.rs
light-config-server/src/main/java/com/networknt/configserver/util/ServiceIdAuthorizationUtil.java

For Rust controller-rs, ServiceJwtVerifier::validate now requires a non-blank sid and compares it to the registration serviceId. It also requires a non-blank host and compares it to the controller hostId. When registration includes envTag, it requires token env and compares it to that envTag.

For Java light-controller, ServiceJwtValidator.validateServiceToken applies the same checks when MicroserviceEndpoint.register passes the requested serviceId.

For Rust config-server, internal-auth::Claims now exposes explicit optional sid, host, and env fields. The configs, certs, and files handlers call authorize_request_context before invoking the read model.

For Java light-config-server, ServiceIdAuthorizationUtil extracts verified claims from AUDIT_INFO and applies the same host and SID checks before ConfigsGetHandler, CertsGetHandler, or FilesGetHandler calls the database helper.

The focused implementation tests cover missing and mismatched host, missing and mismatched sid, missing and mismatched env when envTag is requested, blank values, whitespace trimming, and case-sensitive identifier comparison.

Non-Goals

  • Do not replace JWT verification middleware.
  • Do not redesign OAuth token issuance.
  • Do not require sid for product-level requests that do not carry serviceId.
  • Do not require env when the request omits envTag.
  • Do not trust request headers such as X-Service-Id as a substitute for the JWT claim.
  • Do not use sub as a fallback for service-scoped controller registry or config-server authorization.

Token Contract

Trusted service tokens used for config-server startup access should include:

{
  "host": "<host-id>",
  "sid": "<service-id>",
  "env": "<optional-environment>"
}

sid is the runtime service id that the token is allowed to bootstrap. For example:

{
  "sid": "com.networknt.ai.gateway-1.0.0"
}

sid must be treated as a reserved authorization claim. It should be generated from trusted client configuration or a trusted token request path, not from unvalidated caller input.

Request Contract

For service-scoped requests:

GET /config-server/configs?host=...&serviceId=com.networknt.ai.gateway-1.0.0&envTag=dev
GET /config-server/certs?host=...&serviceId=com.networknt.ai.gateway-1.0.0&envTag=dev
GET /config-server/files?host=...&serviceId=com.networknt.ai.gateway-1.0.0&envTag=dev

The authorization rule is:

token.host == request.host
token.sid == request.serviceId

The comparisons should trim surrounding whitespace but should otherwise be exact and case-sensitive. Host ids and service ids are identifiers, not display names.

For requests without serviceId, the SID rule is not applied:

GET /config-server/configs?host=...&productId=lg&productVersion=1.5.1&envTag=dev

Those requests should continue through the existing product-level behavior, but the host binding rule still applies:

token.host == request.host

For any request with a non-blank envTag, including product-level requests, the environment binding rule also applies:

token.env == request.envTag

Add a small config-server authorization helper in both implementations, and tighten the controller registry validators to use the same sid binding rule.

The helper should accept the decoded JWT claims and the parsed query object, and return either:

  • success when there is no service-scoped request or the sid matches
  • an authorization response when the request is service-scoped and invalid

Pseudo logic:

requestedHost = trim(query.host)
tokenHost = trim(claim.host)
if tokenHost is empty:
    reject 403

if tokenHost != requestedHost:
    reject 403

requestedServiceId = trim(query.serviceId)
if requestedServiceId is not empty:
    tokenServiceId = trim(claim.sid)
    if tokenServiceId is empty:
        reject 403

    if tokenServiceId != requestedServiceId:
        reject 403

requestedEnvTag = trim(query.envTag)
if requestedEnvTag is not empty:
    tokenEnv = trim(claim.env)
    if tokenEnv is empty:
        reject 403

    if tokenEnv != requestedEnvTag:
        reject 403

allow

Run this check before getSnapshotConfigs, getSnapshotCerts, getSnapshotFiles, or any live config query helper.

For controller registry registration, serviceId is not optional and the expected host is the controller’s configured hostId. The same comparisons should run after signature, issuer, and audience validation and before any runtime instance lookup or persistence. A valid sub with a missing sid must still be rejected, and a token with no host must also be rejected.

Response Status

Use 403 Forbidden for SID or host-binding failures.

The JWT has already passed authentication. The failure is authorization: the token is valid but not allowed to access the requested host or service configuration or environment.

Suggested response body:

Token sid does not match requested serviceId
Token host does not match requested host
Token env does not match requested envTag

Avoid echoing the full token or all claims in the response. Logging the requested host, token host, requested serviceId, and token sid at warn level is useful for operations. When envTag is present, also log requested envTag and token env.

Controller Implementation

Rust controller-rs

ServiceJwtVerifier::validate now makes service registration read only claims.sid, trims it, rejects blank or missing values, and compares it with ServiceRegistrationParams.service_id.

The same validation path requires claims.host, trims it, and compares it with Settings.host_id. Do not fall back to claims.sub for registry authorization.

When ServiceRegistrationParams.env_tag is present and non-blank, the same validation path requires a non-blank claims.env and compares it with the requested envTag.

The WebSocket registration tests cover:

  • a token with sid and no sub still registers
  • a token with matching sub but missing sid is rejected
  • a token with matching sub but mismatched sid is rejected
  • a token with missing or mismatched host is rejected
  • a request with envTag and missing or mismatched token env is rejected

Java light-controller

ServiceJwtValidator.validateServiceToken now requires sid when MicroserviceEndpoint.register passes a requested serviceId, and compares it with that serviceId.

The validator also requires host and compares it with ControllerRuntimeConfig.hostId. Do not fall back to JwtClaims.getSubject() for registry authorization.

When envTag is present and non-blank, the validator also requires env and compares it with that envTag.

The registration test token builders now include sid and host for normal service JWTs. Regression tests cover missing and mismatched sid, plus missing and mismatched host, plus missing and mismatched env when envTag is requested.

Rust Config-Server Implementation

Claims

internal-auth::Claims now exposes sid and host as explicit optional fields:

#![allow(unused)]
fn main() {
pub sid: Option<String>,
pub host: Option<String>,
pub env: Option<String>,
}

This keeps the authorization path readable and avoids treating sid and host as generic extension claims. They are first-class authorization claims for config-server and controller runtime access.

Handler Flow

Each handler uses claims rather than _claims:

#![allow(unused)]
fn main() {
async fn get_configs(
    State(state): State<AppState>,
    claims: Claims,
    Query(query): Query<ConfigQuery>,
) -> Response {
    if let Err(response) = authorize_request_context(
        &claims,
        &query.host,
        query.service_id.as_deref(),
        query.env_tag.as_deref(),
    ) {
        return response;
    }

    ...
}
}

The shared helper is:

#![allow(unused)]
fn main() {
fn authorize_request_context(
    claims: &Claims,
    requested_host: &str,
    requested_service_id: Option<&str>,
    requested_env_tag: Option<&str>,
) -> Result<(), Response>
}

Apply the helper to:

get_configs
get_certs
get_files

Rust Tests

The helper tests cover:

  • allows a matching sid
  • allows a matching host
  • allows an absent serviceId
  • rejects missing host
  • rejects mismatched host
  • rejects missing sid when serviceId is present
  • rejects mismatched sid
  • allows absent envTag
  • rejects missing env when envTag is present
  • rejects mismatched env
  • trims surrounding whitespace
  • preserves case-sensitive matching

If the handlers are tested directly, add endpoint-level regressions that prove mismatched host or sid returns 403 before the read model is called.

Java Config-Server Implementation

Claims Source

The Light-4j JwtVerifyHandler places the verified claims in:

Map<String, Object> auditInfo =
    exchange.getAttachment(AttachmentConstants.AUDIT_INFO);

JwtClaims claims =
    (JwtClaims)auditInfo.get(Constants.SUBJECT_CLAIMS);

The shared helper in light-config-server is:

com.networknt.configserver.util.ServiceIdAuthorizationUtil

Implemented API:

public static String authorizeRequestContext(
    HttpServerExchange exchange,
    String requestedHost,
    String requestedServiceId,
    String requestedEnvTag
)
public static String authorizeRequestContext(
    JwtClaims claims,
    String requestedHost,
    String requestedServiceId,
    String requestedEnvTag
)

The exchange overload extracts verified claims from AUDIT_INFO. The claims overload is used by focused unit tests. Both methods return null on success or a short error message when the request must be rejected with 403.

Handler Flow

At the top of each handler, after reading query parameters and before calling the DB helper:

String authorizationError =
    ServiceIdAuthorizationUtil.authorizeRequestContext(exchange, host, serviceId, envTag);
if (authorizationError != null) {
    exchange.setStatusCode(StatusCodes.FORBIDDEN);
    exchange.getResponseSender().send(authorizationError);
    return;
}

Apply the helper to:

ConfigsGetHandler
CertsGetHandler
FilesGetHandler

Java Tests

Focused unit tests cover:

  • allows matching sid
  • allows matching host
  • allows blank serviceId
  • rejects missing claims when host is present
  • rejects missing host
  • rejects mismatched host
  • rejects missing claims when serviceId is present
  • rejects missing sid
  • rejects mismatched sid
  • allows blank envTag
  • rejects missing env when envTag is present
  • rejects mismatched env
  • trims surrounding whitespace
  • remains case-sensitive

Handler-level coverage can be added later if the test harness can cheaply inject AUDIT_INFO. The first implementation relies on focused helper tests plus the existing handler request coverage.

Token Issuance Check

This change depends on runtime service tokens carrying sid for service-scoped startup access and controller registry registration. Before deploying the authorization check broadly, verify the Light OAuth token path used by runtime services.

For long-lived or trusted client_credentials runtime tokens:

  • token custom claims should include host
  • token custom claims should include sid
  • token custom claims may include env, but must include env for runtimes that call config-server or controller with envTag

If a runtime cannot mint a token with host and sid, it should fail early during token setup rather than be allowed to call config-server or register with controller using a broader token.

Backward Compatibility

This is a security-tightening change. It can break clients that currently call config-server with a serviceId or register with controller while using a token that has no host or sid. It can also break clients that pass envTag while using a token with no matching env.

Recommended rollout for deployments that do not already mint service tokens with host and sid:

  1. Verify runtime token issuance includes host and sid. Verify env is included whenever the runtime sends envTag.
  2. Enable the rule by default in Rust and Java, because config-server returns sensitive config and cert material and controller registry defines runtime service identity.
  3. For one release, monitor explicit warning logs on host or SID failures.
  4. Update local and enterprise runtime token setup docs so service tokens carry host and sid.

If a temporary compatibility switch is required, make it explicit and narrow:

enforceSidHostMatch: true

Do not silently ignore mismatches in production deployments.

Error Handling

Use 403 Forbidden for:

  • missing sid with requested serviceId
  • mismatched sid
  • missing host with requested host or controller hostId
  • mismatched host
  • missing env with requested envTag
  • mismatched env
  • missing decoded claims in Java after the security chain has supposedly run

Use existing 401 Unauthorized behavior for:

  • missing Authorization header
  • invalid token signature
  • invalid issuer or audience
  • expired token

This keeps authentication failures separate from service authorization failures.

Observability

On rejection, log:

requestedServiceId
tokenSid
requestedHost
tokenHost
envTag
tokenEnv
endpoint

Do not log the full JWT.

The log should make the exact issue visible:

Token sid com.networknt.light-gateway-1.0.0 does not match requested serviceId com.networknt.ai.gateway-1.0.0
Token host 01964b05-552a-7c4b-9184-6857e7f3dc5f does not match requested host 01964b05-552a-7c4b-9184-6857e7f3dc5e
Token env dev does not match requested envTag prod

Validation Checklist

After implementation, validate these cases against Rust and Java config-server:

sid=A, serviceId=A => 200
sid=A, serviceId=B => 403
sid missing, serviceId=A => 403
host=H1, request host=H1 => 200
host=H1, request host=H2 => 403
host missing, request host=H1 => 403
sid=A, serviceId omitted, productId/productVersion supplied, host matches => existing behavior
env=dev, envTag=dev => 200
env=dev, envTag=prod => 403
env missing, envTag=dev => 403
env missing, envTag omitted => existing behavior
invalid JWT => 401
missing JWT => 401

Also verify the three endpoint families:

/config-server/configs
/config-server/certs
/config-server/files

Validate the same service identity cases against Rust and Java controller registry registration:

token sid=A, register serviceId=A => registered
token sid=A, register serviceId=B => registration rejected
token sid missing, register serviceId=A, token sub=A => registration rejected
token sid blank, register serviceId=A => registration rejected
token host=H1, controller hostId=H1 => registered
token host=H1, controller hostId=H2 => registration rejected
token host missing, controller hostId=H1 => registration rejected
token env=dev, register envTag=dev => registered
token env=dev, register envTag=prod => registration rejected
token env missing, register envTag=dev => registration rejected
token env missing, register envTag omitted => existing behavior
invalid JWT => registration rejected

Focused verification commands used during implementation:

cargo test -p config-server authorize_request_context
cargo test microservice_registration_rejects
cargo test microservice_registration_uses_jwt_env_when_request_omits_env_tag
mvn -q -Dtest=ControllerWebSocketIntegrationTest#rejectsMicroserviceJwtWhenHostClaimIsMissing+rejectsMicroserviceJwtWhenHostClaimDiffersFromControllerHostId+rejectsMicroserviceJwtWhenSidIsMissing+rejectsMicroserviceJwtWhenSidDiffersFromServiceId+rejectsMicroserviceJwtWhenEnvClaimIsMissingAndEnvTagIsRequested+rejectsMicroserviceJwtWhenEnvClaimDiffersFromEnvTag+registersMicroserviceWhenEnvTagAndEnvClaimAreOmitted test
mvn -q -Dtest=ServiceIdAuthorizationUtilTest test

Open Questions

No open questions for SID, host, and environment binding in this phase.

Instance Clone

Context

One portal database can serve several runtime environments. For example, the portal-bff service can have separate instances for these environment tags:

Deploymentenv_tagExample instance
portal-config-loclocportal-bff-loc
portal-config-devdevportal-bff-dev
light-portal-installdemoportal-bff-demo

Creating the second and third instances manually is error-prone because an instance is the root of a bounded configuration graph, not one row in instance_t. Instance Admin should offer a Clone Instance action that copies the current active state of this graph and lets the administrator change the target identity and environment-specific configuration.

This is a same-host operation. Cross-host or cross-database copying remains an event-promotion/import concern.

Goals

  • Clone an active instance and its selected active children with one command.
  • Require a target instance name and environment tag while allowing other instance fields to be overridden.
  • Generate ordinary create events so replay produces the same target graph.
  • Remap owned aggregate identifiers while preserving references to shared host catalog entities.
  • Bind preview and execution to the same source graph and normalized request.
  • Store the complete event/outbox batch atomically and make retries idempotent.
  • Show every source configuration override and let the user copy, replace, or omit it without exposing values unnecessarily.
  • Optionally clone deployment definitions and selected files.
  • Create a new current configuration snapshot as the last event in the clone transaction when requested.
  • Report durable projection and snapshot status after command acceptance.

Non-goals

  • Copy raw historical events or preserve source aggregate versions.
  • Copy an existing configuration snapshot, runtime registration, deployment execution, audit timestamp, or soft-deleted row.
  • Copy OAuth client owners, OAuth clients, client secrets, client tokens, or authorization grants.
  • Automatically infer which values are environment-specific.
  • Perform a cross-host or cross-database promotion.
  • Split one logical clone across independently committed event batches.

Current State

instance-command already declares cloneInstance. Its handler currently emits InstanceClonedEvent with a bare targetInstanceId aggregate subject. Normal Instance aggregates use hostId|instanceId, so the placeholder event is not compatible with the normal Instance stream.

InstanceDeploymentPersistenceImpl.cloneInstance currently discovers related historical events and logs them but does not create a target. There are also older direct-SQL clone helpers near the promotion implementation. The new implementation must replace and remove these incomplete paths. It must not replay source history or copy projection rows directly.

Clone Boundary

Included graph

Only active projection rows are clone candidates:

instance_t
├── entity_tag_t                  entity_type = 'instance'
├── entity_category_t             entity_type = 'instance'
├── instance_property_t
├── instance_file_t               selected explicitly
├── instance_api_t
│   ├── instance_api_property_t
│   └── instance_api_path_prefix_t
├── instance_app_t
│   ├── instance_app_property_t
│   └── instance_app_api_t
│       └── instance_app_api_property_t
└── deployment_instance_t         selected explicitly
    └── deployment_instance_property_t

Instance tag and category associations are copied by default using the existing tag/category IDs and the new target instance ID.

Explicit exclusions

The following rows are not cloned:

  • config_snapshot_t and every snapshot_* row;
  • runtime_instance_t;
  • deployment_t and deployment execution history;
  • auth_client_owner_t, auth_client_t, auth_client_token_t, secrets, and grants, even when an OAuth owner references the source instance;
  • event-store, outbox, notification, and dead-letter rows from the source;
  • inactive children, deletion metadata, source aggregate versions, audit users, and audit timestamps;
  • deployment job IDs, IP addresses, ports, and execution status.

After cloning, the UI offers Create OAuth Client for the target. OAuth material must be created or associated through its normal audited workflow.

Authorization and Transport

Planning and execution both require portal.w. The server applies host, owner-user, and owner-position authorization; the row button is not an authorization boundary.

  • Administrators may clone any visible source, including a read-only source.
  • Non-administrators may clone only a source they own and may not clone a read-only source.
  • A non-administrator becomes the target owner and cannot override ownership.
  • An administrator may select the target owner user or position.

Planning, value reveal, execution, and status lookup use POST bodies. Clone data must never be placed in a /portal/query?cmd=... URL, browser history, or proxy query string. Responses carrying configuration data use Cache-Control: no-store.

Frozen Version 1 Protocol

Phase 0 freezes the initial wire contract as follows:

OperationKindService/action/version
PlanQuerylightapi.net/instance/planInstanceClone/0.1.0
Reveal one property valueQuerylightapi.net/instance/revealInstanceCloneValue/0.1.0
ExecuteCommandlightapi.net/instance/cloneInstance/0.1.0
StatusQuerylightapi.net/instance/getInstanceCloneStatus/0.1.0

All four operations use POST RPC envelopes and require portal.w. Public JSON uses the camel-case field names shown in this document. CloudEvent extension names must satisfy the lowercase CloudEvents wire-name rule:

MeaningCloudEvent extension
Clone correlationclonerequestid
Root instancerootinstanceid
Accepted graph revisioninstancegraphrevision
Redact payload in diagnosticssensitivepayload

Every clone event has the first three extensions. Property and file events also set sensitivepayload=true. Ordinary graph mutation events have rootinstanceid and instancegraphrevision but no clone request ID.

The initial status vocabulary is ACCEPTED, PROJECTED, SNAPSHOT_READY, and FAILED_DLQ. The public error codes frozen for version 1 are:

  • SOURCE_PROJECTION_LAGGING;
  • SOURCE_VALUE_INVALID_FOR_CURRENT_SCHEMA;
  • PLAN_DEPENDENCY_CHANGED;
  • CLONE_LIMIT_EXCEEDED;
  • IDEMPOTENCY_KEY_REUSED;
  • CONCURRENCY_CONFLICT;
  • TARGET_LOGICAL_IDENTITY_CONFLICT.

Canonical encoding and HMACs

The canonical format identifier and limit-policy version are both instance-clone-v1. Normalization occurs before encoding: UUIDs are lowercase, enumerations use their documented uppercase token, integers use minimal base-10 form, strings use Unicode NFC without implicit trimming, and null is distinct from an empty string.

The encoder writes an ordered sequence of typed fields. Each field contains a one-byte type tag, a four-byte unsigned big-endian byte length, and the value bytes. Strings use UTF-8, booleans use 0 or 1, and collections include their count followed by their members. Maps are not encoded directly; each contract defines a field order or converts the map to a sorted record list. Graph rows sort by entity type and stable source identity. Property selectors sort by scope, source parent identities, property ID, and expected aggregate version.

sourceGraphDigest, catalogSchemaDigest, planHash, and the ledger request_hash use HMAC-SHA-256 with distinct domain strings. A value digest is itself an HMAC over its stable selector and raw value before it enters a graph record. The domain strings are:

ArtifactHMAC domain string
Property/file value digestlightapi.instance-clone.value.v1
Source graph digestlightapi.instance-clone.source-graph.v1
Catalog schema digestlightapi.instance-clone.catalog-schema.v1
Plan hashlightapi.instance-clone.plan.v1
Ledger request hashlightapi.instance-clone.request.v1

The wire/storage representation is:

v1.<keyId>.<base64url-without-padding>

keyId matches [A-Za-z0-9_-]{1,32}. The raw HMAC key and unkeyed value digests are never stored or returned. Query and command processes must use the same active key ID and key. Key rotation invalidates outstanding previews unless the old key remains explicitly configured for verification.

Instance Graph Revision

instance_t.aggregate_version is insufficient for clone consistency because each child aggregate has its own version. Add a root graph revision:

CREATE TABLE instance_graph_revision_t (
    host_id             UUID NOT NULL,
    instance_id         UUID NOT NULL,
    accepted_revision   BIGINT NOT NULL DEFAULT 0,
    projected_revision  BIGINT NOT NULL DEFAULT 0,
    accepted_ts         TIMESTAMPTZ,
    projected_ts        TIMESTAMPTZ,
    PRIMARY KEY (host_id, instance_id),
    FOREIGN KEY (host_id) REFERENCES host_t(host_id) ON DELETE CASCADE,
    CHECK (accepted_revision >= 0 AND projected_revision >= 0),
    CHECK (projected_revision <= accepted_revision)
);

CREATE INDEX instance_graph_revision_lag_idx
  ON instance_graph_revision_t(host_id, instance_id)
  WHERE accepted_revision <> projected_revision;

The revision row is command-side coordination state and may exist before the corresponding InstanceCreatedEvent projects, so it intentionally has no foreign key to instance_t. Instance deletion retains its revision tombstone for replay and idempotency; an explicit retention job may remove old tombstones.

During the staged rollout, an instance created after the Phase 1 backfill but before graph-aware writers deploy can temporarily have no revision row. Planning remains read-only and treats that missing row as the baseline accepted_revision=0, projected_revision=0. Execution and ordinary graph commands acquire the graph advisory lock first, then insert the missing baseline row with ON CONFLICT DO NOTHING before locking and updating it. Before clone is enabled, a reconciliation pass repeats the idempotent backfill and requires zero remaining instances without revision rows.

Every command that creates, updates, deletes, locks, or unlocks an instance or one of the included child entities must:

  1. acquire the same transaction-scoped advisory lock derived from (host_id, root_instance_id);
  2. increment accepted_revision in the same transaction that appends its event/outbox batch;
  3. attach rootInstanceId and instanceGraphRevision to each event’s metadata.

After the corresponding projection transaction succeeds, the consumer advances projected_revision. A clone can execute only when the source accepted_revision equals projected_revision. If not, it returns SOURCE_PROJECTION_LAGGING; the caller retries after the projection catches up.

The preview response contains the projected graph revision and an opaque sourceGraphDigest. The digest is an HMAC over canonical, sorted active rows, including entity type, identity, aggregate version, and a digest of each value. It must not expose a reusable unsalted hash of a secret.

API

Plan

planInstanceClone is a non-mutating POST query. cloneRequestId is optional on the first call. The server generates one when absent; subsequent preview calls reuse it so proposed IDs remain stable while the user edits selections.

{
  "host": "lightapi.net",
  "service": "instance",
  "action": "planInstanceClone",
  "version": "0.1.0",
  "data": {
    "hostId": "...",
    "cloneRequestId": "...",
    "sourceInstanceId": "...",
    "targetInstanceName": "portal-bff-demo",
    "targetEnvTag": "demo",
    "targetEnvironment": "demo",
    "targetServiceId": "com.networknt.portal.gateway-1.0.0",
    "targetProductVersionId": "...",
    "includeFiles": false,
    "fileSelections": [],
    "includeApis": true,
    "apiSelections": ["..."],
    "includeApps": true,
    "appSelections": ["..."],
    "includeDeployments": false,
    "deploymentSelections": [],
    "createSnapshot": true,
    "propertySelections": [
      {
        "scopeType": "INSTANCE",
        "sourceParentIds": {
          "instanceId": "..."
        },
        "propertyId": "...",
        "expectedAggregateVersion": 2,
        "action": "REPLACE",
        "replacementValue": "https://local.localhost/#/app/dashboard"
      }
    ]
  }
}

targetInstanceName and targetEnvTag are required. targetEnvironment defaults to targetEnvTag, and targetServiceId defaults to the source service ID. targetProductVersionId defaults to the source product version. The resolved snapshot lookup tuple (host, serviceId, environment) is shown in preview. For the loc, dev, and demo BFF instances, environment and environment tag should normally have the same value.

Optional instance overrides include description, zone, region, line of business, resource name, business name, topic classification, and ownership when the caller is an administrator.

API and app selections are parent-aware. When apiSelections or appSelections is omitted from an initial plan request, every active source API or app is selected. Once a plan has been returned, the client sends the explicit selected IDs. includeApis: false or includeApps: false with an empty selection removes the entire corresponding section. A non-empty selection is invalid when its include flag is false.

Execution requires both apiSelections and appSelections to be present and returns INVALID_CLONE_REQUEST when either field is omitted; older clients must be upgraded before they can execute clones planned with this contract.

Excluding an instance API also excludes its path prefixes, API properties, and every app/API association that references it. Excluding an instance app also excludes its app properties and every app/API association that references it. An association and its properties are copied only when both its app and API are selected. Property selectors whose parent entity is excluded are rejected.

Property selector

Property choices are an array, not a map keyed by configuration/property name. The stable selector is:

scopeType + sourceParentIds + propertyId + expectedAggregateVersion

Supported scopes are INSTANCE, INSTANCE_API, INSTANCE_APP, INSTANCE_APP_API, and DEPLOYMENT_INSTANCE. The server resolves source IDs through the immutable target ID mapping. Actions are:

  • COPY: copy the source value server-side;
  • REPLACE: validate and use replacementValue;
  • OMIT: do not create the target override, allowing lower-precedence configuration to apply.

The default is COPY for every included active override. A stale selector or a selector outside the source graph fails planning or execution.

Both COPY and REPLACE are validated against the current property schema, value type, and constraints during planning and again during execution. COPY does not grandfather a source value merely because it was valid when it was originally saved. If a copied source value violates the current schema, planning returns SOURCE_VALUE_INVALID_FOR_CURRENT_SCHEMA for that selector and blocks execution until the user chooses REPLACE with a valid value or OMIT.

For OMIT, planning validates the resulting effective target configuration. It fails if omission would leave a required property missing or would expose an invalid lower-precedence value.

Sensitive values

The property inventory always returns property metadata but does not return raw values by default. propertyMetadata is keyed by property ID and includes the configuration name, property name, and valueType; selectors continue to carry the stable property UUID. The UI falls back to the UUID when catalog metadata is unavailable.

COPY never requires the raw value to leave the server. A user who needs to inspect a value uses a separate audited POST reveal action with the same owner and portal.w checks. Until reliable sensitivity metadata exists, every value is masked initially. Reveal responses are not cached or logged.

Files are also metadata-only during preview. File content is copied server-side only after explicit selection. A Cert selection requires an additional confirmation; no certificate or file content appears in preview responses.

Reveal

revealInstanceCloneValue reveals one property value, never file or certificate content. It accepts the stable property selector plus the plan correlation and source digest:

{
  "host": "lightapi.net",
  "service": "instance",
  "action": "revealInstanceCloneValue",
  "version": "0.1.0",
  "data": {
    "hostId": "...",
    "cloneRequestId": "...",
    "sourceInstanceId": "...",
    "sourceGraphDigest": "v1.primary.<base64url>",
    "selector": {
      "scopeType": "INSTANCE",
      "sourceParentIds": { "instanceId": "..." },
      "propertyId": "...",
      "expectedAggregateVersion": 2
    }
  }
}

The server reauthorizes the actor and rejects a stale or out-of-graph selector. The response contains only the selector, value type, and selected raw value, sets Cache-Control: no-store, and is never logged. The audit record contains the actor, host, source instance, selector, timestamp, and outcome without the value.

Plan response

The response contains:

  • cloneRequestId and deterministic proposed target IDs;
  • source instance version, graph revision, and opaque graph digest;
  • catalogSchemaDigest over the current schemas and constraints referenced by the plan;
  • planHash, an HMAC over the canonical request, graph digest, schema digest, target mapping, and limit-policy version;
  • property, file, deployment, tag, and category inventories;
  • event counts and serialized event-byte estimates by entity type;
  • resolved snapshot lookup identity;
  • validation errors and warnings.

Planning is read-only; it does not create a request-ledger row. The plan hash binds the later execution to exactly the previewed graph and choices.

Execute

cloneInstance is a POST command containing the complete normalized plan plus the returned sourceGraphDigest and planHash:

{
  "host": "lightapi.net",
  "service": "instance",
  "action": "cloneInstance",
  "version": "0.1.0",
  "data": {
    "hostId": "...",
    "cloneRequestId": "...",
    "planHash": "...",
    "sourceGraphDigest": "...",
    "catalogSchemaDigest": "...",
    "sourceInstanceId": "...",
    "targetInstanceId": "...",
    "targetInstanceName": "portal-bff-demo",
    "targetEnvTag": "demo",
    "targetEnvironment": "demo",
    "targetServiceId": "com.networknt.portal.gateway-1.0.0",
    "targetProductVersionId": "...",
    "includeFiles": false,
    "fileSelections": [],
    "includeApis": true,
    "apiSelections": ["..."],
    "includeApps": true,
    "appSelections": ["..."],
    "includeDeployments": false,
    "deploymentSelections": [],
    "createSnapshot": true,
    "propertySelections": []
  }
}

The server canonicalizes the request, recomputes the graph digest and plan hash, and rejects any mismatch. It also recomputes catalogSchemaDigest, which covers the current schemas and value-type constraints of every referenced configuration property. A schema change after preview returns PLAN_DEPENDENCY_CHANGED and requires a new preview. Client-provided target or child IDs that do not match the deterministic mapping are rejected.

The clone page renders the planned instance APIs and instance apps as explicit checkbox lists with Select all and Clear all controls. Changing either list invalidates the current plan and clears property overrides so the next plan contains only properties whose parents remain selected.

The immediate response is ACCEPTED, not completed. It contains the clone request ID, target instance ID, transaction ID, terminal event ID, event counts, and status URL/action.

Status

getInstanceCloneStatus accepts hostId and cloneRequestId in a POST query and returns one of:

  • ACCEPTED: the event/outbox batch committed but has not projected;
  • PROJECTED: the clone projected successfully without a requested snapshot;
  • SNAPSHOT_READY: the clone and requested current snapshot projected;
  • FAILED_DLQ: the complete projection transaction rolled back and its events were moved to the dead-letter queue.

The response never returns copied values or file content.

Durable Idempotency Ledger

Add a command-side ledger:

CREATE TABLE instance_clone_request_t (
    host_id              UUID NOT NULL,
    clone_request_id     UUID NOT NULL,
    request_hash         VARCHAR(128) NOT NULL,
    source_instance_id   UUID NOT NULL,
    source_graph_digest  VARCHAR(128) NOT NULL,
    catalog_schema_digest VARCHAR(128) NOT NULL,
    target_instance_id   UUID NOT NULL,
    target_instance_name VARCHAR(126) NOT NULL,
    target_service_id    VARCHAR(512) NOT NULL,
    target_env_tag       VARCHAR(16),
    target_product_version_id UUID NOT NULL,
    transaction_id       UUID NOT NULL,
    terminal_event_id    UUID NOT NULL,
    snapshot_id          UUID,
    clone_status         VARCHAR(32) NOT NULL DEFAULT 'ACCEPTED',
    event_count          INTEGER NOT NULL,
    payload_bytes        BIGINT NOT NULL,
    result_summary       JSONB NOT NULL DEFAULT '{}'::jsonb,
    error_code           VARCHAR(64),
    error_message        VARCHAR(2048),
    requested_by         UUID NOT NULL,
    created_ts           TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_ts           TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (host_id, clone_request_id),
    FOREIGN KEY (host_id) REFERENCES host_t(host_id) ON DELETE CASCADE,
    CHECK (clone_status IN (
      'ACCEPTED', 'PROJECTED', 'SNAPSHOT_READY', 'FAILED_DLQ'
    )),
    CHECK (event_count >= 0),
    CHECK (payload_bytes >= 0),
    CHECK (jsonb_typeof(result_summary) = 'object'),
    CHECK (clone_status <> 'SNAPSHOT_READY' OR snapshot_id IS NOT NULL)
);

CREATE INDEX instance_clone_request_target_id_idx
  ON instance_clone_request_t(host_id, target_instance_id);

CREATE INDEX instance_clone_request_target_identity_idx
  ON instance_clone_request_t(
    host_id, target_service_id, target_env_tag, target_product_version_id
  );

CREATE UNIQUE INDEX instance_clone_request_transaction_uk
  ON instance_clone_request_t(host_id, transaction_id);

CREATE INDEX instance_clone_request_status_idx
  ON instance_clone_request_t(host_id, clone_status, updated_ts);

request_hash is computed over the normalized execute request, using digests rather than storing replacement values in result_summary. Repeating the same cloneRequestId and request hash returns the existing status and result. Reusing an ID with different input returns IDEMPOTENCY_KEY_REUSED.

ACCEPTED is the only persisted starting status and is therefore the schema default, although application inserts should still set it explicitly for clarity. error_message is a redacted diagnostic capped at 2048 characters by the column type. When createSnapshot=true, the deterministic snapshot_id is known and retained while the request is ACCEPTED and, if projection fails, FAILED_DLQ; the constraint enforces the reverse invariant that SNAPSHOT_READY can never have a null snapshot ID.

The ledger insert and event/outbox append occur in the same command transaction. A browser timeout therefore has an unambiguous status lookup.

target_instance_id is deterministically derived from cloneRequestId, so a unique constraint on (host_id, target_instance_id) would add no protection beyond the primary key and is intentionally omitted. The actual instance logical uniqueness rule is (host_id, service_id, env_tag, product_version_id). instance_name is not unique; a duplicate name produces a preview warning rather than a conflict.

Clone is an administrative operation with low expected volume, so the first release retains ledger rows indefinitely. An ACCEPTED or recoverable FAILED_DLQ row must never be removed. If later volume requires archival, terminal rows may be compacted only into a permanent tombstone containing host_id, clone_request_id, request_hash, target ID, and final status. Clone request IDs are never reusable, even after the target is hard-deleted.

Identity Mapping

The fixed UUIDv5 namespace is a4594c48-03f3-54f1-83c2-106aa1e7ce67, derived from the DNS namespace and lightapi.net/instance-clone/v1. The UUID name is a concatenation of length-prefixed UTF-8 components, where lp(value) is the decimal UTF-8 byte length, :, and the value. Entity IDs use:

lp("instance-clone-id-v1") + lp(cloneRequestId) +
lp(entityType) + lp(each stable source identity component)

Event IDs use lp("instance-clone-event-v1"), the clone request ID, and the eight-digit zero-padded event sequence. Entity-type tokens are the uppercase names in the identity table. For the golden fixture request 00000000-0000-0000-0000-000000000001 and source instance 00000000-0000-0000-0000-000000000002, the target INSTANCE ID is e0196b37-d81d-5d66-878d-d23457949457; event sequence 1 is 578e7c91-0225-53de-8106-c68ca56dc411.

The server applies this mapping to cloneRequestId, entity type, and source identity:

EntityTarget identity
InstanceUUIDv5 of request and source instance
Instance fileUUIDv5 of request and source file
Instance APIUUIDv5 of request and source instance API
Instance appUUIDv5 of request and source instance app
Deployment instanceUUIDv5 of request and source deployment instance
New snapshotUUIDv5 of request, SNAPSHOT, and target instance ID
Property overrideTarget parent identity plus existing property_id
API path prefixTarget instance API ID plus existing path prefix
App/API linkTarget app ID plus target API ID

Event IDs are also deterministic by clone request and event sequence. This supports diagnosis and protects against accidental duplicate construction; the ledger remains the authoritative idempotency mechanism.

References to product_version_id, api_version_id, app_id, app_version, property_id, pipeline_id, tag_id, and category_id are reused because they identify shared host catalog entities. All target aggregates begin at version 1.

Ordered Event Batch

Generate ordinary events in foreign-key order:

  1. InstanceCreatedEvent
  2. EntityTagCreatedEvent for selected instance tags
  3. EntityCategoryCreatedEvent for selected instance categories
  4. ConfigInstanceCreatedEvent for included instance properties
  5. ConfigInstanceFileCreatedEvent for selected files
  6. InstanceApiCreatedEvent for each API
  7. InstanceApiPathPrefixCreatedEvent for each path prefix
  8. ConfigInstanceApiCreatedEvent for included API properties
  9. InstanceAppCreatedEvent for each app
  10. ConfigInstanceAppCreatedEvent for included app properties
  11. InstanceAppApiCreatedEvent for each app/API association
  12. ConfigInstanceAppApiCreatedEvent for included association properties
  13. DeploymentInstanceCreatedEvent for selected deployment definitions
  14. ConfigDeploymentInstanceCreatedEvent for included deployment properties
  15. ConfigSnapshotCreatedEvent when createSnapshot is true

Every event carries cloneRequestId, rootInstanceId, and instanceGraphRevision metadata. Replaying the batch reconstructs the target graph.

The new path never emits InstanceClonedEvent. Clone audit and idempotency come from the ledger plus correlation metadata; no clone audit event consumes or bypasses the target Instance aggregate stream. The first release retains a deprecated replay-compatible dispatcher because only the current local database has been verified to contain zero historical events. Remove the dispatcher and constant only after every target database and exported fixture has been scanned.

Transactional Execution

CloneInstance cannot use the standard AbstractCommandHandler sequence, which enriches input and later opens a separate event-store transaction. Add a dedicated clone orchestration/provider operation with connection-aware nonce, revision, ledger, and event-persistence APIs.

Execution uses this sequence:

  1. Authorize the caller and canonicalize the request.
  2. Start a SERIALIZABLE database transaction.
  3. Acquire source and target graph advisory locks in stable UUID order, then acquire a logical-target advisory lock derived from (host_id, service_id, env_tag, product_version_id).
  4. Look up the idempotency-ledger row. Return the existing result for an identical request; reject a changed request.
  5. Require source accepted_revision = projected_revision.
  6. Read the complete selected active graph in the transaction and recompute its digest and plan hash.
  7. Validate target uniqueness, catalog references, selections, limits, and every expected child aggregate version.
  8. Allocate the next target graph revision and reserve all user nonces using the same connection.
  9. Build the deterministic event array and choose one transaction ID.
  10. Insert the ledger row, event-store rows, and outbox rows using that connection and transaction ID.
  11. Attempt to commit. Return ACCEPTED only after commit succeeds. On SQLSTATE 40001, roll back and retry the complete transaction with a bounded retry count. If retries are exhausted, return HTTP 409 Conflict with CONCURRENCY_CONFLICT, retryable: true, and no accepted clone result. The rolled-back attempt must leave no ledger, event-store, outbox, revision, or nonce reservation behind. Other transient database-unavailability failures return HTTP 503 Service Unavailable; they never return ACCEPTED.

All instance and child mutation commands must adopt the same graph lock and revision protocol before clone is enabled. Without it, clone cannot promise a stable graph.

All commands that create an instance or change its service ID, environment tag, or product version should also use the same logical-target lock. Within clone, that lock plus the command-side ledger prevents two concurrent clone requests from reserving the same logical target while the first remains unprojected.

Do not generate child events from the InstanceClonedEvent projection handler and do not use the old direct-copy SQL helpers. Both approaches bypass command validation and can leave event history inconsistent with projections.

Projection and Snapshot Completion

The existing outbox assigns one transaction ID to an inserted event array, and the database consumer processes all events with that transaction ID in offset order inside one projection transaction. The clone design relies on this contract.

ConfigSnapshotCreatedEvent, when requested, is always last. It therefore sees all earlier target rows and creates the new current snapshot in the same projection transaction. There is no separate snapshot job in the first release.

After the transaction projects successfully, a transaction-outcome hook:

  • advances the target projected_revision;
  • sets the clone ledger to PROJECTED or SNAPSHOT_READY;
  • records only counts, IDs, and timing.

If any clone event fails, the consumer rolls back the complete transaction, moves the complete batch to the DLQ, and sets the ledger to FAILED_DLQ using the transaction ID. Replaying a repaired DLQ transaction can advance the same ledger to success without appending another clone batch.

Validation

Before append, validate:

  • source exists, is active, is caught up, and is visible to the caller;
  • graph revision, graph digest, catalog schema digest, plan hash, and all selected aggregate versions still match preview;
  • target IDs match the deterministic mapping and are unused;
  • (host_id, service_id, env_tag, product_version_id) is unique;
  • referenced catalog, tag, category, property, API version, app, and pipeline rows exist and are active;
  • file selections remain unique by target instance and configuration phase;
  • target app/API links refer to children in the same target instance;
  • both copied and replacement values pass the current schema and value-type validation, and omitted values leave a valid effective target configuration;
  • deployment overrides satisfy deployment constraints;
  • event count and serialized-byte limits are not exceeded.

Logical target collisions

instance_name is a display field and is not unique in the current schema. The enforced logical identity is (host_id, service_id, env_tag, product_version_id), including the separate null-environment-tag rule.

Under the logical-target lock, clone checks both instance_t and non-failed clone-ledger rows for this tuple. This prevents concurrent clone-versus-clone requests from both appending while one projection is still pending. A soft- deleted instance continues to reserve the tuple under the current database indexes; clone rejects it and directs the user to the normal reactivation flow.

Command-side checks against instance_t remain best-effort because it is an asynchronous projection. A concurrent non-clone createInstance command that was accepted but has not projected may not yet be visible. The database unique constraint is therefore the final authority: if such a cross-command race survives command validation, one projection transaction succeeds and the other complete transaction becomes FAILED_DLQ. The status response reports TARGET_LOGICAL_IDENTITY_CONFLICT; it must not leave a partial clone.

Clone Policy

DataDefaultPolicy
Instance propertiesCopyUser may replace or omit each override
APIs, apps, links, and propertiesCopyPreserve topology with remapped parent IDs
Tags and categoriesCopyReuse shared tag/category IDs
FilesExcludeUser selects each file; Cert needs confirmation
Deployment definitionsExcludeUser selects and reviews each definition
OAuth clients and credentialsNeverCreate through the OAuth workflow afterward
Runtime instancesNeverEphemeral registration state
SnapshotsNever copyOptionally create a new current snapshot
currentfalseAvoid changing the default instance implicitly
readonlyfalseTarget is customizable even from read-only source
OwnershipCurrent callerAdministrator may explicitly override

Deployment selections

Each included deployment definition is addressed by sourceDeploymentInstanceId. The preview exposes and lets the user override:

  • target deployment service ID;
  • system environment and runtime environment;
  • pipeline ID;
  • owner position;
  • each deployment property through the normal stable property selector.

The target receives a new deployment instance ID. platform_job_id, IP address, port, and execution identifiers are cleared, and deploy_status is always NotDeployed.

Size and Resource Limits

Planning calculates both event count and serialized event bytes before values are appended. Recommended initial configurable limits are:

maxEvents = 2000
maxSerializedEventBytes = 16 MiB
maxSingleFileBytes = 4 MiB

These defaults must be benchmarked and may be lowered by an operator. The estimate accounts for both event-store and outbox payloads. A plan exceeding a limit returns CLONE_LIMIT_EXCEEDED with counts and the limiting category. The user can omit files or deployments and preview again.

The first release does not chunk an oversized clone because independently committed chunks violate the atomic-clone guarantee.

Portal UI

Add a ContentCopyIcon row action to /app/instance/InstanceAdmin. It is enabled according to the source ownership and read-only rules, but the server always reauthorizes.

The Clone Instance form contains:

  • read-only source identity and graph revision;
  • target name, environment tag, environment, service ID, and instance fields;
  • searchable property inventory grouped by scope;
  • COPY, REPLACE, and OMIT actions for every property;
  • explicit file selection with certificate warnings;
  • explicit deployment selection and per-deployment overrides;
  • Create Snapshot option;
  • event-count and byte estimates;
  • Preview and Clone actions.

Any form change invalidates the previous planHash and disables Clone until preview succeeds again. The form never embeds clone input in a URL.

After ACCEPTED, the UI polls status by cloneRequestId. It navigates or enables Open Instance, Open Configuration, and Create OAuth Client only after PROJECTED or SNAPSHOT_READY. FAILED_DLQ displays the safe error code, correlation ID, and recovery guidance without property or file values.

For the loc, dev, and demo BFF use case, administrators can locate and replace properties such as:

  • statelessAuth.redirectUri
  • statelessAuth.denyUri
  • statelessAuth.cookieDomain
  • OAuth redirect URI settings
  • CORS allowed origins
  • virtual-host names
  • portal reset/sign-in host

The generic selector UI remains product-neutral. Product-specific suggestions may be added later as metadata, but clone correctness never depends on a curated property list.

Sensitive Logging and Audit

Every clone event is necessarily stored in the event store and outbox, but raw values must not be copied into application logs. Before enabling clone:

  • replace full CloudEvent trace logging in the common command handler with an ID/type/subject summary for sensitive events;
  • replace database-consumer error logging of complete payloads with bounded, redacted diagnostics;
  • mark clone property/file events as sensitive in event metadata;
  • exclude property values, replacement values, file contents, certificates, tokens, and secrets from the ledger, notifications, metrics, and status API;
  • audit value-reveal operations separately.

Structured clone logs contain only host, request ID, source and target IDs, caller, graph revision, event counts, byte counts, duration, transaction ID, status, and safe error code.

Implementation Plan

  1. Add instance_graph_revision_t, shared graph-lock/revision handling, and the logical-instance-identity lock to the relevant instance and child mutation command/projection paths.
  2. Add instance_clone_request_t, transaction outcome updates, and status query support.
  3. Add provider queries that return the complete active clone graph, current property schemas, and canonical graph/schema HMAC digests without logging values.
  4. Add POST planInstanceClone, value-reveal, and status query contracts with server-side ownership authorization.
  5. Replace CloneInstance with the dedicated SERIALIZABLE transactional orchestration path and connection-aware nonce/event append.
  6. Add deterministic ID/event builders and the ordered ordinary create-event batch. Stop emitting InstanceClonedEvent; remove historical-event discovery and unused direct-SQL clone helpers. Retain only the temporary compatibility dispatcher until the fleet-wide history gate permits its removal.
  7. Add transaction-aware projection completion/DLQ ledger updates and place snapshot creation last in the same transaction.
  8. Redact common command and database-consumer payload logging.
  9. Add the Instance Admin form, generic selector tables, previews, limits, status polling, and post-clone actions.

Test Plan

State and mapping

  • Clone a graph containing every included child type and compare source and target after applying the deterministic mapping.
  • Verify tags/categories copy, while OAuth owners, clients, secrets, tokens, runtime instances, old snapshots, and deployment executions do not.
  • Verify inactive and soft-deleted rows are absent.
  • Replay the emitted events into empty projections and obtain the same target.

Consistency and idempotency

  • Change each child type after preview and verify graph revision/digest rejects execution.
  • Verify execution rejects while accepted and projected source revisions differ.
  • Race a child mutation against clone and verify the shared lock serializes them or the serializable transaction retries/fails safely.
  • Execute identical requests concurrently and verify one event transaction.
  • Retry the same request ID and hash and return the original result.
  • Reuse a request ID with changed input and receive IDEMPOTENCY_KEY_REUSED.
  • Verify a serialization retry does not reserve duplicate nonces or events.
  • Exhaust serialization retries and verify HTTP 409, CONCURRENCY_CONFLICT, and no committed ledger/event/revision/nonce state.
  • Verify a transient database outage returns HTTP 503, never ACCEPTED.
  • Race two clones with the same logical target tuple and verify the logical lock/ledger rejects one before event append.
  • Race clone with a non-clone instance creation and verify at most one projects; any loser becomes FAILED_DLQ with TARGET_LOGICAL_IDENTITY_CONFLICT and no partial graph.

Security and authorization

  • Verify plan, reveal, execute, and status enforce host and ownership scope.
  • Verify only administrators can clone read-only sources or override ownership.
  • Verify raw properties/files never occur in URLs, clone logs, status, ledger, notifications, or DLQ diagnostics.
  • Verify COPY works without revealing a value and that reveal is audited and returned with no-store.
  • Verify ambiguous property names at different scopes are independently selectable.
  • Tighten a property schema after the source value was saved and verify COPY returns SOURCE_VALUE_INVALID_FOR_CURRENT_SCHEMA until the user selects a valid REPLACE or OMIT.
  • Change a referenced property schema after preview and verify execution returns PLAN_DEPENDENCY_CHANGED and requires another preview.

Projection and recovery

  • Force failure on every event group and verify the whole projection transaction rolls back and status becomes FAILED_DLQ.
  • Replay the repaired transaction and verify status advances without new source events.
  • Verify status remains ACCEPTED before projection completion.
  • Verify a requested snapshot is last, current, and retrievable using target serviceId and environment tag before status becomes SNAPSHOT_READY.

Policy and limits

  • Verify files are excluded by default, selected files preserve phase and metadata, and certificates require confirmation.
  • Verify deployments are excluded by default and selected definitions apply overrides, clear operational fields, and use NotDeployed.
  • Verify replacements pass schema/value validation and affect only the target.
  • Verify copied values are revalidated against the current schema and omitted values still produce a valid effective target configuration.
  • Verify duplicate instance names produce a warning, while duplicate logical identity tuples are rejected or resolved by the projection/DLQ contract.
  • Verify current=false, readonly=false, and target ownership defaults.
  • Verify event-count, total-byte, and single-file limits at the boundary and return CLONE_LIMIT_EXCEEDED without writes.

Design Decisions

  • Clone is state-based and event-producing; it never replays history or copies projections directly.
  • A root graph revision, opaque graph digest, and plan hash bind preview to execution.
  • The plan also binds the current referenced property schemas; COPY and REPLACE are both revalidated at plan and execution time.
  • A durable request ledger provides idempotency and asynchronous status.
  • Property selectors use scope, parent identity, property ID, and expected version; names are display fields only.
  • Values are copied server-side and masked by default.
  • Files and deployments are explicit user selections and default to excluded.
  • OAuth material and runtime/deployment execution state are never cloned.
  • Snapshot creation is the last event in the same projection transaction.
  • The new path never emits InstanceClonedEvent; its compatibility dispatcher remains for one release unless a fleet-wide history scan proves immediate removal safe.
  • Administrators may clone read-only sources; targets are writable by default.
  • Oversized clones fail planning rather than losing atomicity through chunks.
  • ACCEPTED is returned only after a successful commit; exhausted serialization retries return a retryable conflict with no committed clone state.
  • Logical target uniqueness follows (host_id, service_id, env_tag, product_version_id), not instance name or deterministic target UUID.

Instance File Config Phase

Overview

instance_file_t stores instance-specific files that are not modeled as standard config_property_t rows. Examples include API specifications such as openapi.yaml and custom certificates or supporting files.

The config snapshot model currently separates two kinds of file data:

  • Standard files are flattened into config_snapshot_property_t.
  • Non-standard instance files are copied into snapshot_instance_file_t.

The /config-server/files endpoint must return both sets. It already filters standard files by config_phase through config_snapshot_property_t.config_phase, but instance_file_t and snapshot_instance_file_t do not currently carry config_phase. That makes it impossible to union the two sources while preserving runtime, deployment, and generator phase semantics.

Problem

When a service starts through DefaultConfigLoader, it calls /config-server/files with host, serviceId, and envTag. The endpoint resolves the current snapshot and returns the files that should be written into /config.

For the sidecar case, openapi.yaml exists in both instance_file_t and snapshot_instance_file_t, but it does not exist in config_snapshot_property_t. Since the current /files query reads only config_snapshot_property_t, the response does not include openapi.yaml, and the sidecar cannot write it to /config.

The correct endpoint behavior is:

  1. Read standard files from config_snapshot_property_t.
  2. Read non-standard files from snapshot_instance_file_t.
  3. Filter both sources by the requested config phase.
  4. Return one filename-to-base64-content map.

Decision

Add config_phase to both runtime and snapshot instance file tables:

  • instance_file_t.config_phase
  • snapshot_instance_file_t.config_phase

The allowed values should match config_t.config_phase:

  • G: generator
  • D: deployment
  • R: runtime

The default value for existing and new rows should be R, because current instance files are consumed by runtime startup unless explicitly marked otherwise.

Schema Changes

Runtime Table

ALTER TABLE instance_file_t
  ADD COLUMN config_phase CHAR(1) NOT NULL DEFAULT 'R';

ALTER TABLE instance_file_t
  ADD CHECK (config_phase IN ('G', 'D', 'R'));

ALTER TABLE instance_file_t
  DROP CONSTRAINT IF EXISTS instance_file_uk;

ALTER TABLE instance_file_t
  ADD CONSTRAINT instance_file_uk
    UNIQUE (host_id, instance_id, config_phase, v_file_name);

The unique constraint must include config_phase so the same filename can exist separately for runtime and deployment if needed.

Snapshot Table

ALTER TABLE snapshot_instance_file_t
  ADD COLUMN config_phase CHAR(1) NOT NULL DEFAULT 'R';

ALTER TABLE snapshot_instance_file_t
  ADD CHECK (config_phase IN ('G', 'D', 'R'));

CREATE INDEX idx_snap_inst_file_phase
  ON snapshot_instance_file_t (snapshot_id, config_phase, file_type, active);

The primary key can remain (snapshot_id, host_id, instance_file_id) because instance_file_id identifies the copied runtime row. The phase-aware index supports config-server lookups.

Migration

Existing rows should be backfilled to runtime:

UPDATE instance_file_t
SET config_phase = 'R'
WHERE config_phase IS NULL;

UPDATE snapshot_instance_file_t
SET config_phase = 'R'
WHERE config_phase IS NULL;

If a historical custom file was actually intended for deployment or generator use, it must be corrected explicitly after migration. There is no reliable way to infer that from the current schema.

Snapshot Creation

create_snapshot must copy config_phase from instance_file_t into snapshot_instance_file_t.

Current copy shape:

INSERT INTO snapshot_instance_file_t (
    snapshot_id, host_id, instance_file_id, instance_id, file_type,
    file_name, file_value, file_desc, expiration_ts,
    aggregate_version, active, update_user, update_ts
)
SELECT
    p_snapshot_id, t.host_id, t.instance_file_id, t.instance_id, t.file_type,
    t.file_name, t.file_value, t.file_desc, t.expiration_ts,
    t.aggregate_version, t.active, t.update_user, t.update_ts
FROM instance_file_t t
WHERE t.host_id = p_host_id
  AND t.instance_id = p_instance_id
  AND t.active = TRUE;

Target copy shape:

INSERT INTO snapshot_instance_file_t (
    snapshot_id, host_id, instance_file_id, instance_id, config_phase,
    file_type, file_name, file_value, file_desc, expiration_ts,
    aggregate_version, active, update_user, update_ts
)
SELECT
    p_snapshot_id, t.host_id, t.instance_file_id, t.instance_id, t.config_phase,
    t.file_type, t.file_name, t.file_value, t.file_desc, t.expiration_ts,
    t.aggregate_version, t.active, t.update_user, t.update_ts
FROM instance_file_t t
WHERE t.host_id = p_host_id
  AND t.instance_id = p_instance_id
  AND t.active = TRUE;

Snapshot creation should continue copying all active instance files for the instance. Consumers filter by phase when reading.

Config Server Query

The /files endpoint should union standard files and non-standard instance files for the current snapshot.

Standard files:

SELECT
    p.source_level AS source,
    c.config_name,
    p.property_name,
    p.value_type,
    p.property_value,
    10 AS source_rank
FROM config_snapshot_property_t p
JOIN config_snapshot_t cs ON cs.snapshot_id = p.snapshot_id
JOIN config_t c ON c.config_id = p.config_id
JOIN host_t h ON cs.host_id = h.host_id
WHERE h.sub_domain || '.' || h.domain = ?
  AND cs.current = TRUE
  AND p.config_phase = ?
  AND p.property_type = 'File'
  AND cs.service_id = ?
  AND cs.environment = ?

Non-standard instance files:

SELECT
    'instance_file' AS source,
    'files' AS config_name,
    f.file_name AS property_name,
    'string' AS value_type,
    f.file_value AS property_value,
    100 AS source_rank
FROM snapshot_instance_file_t f
JOIN config_snapshot_t cs
  ON cs.snapshot_id = f.snapshot_id
 AND cs.host_id = f.host_id
 AND cs.instance_id = f.instance_id
JOIN host_t h ON h.host_id = cs.host_id
WHERE h.sub_domain || '.' || h.domain = ?
  AND cs.current = TRUE
  AND f.config_phase = ?
  AND f.file_type = 'File'
  AND f.active = TRUE
  AND cs.service_id = ?
  AND cs.environment = ?

The implementation can combine these with UNION ALL. If the same filename appears in both sources, the instance file should win because it is the instance-specific override. Java can enforce this by inserting standard rows first and custom rows second into the response map. SQL can enforce it with source_rank and DISTINCT ON (property_name) if the response is assembled directly from a result set.

The same model should be applied to /certs with property_type = 'Cert' and file_type = 'Cert', because instance_file_t.file_type already supports certificates.

API and Event Changes

All create, update, query, and replay paths for instance files should include configPhase.

Required behavior:

  • New create/update requests accept configPhase.
  • Missing configPhase defaults to R for backward compatibility.
  • Created and updated events include configPhase.
  • Replay of historical events defaults missing configPhase to R.
  • Query responses expose configPhase.
  • UI forms and grids allow the operator to choose or filter by phase.

Code Impact

Expected implementation surfaces:

  • portal-db/postgres/ddl.sql
  • portal-db/postgres/ddl-dbvis.sql
  • New portal-db/postgres/patch_*.sql
  • portal-db/postgres/sp_tr_fn.sql
  • light-portal/db-provider persistence for create, update, query, snapshot, clone, and replay flows
  • light-config-server snapshot /files and /certs query behavior through ConfigServerQueryPersistenceImpl
  • portal-service/crates/portal-core snapshot file and cert queries
  • portal-service/apps/config-server response assembly if duplicate precedence is handled outside SQL
  • portal-view schemas/forms/pages for instance files

Validation

Minimum checks:

  1. Create or migrate an instance file named openapi.yaml with config_phase = 'R'.
  2. Create a snapshot for the instance.
  3. Verify snapshot_instance_file_t has the same config_phase.
  4. Call /config-server/files?host=dev.lightapi.net&serviceId=...&envTag=dev.
  5. Confirm the response contains both standard files such as logback.xml and non-standard files such as openapi.yaml.
  6. Start a sidecar with DefaultConfigLoader and confirm /config/openapi.yaml is written.

Regression tests should cover:

  • Existing instance files default to runtime.
  • Same filename can exist in different phases.
  • /files filters out non-matching phases.
  • Custom instance files override standard files with the same filename.
  • Java and Rust config-server implementations return the same file keys.

Out of Scope

This change does not move non-standard files into config_snapshot_property_t. Keeping them in snapshot_instance_file_t preserves the distinction between modeled config properties and instance-specific file artifacts.

Deployment

Deployment service allows users to deploy and manage their configured light products. This service is used by the application and api developers and operations.

The deployment service contains pipeline management, platform management and deployment management. It also integrates with product management and instance management services.

Light Portal Install

Purpose

light-portal-install provides a one-command local installation path for Light Portal. The target user should only need Docker Compose on the host machine and should not need to clone the individual service repositories, build Java or Rust projects, install Node.js, or manually copy static assets.

The intended entrypoint is:

curl -sL https://raw.githubusercontent.com/networknt/light-portal-install/main/install.sh | bash

The installer downloads the install bundle, prepares the local data directory, writes the selected image and asset versions, and starts the stack with Docker Compose.

Recommendation

This approach should work if the repo owns the local installation contract instead of acting as a thin pointer to the current developer checkout.

The install repo contains:

  • install.sh, the idempotent installer and updater.
  • docker-compose.yml, the default local stack using the Rust services from all-in-lt.
  • .env.example, the documented image tags, ports, and optional secrets.
  • VERSION, the default portal bundle version.
  • fixed R2 archive names for hybrid-command, hybrid-query, lightapi, and signin assets.
  • README.md, the short public usage guide.

The repo should not require the user to clone portal-config-loc, portal-view, login-view, or any service source repository. light-portal-install consumes released images and released asset bundles.

Runtime Shape

The first version should be a Rust-only all-in-lt stack:

  • postgres
  • config-server
  • light-oauth
  • controller
  • portal-service
  • hybrid-command
  • hybrid-query
  • light-workflow
  • light-gateway
  • light-agent
  • demo customer profile API
  • demo offer decision API

The Compose service names should stay compatible with the current local stack names: controller, config-server, light-oauth, portal-service, light-gateway, hybrid-command, and hybrid-query. Internal URLs and existing bootstrap data already depend on those names.

light-agent and the demo APIs are part of the default local stack, not optional add-ons, because the install repo is meant to support a complete local demo. The Compose file should include an AI agent service based on the released networknt/light-agent image and wire it to PostgreSQL, controller, config-server, hybrid-query, and light-gateway the same way the current all-in-lt Rust profile does.

The default host entrypoint should be the gateway:

https://localhost

If binding to host port 443 fails, the installer should fall back to a documented high port such as 8443 and write the chosen value to .env.

Compose Design

docker-compose.yml should be self-contained for a released local install. It can preserve the current all-in-lt service topology, but it should not require local source-tree mounts for application jars, Rust config folders, SPA dist folders, or seed SQL unless the installer downloads them first.

The Compose file should use image variables with released defaults:

services:
  config-server:
    image: ${CONFIG_SERVER_IMAGE:-networknt/config-server:2.3.5}

  light-oauth:
    image: ${LIGHT_OAUTH_IMAGE:-networknt/light-oauth:2.3.5}

  controller:
    image: ${CONTROLLER_RS_IMAGE:-networknt/controller-rs:2.3.5}

  portal-service:
    image: ${PORTAL_SERVICE_IMAGE:-networknt/portal-service:2.3.5}

  light-gateway:
    image: ${LIGHT_GATEWAY_IMAGE:-networknt/light-gateway:2.3.5}

  light-agent:
    image: ${LIGHT_AGENT_IMAGE:-networknt/light-agent:2.3.5}

  demo-customer-profile-api:
    image: ${DEMO_CUSTOMER_PROFILE_API_IMAGE:-networknt/demo-customer-profile-api:2.3.5}

  demo-offer-decision-api:
    image: ${DEMO_OFFER_DECISION_API_IMAGE:-networknt/demo-offer-decision-api:2.3.5}

The image list is generated by release-docker-images.sh. With --upload-r2, the script uploads docker-images.env to:

light-portal/releases/<tag>/docker-images.env
light-portal/releases/latest/docker-images.env
docker-images.env

For public installs, install.sh downloads docker-images.env for the selected LIGHT_PORTAL_VERSION and runs Compose with both the downloaded image env file and the local .env overrides:

docker compose --env-file docker-images.env --env-file .env up -d

The Compose file should keep persistent state in named volumes by default:

  • postgres-data for PostgreSQL.
  • portal-data for user-uploaded or generated portal data.

The installer may support a --dev-bind-mounts option later, but the default public path should prefer downloaded, immutable release assets over bind mounts to a developer workspace.

Local Authentication

The local install should use the same OAuth authorization code flow through login-view that is used by the current portal-config-loc/all-in-lt and portal-config-dev deployments. The gateway serves both the portal UI and the sign-in UI, and light-oauth remains the local authorization server.

To keep the first public local install simple, the bundle should continue using the existing long-lived local demo tokens already used by the current local stack. Token generation can be revisited later, but it should not block the initial installer.

Installer Flow

install.sh should be idempotent and safe to rerun.

  1. Detect docker compose.
  2. Resolve the requested version. Default to the repo VERSION; allow LIGHT_PORTAL_VERSION=....
  3. Create an install directory, defaulting to $HOME/.light-portal.
  4. Download docker-images.env from light-portal/releases/<version>/docker-images.env.
  5. Download hybrid-command.zip, hybrid-query.zip, lightapi.zip, signin.zip, and events.zip from R2.
  6. Use the checked-in bootstrap config, seed SQL, certificates, and Compose file.
  7. Preserve existing .env values when updating.
  8. Start the stack with docker compose up -d.
  9. Wait for health checks and print the portal URL.

The script should provide explicit subcommands:

install.sh install
install.sh update
install.sh start
install.sh stop
install.sh status
install.sh logs
install.sh uninstall

uninstall should ask for confirmation before deleting volumes or $HOME/.light-portal.

Static Asset Distribution

The former build-artifact repository has been retired. Released static content and install scripts use Cloudflare R2 as the artifact channel, with fixed archive objects, checksums, and rollback.

update-asset.sh --upload-r2 stages refreshed release assets under .release-state/assets and uploads them to bucket lightapi. Directory assets are compressed before upload. The default object paths are:

hybrid-command.zip
hybrid-query.zip
lightapi.zip
signin.zip
events.zip

release-docker-images.sh --upload-r2 uploads the image env file under:

light-portal/releases/<tag>/docker-images.env
light-portal/releases/latest/docker-images.env
docker-images.env

daily-release.sh is the top-level release entrypoint. It calls update-asset.sh --upload-r2 first, then runs the dev copy steps, then calls release-docker-images.sh --upload-r2.

The install repo should treat R2 as an artifact origin, not as the source of truth. Source of truth remains the service and UI repos plus the release pipeline. The release pipeline publishes immutable versioned objects to R2.

The static bundles, scripts, generated manifests, and release asset publishing are now fully served through R2; release generation uses only transient local staging under .release-state/assets.

Current object layout:

hybrid-command.zip
hybrid-query.zip
lightapi.zip
signin.zip
events.zip
docker-images.env
light-portal/releases/
  <tag>/
    docker-images.env
  latest/
    docker-images.env

The installer can default to latest for daily local demo installs, while still allowing LIGHT_PORTAL_VERSION=<tag> for reproducible installs.

Because the installer should not depend on AWS CLI access, it cannot use aws s3 ls to discover R2 objects. It downloads the known compressed archives directly with curl and unpacks them with unzip into the Docker Compose bind-mount directories.

R2 Tradeoffs

R2 is attractive because it supports S3-compatible tooling, public buckets, custom domains, caching through Cloudflare, and no R2 egress bandwidth charges. Cloudflare documents Standard storage pricing, request-class pricing, a free tier, and free egress for R2. Cloudflare also documents that public buckets can be exposed through custom domains for production use, while r2.dev public URLs are intended for non-production traffic.

The main tradeoff is that heavy asset reads still have request-operation cost. The current implementation publishes five compressed archives for hybrid-command, hybrid-query, lightapi, signin, and events.json, plus docker-images.env, to keep install downloads coarse-grained.

For production-quality public distribution, use a custom domain such as:

https://assets.lightapi.net/light-portal/releases/2.3.5/manifest.json

Do not use an r2.dev URL as the documented installer default.

Release Pipeline

The current release pipeline produces one installable daily version with these steps:

  1. daily-release.sh calls update-asset.sh --upload-r2.
  2. update-asset.sh rebuilds/copies portal-view, login-view, hybrid service jars, and events.json.
  3. update-asset.sh replaces the configured R2 asset prefixes in bucket lightapi.
  4. daily-release.sh runs copy-service-dev.sh and copy-site-dev.sh for dev.
  5. daily-release.sh calls release-docker-images.sh --upload-r2.
  6. release-docker-images.sh builds/pushes the selected image profile and writes docker-images.env.
  7. release-docker-images.sh uploads docker-images.env to the versioned, latest, and compatibility R2 paths.

The next release-pipeline improvement should generate and upload:

  • checksums for each archive and metadata file
  • an install smoke-test result

The smoke test should run from an empty install directory and verify:

  • Docker Compose starts all required services.
  • PostgreSQL bootstrap completes.
  • gateway is reachable.
  • sign-in and portal static assets load through gateway.
  • health checks pass for the Rust services.

Settled Decisions

  • Include light-agent in the default docker-compose.yml as the AI agent service for local demos.
  • Include the demo customer profile and offer decision APIs in the default local stack.
  • Use the local OAuth authorization code flow with login-view, matching portal-config-loc/all-in-lt and portal-config-dev.
  • Use the existing long-lived local demo tokens in the first version to keep the installer simple.
  • Move static content, install scripts, generated manifests, and release bundles to Cloudflare R2 for long-term flexibility.
  • Keep release generation and deployment independent of artifact repositories.
  • Keep Docker Compose as the only container runtime dependency; use curl for R2 downloads, not AWS CLI.
  • Use fixed archive names for the current R2 object set until the release pipeline publishes a richer manifest automatically.

Decision

Create light-portal-install as the public local install repo with Docker Compose as the only container runtime dependency. Use the Rust all-in-lt service topology, including light-agent and the demo APIs, but package it as checked-in runtime config plus R2-downloaded service jars, SPA assets, events.json, and docker-images.env. Keep the local OAuth authorization code flow through login-view and use existing long-lived local demo tokens for the first version. Publish static install artifacts to Cloudflare R2 behind a custom domain, download the fixed asset archives with curl, and extract them with unzip.

Timestamp

Okay, let’s break down the best way to persist Java’s OffsetDateTime in PostgreSQL.

1. Best Database Column Type: TIMESTAMP WITH TIME ZONE (or TIMESTAMPTZ)

This is unequivocally the best choice in PostgreSQL for storing OffsetDateTime objects. Here’s why:

  • Preserves the Instant: OffsetDateTime represents a specific instant in time with an offset from UTC. TIMESTAMPTZ is designed precisely for this.
  • UTC Normalization: When you insert a value into a TIMESTAMPTZ column, PostgreSQL uses the provided offset to normalize the timestamp and stores it internally as UTC. This is crucial for correctly representing the absolute point in time, regardless of the original offset.
  • Automatic Conversion on Retrieval: When you select data from a TIMESTAMPTZ column, PostgreSQL automatically converts the stored UTC value back to the current session’s timezone setting (TimeZone parameter). Your JDBC driver then maps this appropriately.
  • Avoids Ambiguity: Using TIMESTAMPTZ prevents the ambiguity that can arise with TIMESTAMP WITHOUT TIME ZONE, where the lack of offset/timezone information can lead to incorrect interpretations depending on server and client settings.

Why NOT TIMESTAMP WITHOUT TIME ZONE (or TIMESTAMP)?

  • This type stores the date and time literally as provided, discarding any timezone or offset information.
  • If you store an OffsetDateTime’s local date/time part into this column, you lose the offset, making it impossible to know the exact instant it represents globally. This is generally incorrect for OffsetDateTime.

2. How to Convert (JDBC)

Modern JDBC drivers (PostgreSQL JDBC driver versions supporting JDBC 4.2+, which is most versions used today) handle the conversion automatically and correctly when you use the appropriate methods.

Persisting (Saving):

  • Use PreparedStatement.setObject(int parameterIndex, OffsetDateTime value)
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;

// Assume 'conn' is your established JDBC Connection
public void saveEventTime(Connection conn, int eventId, OffsetDateTime eventTime) throws SQLException {
    // Use TIMESTAMPTZ in your table definition
    String sql = "UPDATE events SET event_timestamp = ? WHERE id = ?";

    try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
        // Use setObject for OffsetDateTime - the driver handles conversion
        pstmt.setObject(1, eventTime); // Pass the OffsetDateTime directly
        pstmt.setInt(2, eventId);
        pstmt.executeUpdate();
    }
}

// Example Usage:
// OffsetDateTime nowWithOffset = OffsetDateTime.now(); // Uses system default offset
// OffsetDateTime specificTime = OffsetDateTime.of(2023, 10, 27, 10, 30, 0, 0, ZoneOffset.ofHours(-4));
// saveEventTime(connection, 1, specificTime);

The JDBC driver sends the OffsetDateTime (including its offset) to PostgreSQL. PostgreSQL’s TIMESTAMPTZ type normalizes this to UTC for storage.

Retrieving (Loading):

  • Use ResultSet.getObject(int columnIndex, OffsetDateTime.class) or ResultSet.getObject(String columnLabel, OffsetDateTime.class)
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;

public OffsetDateTime loadEventTime(Connection conn, int eventId) throws SQLException {
    String sql = "SELECT event_timestamp FROM events WHERE id = ?";
    OffsetDateTime eventTime = null;

    try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
        pstmt.setInt(1, eventId);
        try (ResultSet rs = pstmt.executeQuery()) {
            if (rs.next()) {
                // Use getObject with the target class - the driver handles conversion
                eventTime = rs.getObject("event_timestamp", OffsetDateTime.class);
            }
        }
    }
    return eventTime;
}

// Example Usage:
// OffsetDateTime retrievedTime = loadEventTime(connection, 1);
// if (retrievedTime != null) {
//     System.out.println("Retrieved: " + retrievedTime);
//     // Note: The offset might be different from the original if your
//     // JVM's default timezone or JDBC connection timezone differs
//     // from the original offset, but it represents the SAME instant in time.
// }

When retrieving, PostgreSQL sends the stored UTC timestamp. The JDBC driver, knowing the target type is OffsetDateTime and considering the session’s timezone setting, converts the UTC value back into an appropriate OffsetDateTime object representing the correct instant.

Summary:

  1. Database Column: Use TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ).
  2. Persisting (Java -> DB): Use PreparedStatement.setObject(index, yourOffsetDateTime).
  3. Retrieving (DB -> Java): Use ResultSet.getObject(column, OffsetDateTime.class).
  4. JDBC Driver: Ensure you are using a modern PostgreSQL JDBC driver that supports JDBC 4.2 / Java 8 Time API.
  5. Session Timezone: Be aware that the OffsetDateTime retrieved might have an offset corresponding to the client/session’s timezone setting, but it will represent the same exact instant as the one stored (because it was normalized to UTC).

Tag

Let’s design a tagging system for your light-portal entities. Tags are typically non-hierarchical keywords or labels that you can assign to entities for flexible organization and discovery, complementing categories.

1. Database Design (PostgreSQL)

For a flexible and efficient tagging system, we’ll use two main tables: a central tags table and a join table entity_tags to create a many-to-many relationship between entities and tags.

a) tag Table: Stores the definitions of the tags themselves.

CREATE TABLE tag_t (
    tag_id        VARCHAR(22) NOT NULL,         -- Unique ID for the tag
    host_id       VARCHAR(22),                  -- null means global tag 
    tag_name      VARCHAR(100) UNIQUE NOT NULL, -- Tag name (e.g., "featured", "urgent", "api", "documentation") - Enforce uniqueness
    tag_desc      VARCHAR(1024),                -- Optional description of the tag
    update_user   VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts     TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY (tag_id)
);

-- Index for efficient lookup by tag_name (common search/filter)
CREATE INDEX idx_tags_tag_name ON tags_t (tag_name);
  • tag_id: Unique identifier for each tag.
  • tag_name: The actual tag value (e.g., “featured”). UNIQUE NOT NULL constraint ensures tag names are unique across the system (global tags in this design).
  • tag_desc: Optional description for the tag.
  • update_user, update_ts: Standard audit columns.
  • UNIQUE (tag_name): Important constraint to ensure tag names are unique. This makes tag management simpler and consistent.

b) entity_tags_t Join Table (Many-to-Many Relationship): Links entities to tags.

CREATE TABLE entity_tags_t (
    entity_id   VARCHAR(22) NOT NULL,      -- ID of the entity (schema, product, document, etc.)
    entity_type VARCHAR(50) NOT NULL,     -- Type of the entity ('schema', 'product', 'document', etc.)
    tag_id      VARCHAR(22) NOT NULL REFERENCES tags_t(tag_id) ON DELETE CASCADE, -- Foreign key to tags_t

    PRIMARY KEY (entity_id, entity_type, tag_id) -- Composite primary key to prevent duplicate tag assignments to the same entity
);

-- Indexes for efficient queries
CREATE INDEX idx_entity_tags_tag_id ON entity_tags_t (tag_id);        -- Find entities by tag
CREATE INDEX idx_entity_tags_entity ON entity_tags_t (entity_id, entity_type); -- Find tags for an entity
  • entity_id: ID of the entity being tagged.
  • entity_type: Type of the entity (must match the types you use for categories and other entity-related tables).
  • tag_id: Foreign key referencing the tags_t table.
  • Composite Primary Key (entity_id, entity_type, tag_id): Ensures that an entity of a specific type cannot be associated with the same tag multiple times.
  • ON DELETE CASCADE: If a tag is deleted from tags_t, all associations in entity_tags_t are automatically removed. Consider ON DELETE RESTRICT if you want to prevent tag deletion if it’s still in use.

2. Service Endpoints

You’ll need service endpoints to manage tags themselves and to manage the associations between tags and entities.

a) Tag Management Endpoints (Likely in a TagService or Admin-Specific Service):

  • POST /tags - Create a new tag
    • Request Body (JSON):
      {
        "tagId": "uniqueTagId123",  // Optional - let backend generate if not provided
        "tagName": "featured",      // Required - unique tag name
        "tagDesc": "Items that are highlighted or promoted" // Optional
      }
      
    • Response: 201 Created, with Location header (URL of the new tag) and response body (created tag JSON).
  • GET /tags - List all tags (with pagination, filtering, sorting - similar to getCategory endpoint)
    • Query Parameters: offset, limit, tagName, tagDesc, etc.
    • Response: 200 OK, JSON array of tag objects (with total count).
  • GET /tags/{tagId} - Get a specific tag by ID
    • Path Parameter: tagId
    • Response: 200 OK, tag object in JSON. 404 Not Found if not exists.
  • PUT /tags/{tagId} - Update an existing tag
    • Path Parameter: tagId
    • Request Body (JSON): (Same structure as POST, but tagId in the path is used for identification)
    • Response: 200 OK, updated tag object in JSON. 404 Not Found if tag not found.
  • DELETE /tags/{tagId} - Delete a tag
    • Path Parameter: tagId
    • Response: 204 No Content. 404 Not Found if tag not found.

b) Entity Tag Association Endpoints (Likely within Entity-Specific Services like SchemaService, ProductService):

  • (Within POST /schemas, PUT /schemas/{schemaId}, etc. entity creation/update endpoints):
    • Request Body for creating or updating an entity should include a field (e.g., tagIds: ["tagId1", "tagId2"]) to specify the tags to associate with the entity.
    • Service logic (like in the updated createSchema and updateSchema methods) will handle updating the entity_tags_t table (deleting old links and inserting new ones) within the same transaction as the entity creation/update.
  • GET /schemas/{schemaId}/tags (or /products/{productId}/tags, etc.) - Get tags associated with a specific entity
    • Path Parameter: schemaId (or productId, etc.)
    • Response: 200 OK, JSON array of tag objects associated with the entity.
  • PUT /schemas/{schemaId}/tags (or similar) - Replace tags associated with an entity (Less common, often handled within the entity update endpoint directly)
    • Path Parameter: schemaId
    • Request Body (JSON): { "tagIds": ["tagIdA", "tagIdB"] } - list of tag IDs to associate.
    • Response: 200 OK, updated entity object (or just 204 No Content).

c) Entity Filtering/Search Endpoints:

  • GET /schemas (or /products, /documents, etc.) - List entities, now with tag filtering:
    • Query Parameter: tagNames (or tagIds, or tags - choose one and be consistent), e.g., tagNames=featured,api&tagNames=urgent (multiple tags to filter by).
    • Backend logic: Modify the getSchema (or getProduct, getDocument, etc.) service methods to:
      1. Parse the tagNames parameter (could be comma-separated, multiple parameters, etc.).
      2. Modify the SQL query to include a JOIN with entity_tags_t and tags_t and add a WHERE clause to filter by the provided tag names. You might need to use EXISTS or IN subqueries for efficient filtering by multiple tags.

Example Query for Filtering Schemas by Tags (using PostgreSQL EXISTS):

SELECT schema_t.*, ... -- Select schema columns
FROM schema_t
WHERE EXISTS (
    SELECT 1
    FROM entity_tags_t et
    INNER JOIN tags_t t ON et.tag_id = t.tag_id
    WHERE et.entity_id = schema_t.schema_id
      AND et.entity_type = 'schema'
      AND t.tag_name IN (?, ?, ?) -- Parameterized tag names list
);

UI Considerations:

  • Tag Management UI: Similar to category management, likely an admin section to create, edit, delete tags.
  • Tag Assignment UI:
    • Entity creation/edit forms should include a tag selection component (e.g., tag input with autocomplete, checkboxes, tag pills).
    • Allow users to search/browse existing tags and assign them.
  • Tag Filtering/Browsing UI:
    • Display tags prominently (tag cloud, list, filters).
    • Clicking/selecting a tag should filter the entity lists to show only entities associated with that tag.

Benefits of this Tagging System:

  • Flexible Organization: Tags are free-form and non-hierarchical, allowing for more flexible and ad-hoc categorization than categories alone.
  • Discoverability: Improves search and filtering capabilities, making it easier for users to find relevant entities.
  • Metadata Enrichment: Tags add valuable metadata to entities.
  • Scalability: The database design is efficient for querying and managing tags and associations even with a large number of entities and tags.

This design provides a solid foundation for a tagging system. You can further refine it based on your specific requirements, such as adding tag groups, permissions for tag management, or more advanced search capabilities.

UUID

In the light-portal database, we are using UUID for most of the keys in order to support event replay between multiple environments. To balance database performance with the need for URL-friendly, we are using the PostgreSQL native UUID type for the key.

CREATE TABLE your_table (
    id UUID PRIMARY KEY,
    -- other columns
);

The PostgreSQL can only generate UUIDv4 and it causes index locality problem. So we are using Java to generate UUIDv7 which is Time-Ordered UUID. These embed a timestamp, making them roughly sequential and significantly improving index locality and insert performance. You’ll need a library for this.

import com.github.f4b6a3.uuid.UuidCreator;
import java.util.UUID;

// In your entity or service
UUID primaryKey = UuidCreator.getTimeOrderedEpoch(); // UUIDv7
// Store this 'primaryKey' directly.

In light-4j utility module, we have a UuidUtil class that can generate the UUIDv7 and also encode/decode to base64 string.

Here is the class.

package com.networknt.utility;

import com.github.f4b6a3.uuid.UuidCreator;
import java.util.Base64;
import java.util.UUID;
import java.nio.ByteBuffer;

public class UuidUtil {

    // Use Java 8's built-in Base64 encoder/decoder
    private static final Base64.Encoder URL_SAFE_ENCODER = Base64.getUrlEncoder().withoutPadding();
    private static final Base64.Decoder URL_SAFE_DECODER = Base64.getUrlDecoder();

    public static UUID getUUID() {
        return UuidCreator.getTimeOrderedEpoch(); // UUIDv7
    }

    /**
     * Generate a UUID and encode it to a URL-safe Base64 string.
     *
     * @return A URL-safe Base64 encoded UUID string.
     */
    public static String uuidToBase64(UUID uuid) {
        ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
        bb.putLong(uuid.getMostSignificantBits());
        bb.putLong(uuid.getLeastSignificantBits());
        return URL_SAFE_ENCODER.encodeToString(bb.array());
    }

    /**
     * Decode a URL-safe Base64 string back to a UUID.
     *
     * @param base64 A URL-safe Base64 encoded UUID string.
     * @return The decoded UUID.
     */
    public static UUID base64ToUuid(String base64) {
        byte[] bytes = URL_SAFE_DECODER.decode(base64);
        ByteBuffer bb = ByteBuffer.wrap(bytes);
        long high = bb.getLong();
        long low = bb.getLong();
        return new UUID(high, low);
    }

}

Entity Creation Uniqueness

Status

Implemented through Phase 5 for Role/Rule stream births and Category/Tag semantic identity, client retries, lifecycle alignment, verified historical materialization, snapshot/import guards, and policy-aware retry-ledger cleanup. Later aggregate groups remain review and migration work for light-portal issue 691. The frozen inventory contains 122 birth events; only eight have reviewed scope and identity shapes, and only Category/Tag currently have semantic-identity reservations. Phase 5 closure is therefore scoped, not program-wide completion.

Executive Decision

Light Portal must enforce entity creation uniqueness in the authoritative command transaction, before a *CreatedEvent is appended. The UI and the asynchronous projection database may provide early feedback, but neither is authoritative.

Two invariants are required:

  1. An aggregate stream can contain only one birth event. A normal create command must use expected version 0, append version 1, and fail if the aggregate stream already exists.
  2. Every creatable aggregate must declare either a semantic creation identity or explicitly declare that multiple semantically similar entities are allowed. This declaration is required even when the storage key is a generated UUID.

A generated UUID makes two rows technically distinct. It does not prove that they represent different business entities. For example, categories with different UUIDs can still be the same category when host, entity type, parent, and normalized name are equal.

The recommended implementation is a versioned creation-policy registry plus an entity_identity_t reservation table. The identity reservation, event-store append, outbox write, nonce allocation, and idempotency record must commit in one PostgreSQL transaction.

Problem

Issue 691 demonstrates the same-aggregate form of the defect:

  1. A role such as abc-123 already exists at aggregate version 1.
  2. A second create request uses the same role identity.
  3. The command path finds the existing stream and emits another RoleCreatedEvent at aggregate version 2.
  4. The role projection refuses to replace an active row and leaves it at version 1.
  5. GetFreshRole compares the projection with the event store and returns ERR11645 because the read model is behind the write model.

This behavior follows directly from the current shared command flow. AbstractCommandHandler.populateAggregateVersion() increments the aggregate version whenever a stream exists; it does not distinguish create from update. The role and rule create projections use conflict handling that only reactivates a soft-deleted row. A conflicting create for an active row is skipped, including its new aggregate version.

The existing event-store constraint on (aggregate_id, aggregate_version) prevents two events from occupying the same version. It does not prevent a second birth event at version 2, 3, or later.

Generated UUID aggregates have a second form of the same problem:

  • CreateCategory and CreateTag generate a new UUID for every request, while their projection tables define business uniqueness using names, scope, entity type, and parent fields.
  • Some handlers, such as platform and config creation, query the projection for an existing business key and reuse its UUID. That check is vulnerable to projection lag and concurrent requests.
  • Two create commands can therefore append two valid-looking event streams before the second event reaches a projection unique constraint.

The failure is not specific to role or rule. It is a missing write-side creation contract shared by many command services.

Goals

  • Reject a second create for an existing aggregate without changing its event stream.
  • Prevent semantic duplicates when each request generates a different UUID.
  • Resolve concurrent create races atomically.
  • Distinguish a retry of the same request from a new conflicting request.
  • Preserve tenant, global, and parent-scoped uniqueness boundaries.
  • Preserve immutable event history and make existing corrupt streams repairable.
  • Provide one reusable implementation rather than handler-specific query checks.
  • Return an actionable conflict to the UI without exposing another tenant’s data.

Non-goals

  • Treating all equal request payloads as the same entity.
  • Preventing valid multiplicity for events such as audit records, workflow runs, messages, or orders.
  • Replacing update optimistic concurrency control.
  • Making the projection database part of the authoritative command transaction.
  • Turning create into an upsert or silently changing an existing entity.
  • Automatically allowing an identity to be reused after delete or rename.

Terminology

Aggregate ID

The event-stream subject. It may be a natural composite ID such as hostId|roleId, or a generated UUID.

Semantic Creation Identity

The minimum immutable domain fields that answer: “Would another create request represent the same entity?” It includes the uniqueness scope.

Examples include:

AggregateStorage IDCandidate semantic creation identity
RolehostId|roleIdhost + normalized role ID
Rulerule ID or composite event subjectglobal/host scope + normalized rule ID
Categorygenerated UUIDglobal/host scope + entity type + parent category + normalized category name
Taggenerated UUIDglobal/host scope + entity type + normalized tag name
Platformgenerated UUIDhost + normalized platform name + platform version
Configgenerated UUIDnormalized config name in its catalog scope
Instancegenerated UUIDhost + service + resolved environment catalog tuple (scope_type, scope_id, normalized value) + product version

These are candidate contracts derived from current event-subject and projection constraints. Phase 0 of implementation must inventory and approve the exact key for every create event.

Idempotency Key

A client-generated identifier for one create attempt. It answers “Is this a retry of the same request?” It does not answer “Is this the same business entity?”

Birth Event

The one event that establishes an aggregate stream, normally a registered *CreatedEvent. A restore or reactivation is a lifecycle mutation, not another birth event.

Required Invariants

One Birth Event Per Aggregate

For a normal command append:

  • a birth event must carry expected aggregate version 0;
  • its new aggregate version must be 1;
  • no event may already exist for the aggregate ID; and
  • a second birth event is rejected even when the existing entity is inactive.

Create handlers must not call the current “max version plus one” behavior. The shared command abstraction should expose an explicit command kind so that create commands always build a provisional version-1 event. The transactional append path remains the final enforcement point.

Declared Semantic Multiplicity

Every registered birth event must have exactly one creation policy:

  • UNIQUE: extract and reserve a semantic creation identity; or
  • ALLOW_MULTIPLE: different aggregate IDs may intentionally have the same semantic fields.

ALLOW_MULTIPLE must be an explicit reviewed decision. It does not permit a second birth event on the same aggregate stream.

This fail-closed inventory prevents a new UUID-based entity from accidentally bypassing semantic uniqueness just because nobody added a handler check.

Write-side Authority

The event-store append transaction and its identity registry are authoritative. The following are advisory or defensive only:

  • create-form duplicate checks;
  • query-service existence checks;
  • projection primary keys and unique indexes; and
  • create-button disabling.

Creation Policy Registry

Add a versioned, exact-event-type EntityCreationPolicyRegistry in the database provider. It should reuse the inventory and fail-closed techniques of the existing replay policy registry without mixing creation semantics into replay policy.

Each entry contains at least:

eventType
eventSchemaVersion
aggregateType
commandKind: CREATE
multiplicity: UNIQUE | ALLOW_MULTIPLE
scopeExtractor: HOST | GLOBAL | HOST_OR_GLOBAL | PARENT | CUSTOM
identityFields
normalizerVersion
reusePolicy: NEVER | EXPLICIT_RELEASE
idempotencyRetention

idempotencyRetention gives the policy-specific retention requirement a configuration owner. Without it the ALLOW_MULTIPLE retention rule is prose that nothing can enforce or validate. The registry startup check must reject an ALLOW_MULTIPLE entry whose retention is shorter than the configured maximum client and gateway retry horizon.

The registry must verify at startup that every portal birth event has exactly one entry. A digest of the complete birth-event inventory should make addition or removal of a create event fail tests until its uniqueness policy is reviewed.

The registry extracts identity fields from the validated CloudEvent data in the database provider. A handler must not supply an opaque, client-controlled identity string that bypasses the registered extractor.

Scope Is Security-sensitive

Uniqueness scope must come from trusted command context, not an unverified hostId in the request body.

  • Host scope uses the authenticated and validated host identity.
  • Coarse permission to invoke a command is decided by Light Gateway against the command’s logical endpoint and its Portal-managed role and CEL policy.
  • Global scope requires a gateway authorization decision that is specific to a global-only endpoint or to the global branch of a combined endpoint policy.
  • Parent scope includes the trusted host plus the parent aggregate identity.
  • The CloudEvent host extension identifies event ownership/routing; it must not be mistaken for entity scope when an authorized administrator creates a global entity.

The command path should stamp an authenticated entity-scope extension when the scope cannot be derived unambiguously by the database provider.

Tenant-local And Global Name Reuse

Semantic uniqueness is enforced within a declared scope, not across every tenant. scope_type and scope_id are part of the complete reservation key, so the same normalized identity may be reserved independently by different tenants. For example, the environment reference table supports a global catalog plus tenant-defined environment values:

Environment valueScope tupleResult
Global dev(GLOBAL, GLOBAL_SENTINEL)one global reservation
Tenant A dev(HOST, tenant-a-host-id)permitted; distinct reservation
Tenant B dev(HOST, tenant-b-host-id)permitted; distinct reservation
Second Tenant A dev(HOST, tenant-a-host-id)rejected as a duplicate in Tenant A

The effective envTag catalog may concatenate or merge global values with the authenticated tenant’s values. That read-side composition does not change the write-side reservation scope: a host reservation neither blocks another host nor claims the global name. If a catalog needs tenant-over-global precedence or deduplication for display, its query policy owns that behavior; the identity registry must not silently turn it into cross-tenant uniqueness.

Every creation policy must therefore name its scope extractor explicitly. A tenant-owned reference value uses trusted host scope, a global reference value uses the global sentinel, and a parent-owned value includes both trusted host and parent identity. A missing or ambiguous scope is a policy error and fails closed.

Current Scope Derivation Is Unsound

The existing code does not yet meet this contract, and identity enforcement must not be built on it as-is. The legacy command layer still makes a coarse authorization decision that now belongs to Light Gateway:

  • handle() decides global scope with role.contains(PortalConstants.ADMIN_ROLE) where ADMIN_ROLE is "admin". Both org-admin and host-admin contain "admin" as a substring, so a tenant-scoped administrator can set the global flag, have hostId removed, and create a global entity.

Replacing that substring test with hasAnyRole() would fix the immediate match bug, but it would preserve duplicated coarse authorization in every command service. The target model is instead:

  1. Light Portal assigns roles and request-access rules to logical endpoints such as lightapi.net/role/createRole/0.1.0 and lightapi.net/rule/createRule/0.1.0.
  2. Light Portal publishes the generated endpoint permissions and rule bodies to the config server. Gateway instances load the effective policy from the config server; static values.yml is not the authorization source.
  3. Light Gateway derives the logical portal endpoint from the hybrid request, exposes the command data as the CEL request context, and denies the request unless the endpoint’s configured policy passes.
  4. After that deployment contract is proven, AbstractCommandHandler stops deciding whether the caller may invoke the command by parsing admin, org-admin, or host-admin itself.

Policy publication needs an activation boundary. The Portal/config-server write must expose a revision or digest, and every gateway instance serving portal commands must acknowledge that effective revision before the command-layer role check is removed. An instance on an unacknowledged revision must be kept out of the portal route. defaultDeny protects an endpoint that is absent from the loaded policy, but it cannot make an older, still-permissive rule equivalent to a new revision.

Assigning roles to an endpoint is necessary but not sufficient. The endpoint must also have an active req-acc rule, and the effective gateway policy must fail closed when either the role permission or rule is absent. For a command that supports both host and global creation, a static list such as admin org-admin host-admin cannot distinguish the two branches. Prefer separate tenant and global logical endpoints. If one endpoint must remain shared, use one combined CEL decision, or accessRuleLogic: all, so that:

  • a host-scoped request requires an assigned role and the request host must match the authenticated host; and
  • a global request requires the exact global-administrator permission.

Do not combine an unconditional role rule and a scope rule under accessRuleLogic: any; either rule succeeding would admit the request.

Gateway authorization is still not a replacement for command-side data integrity. The gateway currently returns allow or deny; it does not turn a caller-supplied hostId or globalFlag into trusted scope and it cannot inspect the stored target owner. The command boundary must therefore remain role-neutral and enforce:

  • effectiveHost() must not prefer a request-body hostId over the trusted host;
  • handle() must not fall back to map.get(HOST_ID) when authenticated host context is required;
  • request host, target host, and parent scope must match trusted context;
  • owner and owner-position checks must use the stored target; and
  • ownership transfer, aggregate version, lifecycle, and uniqueness rules remain database-backed command invariants.

Where an existing helper still uses a raw role to choose owner-wide or global scope, remove that dependency only after the gateway supplies an unforgeable authorization-scope decision or the operation is split into scope-specific logical endpoints. Until then, deleting the check would widen access rather than eliminate duplication.

This is a pre-existing defect rather than one introduced here, but reserving identities under a spoofable scope converts it from a contained privilege bug into permanent cross-tenant name squatting: a host-admin could reserve global identities that, under the default NEVER reuse policy, block every other tenant forever. That is why Phase 0 treats this as a deployment gate.

Canonicalization

Identity canonicalization must be versioned and field-specific:

  • encode a typed, field-named canonical JSON structure in a stable field order;
  • normalize Unicode, whitespace, and case only when the domain and database constraint define those values as equivalent;
  • distinguish null, an empty string, and an absent optional field unless the entity contract says otherwise;
  • use canonical parent IDs rather than display names;
  • when an identity references a scoped catalog value, encode its resolved (scope_type, scope_id, normalized value) tuple rather than the bare display name; and
  • exclude mutable descriptions, timestamps, generated IDs, and secrets.

For example, an Instance identity must distinguish global dev from Tenant A’s dev even though both render the same envTag text. A later change to catalog precedence, fallback, or deduplication must not silently reinterpret an existing reservation. If the resolution contract changes, treat it as an identity normalizer-version change and follow the materialization and mixed-version protocol below.

Do not lowercase all entity names globally and do not hash the entire request. Those shortcuts change domain semantics and make harmless mutable fields part of identity.

The registry stores a SHA-256 digest for indexed lookup. The complete reservation key is (scope_type, scope_id, aggregate_type, identity_schema_version, identity_hash); identity_hash is the discriminating component within a fixed scope, aggregate type, and schema version, not the whole key. Two identities producing the same digest under the same scope, aggregate type, and schema version are treated as the same reserved identity and fail closed.

The canonical value is not persisted by default. A reviewed per-aggregate policy may opt in to retaining specific non-sensitive explanatory fields when operators need to explain a conflict; nothing else is stored. Identity material must not contain credentials, tokens, or other secrets.

Changing a normalizer creates a new identity schema version. Because identity_schema_version is part of the reservation key, a lookup at version N+1 does not see a reservation stored at version N. Retaining old-version rows as aliases is therefore not sufficient: an existing entity would be unprotected under the new hash, and a duplicate create could succeed against it.

Before enforcement is enabled at a new version, every live entity’s identity must be materialized at the current version — recompute the canonical identity with the new normalizer and insert a version-N+1 row with binding_status = CURRENT for each aggregate whose owner row is ACTIVE. Version-N rows are retained unchanged so a create cannot impersonate a pre-migration identity; they keep their own binding_status, and an aggregate legitimately holds a CURRENT binding at both versions for the duration of the migration. Enforcement at version N+1 may only begin once materialization is complete and verified for the aggregate group.

Materialization alone is not enough during a rolling deployment. A node still running version N writes version-N reservations, so it can create an identity after the N+1 backfill has run and that identity will have no version-N+1 row protecting it. Choose one of:

  • Dual-write. Nodes write both version-N and version-N+1 reservations for the duration of the migration. Both rows are inserted in the create transaction, so a late version-N writer still produces the N+1 row that enforcement depends on.
  • Drain. Stop all version-N writers for the aggregate group, then materialize, then enable N+1 enforcement. Simpler, but requires a write pause.

Dual-write is preferred for aggregate groups that cannot tolerate a write pause. Either way, N+1 enforcement may only be enabled after the chosen protocol has completed, and the backfill must be re-verified after the last version-N writer exits.

Persistence Model

Entity Identity Registry

A command-side table reserves semantic identities independently of asynchronous projections:

Entity lifecycle belongs to the aggregate, not to any one identity binding, so it lives in its own owner table:

CREATE TABLE entity_aggregate_t (
    aggregate_type   VARCHAR(255) NOT NULL,
    aggregate_id     VARCHAR(255) NOT NULL,
    entity_status    VARCHAR(16)  NOT NULL,
    created_event_id UUID         NOT NULL,
    created_ts       TIMESTAMPTZ  NOT NULL DEFAULT CURRENT_TIMESTAMP,
    retired_ts       TIMESTAMPTZ,
    PRIMARY KEY (aggregate_type, aggregate_id),
    CHECK (entity_status IN ('ACTIVE', 'RETIRED')),
    CHECK (
        (entity_status = 'ACTIVE' AND retired_ts IS NULL)
        OR (entity_status = 'RETIRED' AND retired_ts IS NOT NULL)
    )
);

CREATE TABLE entity_identity_t (
    scope_type              VARCHAR(16)  NOT NULL,
    scope_id                VARCHAR(255) NOT NULL,
    aggregate_type          VARCHAR(255) NOT NULL,
    identity_schema_version INTEGER      NOT NULL,
    identity_hash           BYTEA        NOT NULL,
    identity_explanation    JSONB,
    aggregate_id            VARCHAR(255) NOT NULL,
    binding_status          VARCHAR(16)  NOT NULL,
    created_event_id        UUID         NOT NULL,
    created_ts              TIMESTAMPTZ  NOT NULL DEFAULT CURRENT_TIMESTAMP,
    demoted_ts              TIMESTAMPTZ,
    PRIMARY KEY (
        scope_type,
        scope_id,
        aggregate_type,
        identity_schema_version,
        identity_hash
    ),
    FOREIGN KEY (aggregate_type, aggregate_id)
        REFERENCES entity_aggregate_t (aggregate_type, aggregate_id),
    CHECK (binding_status IN ('CURRENT', 'ALIAS')),
    CHECK (
        (binding_status = 'CURRENT' AND demoted_ts IS NULL)
        OR (binding_status = 'ALIAS' AND demoted_ts IS NOT NULL)
    )
);

CREATE UNIQUE INDEX entity_identity_current_aggregate_version_uk
    ON entity_identity_t (
        aggregate_type,
        aggregate_id,
        identity_schema_version
    )
    WHERE binding_status = 'CURRENT';

The invariant is one current binding per aggregate per identity schema version, not one row per aggregate. A plain UNIQUE (aggregate_type, aggregate_id) would forbid the aliases that rename requires, and scoping the index to the aggregate alone would forbid the version-N/N+1 dual-write that normalizer migration requires.

identity_explanation is null unless a reviewed policy opts in to retaining non-sensitive explanatory fields. It is never part of the reservation key.

Scoping the index by identity_schema_version is what permits normalizer dual-write: during a migration an aggregate legitimately holds one CURRENT binding at version N and another at version N+1.

Why Lifecycle Is Not Stored On The Binding

An aggregate can hold several identity rows at once — aliases from earlier renames plus one CURRENT binding per schema version during dual-write. Entity lifecycle is a property of the aggregate, so storing entity_status on each binding would denormalize one fact across every one of those rows. Delete, restore, rename, and reparent would each have to update a set whose size depends on rename history and migration state, and any missed row would leave the aggregate simultaneously live and deleted.

entity_aggregate_t holds exactly one entity_status per aggregate:

  • Delete and restore update a single row, so they are correct regardless of how many aliases or schema versions exist. No fan-out, nothing to reconcile.
  • Rename and reparent touch only binding_status, and must demote the old binding and create the new CURRENT binding for every schema version currently participating in dual-write, all in one version-checked transaction. A rename that updated only version N would leave version N+1 pointing at the old name and enforcement inconsistent between them.
  • Conflict responses join to entity_aggregate_t for entity_status. There is one value to read, so ENTITY_RETIRED and ENTITY_ALREADY_EXISTS cannot disagree across bindings.

The foreign key makes an orphaned binding unrepresentable, and the owner row is inserted in the same create transaction as the first binding and the first event.

An ALLOW_MULTIPLE aggregate has no identity reservation and therefore no owner row; its lifecycle remains where it is today, in the projection. This keeps both write-side tables sparse rather than turning entity_aggregate_t into a general aggregate registry by accident.

scope_id is never null. A global scope uses an explicit canonical sentinel and a different scope_type, avoiding PostgreSQL null-uniqueness ambiguity.

An ALLOW_MULTIPLE create inserts no row in this table; the table is sparse by design. Those aggregates are still covered by stream-birth enforcement, which rejects a second birth event on an existing aggregate stream.

The table is a write-side reservation ledger, not a query projection. Its row is inserted in the same transaction as the first event. A rolled-back event append cannot leave an orphaned reservation, and a committed event cannot exist without its reservation.

The two statuses are deliberately separate, and live on different tables, because they answer different questions and have different cardinality:

  • binding_status on entity_identity_t is identity binding: is this row the aggregate’s current identity at this schema version (CURRENT), or a historical name it no longer answers to (ALIAS)? A rename produces an ALIAS row. There are many per aggregate.
  • entity_status on entity_aggregate_t is entity lifecycle: is the entity live (ACTIVE) or deleted (RETIRED)? A delete updates it. There is exactly one per aggregate.

These are orthogonal. A renamed but perfectly live entity has an ALIAS binding while its owner row stays ACTIVE. Collapsing both into one column would make a create that collides with a live entity’s former name report ENTITY_RETIRED, telling the user to restore an entity that was never deleted.

Conflict responses must therefore read entity_status from the owner row, never infer lifecycle from binding_status. Both CURRENT and ALIAS rows reserve an identity by default; the binding only determines which name the aggregate currently answers to.

Operational Ownership And Maintenance

The creation-policy registry and the identity tables have different cardinality and must not be maintained as the same artifact:

  • the creation-policy registry has one reviewed definition per birth event type; for example, one RoleCreatedEvent policy declares its scope, multiplicity, identity fields, normalizer version, and reuse policy;
  • entity_aggregate_t has one row per actual UNIQUE aggregate instance; and
  • entity_identity_t has one or more rows per actual UNIQUE aggregate instance, including aliases and every normalizer version participating in dual-write.

For example, creating 100 roles produces 100 owner rows and at least 100 identity bindings, while the registry still contains only one RoleCreatedEvent policy. The tables therefore cannot be populated or maintained by a hand-written list of event types.

One shared database-backed identity transition component owns the rows. Every append path that can persist a registered lifecycle event must use it, including normal commands, event import, snapshot-generated event import, approved repair, and any replay or internal append path permitted to create authoritative events. Command handlers, UI code, and asynchronous projectors must not issue independent identity-table writes.

Domain transitionentity_aggregate_tentity_identity_t
UNIQUE createinsert one ACTIVE ownerinsert one CURRENT binding per participating normalizer version
Rename or reparentunchangeddemote the old binding and reserve or reclaim the new binding at every participating version
Deleteset the owner to RETIREDleave all bindings reserved
Restoreset the owner to ACTIVEleave all bindings unchanged
Normalizer migrationunchangedmaterialize or dual-write bindings at the new version
Explicit releaseleave the owner row for aggregate lifecycle and auditfor the exact released identity, delete its reservation rows at every normalizer version participating in dual-write after appending the audited release event
Approved repairapply the registered lifecycle ruleapply an audited, versioned identity mutation
ALLOW_MULTIPLE transitionno rowno row

These are synchronous write-side guard tables, not asynchronous query projections. Domain events define the business history and can be reduced to reconstruct the registry, but the corresponding owner and identity transition must commit in the same transaction as the event and outbox writes during normal operation. Projecting it later would reopen the race in which another create is admitted before the first reservation becomes visible.

Every mutation that cannot be derived from an existing domain event, especially an explicit identity release, must have a versioned, audited event or repair record with deterministic rebuild semantics. Direct SQL or a UI edit that changes the guard without durable provenance is forbidden because a later rebuild would silently undo or contradict it.

entity_identity_t contains active reservations only, so its binding_status constraint remains CURRENT | ALIAS; it does not add a RELEASED state that would still occupy the reservation primary key. An EXPLICIT_RELEASE operation must append a versioned *IdentityReleasedEvent containing the aggregate, scope, released schema versions, and identity digests. The shared transition component, not the caller, derives the required schema-version set. During normalizer dual-write the release must cover every participating version in one transaction; an event whose version list is incomplete is rejected before any event or binding mutation. Within that complete version set, release deletes only the exact named identity and never unrelated current or alias bindings. The event store and repair audit retain provenance, and replaying that event deterministically removes the same reservations during a rebuild. The owner row remains. Restoring an aggregate after its identity has been released must reserve a permitted free identity through an explicit command; it cannot silently recreate the deleted binding.

Bootstrap, Backfill, And Generated SQL

For a new empty database, ddl.sql creates empty identity tables. Bootstrap then imports the registered domain events through the same shared append transaction used at runtime; each imported birth, rename, reparent, delete, and restore event updates the identity tables synchronously. The expected order is:

ddl.sql
  -> validate the creation-policy registry
  -> preflight every bootstrap event and planned identity transition
  -> import bootstrap events through the authoritative append path
  -> verify owner/binding counts and registry digest
  -> enable uniqueness enforcement

That sequence is the Phase 5 target contract, not a capability of the legacy bootstrap fixture today. The current event-importer/events.json birth events do not carry the trusted commandkind and entityscope extensions required by the Phase 2 guard, and the importer must not guess whether historical Category or Tag events were tenant-local or global. Therefore a newly created database populated from that fixture is treated exactly like an existing database: keep create traffic fenced, import the events, run the Phase 5 scope-provenance preflight and versioned backfill, verify the completion record, and only then enable reject mode. Only an empty database that imports no historical identity-protected events may enable Phase 2 writers immediately.

The guarded snapshot importer is intentionally not a bootstrap bypass. It requires a matching destination materialization watermark and returns 503 ENTITY_MATERIALIZATION_INCOMPLETE when it is absent. Fresh legacy-history bootstrap instead uses the explicit write-fenced importer mode, then runs report, apply, and verification before any protected create route is enabled. This keeps the exceptional raw-history path operator-controlled and prevents it from being used against a live destination.

Phase 5 records materialization completion per aggregate group. The record is bound to the covered event range or high-water mark and input digest, creation- policy registry version, normalizer version, expected owner/binding counts, and verification digest. Deployment refuses reject mode without a matching completion record. A non-zero reservation count is not an acceptable substitute: a partial import or failed backfill can produce rows while still leaving older identities unprotected.

Preflight is required even when the destination is expected to be empty. It must report duplicate streams, semantic-identity conflicts, unsupported policy or normalizer versions, and invalid scope provenance before the first write. This makes bootstrap, event import, and generated-SQL backfill share the same fail-before-mutation contract.

A generated SQL artifact such as entity-identity-bootstrap.sql may be used to upgrade an existing database that already contains authoritative events. It is an output of the versioned backfill tool, not a hand-maintained source of truth and not the ongoing maintenance mechanism. It must:

  • identify the exact input event range or snapshot, creation-policy registry version, normalizer versions, and input digest;
  • insert entity_aggregate_t owners before their binding rows;
  • preserve current bindings, aliases, lifecycle, and source event provenance;
  • execute in one controlled transaction after report-only conflict analysis;
  • be idempotent only when an existing row has exactly the expected owner and binding, and fail rather than overwrite a conflicting reservation;
  • include expected row counts and a deterministic verification digest; and
  • be regenerated whenever the authoritative input or policy version changes.

After bootstrap, creates, renames, reparents, deletes, restores, releases, imports, and normalizer migrations continue to maintain the rows transactionally. A static SQL file cannot replace those runtime transitions.

Rebuild And Environment Promotion

The event store is the historical source for a complete rebuild, while the identity tables are the authoritative current admission index. A rebuild must run the same versioned identity transition component over events in aggregate-version order, preferably into shadow tables. It must report conflicts, verify owner and binding counts and digests, and replace or enable the rebuilt registry only after the affected aggregate groups are write-fenced or otherwise protected by the documented dual-write protocol.

Event-history promotion is the preferred way to move identity-protected entities between environments:

  1. Export the domain event history and its policy/schema metadata.
  2. Apply approved target-host, scope, and identifier transformations.
  3. Preflight the transformed events against the destination creation-policy registry and report every reservation conflict.
  4. Recompute the destination owner and binding transitions from the transformed events; do not copy source rows with an unverified scope or normalizer version.
  5. Insert each event, identity transition, and outbox record atomically.
  6. Verify the destination registry before enabling writes for the imported groups.

The current-materialized-snapshot flow needs an additional safeguard. A business snapshot contains current values but may omit former names that must remain reserved as ALIAS bindings. Snapshot-only promotion must therefore either carry a versioned identity-reservation manifest containing every required current and alias binding, or be rejected for identity-protected aggregate groups unless the relevant event history accompanies it.

entity_aggregate_t, entity_identity_t, and command_idempotency_t must be excluded from generic projection-table export and generic table-to-CreatedEvent conversion. They are not standalone business aggregates and must never become synthetic EntityAggregateCreatedEvent, EntityIdentityCreatedEvent, or CommandIdempotencyCreatedEvent records. If a snapshot carries an identity manifest, the importer handles that manifest explicitly, validates its policy and normalizer versions, and binds it to the imported business-event or snapshot digest.

command_idempotency_t is different from the identity tables: its rows describe request attempts and cannot be reconstructed from domain events. An in-place identity-registry rebuild must leave the existing ledger untouched. A promotion to a new database starts with an empty ledger unless a separate, security-reviewed active-ledger transfer is performed.

For UNIQUE aggregates, an empty destination ledger does not permit a semantic duplicate because the reconstructed identity reservation remains authoritative. For ALLOW_MULTIPLE aggregates, the ledger is the only retry-deduplication defense. Their create endpoints must therefore be write-fenced during promotion and remain fenced until the maximum supported pre-promotion retry horizon has drained, or the promotion must securely transfer every still-live ledger entry. An idempotency epoch may be changed only when old-epoch keys are explicitly rejected; an old retry must never be treated as a new request merely because the destination ledger is empty.

Administrative UI

There is no general CRUD UI for these tables. Operators must not insert, update, or delete reservation rows directly. An administrative identity-registry view may provide read-only status, rebuild preview, conflict review, and source-event and policy-version diagnostics. Any release or repair action must invoke a dedicated, elevated, audited command and produce the durable event or repair record required for deterministic reconstruction.

Command Idempotency Ledger

Use a separate table for retry semantics:

CREATE TABLE command_idempotency_t (
    scope_type       VARCHAR(16)  NOT NULL,
    scope_id         VARCHAR(255) NOT NULL,
    principal_id     VARCHAR(255) NOT NULL,
    command_type     VARCHAR(255) NOT NULL,
    idempotency_key  VARCHAR(128) NOT NULL,
    request_hash     BYTEA        NOT NULL,
    request_fingerprint_version INTEGER NOT NULL,
    aggregate_id     VARCHAR(255) NOT NULL,
    event_id         UUID         NOT NULL,
    completed_ts     TIMESTAMPTZ  NOT NULL,
    PRIMARY KEY (scope_type, scope_id, principal_id, command_type, idempotency_key)
);

principal_id is the authenticated caller, taken from trusted command context and never from the request body. Binding the key to a principal rather than only to a tenant prevents one user from replaying another user’s client-generated key and receiving the original success result, which would disclose an aggregateId without passing that entity’s own authorization check. It also removes accidental collisions between users whose clients derive weak keys from timestamps or form names.

The ledger stores only the minimum response reference required to reproduce a successful response. It must not persist secrets copied from a create response.

Request Fingerprint

request_hash is a versioned fingerprint over validated client intent plus trusted scope. It must not be computed over the enriched event payload.

Enrichment is where retries diverge: CreateCategory and CreateTag generate a fresh UUID per request, and the command path stamps event IDs, timestamps, and aggregate versions before the event is built. Hashing any of that makes a genuine retry look like a different request, so every retry would return 409 IDEMPOTENCY_KEY_REUSED — turning the mechanism into the opposite of what it is for.

The fingerprint therefore covers:

  • the validated client-supplied command fields, after input validation and after defaults are applied, so that an unchanged resubmission is stable;
  • the trusted scope (scope_type, scope_id) and command_type; and
  • the fingerprint version.

It explicitly excludes server-generated and transient values: generated aggregate UUIDs, event IDs, correlation and trace IDs, aggregate versions, timestamps, audit metadata, and any field the enrichment step introduces.

Serialization follows the same discipline as identity canonicalization — typed, field-named, stable field order — and the version is bumped whenever the included field set or serialization changes.

A retry must be compared using the fingerprint version that produced the stored entry, which is why request_fingerprint_version is persisted alongside request_hash. On lookup, read the stored version, compute the fingerprint with that implementation, and compare. Treating an older version as non-matching would break the guaranteed retry horizon during a rolling deployment: a request accepted by an old node and retried against a new one would return IDEMPOTENCY_KEY_REUSED for an identical request.

Prior fingerprint implementations must therefore be retained and executable for at least the maximum configured idempotencyRetention across all policies. An implementation may only be deleted once no ledger entry can still reference it.

Storing the version only solves the old-to-new direction. It does not help when a new node writes a version-N+1 entry and an old node — which has no N+1 implementation — then serves the retry. That node cannot compute the stored version at all, so the comparison fails and an identical request is rejected. A fingerprint version change is therefore an expand/contract rollout:

  1. Deploy nodes that can read both N and N+1 but still write N.
  2. Drain every node that understands only N.
  3. Switch new entries to write N+1.
  4. Retain the N implementation until no retained ledger row references it.

The ordering is what matters: read capability must be everywhere before any node writes the new version. Skipping step 1 or reordering step 3 before step 2 leaves a window where a retry lands on a node that cannot evaluate the entry it is being asked about.

Retention is policy-specific, not one global TTL:

  • For UNIQUE aggregates, retention may be bounded by the supported client retry window. The permanent entity identity reservation remains the semantic duplicate defense after an idempotency record expires.
  • For ALLOW_MULTIPLE aggregates, there is no identity reservation, so this ledger is the only retry-deduplication defense. Its retention window is a correctness boundary: a delayed retry arriving after expiry creates a second entity. Retention for these aggregates must be at least the maximum client and gateway retry horizon.

Atomic Create Algorithm

The create path should be implemented inside the existing graph-aware command append transaction, not as a query followed by an unrelated event insert.

BEGIN
  validate the registered creation policy and event schema
  derive trusted scope
  if multiplicity = UNIQUE:
      derive canonical semantic identity
  compute the lock key set:
      aggregate lock
      idempotency lock (always, when an idempotency key is supplied)
      semantic-identity lock (only when multiplicity = UNIQUE)
  acquire the deduplicated, numerically sorted lock set
  resolve the idempotency ledger:
      same key and same request fingerprint -> return the original result
      same key and different fingerprint    -> IDEMPOTENCY_KEY_REUSED
  verify that the aggregate stream does not exist
  if multiplicity = UNIQUE:
      insert the entity_aggregate_t owner row (entity_status = ACTIVE)
      insert the entity_identity_t reservation (binding_status = CURRENT)
      during dual-write, insert one CURRENT binding per participating version
  reserve graph revision and user nonce as applicable
  insert event_store_t and outbox_message_t rows
  complete the idempotency record
COMMIT

The semantic-identity steps are conditional. An ALLOW_MULTIPLE create derives no identity and inserts no reservation, but still takes the aggregate and idempotency locks and still enforces stream birth.

The idempotency lock is what makes retry deduplication work for ALLOW_MULTIPLE. Those aggregates generate a fresh UUID per request, so two concurrent retries carrying the same idempotency key take different aggregate locks and would otherwise both pass the ledger check. The command_idempotency_t primary key still prevents two entities from being created — the loser fails on the unique constraint and rolls back — but the client receives a constraint error at exactly the moment idempotency was supposed to shield it. Locking on the idempotency key first makes the loser wait, observe the completed row, and return the original success result instead.

Advisory Lock Keys

PostgreSQL advisory locks take a bigint, so the lock key is derived, not the logical identity string. There are three lock domains, each with a fixed prefix. The prefixes remove serialization ambiguity — they guarantee that a field value in one domain cannot serialize to the same string as a different field in another. They do not and cannot prevent two distinct strings from hashing to the same BIGINT; that is handled by cross-domain deduplication below.

aggregate:   "eiu:v1:agg\x1F"  || aggregate_id
identity:    "eiu:v1:sid\x1F"  || scope_type || "\x1F" || scope_id || "\x1F"
                                || aggregate_type || "\x1F"
                                || identity_schema_version || "\x1F"
                                || lower(hex(identity_hash))
idempotency: "eiu:v1:idem\x1F" || scope_type || "\x1F" || scope_id || "\x1F"
                                || principal_id || "\x1F" || command_type || "\x1F"
                                || idempotency_key

The encoding rules are exact, because two implementations that serialize differently do not share a lock:

  • The aggregate lock follows the current event-store stream key and UNIQUE (aggregate_id, aggregate_version) constraint, so it deliberately excludes aggregate_type. Two candidate births with the same aggregate ID must serialize on one lock even if their declared types differ. Changing the event-store stream key requires a coordinated lock-format migration.

  • UTF-8, no normalization, no case folding — the canonical identity was already normalized before hashing.

  • \x1F (ASCII unit separator) is the only field delimiter, and it may not appear in any field value. Fields that could contain it are rejected at validation.

  • No field is optional or omitted. scope_id is never null; a global scope uses its canonical sentinel.

  • eiu:v1: is the version prefix. Changing any encoding rule requires bumping it.

Because a bumped prefix produces different BIGINT keys, old and new nodes would lock disjoint key spaces and provide no mutual exclusion at all — the most dangerous moment being precisely a rolling deployment. A lock-format change is therefore a three-step rollout, not a swap:

  1. Deploy a version that computes both the old and the new lock keys, adds both to the same set, and deduplicates and numerically sorts them together with every other key. Correctness is preserved because any two nodes now share at least one key for the same logical lock.
  2. Wait until all writers running the old-only format are drained.
  3. Deploy a version that computes the new format only.

Skipping step 1 leaves a window in which two concurrent creates for the same identity take different locks and both proceed to the unique constraint — which still holds, but surfaces as a constraint error rather than an orderly conflict.

Then:

  1. Compute each key with hashtextextended(<serialized lock key>, 0).
  2. Deduplicate the resulting signed BIGINT values across all three domains together, not per domain.
  3. Sort the deduplicated values numerically.
  4. Call pg_advisory_xact_lock on each, in that order, within the lock-order phase below.

Sorting must be on the numeric key, not on the logical identity string. Two strings that sort one way but hash into the opposite order would otherwise let concurrent transactions acquire locks in conflicting order and deadlock. Deduplication must span domains for the same reason: an aggregate key and an identity key can collide into one BIGINT, and a single sorted pass would then attempt the same key twice.

pg_advisory_xact_lock is required rather than a session-scoped lock. Transaction locks release at commit or rollback; session locks would leak across pooled connections and outlive the command.

Key collisions may add contention but cannot weaken correctness, because the database unique constraints remain the final arbiter.

The identity, aggregate, and idempotency locks join the existing documented lock order in GraphCommandPersistence. All call paths must use the same order:

  1. graph-root locks;
  2. logical instance-identity locks;
  3. materialization-completion rows (FOR SHARE for live writes and FOR UPDATE, sorted by aggregate type, for apply);
  4. the single sorted set of aggregate, semantic-identity, and idempotency locks;
  5. identity, idempotency, and graph-revision rows;
  6. user nonce;
  7. event offset, event store, outbox, and notification rows.

The database unique constraints are the final race arbiter even when an advisory lock is omitted by a future bug. SQL unique-constraint failures must be translated to the same typed conflict instead of a generic database error.

Concurrent Example

Two category requests can generate UUID A and UUID B while carrying the same host, entity type, parent, and category name.

  • Both derive the same canonical semantic identity.
  • One transaction obtains the identity lock and commits UUID A plus its event.
  • The other waits, observes the committed reservation, and returns a conflict.
  • No event for UUID B is appended, so the projection never has to repair the race.

Result Contract

The command path returns a typed result rather than relying on projection or SQL error text.

ConditionResult
New aggregate and new semantic identitycreate succeeds
Same idempotency key and same request hashreturn the original success result
Same idempotency key and different request hash409 IDEMPOTENCY_KEY_REUSED
Existing aggregate stream409 ENTITY_ALREADY_EXISTS
Different UUID with an existing semantic identity409 ENTITY_ALREADY_EXISTS
Identity held by a live entity’s former name (ALIAS binding, owner ACTIVE)409 ENTITY_ALREADY_EXISTS
Identity held by a deleted entity (owner RETIRED) and reuse is not allowed409 ENTITY_RETIRED
Missing creation policyfail closed; deployment/startup error

A permitted response may include existingAggregateId so the UI can open the existing entity. It must only do so after authorization in the same trusted scope. Cross-tenant conflicts should return the generic not-found/conflict behavior and must not disclose another tenant’s identifier or fields.

Retry And UI Behavior

portal-view should generate an idempotency key when a create form is opened and reuse it for every retry of that submission. It generates a new key only after the attempt completes or the form is intentionally reset. Network/unknown failures retain the key. Typed terminal conflicts (ENTITY_ALREADY_EXISTS, ENTITY_RETIRED, or IDEMPOTENCY_KEY_REUSED) preserve the form but close the attempt, so a later edited submission receives a new key.

The UI should also:

  • disable the submit action while a create request is pending;
  • optionally perform a debounced duplicate lookup for faster feedback;
  • treat the lookup as advisory and still handle a command-side conflict;
  • show “This entity already exists” instead of a generic aggregate-version error;
  • show “This identity belongs to a retired entity; restore it or choose another identity.” for ENTITY_RETIRED, because the remedy differs from a live conflict;
  • link to the existing entity when the response is authorized to reveal its ID; and
  • preserve the user’s form values when a conflict is returned.

The idempotency key and submit-button guard handle network retries and double clicks. The semantic identity reservation handles a new browser session, a new idempotency key, API clients, and concurrent users.

Delete, Restore, And Rename

A delete does not erase the entity’s history or release its creation identity. The default NEVER reuse policy keeps every identity row reserved. Delete sets entity_status to RETIRED and records retired_ts on the single entity_aggregate_t row while leaving every binding unchanged. Restore sets the owner back to ACTIVE and clears retired_ts. Because lifecycle lives in one place, both operations are correct however many aliases and schema versions the aggregate has accumulated.

Reactivating a soft-deleted entity must use an explicit restore/reactivate command and a *RestoredEvent or *ReactivatedEvent at the next aggregate version. It must not append another *CreatedEvent.

If a domain genuinely requires identity reuse, it must opt into EXPLICIT_RELEASE, define the retention and audit rules, and provide a dedicated release operation. Delete alone is not an implicit release. The release event and binding deletion follow the transaction contract in Operational Ownership And Maintenance; direct deletion and a RELEASED binding status are not supported. Like rename and reparent, release is a binding change and must fan out across every normalizer version currently participating in dual-write. A partial-version release fails closed and leaves the event store and every binding unchanged.

When a mutable field also participates in a uniqueness constraint, update handling must atomically reserve the new identity before changing it. Under the default never-reuse policy, the old identity remains as an ALIAS binding so a later create cannot impersonate the renamed entity. The owner row is untouched — renaming a live entity leaves an ALIAS binding while entity_status stays ACTIVE, so a create colliding with the former name returns ENTITY_ALREADY_EXISTS, not ENTITY_RETIRED.

During a normalizer migration the rename must demote and re-bind at every schema version currently participating in dual-write, in the same version-checked transaction. Renaming at version N alone would leave N+1 bound to the old name.

Domains that need old-name reuse require an explicit policy and migration design.

Parent Lifecycle And Child Identities

Parent-scoped identities follow the same never-release default as every other identity:

  • Deleting a parent does not cascade to child identity reservations and does not release them. The children’s rows stay as they are. A delete is a lifecycle mutation on the parent, not a licence to reuse names beneath it.
  • Restoring a parent preserves its children’s reservations. Because nothing was released, restore needs no identity repair and cannot collide with names created in the interim.
  • Reparenting explicitly transfers the child’s current identity binding. The child’s identity includes its parent, so a reparent demotes the identity under the old parent to ALIAS and reserves the new one as CURRENT in the same transaction, gated by the child’s expected version, and across every schema version participating in dual-write. The owner row is untouched — the child stays live throughout. If the destination parent already holds that name, the reparent fails with ENTITY_ALREADY_EXISTS and the original child is unchanged.

An aggregate may reclaim its own alias during a version-checked rename. Renaming A to B and back to A flips the A row’s binding from ALIAS to CURRENT and clears its demoted_ts; it demotes B to ALIAS and records demoted_ts, both in one transaction gated by the aggregate’s expected version. This is safe because the reclaiming aggregate is the same one that created the alias, so no impersonation is possible. Another aggregate may never claim an alias: a reservation row belongs to exactly one aggregate_id, so a different aggregate’s create simply collides with the existing row and is rejected.

Projection Contract

Projection primary keys and unique indexes remain required as defense in depth. They must agree with the creation-policy registry, and conformance tests must fail when the registry identity and projection constraint diverge.

After enforcement:

  • a normal *CreatedEvent is always version 1;
  • exact redelivery of the same event is idempotent by event identity;
  • a different create event for an existing active or retired identity is a permanent projection failure, not an upsert; and
  • restore/reactivate events own the transition from inactive to active.

Projection code must not silently skip a conflicting create while leaving its aggregate version behind. That behavior is what turns the original error into ERR11645 rather than surfacing the invalid event at its source.

Existing Data Repair

The new guard prevents future corruption but does not repair streams that already contain multiple birth events.

Before enforcement, run an inventory that finds:

  • aggregate streams with more than one registered birth event;
  • projection rows whose aggregate version is below the event-store maximum;
  • multiple aggregate IDs that map to one semantic identity; and
  • projection uniqueness violations or permanent create-event failures; and
  • global-scoped entities whose creating principal and historical policy evidence do not prove exact global-create authority at the time of creation.

Older events may not contain enough authorization evidence to prove the historical decision. Classify those records as UNKNOWN_SCOPE_PROVENANCE; do not assume that a global row was legitimate merely because it exists. A global identity created through the legacy substring-role path must be reviewed or quarantined before backfill, otherwise Phase 5 would turn historical cross-tenant name squatting into a permanent reservation.

Repair procedure:

  1. Validate or operator-attest the trusted scope provenance; quarantine ambiguous or unauthorized global creations before producing reservations.
  2. Select the first valid birth event and entity as canonical.
  3. Classify later creates as exact retries, harmless conflicting creates, or conflicts with dependent side effects.
  4. Backfill the canonical aggregate’s entity_aggregate_t owner row, then its entity_identity_t bindings under the foreign key.
  5. For a harmless legacy duplicate, apply an approved, versioned projection repair that keeps the original entity fields but advances the projection version past the invalid create.
  6. Require operator review when the later event changed data, created children, or triggered external side effects.
  7. Record the repair decision and evidence in the existing event-replay repair and audit workflow.

Do not delete or rewrite the later event directly, and do not copy the conflicting create payload over the original entity merely to make versions equal.

Why Common Alternatives Are Insufficient

UI-only Duplicate Check

Two users can pass the check concurrently, and the projection may be stale. Keep it for usability only.

Query The Projection In Each Create Handler

This is the current pattern for some UUID entities. It cannot close the race between the query and append and creates inconsistent rules across services.

Projection Unique Constraint Only

The constraint is evaluated after the event is authoritative. Rejecting the projection cannot undo the event or outbox publication.

Create As Upsert

An upsert silently changes the original entity and makes create behave like update without an expected version. It hides client errors and weakens audit meaning.

UUID Uniqueness

UUID uniqueness proves that two storage identifiers differ. It says nothing about whether the domain entities are duplicates.

Deterministic UUID From The Name

This embeds normalization, scope, and rename policy into an identifier, complicates migration, and still needs collision and lifecycle rules. A separate semantic identity registry keeps the surrogate ID stable and the domain contract explicit.

Hash The Entire Request

Descriptions, timestamps, ordering, and defaults may differ while the request still describes the same entity. Conversely, identical payloads can be valid for an ALLOW_MULTIPLE entity. Identity fields must be declared by the domain.

Implementation Plan

Phase 0: Freeze The Inventory And Move Coarse Authorization To Gateway

Deployment gate. Identity enforcement must not ship until every participating portal command has fail-closed gateway authorization and global and host scope are derived from trusted context. This is a prerequisite, not a task that can run in parallel with Phase 2.

  • Inventory every logical birth endpoint and classify it as tenant-only, global-only, or combined tenant/global.
  • Configure its role permissions and req-acc rule in Light Portal, publish the generated policy through the config server, and verify the effective policy on each gateway instance before removing command-layer coarse role checks.
  • Verify through the standard config-server reload and deployment health checks that every gateway instance serving Portal commands has loaded the intended policy publication. Block promotion or remove a stale instance from routing rather than assuming an older policy fails closed.
  • Prefer separate tenant and global logical endpoints. For a combined endpoint, use one CEL expression or accessRuleLogic: all to bind role, requested scope, and authenticated host in the same authorization decision.
  • Remove the role.contains(PortalConstants.ADMIN_ROLE) branch from AbstractCommandHandler.handle() after the gateway deployment gate passes; do not replace it with another command-layer list of admin, org-admin, and host-admin.
  • Derive host scope only from authenticated context; stop preferring the request-body hostId in effectiveHost() and stop falling back to map.get(HOST_ID) in handle().
  • Preserve command-side target-host, parent, owner, owner-transfer, lifecycle, aggregate-version, and uniqueness validation. Replace any remaining raw-role scope exemption only with an unforgeable gateway authorization-scope decision or a scope-specific endpoint.
  • Add tests proving org-admin and host-admin can call only their configured host-scoped endpoints and cannot select global scope. Prove admin can use the separately protected global endpoint or global branch.
  • Add qualification tests proving that a missing role permission, missing req-acc rule, or unavailable effective policy denies the command before it reaches the service, and that an unacknowledged config-server revision blocks deployment or routing to the stale gateway instance.
  • Enumerate all registered portal birth events and their command handlers.
  • Record aggregate ID, scope, projection key, projection unique constraints, semantic identity, normalization, delete behavior, and multiplicity.
  • Require UNIQUE or ALLOW_MULTIPLE for every entry.
  • Add contract fixtures for role, rule, category, tag, platform, config, instance, and at least one intentional-multiplicity aggregate.
  • Audit global entities and child-resource scopes separately.

Phase 1: Stop Same-stream Duplicate Births

  • Add explicit create command kind to the shared command abstraction.
  • Set create expected/new versions to 0/1; never use max-plus-one for create.
  • Enforce stream absence transactionally in GraphCommandPersistence.
  • Translate aggregate/version uniqueness conflicts to 409 ENTITY_ALREADY_EXISTS.
  • Migrate role and rule first and prove that the second create appends no event.
  • Stop silently skipping a conflicting create in projections. Make it a loud, alerting failure now rather than in Phase 4: while append protection is rolling out group by group, this is the only detector for unprotected append paths such as importers, replay tooling, and direct SQL. Permanent-failure enforcement can then be enabled per aggregate group as protection lands.
  • Migrate aggregate versions from int to long as a coordinated provider-interface change. PostgreSQL already stores aggregate_version as BIGINT, so the Java contract should match it throughout. This touches PortalDbProvider, EventPersistence, their implementations (PortalDbProviderImpl, EventPersistenceImpl), test doubles, the ProductVersionConfig*Enricher call sites, and the private helper in SchedulePersistenceImpl. Because db-provider exists so databases can be swapped, alternate implementations break on recompile and the change must be version-coordinated rather than treated as a local type cleanup.

This phase directly resolves the reported ERR11645 path.

Phase 2: Protect Generated UUID Entities

  • Add the versioned creation-policy registry.
  • Add entity_identity_t and semantic identity locks.
  • Reserve identity and append event/outbox in one transaction.
  • Migrate UUID-based entities in groups, starting with tables that already have natural unique constraints.
  • Replace projection existence lookups as correctness checks; retain them only for optional UX or ID discovery during migration.

Phase 3: Idempotent Client Retries

The initial implementation enables the public retry contract only for Category and Tag. Both are single-event creates whose original success response can be reconstructed from the ledger’s aggregate ID after UUID enrichment runs again. The append-side ledger and lock implementation is generic for any single-event CREATE, but another handler must explicitly declare its replay aggregate-ID field before accepting Idempotency-Key. Multi-event create responses remain fail closed until their durable result-reference contract is defined.

  • Add command_idempotency_t.
  • Define and version the request fingerprint over validated client intent plus trusted scope, excluding enrichment output. Prove a retry whose enrichment regenerates a UUID still matches.
  • Add the principal-bound idempotency advisory lock to the sorted lock set so concurrent ALLOW_MULTIPLE retries replay instead of failing on the ledger constraint.
  • Ship fingerprint read capability before write capability, following the expand/contract order, and gate the N+1 write switch on old readers being drained.
  • Accept and propagate a standard create idempotency key.
  • Advertise idempotency support per logical endpoint. An unmigrated handler must reject Idempotency-Key instead of silently performing a non-idempotent create; multi-event creates remain unsupported until their durable replay result is defined.
  • Update portal-view create forms to reuse one key per submission attempt.
  • Return the original success for an exact retry and a conflict for key reuse with a different request hash.

Phase 4: Projection And Lifecycle Alignment

Implemented for the currently enforced Category and Tag semantic-identity groups. UpdatedEvent, DeletedEvent, and explicit RestoredEvent transitions now run through the same database transaction and globally sorted advisory-lock set as stream and identity creation. A lifecycle mutation requires the client’s expected aggregate version, a materialized owner row, and a next-version event before any nonce, graph revision, event, outbox, owner, or binding change occurs. Remaining REVIEW_PENDING aggregate groups stay outside semantic enforcement until Phase 5 inventory, review, and materialization.

Lifecycle request schemas do not require a request-body hostId. Tenant scope is derived from authenticated command context; a global Category or Tag request is identified by the qualified gateway scope decision and globalFlag, while the UI omits the null host returned for global query rows.

  • Replace create-as-reactivate behavior with explicit restore/reactivate events.
  • Keep permanent-failure detection and alerting for remaining aggregate groups; their reject-mode migration remains gated on Phase 5 review and materialization.
  • Add registry-to-DDL conformance tests for semantic unique constraints.
  • Define explicit identity transfer/release behavior for mutable unique fields.
  • Resolve identity collisions through the owner row once delete/restore is implemented: an ACTIVE owner, including a former-name alias, returns ENTITY_ALREADY_EXISTS; a RETIRED owner returns ENTITY_RETIRED.

Phase 5: Backfill And Enforce

  • Run the legacy duplicate, version-gap, and historical scope-provenance inventory.
  • Quarantine global entities whose creation lacks exact historical global-create authority or an approved operator attestation; never backfill them blindly.
  • Repair or quarantine existing conflicts with audit evidence.
  • Backfill owner rows and identity reservations with the versioned generator; optionally emit a reviewable generated SQL artifact for existing databases.
  • Add both guard tables and the idempotency ledger to the generic snapshot export and conversion skip lists.
  • Make event import invoke the shared identity transition component and add a preflight mode that reports every destination reservation conflict before any event is written.
  • Require event history or a validated identity-reservation manifest for snapshot-only promotion of identity-protected aggregate groups.
  • Operate first in report-only mode, then reject mode by aggregate group.
  • Persist the verified per-group materialization completion record and make the deployment gate reject Category/Tag create traffic until its event coverage, registry/normalizer versions, expected counts, and digest match.
  • Reject deployment when a new birth event lacks a reviewed creation policy.
  • Follow the dual-write or drain protocol for any normalizer-version migration, and re-verify the backfill after the last old-version writer exits.
  • Add policy-aware idempotency-ledger cleanup and bounded age/cardinality telemetry. Treat each registry duration as a minimum retention requirement, never as one global TTL, and require this worker before enabling a public ALLOW_MULTIPLE create endpoint. Phase 5 implements this in the operational cleanup worker; deployments that disable it must not enable such an endpoint.

Test Matrix

The shared implementation must cover at least:

ScenarioExpected result
First create for natural aggregate IDevent version 1 and success
Sequential second create for same role/ruleconflict; event count unchanged
Concurrent creates for same aggregate IDone success, one conflict
Two UUIDs with same category semantic keyone success, one conflict
Same name in two permitted host scopesboth succeed
Tenant A and Tenant B each define environment devboth succeed with distinct host-scoped reservations
Global environment dev and Tenant A environment devboth succeed; effective catalog composition remains a query concern
Second environment dev within Tenant Aconflict in Tenant A only
Global and tenant identity according to policyscope-specific expected result
Same idempotency key and request hashoriginal success returned
Same idempotency key with changed requestidempotency conflict
New key for existing semantic identityentity-exists conflict
Create after soft deleteretired conflict
Explicit restore after soft deletenext version succeeds
Sibling names under one parent scopeduplicate rejected within the parent
Same child name under two different parentsboth succeed
Parent deleted while a child identity is reservedchild reservations retained; not released
Create child name under a deleted parentconflict; reservation still held
Parent restored after deletechild reservations intact; no repair needed
Child reparented to a free destinationold identity retired, new one reserved, one transaction
Child reparented into a scope holding that nameconflict; original child unchanged
Aggregate reclaims its own aliasversion-checked rename succeeds
Another aggregate claims an aliasconflict; alias unchanged
Create colliding with a live entity’s former nameENTITY_ALREADY_EXISTS, not ENTITY_RETIRED
Normalizer dual-write holds CURRENT at N and N+1both rows permitted by the partial index
Delete, then restore, with aliases at N and N+1one owner row flips; every binding unchanged and still reserved
Rename during normalizer dual-writedemoted and re-bound at N and N+1 in one transaction
Reparent during normalizer dual-writetransferred at N and N+1 in one transaction; owner row untouched
Binding rows referencing a missing owner rowrejected by the foreign key; unrepresentable
Conflict lookup for an aliased live entitysingle owner read; ENTITY_ALREADY_EXISTS
Intentional ALLOW_MULTIPLE entitytwo UUID streams succeed
Concurrent ALLOW_MULTIPLE retries, same idempotency keyone create; the other returns the original result, not a constraint error
Retry after enrichment regenerates a UUIDsame fingerprint; original success returned
Normalizer upgrade before materialization completesenforcement blocked for that aggregate group
Version-N idempotency retry after version N+1 is deployedstored version used; identical request replays
N+1 write attempted while an N-only reader still serves trafficrejected; writes stay at N until step 2 completes
Concurrent old-format and new-format lock writers, same idempotency keyshared key acquired; one create, one replay
Version-N writer creates after N+1 materializationdual-write produces the N+1 row, or the writer is drained first
Projection lag during duplicate createcommand-side conflict still occurs
Identity normalizer version migrationold aliases remain protected
Unauthorized cross-tenant duplicate probeno existing ID disclosed
Failure after identity insert before event insertwhole transaction rolls back
New database bootstrap imports a UNIQUE birth eventevent, owner, binding, and outbox commit together
Generated upgrade SQL reruns against identical rowsverifies exact matches without overwriting them
Generated upgrade SQL encounters a different owner or bindingfails closed; no partial backfill
Event-history promotion rewrites the target host scopedestination identity is recomputed under the target scope
Snapshot-only promotion omits identity historyrejected for identity-protected groups
Snapshot promotion includes a valid identity manifestall current and alias bindings are restored and verified
Generic snapshot conversion encounters guard tablestables are skipped; no synthetic guard-table CreatedEvents are emitted
Operator attempts direct identity-table CRUDno UI/API path exists; audited command or repair workflow is required
Bootstrap input contains a semantic conflictpreflight reports it and writes no event or reservation
Historical global entity lacks exact authorization evidenceclassified UNKNOWN_SCOPE_PROVENANCE and quarantined before backfill
Explicit release covers every dual-write schema versionaudited release event and exact binding deletions commit together; owner remains
Explicit release omits one participating schema versionrejected before append; no binding is deleted
Rebuild encounters an identity release eventreleased bindings remain absent after deterministic reduction
Catalog precedence changes which scoped dev an Instance resolvesrequires a new normalizer version; existing identity is not silently reinterpreted
Promotion of UNIQUE entities starts with an empty idempotency ledgeridentity reservations still prevent semantic duplicates
Promotion of ALLOW_MULTIPLE entities with live retry keyswrites remain fenced until the retry horizon drains or live ledger entries transfer
Portal role permission and req-acc rule published through the config servergateway admits only configured logical endpoints
Role permission exists but req-acc rule is absentgateway denies before command dispatch
Gateway effective policy is unavailablecommand fails closed; no event or reservation is written
Gateway has not loaded the intended config-server policy publicationdeployment or routing to that instance remains blocked by operational readiness checks
org-admin or host-admin submits global scope on a combined endpointgateway denies; command handler is not invoked
Host-scoped request contains another tenant’s hostIdgateway or command boundary denies; no reservation is written
Direct command invocation bypasses gateway authorizationprevented by ingress isolation or a mutually authenticated command-service hop

PostgreSQL-backed concurrency tests are required. Mock-only tests cannot prove the unique constraint, advisory-lock, and rollback behavior.

Observability

Add bounded metrics without identity values:

  • entity_create_accepted_total{aggregateType}
  • entity_create_conflict_total{aggregateType,reason}
  • entity_create_idempotent_replay_total{aggregateType}
  • entity_create_policy_missing_total{eventType}
  • entity_identity_backfill_conflict_total{aggregateType}
  • duplicate_create_projection_failure_total{aggregateType}

The current Dropwizard registry encodes the bounded aggregateType and reason dimensions as metric-name suffixes rather than native labels. Aggregate values come only from the frozen birth-event registry, reason values are stable codes, and all other values collapse to Unknown or OTHER. Tenant IDs, aggregate IDs, names, canonical identities, and request values are forbidden in metric names. Cleanup also publishes rows remaining, oldest-row age, deleted rows, unsupported rows, and lock-skipped runs on every sweep, including zero-deletion sweeps.

Structured logs may include event type, aggregate type, event ID, correlation ID, scope type, policy version, and a short identity-digest prefix. They must not log raw canonical identities, request payloads, or credentials.

Alert on any duplicate-create projection failure after an aggregate group enters enforce mode. Once command-side enforcement is active, that signal indicates an unprotected append path, importer problem, or policy drift.

Security And Abuse Considerations

  • Derive scope from authenticated command context.
  • Do not reveal an existing aggregate ID until authorization is confirmed.
  • Bound identity field count and canonical size before hashing.
  • Never use secret fields as semantic identities.
  • Rate-limit advisory duplicate lookups so they cannot enumerate catalog names.
  • Treat identity-registry or policy-registry unavailability as fail-closed for create commands.
  • Require elevated, audited authorization for identity release and legacy repair.

Acceptance Criteria

These are program-wide criteria for issue 691. Phase 5 satisfies them only for the scoped Role/Rule stream and Category/Tag semantic-identity rollout described in Status; criteria involving the remaining 114 REVIEW_PENDING birth events remain open until their domain policies and migrations are reviewed.

The design is fully implemented when:

  1. A second role or rule create returns a typed conflict and appends no event.
  2. No normal aggregate stream can contain two registered birth events.
  3. Every birth event declares UNIQUE or ALLOW_MULTIPLE in a versioned registry.
  4. Concurrent create tests prove one winner for both natural-ID and UUID-based semantic identities.
  5. Identity reservation, event store, outbox, nonce, and idempotency changes are atomic.
  6. A generated UUID cannot bypass a declared business unique key.
  7. Delete does not silently release identity, and restore is not modeled as create.
  8. Projection constraints and creation policies have executable conformance tests.
  9. The UI uses stable per-attempt idempotency keys and handles authorized conflicts.
  10. Existing multiple-create streams are inventoried and repaired or quarantined before full enforcement.
  11. Metrics and logs expose conflict cause without leaking identity values.
  12. Adding a new birth event without a creation policy fails the deployment gate.
  13. Every participating logical command endpoint has a Portal-managed, config-server-published role permission and req-acc policy that fails closed.
  14. Every gateway instance serving Portal commands passes the standard config-server reload and deployment health checks for the intended policy publication or is excluded from routing.
  15. Java command handlers do not use admin, org-admin, or host-admin token parsing as the coarse endpoint-invocation authorization boundary.
  16. Host, parent, owner, lifecycle, version, and uniqueness invariants remain enforced at the database-backed command boundary.
  17. Bootstrap, import, replay, and approved repair use the same versioned identity transition component as normal commands.
  18. A new database either appends already-qualified bootstrap events through the guarded command boundary, or imports legacy history under an explicit write fence and completes verified materialization before protected writes; generated SQL is limited to versioned, verified upgrade backfills.
  19. Event-history promotion reconstructs destination reservations, and snapshot-only promotion cannot silently lose aliases.
  20. The guard and idempotency tables are excluded from generic snapshot conversion and cannot be maintained through direct UI CRUD.
  21. The same normalized name may be reserved independently in global and different trusted tenant scopes, while a duplicate within one scope is rejected.
  22. Historical global creations with missing or invalid authorization provenance are quarantined rather than converted into permanent reservations.
  23. Bootstrap preflight reports all reservation and scope conflicts before the first mutation.
  24. Explicit identity release has event-backed deletion semantics that can be reproduced during rebuild.
  25. Promotion accounts for the non-reconstructible idempotency ledger and fences ALLOW_MULTIPLE writes across any unprotected retry horizon.
  26. Explicit release covers every normalizer version participating in dual-write or fails before appending its event or deleting any binding.
  27. Identities that reference scoped catalog values encode the resolved scope and normalized value, and a resolution-rule change uses normalizer migration.
  28. Idempotency-ledger cleanup honors each policy’s minimum retention, exposes bounded age/cardinality telemetry, and is deployed before any public ALLOW_MULTIPLE create endpoint is enabled.

Settled Decisions

  • The authoritative check belongs in the database-backed command append path.
  • UUIDs remain surrogate storage IDs; semantic uniqueness is separate.
  • Projection and UI checks are helpful but never authoritative.
  • Creation-policy entries are per birth event type; owner and identity rows are per actual aggregate instance and are maintained by a shared synchronous identity transition component.
  • New databases populate the guard tables by importing bootstrap events. A generated SQL file is an optional, versioned upgrade artifact, never a hand-maintained registry or runtime maintenance mechanism.
  • Event history is the preferred promotion source. Snapshot-only promotion of an identity-protected group requires a validated reservation manifest so aliases and lifecycle cannot be lost.
  • The guard tables have no direct CRUD UI and are not asynchronous projections.
  • Semantic uniqueness is scoped. Global and tenant-local reference values, and values belonging to different tenants, may share a normalized name because their trusted scope tuples differ; only duplicates within one scope conflict.
  • Explicit release removes the named active reservation rows at every normalizer version participating in dual-write and records one versioned audited event; incomplete version coverage fails closed and RELEASED is not a binding status.
  • Scoped catalog references are canonicalized as resolved scope/value tuples, not bare display names. A change to resolution semantics is a normalizer-version migration.
  • Idempotency ledger entries are request facts, not event-derived state. In-place identity rebuilds retain them, while cross-database promotion fences ALLOW_MULTIPLE writes until active retry protection is preserved or drained.
  • Semantic identity and request idempotency are separate mechanisms.
  • Create is not upsert, restore, or update.
  • Identity remains reserved after delete by default.
  • Every create event must explicitly declare uniqueness or intentional multiplicity.
  • Coarse command authorization belongs to Light Gateway logical-endpoint policy. Light Portal owns role permissions and rules and publishes them through the config server; static values.yml is not the policy source.
  • Java command handlers do not duplicate endpoint invocation policy by parsing admin, org-admin, or host-admin, but trusted host, target, owner, lifecycle, version, and uniqueness checks remain command-side invariants.
  • Combined host/global commands use scope-specific endpoints where practical; otherwise one fail-closed CEL decision binds role, requested scope, and trusted host. accessRuleLogic: any must not separate those required conditions.
  • Trusted scope derivation and effective gateway policy qualification are prerequisites for enforcement, not parallel workstreams.
  • The reservation key is scope, aggregate type, schema version, and identity_hash together; equal digests within one such key are one identity.
  • Idempotency keys are bound to the authenticated principal, not only the tenant.
  • Identity binding (CURRENT/ALIAS) and entity lifecycle (ACTIVE/RETIRED) are separate concerns on separate tables. Lifecycle is stored once per aggregate; conflict responses read it from the owner row.
  • One current binding per aggregate per identity schema version.
  • Binding changes fan out across every schema version participating in dual-write; lifecycle changes never fan out at all.
  • Every versioned artifact — fingerprint, lock encoding, normalizer — needs a mixed-version transition protocol, and it must work in both directions: old nodes reading new data as well as new nodes reading old. Rolling deployments run both versions at once, so a version bump is never a swap, and read capability always ships before write capability.

Composit key vs Surrogate UUID key

Composite key with 5 or more columns

User the following three tables as examples. We have composite key with 5 columns and some of them are varchar types in product version_property_t table. Is is a good idea to create UUID keys for config_property_t and product_version_t?

-- each config file will have a config_id reference and this table contains all the properties including default. 
CREATE TABLE config_property_t (
    config_id                 UUID NOT NULL,
    property_name             VARCHAR(64) NOT NULL,
    property_type             VARCHAR(32) DEFAULT 'Config' NOT NULL,
    light4j_version           VARCHAR(12), -- only newly introduced property has a version.
    display_order             INTEGER,
    required                  BOOLEAN DEFAULT false NOT NULL,
    property_desc             VARCHAR(4096),
    property_value            TEXT,
    value_type                VARCHAR(32),
    property_file             TEXT,
    resource_type             VARCHAR(30) DEFAULT 'none',
    update_user               VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts                 TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

ALTER TABLE config_property_t
    ADD CHECK ( property_type IN ( 'Cert', 'Config', 'File') );


COMMENT ON COLUMN config_property_t.property_value IS
    'Property Default Value';

COMMENT ON COLUMN config_property_t.value_type IS
    'One of string, boolean, integer, float, map, list';

COMMENT ON COLUMN config_property_t.resource_type IS
  'One of none, api, app, app_api, api|app_api, app|app_api, all';

ALTER TABLE config_property_t ADD CONSTRAINT config_property_pk PRIMARY KEY ( config_id, property_name );



CREATE TABLE product_version_t (
    host_id                     UUID NOT NULL,
    product_id                  VARCHAR(8) NOT NULL,
    product_version             VARCHAR(12) NOT NULL, -- internal product version 
    light4j_version             VARCHAR(12) NOT NULL, -- open source release version
    break_code                  BOOLEAN DEFAULT false, -- breaking code change to upgrade to this version.
    break_config                BOOLEAN DEFAULT false, -- config server need this to decide if clone is allowed for this version. 
    release_note                TEXT,
    version_desc                VARCHAR(1024),
    release_type                VARCHAR(24) NOT NULL, -- Alpha Version, Beta Version, Release Candidate, General Availability, Production Release
    current                     BOOLEAN DEFAULT false,
    version_status              VARCHAR(16) NOT NULL, 
    update_user                 VARCHAR (255) DEFAULT SESSION_USER NOT NULL,
    update_ts                   TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY(host_id, product_id, product_version)
);


-- customized property for product version within the host.
CREATE TABLE product_version_property_t (
    host_id          UUID NOT NULL,
    product_id       VARCHAR(8) NOT NULL,
    product_version  VARCHAR(12) NOT NULL,
    config_id        UUID NOT NULL,
    property_name    VARCHAR(64) NOT NULL,
    property_value   TEXT,
    property_file    TEXT,
    update_user      VARCHAR (126) DEFAULT SESSION_USER NOT NULL,
    update_ts        TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

ALTER TABLE product_version_property_t
    ADD CONSTRAINT product_version_property_pk PRIMARY KEY ( host_id, 
                                                             product_id,
                                                             product_version,
                                                             config_id,
                                                             property_name);

Replacing the wide, multi-column composite primary key in product_version_property_t with one based on surrogate UUID keys from the parent tables is generally a very good idea for several reasons, primarily related to performance and maintainability.

Let’s break down the pros and cons:

Current Situation (Composite Keys):

  • config_property_t PK: (config_id UUID, property_name VARCHAR(64)) - Size: 16 + (up to 64 + overhead) bytes. Reasonably sized.
  • product_version_t PK: (host_id UUID, product_id VARCHAR(8), product_version VARCHAR(12)) - Size: 16 + (8 + overhead) + (12 + overhead) bytes. Getting wider.
  • product_version_property_t PK: (host_id UUID, product_id VARCHAR(8), product_version VARCHAR(12), config_id UUID, property_name VARCHAR(64)) - Size: 16 + (8+oh) + (12+oh) + 16 + (64+oh) bytes. This is quite wide.

Problems with the Wide Composite Key in product_version_property_t:

  1. Large Primary Key Index: The B-tree index for this 5-column PK will be large. Larger indexes consume more disk space, more memory (reducing cache efficiency), and take longer to scan during queries, updates, and deletes.
  2. Large Foreign Keys (Implicit): If any other table were to reference product_version_property_t (unlikely given its nature, but possible), the foreign key would also need all 5 columns.
  3. Larger Secondary Indexes: Any other indexes you create on product_version_property_t will typically include the primary key columns implicitly (in PostgreSQL leaf nodes). A wide PK bloats all other indexes on the table, magnifying the performance impact.
  4. Update Overhead: Updating any of the PK columns (though generally discouraged) is very expensive. More importantly, index maintenance during INSERTs and DELETEs is heavier with wider keys.
  5. Join Complexity: Joining on 5 columns is syntactically more verbose.

Proposed Solution (Surrogate UUID Keys):

  1. Add Surrogate Key to config_property_t:

    • Add a new column: cp_id UUID PRIMARY KEY DEFAULT gen_random_uuid() (or use UUIDv7 generator).
    • Add a UNIQUE constraint on the original natural key: ALTER TABLE config_property_t ADD CONSTRAINT config_property_nat_key UNIQUE (config_id, property_name); (This is CRITICAL to maintain data integrity).
    • Size of new PK: 16 bytes.
  2. Add Surrogate Key to product_version_t:

    • Add a new column: pv_id UUID PRIMARY KEY DEFAULT gen_random_uuid() (or UUIDv7).
    • Add a UNIQUE constraint on the original natural key: ALTER TABLE product_version_t ADD CONSTRAINT product_version_nat_key UNIQUE (host_id, product_id, product_version); (CRITICAL).
    • Size of new PK: 16 bytes.
  3. Modify product_version_property_t:

    • Remove the existing 5-column PK.
    • Replace the 5 individual columns used for the PK/FK relationship with two new columns: pv_id UUID and cp_id UUID.
    • Create the new Primary Key: ALTER TABLE product_version_property_t ADD CONSTRAINT product_version_property_pk PRIMARY KEY (pv_id, cp_id);
    • Add Foreign Key constraints:
      ALTER TABLE product_version_property_t
          ADD CONSTRAINT product_version_property_fk_pv
          FOREIGN KEY (pv_id) REFERENCES product_version_t(pv_id) ON DELETE CASCADE; -- Or appropriate action
      
      ALTER TABLE product_version_property_t
          ADD CONSTRAINT product_version_property_fk_cp
          FOREIGN KEY (cp_id) REFERENCES config_property_t(cp_id) ON DELETE CASCADE; -- Or appropriate action
      
    • Size of new PK: 16 + 16 = 32 bytes.

Advantages of the Proposed Solution:

  1. Significantly Smaller PK Index: The PK index on product_version_property_t shrinks from potentially >100 bytes wide to just 32 bytes. This is the biggest win.
  2. Smaller Secondary Indexes: All other indexes on product_version_property_t will also be smaller.
  3. Improved Query Performance: Smaller indexes mean faster scans, better cache utilization, and quicker lookups, updates, and deletes involving the PK or indexes on product_version_property_t.
  4. Simpler Joins: Joining product_version_property_t to its parent tables now only requires joining on the single pv_id or cp_id columns.
  5. Simpler Foreign Keys: The FK definitions are simpler.

Disadvantages/Considerations:

  1. Need for UNIQUE Constraints: You absolutely must add the UNIQUE constraints on the original natural keys in the parent tables (config_property_t, product_version_t) to prevent duplicate entries based on business logic. This adds two new indexes.
  2. Storage Overhead (Parent Tables): You add 16 bytes per row to config_property_t and product_version_t for the new UUID PKs.
  3. Migration Effort: You need to:
    • Add the new UUID columns to the parent tables.
    • Populate them with unique UUIDs for all existing rows.
    • Add the new FK columns (pv_id, cp_id) to product_version_property_t.
    • Update product_version_property_t to populate these new columns by joining back to the parent tables based on the original composite keys.
    • Drop the old PK and FK constraints (or the old columns if they are no longer needed besides the value overrides).
    • Add the new PK and FK constraints.
    • Add the UNIQUE constraints on the parent tables.
  4. Lookups by Natural Key: Queries that previously used the composite PK directly on the parent tables (e.g., SELECT ... FROM product_version_t WHERE host_id = ? AND product_id = ? AND product_version = ?) will now use the new UNIQUE index instead of the PK index. Performance should still be very good, but it’s technically hitting a different index.

Conclusion:

Yes, it is a good idea. The performance benefits gained from dramatically reducing the size of the primary (and consequently, secondary) indexes on the frequently accessed product_version_property_t table likely outweigh the disadvantages (extra storage on parent tables, migration effort, extra unique indexes). The simplification of joins and foreign keys is also a significant maintainability improvement.

Recommendation:

Proceed with the plan to introduce surrogate UUID keys (preferably UUIDv7 for generation if using a library) on config_property_t and product_version_t, and change product_version_property_t to use these two UUIDs as its composite primary key. Do not forget to add the UNIQUE constraints on the original natural keys in the parent tables.

Composite key with 3 or 4 columns

If a composite key with 3 or 4 columns, it is less definitive than the 5-column case. It moves into a “it depends” territory, requiring weighing the pros and cons more carefully based on specifics.

Here’s a breakdown of factors to consider for 3 or 4 column composite primary keys:

Arguments for Sticking with the Composite Natural Key (CNK):

  1. Simplicity (Potentially): No need for an extra surrogate key column and an extra UNIQUE index on the natural key columns. The schema might feel slightly less cluttered if the natural key is intuitive and stable.
  2. Reduced Storage (Parent Table): Avoids adding 16 bytes per row for the UUID PK in the table itself.
  3. Meaningful Key: The PK components have inherent business meaning, which can sometimes be useful for direct queries or understanding relationships without extra joins (though the UNIQUE index on the SUK approach provides this lookup too).
  4. Migration Cost: Avoids the effort of adding columns, backfilling data, and changing referencing tables.

Arguments for Refactoring to a Surrogate UUID Key (SUK):

  1. Index Size (Still Relevant): This is the biggest factor.
    • Calculate the Width: Add up the maximum potential size of the 3 or 4 columns in the CNK.
      • UUID: 16 bytes
      • INT: 4 bytes
      • BIGINT: 8 bytes
      • VARCHAR(N): N bytes + 1 or 4 bytes overhead (depending on length)
      • TIMESTAMP: 8 bytes
      • BOOLEAN: 1 byte
    • Compare: Compare the calculated width to the typical width of a surrogate key reference (16 bytes for one UUID, or 32 bytes if the child table needs two UUIDs like in your product_version_property_t example).
    • Threshold: If the CNK width starts exceeding ~32-40 bytes, the performance benefits of a narrower SUK (especially for secondary indexes and joins) become increasingly attractive. Even a 3-column key like (UUID, VARCHAR(8), VARCHAR(12)) is already 16 + (8+1) + (12+1) = 38 bytes. A 4-column key is almost certainly wider.
  2. Secondary Index Bloat: Remember, all other indexes on the table implicitly include the PK columns. A wide CNK makes every index larger, impacting cache efficiency and scan speed across the board. This effect is magnified if you have many secondary indexes.
  3. Join Performance/Simplicity: Joining on a single UUID column is generally faster and syntactically simpler than joining on 3 or 4 columns, especially if some are strings.
  4. Foreign Key Simplicity: Tables referencing this table only need to store a single UUID column as the foreign key, rather than 3 or 4 columns. This significantly reduces storage and complexity in child tables.
  5. Immutability/Stability: Surrogate keys are inherently stable. If there’s any chance the values in the natural key columns might need to change (which is generally bad practice for PKs but sometimes unavoidable), using a SUK provides crucial insulation.

Decision Rubric (Heuristics):

  • Scenario 1: Key is mostly small, fixed-width types (e.g., 3 INTs, 2 INTs + UUID). Total width < ~32 bytes.
    • Lean towards: Keeping the CNK might be acceptable, especially if the table isn’t heavily referenced or doesn’t have many secondary indexes. The benefits of SUK are less pronounced.
  • Scenario 2: Key includes even one moderately sized VARCHAR (e.g., VARCHAR(20+)) or multiple smaller VARCHARs. Total width > ~32-40 bytes.
    • Lean towards: Refactoring to SUK. The performance benefits from reduced index sizes (primary and secondary) and simpler joins/FKs likely start to outweigh the costs.
  • Scenario 3: Key includes wide VARCHARs (VARCHAR(50+)).
    • Lean towards: Strongly consider refactoring to SUK. Benefits are significant.
  • Scenario 4: The table is frequently referenced by other tables via FK.
    • Lean towards: Refactoring to SUK to simplify the child tables.
  • Scenario 5: The table has many secondary indexes.
    • Lean towards: Refactoring to SUK to keep those indexes smaller.
  • Scenario 6: Any component of the natural key is potentially volatile.
    • Lean towards: Refactoring to SUK for stability.

Conclusion:

For 3 or 4 columns, it’s less of a slam dunk than for 5 columns, but refactoring to a surrogate UUID key is still often beneficial, especially if VARCHARs are involved or the total key width exceeds roughly 32-40 bytes. The key drivers are the reduction in primary and secondary index size and the simplification of foreign keys and joins.

Carefully calculate the width of your specific 3 or 4 column composite keys and consider the usage patterns of the table before deciding.

Atomic Events vs Composite Events

Event Design

This is a fundamental design decision in any Event Sourcing system, and the choice has significant long-term consequences for your system’s flexibility, maintainability, and clarity.

The overwhelming consensus and best practice is to design atomic events.

Let’s break down why, and then look at the trap of composite events.


An atomic event represents a single, granular, immutable fact that has occurred in your system. It is the smallest possible unit of change that has meaning to the business domain.

Example Scenario: A user updates their profile by changing their name and their shipping address on the same form.

With atomic events, this single user action (the Command) would result in two separate events being written to the stream:

  1. CustomerNameChanged { customerId: "123", newName: "Jane Doe" }
  2. CustomerAddressChanged { customerId: "123", newAddress: "..." }

Why Atomic Events are Superior:

a) Maximum Flexibility and Reusability:

  • Targeted Consumers: You can have different parts of your system (projections, process managers, other microservices) subscribe to only the events they care about. The shipping department only needs to know about CustomerAddressChanged, while the marketing department might only care about CustomerNameChanged. With a composite event, both would have to subscribe and parse the larger event to see if the part they care about was updated.
  • Future-Proofing: Six months from now, you might need to build a new feature that triggers a welcome kit to be sent when a customer provides an address for the first time. It’s trivial to add a new consumer for the CustomerAddressChanged event.

b) Clear and Unambiguous Intent:

  • Each event has a single, well-defined purpose. The name of the event, like ItemAddedToCart, tells you exactly what happened.
  • A composite event like ProfileUpdated is ambiguous. Did the name change? The address? The phone number? The password? You have to inspect the payload to understand the actual change, which makes the event stream harder to read and debug.

c) Simplified Event Handlers (Projections):

  • Your event handlers become small and simple. The logic for handling CustomerAddressChanged is focused purely on updating the address.
  • With a composite event, your handler becomes a complex series of if statements: if (event.newName != null) { updateName(); } if (event.newAddress != null) { updateAddress(); } .... This is brittle and hard to maintain.

d) Better for Versioning:

  • Events are immutable and stored forever. Your system will evolve. It’s much easier to version a small, focused event (e.g., add an optional field to CustomerAddressChanged_v2) than it is to version a large, complex composite event where any one of its dozen fields might change.

2. Composite Events (The Anti-Pattern)

A composite event attempts to bundle multiple state changes into a single, large event. It’s often a result of developers mapping the event directly to the UI form or the incoming command.

Using the same scenario (updating name and address):

A composite event might look like this:

  • CustomerProfileUpdated { customerId: "123", name: "Jane Doe", address: "...", phoneNumber: null }

The Problems with this Approach:

  • Loss of Granularity: As explained above, you lose the ability to react to specific changes.
  • Ambiguous Intent: The event name doesn’t tell the whole story.
  • Sparse Payloads: What if the user only changed their name? The address and phoneNumber fields in the event payload would be null or unchanged. Your consumers have to handle these sparse, optional fields, leading to more complex logic.
  • False Cohesion: It groups things together that only changed at the same time but are not necessarily part of the same business fact.

3. The Key Insight: The Role of the Aggregate

You might be thinking, “But the name and address change must be atomic! What if the system crashes after writing the first event but before the second?”

This is where the Aggregate from Domain-Driven Design (DDD) comes in. The Aggregate is the consistency boundary.

Here is the correct flow:

  1. Command: A single UpdateCustomerProfileCommand is sent to the system. It contains both the new name and the new address.
  2. Aggregate: The Customer Aggregate receives the command. It validates the business rules (e.g., the name isn’t empty, the address is valid).
  3. Event Generation: If the rules pass, the Aggregate’s method produces a list of atomic events: [CustomerNameChanged, CustomerAddressChanged].
  4. Atomic Persistence: The Event Store then takes this list of events and persists them to the event stream in a single, atomic transaction. It’s all or nothing. You will never have a CustomerNameChanged in the stream without the corresponding CustomerAddressChanged from the same command.

Key Takeaway: The Command can be composite (representing a single user gesture), but it should be resolved into a set of atomic Events. The atomicity of writing these events is guaranteed by your Aggregate and Event Store, not by bundling them into a single event type.


How to Design Good Atomic Events (A Checklist)

  • Name it in the Past Tense: UserRegistered, OrderShipped, PasswordReset. It’s a fact that has already happened.
  • Capture Business Intent: Don’t just record a CRUD-like change. ProductPriceUpdated is okay, but PriceAdjustedForSale is better because it captures the why.
  • Ensure it’s a Complete Fact: Include all necessary data for a consumer to understand the event without having to look up previous state. For example, ItemAddedToCart should include productId, quantity, and priceAtTimeOfAdding, not just productId.
  • Include Causation and Correlation IDs: Add metadata to your events. Who triggered this change (userId)? What command caused it (causationId)? What overall business process is this part of (correlationId)?
  • Think “What happened?” not “What changed?”: An event is a story. OrderSubmitted is a great event. A composite event like OrderStateChanged { oldState: "Pending", newState: "Submitted" } is far less expressive.

Final Verdict

Always design atomic events.

Use the Aggregate pattern to ensure that multiple atomic events resulting from a single command are persisted transactionally. This approach gives you a system that is far more flexible, maintainable, readable, and ready for future evolution.

Transaction

When using Kafka to handle the events, we need to handle the transactions on both producer side and consumer side along with database transation to ensure multiple events can be handled together. The choice you make here directly impacts the atomicity, ordering, and resilience of your entire system.

Let’s break down the options and the correct way to handle transactions on both the producer and consumer side.

The Correct Approach: Multiple Events, One Transaction

The best practice, by far, is to push multiple, individual event messages per command, wrapped in a single Kafka producer transaction.

This approach maintains the benefits of atomic events (flexibility, clarity) while guaranteeing the all-or-nothing atomicity you need.

How to Implement This (Producer Side)

Your service that processes the command and talks to the Aggregate will do the following:

  1. Generate Events: The Customer Aggregate generates the list of atomic events: [CustomerNameChanged, CustomerAddressChanged].
  2. Use Kafka Producer Transactions: This is the key feature that makes it all work.

Here is a conceptual code flow (using Java-like syntax):

// IMPORTANT: Configure your producer for transactions and idempotence
// props.put("transactional.id", "my-unique-transactional-id");
// props.put("enable.idempotence", "true");
KafkaProducer<String, Event> producer = new KafkaProducer<>(props);

// The list of events from your Aggregate
List<Event> events = customerAggregate.handle(updateProfileCommand);

// 1. Initialize the transaction
producer.initTransactions();

try {
    // 2. Begin the transaction
    producer.beginTransaction();

    // The Aggregate ID (e.g., "customer-123") is the Kafka Key
    String aggregateId = customerAggregate.getId();

    for (Event event : events) {
        // 3. Send EACH event as a SEPARATE message.
        // CRUCIAL: All events for this transaction MUST have the same key.
        // This ensures they all go to the same partition and are consumed in order.
        producer.send(new ProducerRecord<>("customer-events-topic", aggregateId, event));
    }

    // 4. Commit the transaction.
    // This makes all messages in the transaction visible to consumers atomically.
    producer.commitTransaction();

} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
    // These are fatal errors, we should close the producer
    producer.close();
} catch (KafkaException e) {
    // 5. If anything goes wrong, abort. None of the messages will be visible.
    producer.abortTransaction();
}

producer.close();

Why this is the best way:

  • Atomicity Guaranteed: Kafka guarantees that consumers will either see ALL the messages from commitTransaction or NONE of them (if you abortTransaction).
  • Ordering Guaranteed: By using the same key (aggregateId) for all events in the transaction, you ensure they are written to the same partition in the exact order you sent them. Your consumer will read them in that same order.
  • Consumer Flexibility: Your stream processors can now consume individual, meaningful events. A shipping-related processor can filter for and process only CustomerAddressChanged events, completely ignoring CustomerNameChanged.

How to Process Events Transactionally (Consumer Side)

Now, how does your streams processor populate the database tables while maintaining consistency? This is often called the “Transactional Outbox” pattern, but in reverse—a “Transactional Inbox”.

The goal is to atomically update the database AND commit the Kafka offset. You never want to commit an offset for a message whose database update failed.

Here is the standard, robust pattern for a custom consumer/streams processor:

  1. Disable Kafka Auto-Commit: This is the most important step. Your application must take manual control of committing offsets. In your consumer configuration, set enable.auto.commit=false.

  2. Consume and Process in Batches:

// This is a conceptual loop for your consumer
while (true) {
    // 1. Poll for a batch of records. Kafka gives you a batch.
    ConsumerRecords<String, Event> records = consumer.poll(Duration.ofMillis(1000));

    if (records.isEmpty()) {
        continue;
    }

    // Get your database connection
    Connection dbConnection = database.getConnection();
    dbConnection.setAutoCommit(false); // Start manual DB transaction management

    try {
        // 2. Process each record in the polled batch
        for (ConsumerRecord<String, Event> record : records) {
            Event event = record.value();
            // Apply the change to the database based on the event type
            processEvent(event, dbConnection);
        }

        // 3. If all events in the batch were processed successfully, commit the database transaction
        dbConnection.commit();

        // 4. IMPORTANT: Only after the DB commit succeeds, commit the Kafka offset.
        // This tells Kafka "I have successfully and durably processed all messages up to this point."
        consumer.commitSync();

    } catch (SQLException e) {
        // 5. If the DB update fails, rollback the DB transaction...
        dbConnection.rollback();
        // ...and DO NOT commit the Kafka offset.
        // The consumer will re-poll and re-process this same batch of messages later.
        // This is why your processing logic MUST be idempotent.
        System.err.println("Database update failed. Rolling back. Will retry batch.");
        // You might want to seek to the beginning of the failed batch to be explicit
        // consumer.seek(record.topic(), record.partition(), record.offset());
    } finally {
        dbConnection.close();
    }
}

It is possible to handle transactions in a Kafka Streams processor, but it requires using the low-level Processor API and is significantly more complex than the standard consumer approach. You cannot achieve this with the high-level DSL (.map(), .filter(), etc.) alone.

If your processor’s only job is to read from Kafka and write to a database: Use the Plain Kafka Consumer. It is simpler, more direct, less error-prone, and purpose-built for this task. You are essentially building a custom, lightweight Kafka Connect sink.

The Critical Need for Idempotency

Because a failure can occur after the DB commit but before the Kafka offset commit, your application might restart and re-process the same batch of events.

Your database update logic must be idempotent. This means running the same update multiple times produces the same result as running it once.

Examples of Idempotent Operations:

  • INSERT with a primary key: INSERT INTO customers (...) VALUES (...) ON DUPLICATE KEY UPDATE ... (MySQL) or INSERT ... ON CONFLICT ... DO UPDATE ... (PostgreSQL).
  • UPDATE statements: UPDATE customers SET name = 'Jane Doe' WHERE customer_id = '123'. Running this 5 times is the same as running it once.
  • Using Versioning: Store a version or last_processed_event_id in your database table.
    UPDATE customers
    SET name = 'Jane Doe', version = 2
    WHERE customer_id = '123' AND version = 1;
    
    If the update tries to run again, the WHERE clause will not match, and no rows will be affected.

Why Not Put a List of Events in One Message?

This is an anti-pattern that solves one problem (producer atomicity) by creating many more downstream.

  • Loss of Meaning: The fundamental unit is the event, not a list of events. A Kafka message should represent one fact.
  • Consumer Complexity: Every single consumer now has to be written to expect a list. It has to deserialize the list and loop through it.
  • No Filtering: A consumer who only cares about CustomerAddressChanged still has to receive and parse the entire message containing the CustomerNameChanged event, only to discard it. This is inefficient and tightly couples your consumers to the producer’s batching behavior.
  • Versioning Hell: Versioning a list of events is much harder than versioning a single event.

Summary

ActionRecommended Approach
Event DesignAtomic Events: CustomerNameChanged, CustomerAddressChanged.
Producing to KafkaMultiple Messages, One Kafka Transaction: Use producer.beginTransaction() and producer.commitTransaction().
Kafka Message KeyAggregate ID: Use the same key (e.g., customer-123) for all events from the same command to ensure ordering.
Consuming from KafkaManual Offset Commits: Disable auto-commit.
Database UpdatesTransactional Batch Processing: [Start DB Tx] -> [Process Batch] -> [Commit DB Tx] -> [Commit Kafka Offset].
Database LogicIdempotent: Your UPDATE/INSERT logic must handle being re-run on the same event without causing errors or incorrect data.

Mixed Aggregates vs Single Aggregate

In the simple batch-processing consumer example I provided, the Kafka message key is not being used to segregate processing. The example processes a batch of records polled from Kafka, and that batch can indeed contain events for many different user_ids or host_ids, all mixed together in a single database transaction.

Let’s break down why this happens, the implications, and how to design a consumer that does respect aggregate boundaries for processing.


Why the Simple Batch Consumer Mixes Aggregates

  1. Kafka’s Partitioning: You use the user_id/host_id as the key. Kafka’s producer hashes this key to determine which partition the message goes to. This is excellent because it guarantees that all events for a single user (a single aggregate) will always go to the same partition and will be consumed in the order they were produced.

  2. The Consumer’s Polling: A Kafka consumer is assigned one or more partitions to read from. When it calls consumer.poll(), it fetches a batch of records that have arrived on all of its assigned partitions since the last poll.

    • If your consumer is assigned Partition 0, and events for User A, User B, and User C have all landed on Partition 0, your polled batch will contain [EventA1, EventB1, EventC1, EventA2, ...].
    • They are mixed together, but the ordering per key is preserved (Event A1 will always come before Event A2).
  3. The Simple Transaction Loop: The example loop I showed takes this entire mixed batch (records) and processes it within one DB transaction.

    // This loop combines multiple aggregates into one DB transaction
    dbConnection.beginTransaction();
    for (ConsumerRecord record : records) { // 'records' contains events for User A, B, C...
        updateDatabase(record.value());
    }
    dbConnection.commit();
    

Is This a Problem? (The Trade-offs)

For many use cases, processing mixed aggregates in a single batch is perfectly fine and often more performant.

  • Pro: High Throughput. Batching database commits is much more efficient than committing after every single event. Committing a transaction that updates 100 rows for 50 different users is faster than running 100 separate transactions.
  • Con: “Noisy Neighbor” Problem. If processing an event for User C throws an unrecoverable SQLException, the entire batch transaction will be rolled back. This means the valid updates for User A and User B will also be rolled back and retried. The failure of one aggregate’s event processing blocks the progress of others in the same batch.
  • Con: Loss of Concurrency. You are processing everything serially within a single consumer thread. You aren’t taking advantage of the fact that User A’s events are independent of User B’s events.

The Better Approach: Processing per Aggregate

If you want to isolate failures and potentially parallelize work, you need to change your consumer logic to process events grouped by their key (user_id/host_id).

This pattern is more complex but far more robust for multi-tenant systems.

Conceptual Code for Aggregate-based Processing

This approach reorganizes the polled batch by key before processing.

// Still disable auto-commit: enable.auto.commit=false
while (true) {
    ConsumerRecords<String, Event> records = consumer.poll(Duration.ofMillis(1000));
    if (records.isEmpty()) continue;

    // 1. Group the polled records by their key (the aggregate ID)
    Map<String, List<ConsumerRecord<String, Event>>> recordsByAggregate = new HashMap<>();
    for (ConsumerRecord<String, Event> record : records) {
        recordsByAggregate
            .computeIfAbsent(record.key(), k -> new ArrayList<>())
            .add(record);
    }

    // This map now holds the highest offset for each partition from this poll
    Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = new HashMap<>();

    // 2. Process the events for EACH aggregate in its OWN transaction
    for (Map.Entry<String, List<ConsumerRecord<String, Event>>> entry : recordsByAggregate.entrySet()) {
        String aggregateId = entry.getKey();
        List<ConsumerRecord<String, Event>> aggregateEvents = entry.getValue();

        // Start a DB transaction FOR THIS AGGREGATE ONLY
        Connection dbConnection = database.getConnection();
        dbConnection.setAutoCommit(false);

        try {
            for (ConsumerRecord<String, Event> record : aggregateEvents) {
                // Your idempotent database logic
                updateDatabaseForAggregate(record.value(), dbConnection);

                // Keep track of the highest offset we've successfully processed
                TopicPartition partition = new TopicPartition(record.topic(), record.partition());
                OffsetAndMetadata offset = new OffsetAndMetadata(record.offset() + 1);
                offsetsToCommit.merge(partition, offset, (oldVal, newVal) -> newVal.offset() > oldVal.offset() ? newVal : oldVal);
            }
            // Commit the DB transaction for this one aggregate
            dbConnection.commit();

        } catch (Exception e) {
            // FAILURE for a single aggregate!
            System.err.println("Failed to process batch for aggregate: " + aggregateId + ". Rolling back.");
            dbConnection.rollback();
            // What to do now?
            // Option A: Skip this aggregate and continue with others (might break ordering).
            // Option B (Better): Stop processing the entire poll, log the poison pill, and DO NOT commit any offsets.
            // Let's assume Option B. We would break out of this loop.
            // For simplicity, we'll just log and continue, but in reality, you need a robust dead-letter queue strategy here.
        } finally {
            dbConnection.close();
        }
    }

    // 3. After attempting to process all aggregates in the batch, commit the offsets
    // for all the partitions where we made progress.
    if (!offsetsToCommit.isEmpty()) {
        consumer.commitSync(offsetsToCommit);
    }
}

Key Differences and Improvements in this Pattern:

  1. Isolation: A failure in processing for user-123 no longer affects user-456. The transaction for user-456 can still be committed.
  2. Correctness: The database state for each aggregate is updated transactionally, respecting its own event sequence.
  3. Concurrency (Potential): This design is a stepping stone to true parallel processing. You could submit each List<ConsumerRecord> (the events for one aggregate) to a thread pool for processing, allowing you to update multiple users’ data concurrently. This requires careful management of the offsets to commit.

Summary and Recommendation

Your intuition was exactly right. The simple batch consumer ignores the key for processing logic, which can be a problem.

ApproachWhen to UseKey BenefitKey Drawback
Simple Batch Processing- High-throughput, simple ETL jobs.
- When a single failure blocking a whole batch is acceptable.
- Low-contention systems.
Simplicity & Performance. Easiest to write and often the fastest for bulk loading.Noisy Neighbor Problem. One bad event can halt processing for all others in the batch.
Per-Aggregate Processing- Multi-tenant systems (your case).
- Systems requiring high reliability and fault isolation.
- When you need to guarantee progress for healthy aggregates even if others are failing.
Robustness & Isolation. Failures are contained to a single aggregate.Increased Complexity. Requires more code to group records and manage transactions and offsets carefully.

For your system, especially a multi-tenant one, the “Per-Aggregate Processing” model is strongly recommended. The added complexity is a worthwhile investment for the significant gains in robustness and fault isolation. It correctly uses the Kafka key not just for partitioning, but also as the logical boundary for your downstream processing and transactions.

DLQ and Process Flow

This is the final, crucial piece of the puzzle for building a truly resilient system. You’ve identified the exact scenario that separates a robust consumer from a brittle one: handling a “poison pill” message.

A poison pill is a message that your consumer cannot process successfully, no matter how many times it retries. A database constraint violation (UNIQUE constraint failed, FOREIGN KEY constraint failed) is a perfect example. If you simply roll back and retry, you will be stuck in an infinite loop, processing the same failing message forever and making no progress.

Yes, you absolutely must handle this by routing the problematic events to a Dead Letter Queue (DLQ), which is typically another Kafka topic.

Here is the complete, robust workflow for handling these failures.


The Dead Letter Queue (DLQ) Pattern

The goal is to isolate the “bad” event(s) so that the consumer can move on and continue processing subsequent “good” events for that aggregate and others.

1. The DLQ Topic

First, you create another Kafka topic specifically for these failed events. For example, if your main topic is customer-events, your DLQ topic might be customer-events-dlq.

The messages in the DLQ topic should be enriched with metadata about the failure:

  • The original event payload.
  • The original topic, partition, and offset.
  • The consumer-group that failed to process it.
  • A timestamp of the failure.
  • The error message or stack trace (e.g., “UNIQUE constraint failed on customers.email”).

2. Modified Consumer Logic with DLQ

Let’s refine the “Per-Aggregate Processing” logic to include the DLQ step.

// Assumes you have a separate KafkaProducer instance for the DLQ
KafkaProducer<String, DeadLetterEvent> dlqProducer = ...;

while (true) {
    ConsumerRecords<String, Event> records = consumer.poll(...);
    if (records.isEmpty()) continue;

    // Group records by aggregate key
    Map<String, List<ConsumerRecord<String, Event>>> recordsByAggregate = groupRecordsByKey(records);

    Map<TopicPartition, OffsetAndMetadata> offsetsToCommit = new HashMap<>();

    for (Map.Entry<String, List<ConsumerRecord<String, Event>>> entry : recordsByAggregate.entrySet()) {
        String aggregateId = entry.getKey();
        List<ConsumerRecord<String, Event>> aggregateEvents = entry.getValue();

        Connection dbConnection = database.getConnection();
        dbConnection.setAutoCommit(false);

        try {
            for (ConsumerRecord<String, Event> record : aggregateEvents) {
                // Your idempotent database update logic
                updateDatabaseForAggregate(record.value(), dbConnection);
            }
            // If all events for this aggregate succeed, commit the DB transaction
            dbConnection.commit();

            // And mark the final offset for this aggregate as ready to commit
            markOffsetsAsProcessed(aggregateEvents, offsetsToCommit);

        } catch (SQLException e) {
            // A "poison pill" or unrecoverable error was detected!
            dbConnection.rollback(); // Roll back any partial DB changes for this aggregate

            System.err.println("Unrecoverable error processing aggregate " + aggregateId + ". Sending to DLQ. Error: " + e.getMessage());

            // **THE DLQ LOGIC**
            // Send the entire batch of events for this failing aggregate to the DLQ.
            // It's crucial to send the whole batch to preserve their relative order.
            for (ConsumerRecord<String, Event> failedRecord : aggregateEvents) {
                DeadLetterEvent dlqEvent = createDlqEvent(failedRecord, e);
                // Use the same key to keep ordering in the DLQ
                dlqProducer.send(new ProducerRecord<>("customer-events-dlq", aggregateId, dlqEvent));
            }

            // **CRITICAL STEP:** We have now "handled" these poison pill events by sending them to the DLQ.
            // We must treat them as successfully processed from the perspective of the main topic
            // so we can commit their offsets and move on.
            markOffsetsAsProcessed(aggregateEvents, offsetsToCommit);

        } catch (Exception otherException) {
            // Handle transient errors (e.g., network timeout) differently.
            // You might want to retry these without going to the DLQ immediately.
            dbConnection.rollback();
            // Don't commit offsets, let the consumer re-poll and retry.
            System.err.println("Transient error occurred. Will retry batch.");
            // Break the loop and wait for the next poll
            break;

        } finally {
            dbConnection.close();
        }
    }

    // After processing all aggregates in the poll...
    if (!offsetsToCommit.isEmpty()) {
        // Commit the offsets for both successfully processed and DLQ'd aggregates.
        // This advances the consumer past the poison pill.
        consumer.commitSync(offsetsToCommit);
    }
}

// Helper method to create a DLQ event
private DeadLetterEvent createDlqEvent(ConsumerRecord record, Exception e) {
    // Populate with original event, error message, topic, offset, etc.
    return new DeadLetterEvent(...);
}

// Helper method to update the offsets map
private void markOffsetsAsProcessed(List<ConsumerRecord<String, Event>> records, Map<TopicPartition, OffsetAndMetadata> offsets) {
    records.forEach(rec -> {
        TopicPartition partition = new TopicPartition(rec.topic(), rec.partition());
        OffsetAndMetadata offset = new OffsetAndMetadata(rec.offset() + 1);
        offsets.merge(partition, offset, (oldVal, newVal) -> newVal.offset() > oldVal.offset() ? newVal : oldVal);
    });
}

What to Do with the DLQ Topic?

The DLQ is not a garbage can. It’s a hospital for sick messages. You need a strategy for managing it.

  1. Monitoring and Alerting: Set up alerts on the DLQ topic. A message landing here is an exceptional event that indicates a bug, bad data, or a system inconsistency. A human needs to be notified.

  2. Manual Intervention: An operator or developer should inspect the DLQ message.

    • Is it a bug in the consumer? If so, deploy a fix to the consumer code.
    • Is it bad data from the producer? For example, a UserRegistered event was sent with an email that already exists. The upstream service needs to be fixed.
    • Is it a state inconsistency? Maybe an event arrived out of order due to a misconfiguration, and the state it expects in the database doesn’t exist yet.
  3. Reprocessing (The “Re-drive” Pattern): Once the underlying issue is fixed (e.g., the consumer bug is patched, or the inconsistent DB state is manually corrected), you need a way to re-introduce the events from the DLQ back into the main processing flow. This is typically done with a separate utility or “re-driver” application that reads from the DLQ and publishes the original event back to the original topic.

Differentiating Error Types

It’s crucial to distinguish between:

  • Transient Errors: Network issues, temporary database unavailability, lock timeouts. These are retryable. The correct response is to roll back and not commit the offset, forcing a retry on the next poll.
  • Permanent Errors: DB constraint violations, deserialization errors, unrecoverable business logic failures (NullPointerException). These are not retryable. The correct response is to route to the DLQ and commit the offset to move on.

Your catch blocks should be structured to differentiate these.

try {
    // ... processing logic
} catch (SQLIntegrityConstraintViolationException | DeserializationException e) {
    // PERMANENT: Rollback, send to DLQ, commit offset
} catch (SQLTransientConnectionException | LockTimeoutException e) {
    // TRANSIENT: Rollback, DO NOT commit offset, let it retry
} catch (Exception e) {
    // Generic catch-all, probably treat as permanent to be safe
    // Rollback, send to DLQ, commit offset
}

By implementing this complete pattern, you create a system that is not only transactional and correct but also self-healing. It can automatically isolate failures, alert you to the problem, and continue operating for all healthy aggregates, preventing a single bad event from bringing your entire system to a halt.

Notification and Event Store


1. Is notification_t replacing the DLQ?

Short Answer: No, not effectively. They serve different primary purposes, though they can complement each other.

Let’s clarify the roles:

  • Dead Letter Queue (DLQ - Kafka Topic):

    • Primary Purpose: Operational recovery. It’s a queue of unprocessable messages that allows your consumer to move on and continue processing subsequent messages. It’s designed for reprocessing the original event once the underlying issue (code bug, bad data, external system outage) is resolved.
    • Nature: A temporary holding area for raw events that need to be re-driven into the main processing flow. It’s part of your automated error handling and retry mechanism.
    • Mechanism: It preserves the original message payload (and its context) in a format easily consumable by other Kafka applications (like a re-driver).
  • notification_t (Database Table):

    • Primary Purpose: Audit, visibility, and user-facing reporting. It’s a record of processing outcomes (success/failure) and associated metadata (error messages). It’s a read model or a projection for displaying status.
    • Nature: A durable log or materialized view of processing activity. It’s primarily for human intervention and analysis.
    • Mechanism: Stores a summary or specific details about what happened during processing, typically in a structured way that can be queried and displayed.

Why notification_t doesn’t replace a DLQ:

  1. Reprocessing:

    • If an event fails and you only log it to notification_t, your Kafka consumer is still stuck. If it commits the offset for that failed message, the message is lost from the Kafka topic (due to retention policies). You’d then have to reconstruct the original message from notification_t and manually re-publish it to Kafka, which is cumbersome.
    • A DLQ (Kafka topic) already holds the raw message and allows for a more automated re-driving process.
  2. Operational Flow:

    • A DLQ is part of an automated pipeline: consumer fails -> sends to DLQ -> consumer moves on. Alerts are triggered.
    • With just notification_t, you need an external mechanism (human reading the UI, another scheduled job) to query the table, identify failures, and trigger manual re-publishing. This is less reactive and scalable.
  3. Mixing Concerns:

    • Your notification_t table correctly stores processing results. This is a projection of the events.
    • The raw events themselves are what need to be re-driven.
    • A DLQ focuses solely on holding the raw, unprocessable events.

How they can complement each other:

  • When an event is sent to the DLQ, you also log an entry in notification_t indicating the failure, which event was sent to DLQ, and why. This provides the user-facing visibility you want while maintaining the operational robustness of the DLQ.
  • Your re-driver for the DLQ could also update the notification_t entry when an event is successfully re-processed.

Conclusion on DLQ vs. notification_t: Your notification_t is a valuable audit and reporting tool, but it should not be your sole mechanism for handling unprocessable Kafka messages. The DLQ pattern with a dedicated Kafka topic is the industry standard for robust, scalable error handling and reprocessing in a streaming architecture.


2. Using notification_t as the Event Store for replay?

Short Answer: This is generally a poor idea due to mixed concerns and potential data loss, unless your notification_t is specifically designed as a pure Event Store.

Let’s define “Event Store” in Event Sourcing:

  • The Event Store: This is the single, authoritative source of truth for your system’s state. It stores all historical domain events (atomic, immutable facts) in the exact order they occurred, for all time (or at least for a very long retention period). It’s used to:
    • Rebuild the current state of an aggregate.
    • Replay all events to build new read models (projections).
    • Perform historical analysis.

Evaluating notification_t as an Event Store:

  • “Save all the events”: This is the fundamental requirement. If it indeed stores the full, raw, original event payload for every event that enters your system, then this part is met.

  • “Success or failure of the processing with error message”: This is where it breaks the Event Store principle. An Event Store should only contain facts that happened. Whether an event was processed successfully or failed is a derived state (a projection or audit log entry), not the event itself.

    • Problem 1: Mixing Concerns: Mixing raw events with processing results violates the purity of an Event Store. It makes the Event Store harder to reason about and potentially less efficient for replay.
    • Problem 2: Data Integrity/Purity for Replay: If you replay events from this table, do you replay the “success/failure” status? No, you only care about the event itself. This metadata is irrelevant for rebuilding aggregate state or building new projections.
  • “Kafka topic might not contain all the events”: This is a critical point.

    • If your Kafka topics have short retention (e.g., 7 days), then yes, you absolutely need an external, durable Event Store that retains events indefinitely.
    • A relational database is a perfectly valid choice for an Event Store. Many Event Sourcing implementations use a relational DB table (events or event_stream) where each row is an event, uniquely identified, with the aggregate ID, sequence number, event type, and event payload.

Recommendation for your Event Store:

  1. Dedicate a separate table as your Event Store: If you want to use a database for event storage (which is fine!), create a table specifically for events or event_store.

    • This table should only store the raw, immutable event data (ID, aggregate ID, sequence number, timestamp, type, payload).
    • It should not include processing status or error messages. These are projection data.
  2. Maintain notification_t as a separate read model: Your notification_t table should remain exactly as it is: a valuable projection that tracks the processing success/failure for auditing and user visibility. It is derived from the events, not the source of events.

  3. Choose your Event Store carefully:

    • Kafka: If you configure Kafka topics for infinite retention (or very long, e.g., years), Kafka can serve as your primary Event Store. This is powerful for stream processing.
    • Relational Database: If Kafka retention is short, or if you prefer a traditional database for your Event Store, design a dedicated events table that conforms to the Event Store principles.
    • Specialized Event Stores: There are also purpose-built Event Stores (e.g., EventStoreDB) that excel at this task.

Combined Ideal Architecture:

  1. Producers send atomic events to Kafka topics (e.g., customer-events) using Kafka transactions for atomicity.
  2. Kafka topics are configured with long retention if they are your Event Store, or short retention if you’re using a separate database as Event Store.
  3. Consumers/Stream Processors read from Kafka.
    • They process events (e.g., update a materialized view, send to another service).
    • They also persist a copy of the event to your dedicated, pure Event Store table (if Kafka isn’t your long-term store). This must happen transactionally with their other updates or be a separate, guaranteed write.
    • They also update your notification_t table with the processing status (success/failure) for audit/UI.
    • If an unrecoverable error occurs, they send the original event to a DLQ topic (e.g., customer-events-dlq) and log the failure to notification_t.
    • They then commit their offsets on the main topic.
  4. DLQ management: Separate processes monitor the DLQ, allow manual inspection, and facilitate re-driving events back to the main topic after a fix.
  5. Replaying: When needed, new services or new read models can be built by replaying all events from your chosen Event Store (long-retention Kafka topics or your dedicated events database table).

This separation of concerns leads to a much more robust, understandable, and maintainable system.

Event Store

When to populate the event store is a crucial design decision for ensuring the integrity and completeness of your Event Store. There are two main patterns to consider, each with its own trade-offs.


Option 1: Populating the Event Store (DB Table) Before Publishing to Kafka (Transactional Outbox Pattern)

This is generally the most robust and recommended approach for ensuring at-least-once (often effectively once) persistence of your events. It guarantees that an event is durably stored in your Event Store before it is ever considered for publishing to Kafka.

How it works:

  1. Command Processing:

    • Your Aggregate receives a command and generates a list of atomic events.
    • These events are persisted to your dedicated Event Store table (e.g., events_store_t) within the same local database transaction as any state changes to your aggregate’s materialized view (if applicable). This is the key: a single local transaction.
    • Alongside storing the event in events_store_t, the event is also stored in an “Outbox” table (e.g., outbox_messages) in the same database transaction. The outbox_messages table serves as a temporary holding area for events that need to be published to Kafka.
  2. Outbox Relayer/Publisher:

    • A separate, dedicated process (the “Outbox Relayer” or “Change Data Capture (CDC) Publisher”) continuously monitors the outbox_messages table for new entries.
    • When it finds new events in the outbox_messages table, it reads them and publishes them to Kafka.
    • After successfully publishing to Kafka, it marks the event as “published” in the outbox_messages table or deletes it.

Why this is best:

  • Atomicity Guaranteed (Local): The critical guarantee is that the event is either stored in your Event Store AND in the Outbox table, or neither. If the application crashes after generating events but before publishing to Kafka, the events are durably stored in the Outbox and will be published later by the relayer.
  • No Data Loss: Events are never lost between generation and publication to Kafka.
  • Decoupling: The service generating events doesn’t need to know about Kafka’s availability. It only needs to commit to its local database. The Outbox Relayer handles the Kafka dependency.
  • Effective Once: Combined with Kafka’s idempotent producer, this provides effectively once-delivery.
  • Source of Truth: The event_store_t database table will be our source of truth and it allows queries against it.

Where the events_store_t is populated:

  • In the same local DB transaction where the events are generated and recorded in the Outbox table.

Option 2: Populating the Event Store (DB Table) After Consuming from Kafka

This approach involves two stages of atomicity: first, the producer guarantees delivery to Kafka, and then the consumer guarantees persistence from Kafka to your Event Store.

How it works:

  1. Command Processing & Kafka Publishing:

    • Your Aggregate generates events.
    • These events are immediately published to Kafka using Kafka producer transactions (as we discussed previously, to guarantee all events from a command are published atomically).
  2. Consumer Processing:

    • Your Kafka consumer (the one responsible for populating your Event Store) reads events from Kafka.
    • For each event (or batch of events from the same aggregate), it persists the event to your dedicated events_store_t table within a local database transaction.
    • Crucially: It commits the Kafka offset only after the database transaction to events_store_t is successful.

Why this is generally less ideal for the primary Event Store:

  • Producer Responsibility: The service that generates the events also has the responsibility of publishing to Kafka. If Kafka is down or slow, the producer service might be blocked or need to implement complex retry logic.
  • Data Durability Gap: There’s a theoretical, albeit small, window where events are generated but might not yet be durably committed to your authoritative events_store_t database if the consumer or Kafka has issues. (Kafka itself provides durability, but your application’s Event Store is separate).
  • Complexity for Replay: If your consumer fails and you need to replay events, where do you replay from? Kafka? What if Kafka’s retention is short? This pattern requires Kafka to be the true long-term Event Store, or it introduces a reliance on the consumer correctly populating the DB.
  • Source of Truth: The Kafka topic is written first and it will be our event store. It doesn’t support query on the events directly.

Where the events_store_t is populated:

  • In the consumer process, within a local DB transaction, after polling from Kafka.

Answering your Specific Questions:

  • “Where is the best place to populate this table?” The best place is in the same database transaction where the event is generated and stored in an Outbox table (Option 1). This ensures that your authoritative Event Store (your events_store_t table) is always the first and most reliable source of truth.

  • “In the second case, should we populate the failed events (events went to DLQ) to the event store?” This question is about what constitutes “truth” in your Event Store.

    No, you should populate all original events to the events_store_t table regardless of whether they later cause a processing error or end up in a DLQ.

    Reasoning:

    • The events_store_t is a record of what happened in the domain. An event like OrderPlaced is a fact that occurred, regardless of whether a downstream system successfully processed it or failed due to a unique constraint violation.
    • The events_store_t should be pure. It tells the story of your system’s state changes.
    • The fact that an event failed to be processed by a consumer is a processing audit detail that belongs in your notification_t table or system logs, not in the fundamental Event Store.
    • If you don’t put the failed event in events_store_t, you are losing part of your system’s history. When you rebuild state by replaying from events_store_t, you would miss this event, leading to an incorrect state.

    In summary:

    • events_store_t: Stores all events that happened, always.
    • notification_t: Stores the status of processing each event (success/failure, error message), as a projection.
    • DLQ: Stores unconsumable events for reprocessing.

Conclusion

I strongly recommend implementing the Transactional Outbox pattern (Option 1) for populating your events_store_t table. This pattern has become an industry best practice for achieving reliable event publishing from a database-backed service. It is more complex initially but provides superior durability and resilience compared to directly publishing to Kafka from your domain service.

And regardless of the publishing mechanism, your events_store_t should be a complete, immutable log of all domain events, untainted by processing outcomes.

Change Data Capture

Using Change Data Capture (CDC) (like Debezium) for the Transactional Outbox is the gold standard for reliably publishing events from a database-backed service to Kafka.

Here’s a detailed design and a conceptual Java implementation for the producer side, along with the Debezium configuration.


Overall Architecture

  1. Producer Service (Your Java Application):

    • Receives commands (e.g., UpdateCustomerProfileCommand).
    • Interacts with the Customer Aggregate.
    • Generates a list of atomic domain events (e.g., CustomerNameChanged, CustomerAddressChanged).
    • Crucially: Persists these events to two database tables within a single local database transaction:
      • events_store_t: Your immutable, authoritative Event Store (long-term historical log).
      • outbox_messages: A temporary table used by CDC to pick up events for Kafka.
  2. Transactional Outbox Table (outbox_messages):

    • A simple database table that acts as a queue for events to be published.
    • Rows are inserted into this table in the same transaction as any other domain state changes.
  3. CDC Tool (Debezium):

    • Monitors the outbox_messages table (and potentially events_store_t if you want a separate stream for the full event store, though typically you’d monitor the outbox).
    • Detects new rows (inserts).
    • Captures the after image of the inserted row.
    • Transforms this data into a Kafka message.
    • Publishes the Kafka message to the configured topic.
  4. Kafka Topic(s):

    • Events are published here. You can configure Debezium to route events to different topics based on the aggregate_type or event_type from your outbox_messages table.
  5. Kafka Consumers:

    • Your downstream services (stream processors, materialized view builders, notification services) consume from these Kafka topics.
    • They process the events, update their read models, and commit their offsets.

Design of the Database Tables

1. events_store_t (Your Primary Event Store)

This table holds the immutable, ordered sequence of all domain events.

CREATE TABLE events_store_t (
    id UUID PRIMARY KEY,                   -- Unique ID for the event itself
    aggregate_id VARCHAR(255) NOT NULL,    -- The ID of the aggregate (e.g., customer-123)
    aggregate_type VARCHAR(255) NOT NULL,  -- The type of aggregate (e.g., 'Customer')
    event_type VARCHAR(255) NOT NULL,      -- The specific type of event (e.g., 'CustomerNameChanged')
    sequence_number BIGINT NOT NULL,       -- Monotonically increasing sequence number per aggregate
    timestamp TIMESTAMP WITH TIME ZONE NOT NULL, -- When the event occurred
    payload JSONB NOT NULL,                -- The full event payload (JSON)
    metadata JSONB,                        -- Optional: correlation IDs, causation IDs, user ID, etc.
    -- Constraints for event order and uniqueness per aggregate
    UNIQUE (aggregate_id, sequence_number)
);

-- Index for efficient lookup by aggregate
CREATE INDEX idx_events_store_aggregate ON events_store_t (aggregate_id);

2. outbox_messages (For CDC Publishing)

This table serves as the bridge to Kafka.

CREATE TABLE outbox_messages (
    id UUID PRIMARY KEY,                   -- Unique ID for this outbox message
    aggregate_id VARCHAR(255) NOT NULL,    -- The ID of the aggregate (for Kafka key)
    aggregate_type VARCHAR(255) NOT NULL,  -- The type of aggregate (for Kafka topic routing)
    event_type VARCHAR(255) NOT NULL,      -- The specific type of event
    timestamp TIMESTAMP WITH TIME ZONE NOT NULL, -- When the event was created
    payload JSONB NOT NULL,                -- The full event payload (JSON)
    metadata JSONB,                        -- Optional: correlation IDs, causation IDs, user ID, etc.
    -- Note: No sequence_number here, as the Event Store manages that.
    -- Debezium will process these by insertion order.
);
-- An index on timestamp can be useful for manual cleanup or if not using CDC
-- CREATE INDEX idx_outbox_timestamp ON outbox_messages (timestamp);

Java Implementation (Producer Service)

We’ll use Spring Boot for simplicity, Spring Data JPA for database interaction, and Jackson for JSON serialization.

Dependencies (build.gradle):

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.postgresql:postgresql' // Or your chosen DB driver
    runtimeOnly 'com.h2database:h2' // For in-memory testing convenience
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    implementation 'com.fasterxml.jackson.core:jackson-databind' // For JSON
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' // For Java 8 Date/Time
}

1. Domain Events

// domain/events/DomainEvent.java
package com.example.eventoutbox.domain.events;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

import java.time.Instant;
import java.util.UUID;

// Use JsonTypeInfo for polymorphic deserialization (if you need to deserialize events later)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "eventType")
@JsonSubTypes({
    @JsonSubTypes.Type(value = CustomerNameChanged.class, name = "CustomerNameChanged"),
    @JsonSubTypes.Type(value = CustomerAddressChanged.class, name = "CustomerAddressChanged")
})
public abstract class DomainEvent {
    private final UUID eventId;
    private final Instant timestamp;
    private final String aggregateId;
    private final String aggregateType;
    private final long sequenceNumber; // Important for Event Sourcing

    public DomainEvent(UUID eventId, Instant timestamp, String aggregateId, String aggregateType, long sequenceNumber) {
        this.eventId = eventId;
        this.timestamp = timestamp;
        this.aggregateId = aggregateId;
        this.aggregateType = aggregateType;
        this.sequenceNumber = sequenceNumber;
    }

    public UUID getEventId() { return eventId; }
    public Instant getTimestamp() { return timestamp; }
    public String getAggregateId() { return aggregateId; }
    public String getAggregateType() { return aggregateType; }
    public long getSequenceNumber() { return sequenceNumber; }

    public abstract String getEventType();
}

// domain/events/CustomerNameChanged.java
package com.example.eventoutbox.domain.events;

import java.time.Instant;
import java.util.UUID;

public class CustomerNameChanged extends DomainEvent {
    private final String newName;

    public CustomerNameChanged(UUID eventId, Instant timestamp, String customerId, long sequenceNumber, String newName) {
        super(eventId, timestamp, customerId, "Customer", sequenceNumber);
        this.newName = newName;
    }

    public String getNewName() { return newName; }

    @Override
    public String getEventType() { return "CustomerNameChanged"; }
}

// domain/events/CustomerAddressChanged.java
package com.example.eventoutbox.domain.events;

import java.time.Instant;
import java.util.UUID;

public class CustomerAddressChanged extends DomainEvent {
    private final String newAddress; // Simple string for address example

    public CustomerAddressChanged(UUID eventId, Instant timestamp, String customerId, long sequenceNumber, String newAddress) {
        super(eventId, timestamp, customerId, "Customer", sequenceNumber);
        this.newAddress = newAddress;
    }

    public String getNewAddress() { return newAddress; }

    @Override
    public String getEventType() { return "CustomerAddressChanged"; }
}

2. Aggregate

// domain/Customer.java
package com.example.eventoutbox.domain;

import com.example.eventoutbox.domain.events.CustomerAddressChanged;
import com.example.eventoutbox.domain.events.CustomerNameChanged;
import com.example.eventoutbox.domain.events.DomainEvent;
import lombok.Getter;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

// This is a simplified Aggregate. In a real ES system, you'd load state from events.
// For this example, we're just focusing on event generation.
@Getter
public class Customer {
    private final String customerId;
    private String name;
    private String address;
    private long currentSequenceNumber; // Tracks the next sequence number for new events

    private final List<DomainEvent> uncommittedEvents = new ArrayList<>();

    public Customer(String customerId, long currentSequenceNumber) {
        this.customerId = customerId;
        this.currentSequenceNumber = currentSequenceNumber;
    }

    public static Customer create(String customerId) {
        return new Customer(customerId, 0L); // Start with seq 0 for a new aggregate
    }

    public void changeName(String newName) {
        if (!newName.equals(this.name)) { // Only emit event if something actually changed
            this.name = newName;
            this.currentSequenceNumber++;
            uncommittedEvents.add(new CustomerNameChanged(UUID.randomUUID(), Instant.now(), customerId, currentSequenceNumber, newName));
        }
    }

    public void changeAddress(String newAddress) {
        if (!newAddress.equals(this.address)) {
            this.address = newAddress;
            this.currentSequenceNumber++;
            uncommittedEvents.add(new CustomerAddressChanged(UUID.randomUUID(), Instant.now(), customerId, currentSequenceNumber, newAddress));
        }
    }

    // After events are stored, clear them
    public void markEventsCommitted() {
        this.uncommittedEvents.clear();
    }
}

3. Persistence Layer (Entities and Repositories)

// infrastructure/persistence/outbox/OutboxMessage.java
package com.example.eventoutbox.infrastructure.persistence.outbox;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "outbox_messages")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OutboxMessage {
    @Id
    private UUID id; // Event ID

    private String aggregateId;
    private String aggregateType;
    private String eventType;
    private Instant timestamp;

    @JdbcTypeCode(SqlTypes.JSON) // For PostgreSQL JSONB type
    @Column(columnDefinition = "jsonb")
    private String payload; // Store payload as JSON string

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")
    private String metadata; // Optional metadata as JSON string
}

// infrastructure/persistence/outbox/OutboxMessageRepository.java
package com.example.eventoutbox.infrastructure.persistence.outbox;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

public interface OutboxMessageRepository extends JpaRepository<OutboxMessage, UUID> {}

// infrastructure/persistence/eventstore/EventStoreEvent.java
package com.example.eventoutbox.infrastructure.persistence.eventstore;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "events_store_t")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EventStoreEvent {
    @Id
    private UUID id; // Event ID

    private String aggregateId;
    private String aggregateType;
    private String eventType;
    private Instant timestamp;
    private long sequenceNumber;

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")
    private String payload; // Store payload as JSON string

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")
    private String metadata; // Optional metadata as JSON string
}

// infrastructure/persistence/eventstore/EventStoreEventRepository.java
package com.example.eventoutbox.infrastructure.persistence.eventstore;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

public interface EventStoreEventRepository extends JpaRepository<EventStoreEvent, UUID> {}

4. Application Service (Handles Commands and Persistence)

This is where the magic of the single transaction happens.

// application/CustomerApplicationService.java
package com.example.eventoutbox.application;

import com.example.eventoutbox.domain.Customer;
import com.example.eventoutbox.domain.events.DomainEvent;
import com.example.eventoutbox.infrastructure.persistence.eventstore.EventStoreEvent;
import com.example.eventoutbox.infrastructure.persistence.eventstore.EventStoreEventRepository;
import com.example.eventoutbox.infrastructure.persistence.outbox.OutboxMessage;
import com.example.eventoutbox.infrastructure.persistence.outbox.OutboxMessageRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class CustomerApplicationService {

    private final OutboxMessageRepository outboxMessageRepository;
    private final EventStoreEventRepository eventStoreEventRepository;
    private final ObjectMapper objectMapper; // For JSON serialization

    // Represents an incoming command from e.g., a REST endpoint
    public record UpdateCustomerProfileCommand(String customerId, String newName, String newAddress) {}

    // @Transactional ensures that all database operations within this method
    // (saving to outbox_messages and events_store_t) are part of a single DB transaction.
    @Transactional
    public void updateCustomerProfile(UpdateCustomerProfileCommand command) {
        // --- 1. Load/Create Aggregate (Simplified for this example) ---
        // In a real Event Sourcing system, you would load the Customer's state
        // by replaying events from eventStoreEventRepository for command.customerId.
        // For simplicity, we'll assume a new customer or just focus on event generation.
        Customer customer = Customer.create(command.customerId);
        // customer.loadFromEvents(eventStoreEventRepository.findByAggregateIdOrderBySequenceNumberAsc(command.customerId));
        
        // --- 2. Apply Business Logic & Generate Events ---
        if (command.newName() != null) {
            customer.changeName(command.newName());
        }
        if (command.newAddress() != null) {
            customer.changeAddress(command.newAddress());
        }

        // --- 3. Persist Events to Event Store & Outbox (Atomically) ---
        List<DomainEvent> eventsToStore = customer.getUncommittedEvents();
        if (eventsToStore.isEmpty()) {
            return; // No changes, no events to publish
        }

        List<EventStoreEvent> eventStoreEntities = eventsToStore.stream()
            .map(this::mapToEventStoreEvent)
            .collect(Collectors.toList());
        eventStoreEventRepository.saveAll(eventStoreEntities); // Save to the authoritative Event Store

        List<OutboxMessage> outboxMessages = eventsToStore.stream()
            .map(this::mapToOutboxMessage)
            .collect(Collectors.toList());
        outboxMessageRepository.saveAll(outboxMessages); // Save to the Outbox for CDC

        customer.markEventsCommitted(); // Clear uncommitted events after successful persistence
    }

    private OutboxMessage mapToOutboxMessage(DomainEvent event) {
        try {
            return OutboxMessage.builder()
                .id(event.getEventId())
                .aggregateId(event.getAggregateId())
                .aggregateType(event.getAggregateType())
                .eventType(event.getEventType())
                .timestamp(event.getTimestamp())
                .payload(objectMapper.writeValueAsString(event)) // Serialize event to JSON
                .metadata(null) // Add actual metadata if needed
                .build();
        } catch (IOException e) {
            throw new RuntimeException("Failed to serialize event to JSON: " + event.getEventId(), e);
        }
    }

    private EventStoreEvent mapToEventStoreEvent(DomainEvent event) {
        try {
            return EventStoreEvent.builder()
                .id(event.getEventId())
                .aggregateId(event.getAggregateId())
                .aggregateType(event.getAggregateType())
                .eventType(event.getEventType())
                .timestamp(event.getTimestamp())
                .sequenceNumber(event.getSequenceNumber())
                .payload(objectMapper.writeValueAsString(event)) // Serialize event to JSON
                .metadata(null) // Add actual metadata if needed
                .build();
        } catch (IOException e) {
            throw new RuntimeException("Failed to serialize event to JSON: " + event.getEventId(), e);
        }
    }
}

5. REST Controller (Entry Point)

// application/CustomerController.java
package com.example.eventoutbox.application;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/customers")
@RequiredArgsConstructor
public class CustomerController {

    private final CustomerApplicationService customerApplicationService;

    @PostMapping("/profile")
    public ResponseEntity<String> updateCustomerProfile(@RequestBody CustomerApplicationService.UpdateCustomerProfileCommand command) {
        customerApplicationService.updateCustomerProfile(command);
        return ResponseEntity.ok("Customer profile update command received and processed.");
    }
}

6. Spring Boot Application (and application.properties)

// EventOutboxApplication.java
package com.example.eventoutbox;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class EventOutboxApplication {
    public static void main(String[] args) {
        SpringApplication.run(EventOutboxApplication.class, args);
    }
}
# application.properties (for H2 in-memory for testing)
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update # Use 'update' for schema management in dev
spring.jackson.serialization.write-dates-as-timestamps=false # Good practice for Instant

# If using PostgreSQL:
# spring.datasource.url=jdbc:postgresql://localhost:5432/yourdb
# spring.datasource.username=youruser
# spring.datasource.password=yourpassword
# spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

Debezium Configuration (Conceptual)

You’ll deploy Debezium as a Kafka Connect connector. Here’s a sample configuration (e.g., postgresql-outbox-connector.json) for PostgreSQL.

{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "postgres",
    "database.password": "secret",
    "database.dbname": "configserver",
    "database.server.name": "postgres",
    "topic.prefix": "portal-event",
    "schema.include.list": "public",
    "table.include.list": "public.outbox_message_t",
    "message.key.columns": "public.outbox_message_t:host_id",
    "plugin.name": "pgoutput",
    "publication.name": "dbz_publication",
    "slot.name": "dbz_replication_slot",
    "slot.drop.on.stop": "false",
    "signal.when.disconnected": "true",
    "tombstones.on.delete": "true",
    "max.retries": 5,
    "retry.delay.ms": 10000,
    "value.converter": "org.apache.kafka.connect.storage.StringConverter",
    "value.converter.schemas.enable": "false",
    "key.converter": "org.apache.kafka.connect.storage.StringConverter",
    "key.converter.schemas.enable": "false",
    "transforms": "unwrap,addTransactionIdHeader,timestamp_converter,outbox,extractPayload,extractKey,final_route",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": "true",
    "transforms.unwrap.delete.handling.mode": "drop",
    "transforms.addTransactionIdHeader.type": "org.apache.kafka.connect.transforms.HeaderFrom$Value",
    "transforms.addTransactionIdHeader.fields": "transaction_id,transaction_ordinal,transaction_count",
    "transforms.addTransactionIdHeader.headers": "transaction_id,transaction_ordinal,transaction_count",
    "transforms.addTransactionIdHeader.operation": "copy",
    "transforms.timestamp_converter.type": "org.apache.kafka.connect.transforms.TimestampConverter$Value",
    "transforms.timestamp_converter.field": "event_ts",
    "transforms.timestamp_converter.target.type": "unix",
    "transforms.timestamp_converter.format": "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.id": "id",
    "transforms.outbox.table.field.event.key": "host_id",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.table.field.event.timestamp": "event_ts",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.table.field.event.metadata": "metadata",
    "transforms.outbox.table.field.aggregate.type": "aggregate_type",
    "transforms.outbox.table.field.aggregate.id": "aggregate_id",
    "transforms.extractPayload.type": "org.apache.kafka.connect.transforms.ExtractField$Value",
    "transforms.extractPayload.field": "payload",
    "transforms.extractKey.type": "org.apache.kafka.connect.transforms.ExtractField$Key",
    "transforms.extractKey.field": "host_id",
    "transforms.final_route.type": "org.apache.kafka.connect.transforms.RegexRouter",
    "transforms.final_route.regex": "portal-event\\.public\\.outbox_message_t",
    "transforms.final_route.replacement": "portal-event"
  }
}

And here is the curl command to create the connector locally.

curl --location --request POST 'http://localhost:8083/connectors' \
--header 'Content-Type: application/json' \
--data-raw '{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "tasks.max": "1",
    
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "postgres",
    "database.password": "secret",
    "database.dbname": "configserver",
    "database.server.name": "postgres",
    "topic.prefix": "portal-event", 

    "schema.include.list": "public",
    "table.include.list": "public.outbox_message_t",
    "message.key.columns": "public.outbox_message_t:host_id",

    "plugin.name": "pgoutput",
    "publication.name": "dbz_publication",
    "slot.name": "dbz_replication_slot",
    "slot.drop.on.stop": "false", 
    "signal.when.disconnected": "true",
    "tombstones.on.delete": "true",
    "max.retries": 5,
    "retry.delay.ms": 10000,

    "value.converter": "org.apache.kafka.connect.storage.StringConverter",
    "value.converter.schemas.enable": "false",
    "key.converter": "org.apache.kafka.connect.storage.StringConverter",
    "key.converter.schemas.enable": "false",

    "transforms": "unwrap,addTransactionIdHeader,timestamp_converter,outbox,extractPayload,extractKey,final_route",

    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.drop.tombstones": "true",
    "transforms.unwrap.delete.handling.mode": "drop",

    "transforms.addTransactionIdHeader.type": "org.apache.kafka.connect.transforms.HeaderFrom$Value",
    "transforms.addTransactionIdHeader.fields": "transaction_id,transaction_ordinal,transaction_count",
    "transforms.addTransactionIdHeader.headers": "transaction_id,transaction_ordinal,transaction_count",
    "transforms.addTransactionIdHeader.operation": "copy",

    "transforms.timestamp_converter.type": "org.apache.kafka.connect.transforms.TimestampConverter$Value",
    "transforms.timestamp_converter.field": "event_ts",
    "transforms.timestamp_converter.target.type": "unix",
    "transforms.timestamp_converter.format": "yyyy-MM-dd'\''T'\''HH:mm:ss.SSSSSS'\''Z'\''",

    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.id": "id",
    "transforms.outbox.table.field.event.key": "host_id",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.table.field.event.timestamp": "event_ts",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.table.field.event.metadata": "metadata",
    "transforms.outbox.table.field.aggregate.type": "aggregate_type",
    "transforms.outbox.table.field.aggregate.id": "aggregate_id",

    "transforms.extractPayload.type": "org.apache.kafka.connect.transforms.ExtractField$Value",
    "transforms.extractPayload.field": "payload",

    "transforms.extractKey.type": "org.apache.kafka.connect.transforms.ExtractField$Key",
    "transforms.extractKey.field": "host_id",

    "transforms.final_route.type": "org.apache.kafka.connect.transforms.RegexRouter",
    "transforms.final_route.regex": "portal-event\\.public\\.outbox_message_t", 
    "transforms.final_route.replacement": "portal-event"
  }
}
'

The following are the commands to check the connector status and config:

# Check connector status
curl http://localhost:8083/connectors/outbox-connector/status

# Check connector config
curl http://localhost:8083/connectors/outbox-connector/config

Important Notes on Debezium Transforms:

  • EventRouter Transform: This is a specialized Debezium SMT (Single Message Transform) designed specifically for the Transactional Outbox pattern.
    • It expects id, aggregate_id, aggregate_type, event_type, timestamp, payload, and metadata fields in your outbox_messages table.
    • It automatically wraps the payload into the Kafka message value and sets the Kafka key based on aggregate_id.
    • It can route to specific topics (e.g., outbox.Customer, outbox.Order) based on aggregate_type.
    • It filters out DELETE operations on the outbox_messages table (which is what your clean-up process would do, if you had one).
  • CDC (Debezium) only processes INSERTs: When you insert a row into outbox_messages, Debezium picks it up. After it’s published, you can (optionally) have a separate, idempotent cleanup job or a Debezium signal that deletes the record from outbox_messages. Debezium will then capture this DELETE event, but the EventRouter transform will typically filter it out, preventing re-publishing.

How to test the Java Producer Service

  1. Run your Spring Boot application.

  2. Use a tool like curl or Postman to send a POST request:

    curl -X POST http://localhost:8080/customers/profile \
    -H "Content-Type: application/json" \
    -d '{
      "customerId": "customer-abc-123",
      "newName": "Alice Smith",
      "newAddress": "123 Main St, Anytown"
    }'
    
  3. Check your database events_store_t and outbox_messages tables. You should see entries for CustomerNameChanged and CustomerAddressChanged in both, all committed atomically.


Key Benefits of this Setup

  • Guaranteed Event Persistence: Events are first stored in your durable events_store_t and outbox_messages tables within a single, local, ACID transaction. This means if your application crashes before the event is published to Kafka, it’s still safe in your database and will be picked up by Debezium later.
  • Decoupling: Your core business logic (in CustomerApplicationService) doesn’t directly interact with Kafka. It only interacts with the database. This makes your service more resilient to Kafka outages.
  • Simplified Retries: Debezium and Kafka Connect handle the complexities of retrying Kafka publication.
  • Single Source of Truth: Your events_store_t remains the authoritative event log for replay and aggregate reconstruction.
  • Scalability: You can scale your application service and Debezium independently.

This pattern is a fundamental building block for highly reliable, event-driven microservices.

Multiple Topics

This is a classic scenario in event-driven architectures: an event needs to trigger processing in multiple downstream systems. The key is maintaining atomicity and understanding transaction boundaries.

Given your setup where:

  1. ScheduleCreatedEvent originates from your service’s outbox.
  2. Debezium pushes it to portal-event.
  3. Your PortalEventConsumer reads from portal-event and performs database updates (like notification_t).
  4. The same event needs to go to be processed by the Schedule Kafka Streams.
  5. All operations related to processing this event should ideally be atomic.

Understanding the Transactional Challenge

Your PortalEventConsumer has a well-defined transactional boundary: [Start DB Tx] -> [DB Updates (e.g., notification_t)] -> [DB Commit] -> [Kafka Consumer Offset Commit]

You want to add “push to light-schedule” into this atomic unit.

Options for Pushing to light-schedule

Let’s evaluate the best places:

  • Approach: Inside the PortalEventConsumer loop, after processing ScheduleCreatedEvent and before conn.commit(), instantiate a Kafka Producer and producer.send() the event to light-schedule.
  • Problem: This is incredibly difficult to make truly atomic across all three resources (source Kafka topic portal-event offset, your database transaction, AND the target Kafka topic light-schedule).
    • If producer.send() to light-schedule fails after conn.commit() but before consumer.commitSync(), you have an inconsistent state: notification_t is updated, but light-schedule didn’t get the event. The consumer will re-process, leading to duplicates in notification_t (which requires idempotency) and potential duplicates to light-schedule.
    • Managing Kafka Producer transactions nested within a JDBC transaction is not standard and adds immense complexity.
  • Approach:
    1. When PortalEventConsumer processes ScheduleCreatedEvent from portal-event, it updates notification_t (and any other DB projections) in its current DB transaction.
    2. Within the same DB transaction, it also inserts a record (representing the ScheduleCreatedEvent for light-schedule) into a new, dedicated outbox table (e.g., schedule_events_outbox_t).
    3. A second Debezium connector (or a polling publisher) then monitors schedule_events_outbox_t and pushes events to the light-schedule topic.
  • Benefits:
    • True Atomicity: The event lands in notification_t AND is queued for light-schedule publishing, all within the PortalEventConsumer’s single DB transaction. This is guaranteed.
    • High Reliability: Leverages the proven Transactional Outbox pattern again.
  • Drawbacks:
    • Adds another outbox table to manage.
    • Requires another Debezium connector instance.
    • More operational overhead.
  • Approach:
    1. Your PortalEventConsumer continues to subscribe to portal-event and performs its database updates to notification_t (and other projections) as it currently does. It remains the sink for all events from portal-event into your relational database.
    2. Create a separate, dedicated Kafka Streams application whose sole purpose is to process scheduling events.
    3. This Kafka Streams application subscribes directly to the portal-event topic.
    4. It uses Kafka Streams DSL to filter for ScheduleCreatedEvents.
  • Benefits:
    • Clean Separation of Concerns: Your PortalEventConsumer is a database sink. Your Kafka Streams app is a stream processor.
    • Kafka Streams EOS (Exactly-Once Semantics): Kafka Streams handles transactional guarantees (atomic consumption from portal-event and process the scheduled events natively.
    • Simpler Code: No complex producer/consumer/DB transaction coordination in one app.
    • Scalability: Each application can scale independently.
  • Drawbacks:
    • Adds another logical application to deploy and manage.

Best Place to Push to light-schedule:

For your setup, the Separate Kafka Streams Application (Option 3) is generally the best approach.

  • Your PortalEventConsumer’s role: It acts as a generic projection builder into your relational database, consuming all events from portal-event and updating notification_t (and any other necessary read models). This ensures a full audit and visibility for all processed events in your DB.
  • The new Kafka Streams app’s role: It acts as a specialized router and processor for ScheduleCreatedEvents specifically, forwarding them to the appropriate Kafka Streams pipeline (light-schedule).

This maintains a clean, decoupled architecture where each component has a clear responsibility and leverages Kafka’s native stream processing capabilities for atomic Kafka-to-Kafka operations.

Database Concurrency

Multiple users updating the same aggregate is a classic concurrency problem in multi-user applications, often referred to as the “lost update” problem. In an Event Sourcing system, preventing this overwrite is crucial because the sequence of events defines the state.

The standard and most effective way to prevent concurrent updates from overwriting each other in an Event Sourcing system is through Optimistic Concurrency Control (OCC), specifically using version numbers (or sequence numbers) at the aggregate level.


How Optimistic Concurrency Control (OCC) Works in Event Sourcing

  1. Version Tracking (Sequence Number):

    • Every Aggregate (e.g., a Customer, an Order, a Product) has a version, which is typically its current sequence number in the event stream. This sequence number represents the number of events that have been applied to build its current state.
    • Your events_store_t table already has sequence_number for this purpose:
      CREATE TABLE events_store_t (
          id UUID PRIMARY KEY,
          aggregate_id VARCHAR(255) NOT NULL,
          -- ... other fields ...
          sequence_number BIGINT NOT NULL,       -- This is the key!
          UNIQUE (aggregate_id, sequence_number) -- CRITICAL constraint!
      );
      
      The UNIQUE (aggregate_id, sequence_number) constraint is the fundamental database-level guarantee against concurrent writes for the same aggregate at the same version.
  2. Load the Aggregate’s Current Version:

    • When your application service wants to modify an aggregate, it first loads the aggregate’s current state by replaying all events for that aggregate_id from the events_store_t.
    • During this replay, it tracks the currentSequenceNumber (the sequence number of the last event applied).
  3. Pass Expected Version with Command:

    • The user interface (UI) or the client application that initiated the change should also hold the currentSequenceNumber it observed when it last fetched the aggregate’s state.
    • This expectedVersion (or expectedSequenceNumber) is then sent along with the command (e.g., UpdateCustomerProfileCommand(customerId, newName, newAddress, expectedSequenceNumber)).
  4. Conditional Event Appending:

    • When your CustomerApplicationService receives the command:
      • It loads the Customer aggregate from the events_store_t, determining its actual currentSequenceNumber.
      • It compares the command.expectedSequenceNumber with the customer.actualCurrentSequenceNumber (derived from the Event Store).
      • If command.expectedSequenceNumber does NOT match customer.actualCurrentSequenceNumber: This means another concurrent transaction has already written new events for this aggregate since the client loaded its state. A ConcurrencyException (or similar domain-specific exception) is thrown.
      • If they DO match: The aggregate’s business logic is applied, generating new events. These new events will have customer.actualCurrentSequenceNumber + 1, customer.actualCurrentSequenceNumber + 2, etc.
  5. Atomic Persistence (The DB Constraint):

    • The new events are then attempted to be saved to events_store_t (and outbox_messages) within a single database transaction.
    • If a concurrency conflict was not detected at step 4 (meaning two commands arrived almost simultaneously and passed the initial check), the UNIQUE (aggregate_id, sequence_number) constraint in the events_store_t table will prevent the “lost update.” Only the first transaction to successfully insert events with the “next” sequence numbers will succeed. The second will fail with a DataIntegrityViolationException (or similar).

Example Flow:

  1. User A fetches Customer-123. The current state (replayed from events_store_t) shows sequenceNumber = 5.
  2. User B also fetches Customer-123. It also sees sequenceNumber = 5.
  3. User A sends UpdateCustomerProfileCommand(customerId="123", newName="Alice", expectedSequenceNumber=5).
    • App Service loads Customer-123, actual sequenceNumber = 5. Matches expectedSequenceNumber.
    • Generates CustomerNameChanged event with sequenceNumber = 6.
    • Attempts to save event(s) to events_store_t (and outbox_messages). Succeeds.
  4. User B sends UpdateCustomerProfileCommand(customerId="123", newAddress="456 Oak", expectedSequenceNumber=5).
    • App Service loads Customer-123. It now replays events up to sequenceNumber = 6. So, actualSequenceNumber = 6.
    • It compares command.expectedSequenceNumber=5 with customer.actualSequenceNumber=6. They do NOT match!
    • The CustomerApplicationService throws a ConcurrencyException.
    • The transaction is rolled back, and no events are written from User B’s command.

Java Implementation Changes

Let’s modify the previous CustomerApplicationService and add a way to load the aggregate from events.

1. Customer Aggregate (Revised)

// domain/Customer.java (Revised)
package com.example.eventoutbox.domain;

import com.example.eventoutbox.domain.events.CustomerAddressChanged;
import com.example.eventoutbox.domain.events.CustomerNameChanged;
import com.example.eventoutbox.domain.events.DomainEvent;
import lombok.Getter;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Getter
public class Customer {
    private final String customerId;
    private String name;
    private String address;
    private long version; // This is the 'sequenceNumber' of the LAST applied event

    private final List<DomainEvent> uncommittedEvents = new ArrayList<>();

    // Constructor for creating a new aggregate
    public Customer(String customerId) {
        this.customerId = customerId;
        this.version = 0; // New aggregates start at version 0
    }

    // Static factory method to load an aggregate from its events
    public static Customer loadFromEvents(String customerId, List<DomainEvent> history) {
        Customer customer = new Customer(customerId);
        history.forEach(customer::applyEvent); // Apply each historical event
        return customer;
    }

    // Method to apply an event to the aggregate's state
    private void applyEvent(DomainEvent event) {
        // This is where you would update the aggregate's internal state
        // based on the specific event type.
        if (event instanceof CustomerNameChanged nameChanged) {
            this.name = nameChanged.getNewName();
        } else if (event instanceof CustomerAddressChanged addressChanged) {
            this.address = addressChanged.getNewAddress();
        }
        this.version = event.getSequenceNumber(); // Update version to the sequence number of the applied event
    }

    // Domain behavior methods that generate new events
    public void changeName(String newName) {
        if (!newName.equals(this.name)) {
            // New events get the *next* sequence number
            long nextSequence = this.version + 1;
            CustomerNameChanged event = new CustomerNameChanged(UUID.randomUUID(), Instant.now(), customerId, nextSequence, newName);
            uncommittedEvents.add(event);
            applyEvent(event); // Apply immediately to current state for consistency
        }
    }

    public void changeAddress(String newAddress) {
        if (!newAddress.equals(this.address)) {
            long nextSequence = this.version + 1;
            CustomerAddressChanged event = new CustomerAddressChanged(UUID.randomUUID(), Instant.now(), customerId, nextSequence, newAddress);
            uncommittedEvents.add(event);
            applyEvent(event);
        }
    }

    public void markEventsCommitted() {
        this.uncommittedEvents.clear();
    }
}

2. ConcurrencyException

// domain/ConcurrencyException.java
package com.example.eventoutbox.domain;

public class ConcurrencyException extends RuntimeException {
    public ConcurrencyException(String message) {
        super(message);
    }
}

3. CustomerApplicationService (Revised)

// application/CustomerApplicationService.java (Revised)
package com.example.eventoutbox.application;

import com.example.eventoutbox.domain.ConcurrencyException;
import com.example.eventoutbox.domain.Customer;
import com.example.eventoutbox.domain.events.DomainEvent;
import com.example.eventoutbox.infrastructure.persistence.eventstore.EventStoreEvent;
import com.example.eventoutbox.infrastructure.persistence.eventstore.EventStoreEventRepository;
import com.example.eventoutbox.infrastructure.persistence.outbox.OutboxMessage;
import com.example.eventoutbox.infrastructure.persistence.outbox.OutboxMessageRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class CustomerApplicationService {

    private final OutboxMessageRepository outboxMessageRepository;
    private final EventStoreEventRepository eventStoreEventRepository;
    private final ObjectMapper objectMapper;

    // Command now includes expectedVersion
    public record UpdateCustomerProfileCommand(String customerId, String newName, String newAddress, long expectedVersion) {}

    @Transactional
    public void updateCustomerProfile(UpdateCustomerProfileCommand command) {
        // --- 1. Load Aggregate State ---
        List<EventStoreEvent> historicalEvents = eventStoreEventRepository.findByAggregateIdOrderBySequenceNumberAsc(command.customerId());

        Customer customer;
        if (historicalEvents.isEmpty()) {
            customer = new Customer(command.customerId());
            // If it's a new aggregate, expectedVersion must be 0
            if (command.expectedVersion() != 0) {
                 throw new ConcurrencyException("Customer with ID " + command.customerId() + " does not exist or expected version is incorrect.");
            }
        } else {
            // Deserialize historical events to DomainEvent objects
            List<DomainEvent> domainEventsHistory = historicalEvents.stream()
                .map(this::deserializeEventStoreEvent)
                .collect(Collectors.toList());
            customer = Customer.loadFromEvents(command.customerId(), domainEventsHistory);

            // --- 2. OPTIMISTIC CONCURRENCY CHECK ---
            if (customer.getVersion() != command.expectedVersion()) {
                throw new ConcurrencyException(
                    "Customer with ID " + command.customerId() + " has been updated by another user. " +
                    "Expected version " + command.expectedVersion() + " but found " + customer.getVersion() + "."
                );
            }
        }

        // --- 3. Apply Business Logic & Generate Events ---
        if (command.newName() != null) {
            customer.changeName(command.newName());
        }
        if (command.newAddress() != null) {
            customer.changeAddress(command.newAddress());
        }

        // --- 4. Persist Events to Event Store & Outbox (Atomically) ---
        List<DomainEvent> eventsToStore = customer.getUncommittedEvents();
        if (eventsToStore.isEmpty()) {
            return; // No changes, no events to publish
        }

        try {
            List<EventStoreEvent> eventStoreEntities = eventsToStore.stream()
                .map(this::mapToEventStoreEvent)
                .collect(Collectors.toList());
            eventStoreEventRepository.saveAll(eventStoreEntities);

            List<OutboxMessage> outboxMessages = eventsToStore.stream()
                .map(this::mapToOutboxMessage)
                .collect(Collectors.toList());
            outboxMessageRepository.saveAll(outboxMessages);

            customer.markEventsCommitted();
        } catch (DataIntegrityViolationException e) {
            // This catches the UNIQUE constraint violation on (aggregate_id, sequence_number)
            // This means another transaction has just written to this aggregate
            throw new ConcurrencyException(
                "Another concurrent update detected for customer " + command.customerId() + ". " +
                "Please refresh and try again.", e
            );
        } catch (IOException e) {
            throw new RuntimeException("Failed to serialize event to JSON", e);
        }
    }

    // Helper methods for mapping/deserializing (similar to before)
    private OutboxMessage mapToOutboxMessage(DomainEvent event) {
        try {
            return OutboxMessage.builder()
                .id(event.getEventId())
                .aggregateId(event.getAggregateId())
                .aggregateType(event.getAggregateType())
                .eventType(event.getEventType())
                .timestamp(event.getTimestamp())
                .payload(objectMapper.writeValueAsString(event))
                .metadata(null)
                .build();
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize event to JSON: " + event.getEventId(), e);
        }
    }

    private EventStoreEvent mapToEventStoreEvent(DomainEvent event) {
        try {
            return EventStoreEvent.builder()
                .id(event.getEventId())
                .aggregateId(event.getAggregateId())
                .aggregateType(event.getAggregateType())
                .eventType(event.getEventType())
                .timestamp(event.getTimestamp())
                .sequenceNumber(event.getSequenceNumber())
                .payload(objectMapper.writeValueAsString(event))
                .metadata(null)
                .build();
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize event to JSON: " + event.getEventId(), e);
        }
    }

    private DomainEvent deserializeEventStoreEvent(EventStoreEvent eventStoreEvent) {
        try {
            // Assuming your event JSON includes the 'eventType' field for polymorphic deserialization
            return objectMapper.readValue(eventStoreEvent.getPayload(), DomainEvent.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to deserialize event: " + eventStoreEvent.getId(), e);
        }
    }
}

4. EventStoreEventRepository (Add find method)

// infrastructure/persistence/eventstore/EventStoreEventRepository.java (Revised)
package com.example.eventoutbox.infrastructure.persistence.eventstore;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

public interface EventStoreEventRepository extends JpaRepository<EventStoreEvent, UUID> {
    List<EventStoreEvent> findByAggregateIdOrderBySequenceNumberAsc(String aggregateId);
}

5. CustomerController (Handle Exception)

// application/CustomerController.java (Revised)
package com.example.eventoutbox.application;

import com.example.eventoutbox.domain.ConcurrencyException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/customers")
@RequiredArgsConstructor
public class CustomerController {

    private final CustomerApplicationService customerApplicationService;

    public record UpdateCustomerProfileRequest(String customerId, String newName, String newAddress, long expectedVersion) {}

    @PostMapping("/profile")
    public ResponseEntity<String> updateCustomerProfile(@RequestBody UpdateCustomerProfileRequest request) {
        CustomerApplicationService.UpdateCustomerProfileCommand command =
            new CustomerApplicationService.UpdateCustomerProfileCommand(
                request.customerId(), request.newName(), request.newAddress(), request.expectedVersion()
            );
        customerApplicationService.updateCustomerProfile(command);
        return ResponseEntity.ok("Customer profile update command received and processed.");
    }

    @ExceptionHandler(ConcurrencyException.class)
    public ResponseEntity<String> handleConcurrencyException(ConcurrencyException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage());
    }
}

How to Handle Concurrency Conflicts on the Client/UI Side:

When ConcurrencyException is thrown:

  1. Inform the User: Display a message like “This item has been updated by another user. Please refresh the page to see the latest changes and try your update again.”
  2. Retry (less common for user-facing, but possible for background jobs): For non-interactive or automated processes, you might implement a retry mechanism. This retry would need to:
    • Fetch the latest state of the aggregate from a read model.
    • Re-create the command based on the original intent and the newly fetched expected version.
    • Re-send the command.
    • This is typically only done if the change is “safe” to re-apply (e.g., adding an item, not changing a specific value).

By combining the version check in your application service with the UNIQUE constraint in your database, you create a robust optimistic concurrency control mechanism that prevents lost updates effectively.

What if event consumer fails to apply an event to its read model

In this case, the read model becomes stale, and subsequent attempts to update based on that stale data will lead to conflicts.

Let’s break down the scenario and the robust solution.


The Problem Scenario (as you described)

  1. UI: Queries entity_t table (read model), gets Entity (aggregate_version = 5).
  2. User: Makes changes.
  3. UI: Sends UpdateCommand (..., expectedVersion = 5) to the write model.
  4. Write Model (Command Handler):
    • Loads aggregate from event_store_t. Let’s say its actualVersion is 5.
    • OCC Check: actualVersion (5) == expectedVersion (5). Success.
    • Generates Event (..., sequence_number = 6).
    • Persists Event (..., sequence_number = 6) to event_store_t and outbox_message_t in an ACID transaction. This commits version 6 to the event_store_t.
    • Debezium publishes this event to Kafka.
  5. Kafka Consumer (PortalEventConsumer):
    • Reads Event (..., sequence_number = 6, expectedVersion = 5).
    • Tries to update entity_t (your read model): UPDATE entity_t SET ..., aggregate_version = 6 WHERE entity_id = ? AND aggregate_version = 5.
    • FAILURE: An exception occurs in the database update (e.g., a network error, a constraint violation unrelated to aggregate_version, or the consumer’s JVM crashes).
    • Result: The entity_t table is NOT updated and remains at aggregate_version = 5. The event_store_t is at aggregate_version = 6. The read model is now stale.
  6. Next UI interaction:
    • UI queries entity_t again. It still gets Entity (aggregate_version = 5) because the read model is stale.
    • UI sends UpdateCommand (..., expectedVersion = 5).
  7. Write Model (Command Handler) - Second Attempt:
    • Loads aggregate from event_store_t. Its actualVersion is 6.
    • OCC Check: actualVersion (6) != expectedVersion (5). Conflict detected!
    • Result: The command handler throws a ConcurrencyException. It does NOT try to insert a new event into event_store_t with sequence_number=6 (because that would be a duplicate and would indeed fail on the unique constraint). It correctly rejects the command.

The specific symptom you mentioned (“new event insert into the event_store_t and it will fail because the aggregate version is used before”) should ideally not happen if the write model correctly detects OCC. The ConcurrencyException should prevent the duplicate event generation.

The core problem, then, is stale read models due to consumer processing failures, which then lead to ConcurrencyException at the write model.


The Solution: Robust Kafka Consumer Processing (Retry & DLQ)

The solution lies entirely within your Kafka Consumer’s (PortalEventConsumerStartupHook) error handling strategy.

Your most recent incremental code includes the processSingleEventWithRetries method with retry and DLQ logic. This is precisely the mechanism designed to handle this situation.

Here’s how it’s supposed to work and what you need to ensure is functioning correctly:

  1. Idempotency of Read Model Updates:

    • All your dbProvider.createXxx, updateXxx, deleteXxx methods (e.g., updateRole, deleteRole, createRole) must be idempotent in their database effects.
    • For UPDATE and DELETE, WHERE aggregate_version = expectedVersion makes them idempotent. If the update was already applied (or a newer version is present), 0 rows affected means no harm done (though it might still trigger a ConcurrencyException within the consumer’s dbProvider methods if you implement the record-not-found-vs-conflict check).
    • For INSERT, use INSERT ... ON CONFLICT (primary_key) DO UPDATE SET aggregate_version = excluded.aggregate_version, ... (UPSERT) if the “create” event might be re-delivered and you expect it to update an existing record (e.g., in a snapshot table). Otherwise, if it’s strictly a “create-only” and a duplicate PK is a bug, the SQLException for unique constraint violation is correct.
  2. Consumer’s Retry/DLQ Logic (The core fix): The processSingleEventWithRetries method is crucial.

    • Transient Errors:

      • If dbProvider.updateXxx (or any other part of processSingleEventWithRetries) throws a transient SQLException (e.g., connection timeout, deadlock), the currentRetry is incremented, and Thread.sleep occurs.
      • If maxRetries is not exhausted, processSingleEventWithRetries will return false.
      • The onCompletion loop will then break; (meaning it won’t commitSync() any offsets for this batch).
      • On the next readRecords call, the entire batch (including the transiently failed record) will be re-polled and re-processed. This relies on idempotency.
    • Permanent Errors:

      • If dbProvider.updateXxx throws a DbProvider.ConcurrencyException (meaning the read model’s version was stale, so the WHERE aggregate_version = expectedVersion update in the consumer failed with 0 rows, but the record did exist at a higher version) or an IllegalArgumentException (bad data) or a permanent SQLException (e.g., unique constraint violation on an INSERT where it shouldn’t happen, or foreign key constraint violation):
        • processSingleEventWithRetries will catch it and call handlePermanentFailure.
        • handlePermanentFailure sends the original Kafka record to the DLQ.
        • processSingleEventWithRetries then returns true (because the event has been “handled” by being DLQ’d).
        • onCompletion then does include this record’s offset in offsetsToCommit and proceeds to commitSync() for the batch.
        • Result: The consumer makes progress past this “poison pill.” The stale event in entity_t is not updated by this specific event, but the consumer doesn’t get stuck.

How to Handle the Stale UI Problem

Once the consumer’s retry/DLQ is robust, the stale UI becomes a UX problem rather than a system consistency problem.

  1. Producer’s ConcurrencyException is Key: When the UI sends UpdateCommand(..., expectedVersion = 5) and the event_store_t is already at version 6, the write model will throw ConcurrencyException. This is the correct behavior.

  2. UI Response to ConcurrencyException:

    • The UI must catch this ConcurrencyException from the API.
    • It should display a clear message to the user: “This record has been updated by another user. Please refresh the page to see the latest changes and re-apply your modifications if desired.”
    • It should then trigger a refresh of the data from the read model, which will then fetch Entity (aggregate_version = 6) (assuming the consumer eventually caught up or the record was DLQ’d and a manual fix was applied).

Summary and What to Focus On:

  1. Ensure processSingleEventWithRetries is fully correct and deployed. This is your primary defense against the consumer getting stuck.
  2. Carefully verify the isTransientSqlError logic. It must accurately identify only recoverable database errors. Misclassifying permanent errors as transient will exhaust retries and then DLQ; misclassifying transient as permanent sends to DLQ too soon.
  3. Confirm handlePermanentFailure correctly sends to DLQ and logs.
  4. Confirm your dbProvider methods are truly idempotent for re-delivered messages. UPDATE ... WHERE aggregate_version = expectedVersion is idempotent. INSERTs are trickier.
  5. Educate your UI/API layer: They must handle the ConcurrencyException from the write model by forcing a refresh and user notification.

Your recent changes to PortalEventConsumerStartupHook already implement this retry/DLQ pattern. The next step is to ensure that code is compilable, deployed correctly, and that the dbProvider methods are fully integrated with the aggregate_version for idempotency and OCC checking as discussed in the previous detailed answer.

What if DLQ messages are not handled in a timely fashion

This is a critical operational risk with the Dead Letter Queue (DLQ) pattern: DLQ messages are “handled exceptions,” not “disappearing problems.” If they aren’t processed, they represent real, unapplied business facts that can lead to data inconsistencies and broken business processes over time.

If a message sits in the DLQ for 30 days, your read models will be stale, your UIs will report incorrect data, and downstream systems relying on that information will also be out of sync. This can severely damage data integrity and user trust.


The DLQ is a “Hospital” or “Quarantine Zone,” Not a “Graveyard”

It’s a place for messages that need human intervention or a specific, non-automated re-driving process. It’s not a place for messages to just die.

Strategies to Prevent DLQ Message Stagnation

To ensure DLQ messages are handled in a timely fashion, you need a robust DLQ management strategy that goes beyond just pushing messages to the topic.

1. Robust Monitoring & Alerting (Immediate Action)
  • Metric: Count of messages in DLQ topics (kafka_topic_partition_current_offset, kafka_consumer_group_lag, or custom JMX metrics).
  • Alerting Thresholds:
    • Urgent: Alert immediately (PagerDuty, Slack, SMS) if the number of messages in any DLQ topic goes above 0 or a very small threshold (e.g., 5-10 messages). A DLQ is an exceptional queue.
    • Warning: Alert if messages persist for a certain duration (e.g., 1 hour, 4 hours).
  • Dashboards: Create a dashboard that prominently displays the number of messages in each DLQ topic and their age.
2. Clear Ownership & Standard Operating Procedures (SOPs)
  • Who owns the DLQ? Assign clear responsibility to a specific team (e.g., SRE, Development team for that microservice).
  • What’s the process? Define a clear SOP for handling DLQ alerts:
    1. Acknowledge alert.
    2. Inspect the DLQ message content (payload, error message, original topic/offset).
    3. Identify the root cause (code bug, malformed data, transient external system outage, business process error).
    4. Decide on action:
      • Fix Code/Data: If it’s a bug, deploy a fix. If it’s bad data, decide if it needs manual correction in the database or if upstream data entry needs fixing.
      • Re-drive: After fixing the root cause, re-drive the message(s) back to the original topic.
      • Discard (Rare & Documented): Only if the message is truly unrecoverable garbage or a test message that accidentally ended up there, and its impact is negligible. This decision must be audited and requires strong justification.
3. Automated DLQ Re-driving with Human Trigger (Operational Playbook)
  • You’ll need a “re-driver” tool/application.
  • Purpose: This tool reads messages from the DLQ, and publishes them back to their original topic for re-processing.
  • Features:
    • Preview: Show content of DLQ messages before re-driving.
    • Selectivity: Allow re-driving specific messages, or ranges of messages.
    • Filtering: Filter by error type, timestamp, etc.
    • Audit: Log who re-drove what message.
  • Integration:
    • Could be a simple command-line tool.
    • Could be integrated into your internal developer portal or ops dashboard.
    • Could be a scheduled job that runs periodically but requires explicit human approval before actually publishing.
4. Automated Retries (Beyond Initial Consumer)

For certain classes of “permanent-but-maybe-not-really” errors (e.g., external API rate limits, very long-running external process), you could have a separate, simpler consumer that specifically subscribes to the DLQ.

  • Purpose: This DLQ consumer would only handle a very specific, narrow class of DLQ messages.
  • Logic: It would apply its own retry logic (e.g., exponential backoff for a longer period, up to 24 hours).
  • Re-DLQ: If this DLQ consumer also fails after its retries, it would push the message back to the same DLQ topic (or a different, truly “unresolvable” DLQ) to re-trigger human intervention.
  • Caution: This adds complexity and should only be done for errors you’ve explicitly identified as potentially auto-recoverable over a very long time.
5. Data Reconciliation (Long-Term Strategy)
  • For critical business data, even with DLQs, sometimes manual reconciliation or a dedicated reconciliation service might be needed.
  • This involves periodically comparing your read model state with the authoritative Event Store (or an authoritative external system) to detect and resolve discrepancies that might have slipped through.

Addressing “Most errors cannot be recovered anyway”

If “most errors cannot be recovered anyway” and always lead to DLQ, it points to a need for:

  1. Better isTransientSqlError logic: Are you correctly identifying all transient SQL errors that could be retried by the main consumer?
  2. More robust validation upstream: Can you prevent more malformed/invalid data from even generating events that will fail downstream? (E.g., more validation in the command handler).
  3. Refined Domain Model/Business Rules: Sometimes, a “permanent” error in the consumer points to a business rule that isn’t fully handled by events, or a valid state transition that wasn’t expected.

DLQs are not a substitute for preventing errors, but they are essential for graceful failure and ensuring eventual consistency. Their effectiveness hinges entirely on the operational discipline and tooling around them.

How to handle the scenario that it takes weeks or even months to fix a bug and get it deployed

This is an issue regarding a major reality of enterprise software: deployment cycles can be slow, and manual intervention for DLQs simply isn’t scalable for a continuous stream of errors. This is precisely why a purely “human-in-the-loop” DLQ management strategy breaks down in a corporate context with persistent bugs.

To handle a bug that takes weeks/months to deploy, you need a strategy that focuses on containment, bypass, and eventually, reconciliation.


Strategy for Long-Deployment-Cycle Bugs (Weeks/Months)

The goal is to stop the errors from piling up indefinitely in the DLQ, and to allow the rest of the system to continue processing, even if some data is temporarily inconsistent.

1. Immediate Containment: Filtering or Skipping “Poison Pill” Messages
  • Implement a “Hot Fix” Filter (Code-based or SMT-based):

    • In your Kafka Consumer (PortalEventConsumerStartupHook): If you identify a bug where a specific type of event (or event with specific data) consistently causes failures:
      • Add a temporary code filter. For instance, if ScheduleCreatedEvent with null userId is causing NullPointerException, add:
        if (eventType.equals(PortalConstants.SCHEDULE_CREATED_EVENT) && eventMap.get("userId") == null) {
            logger.warn("Skipping known bug event type {} for record {} due to null userId. Not processing.", eventType, record.offset());
            handlePermanentFailure(record, "Known bug: null userId for " + eventType, "KnownBugSkip");
            return true; // Mark as handled (DLQ'd), commit offset, move on.
        }
        
      • If the bug is in a specific dbProvider method: You can wrap that call in a try-catch for PermanentProcessingException specifically for that event type, and if it’s the known bug, send it to DLQ and commit.
    • Using Kafka Connect SMT (if source is Kafka Connect): You could implement a custom Filter SMT that drops/routes specific problematic messages before they even hit your consumer app. This requires deploying a new SMT, but it can be faster than an app deployment.
  • Why: This immediately stops the DLQ from growing uncontrollably with known bad messages. It sacrifices processing that specific message but ensures the consumer stays healthy.

2. Automated (Limited) Re-driving for Transient/Known Issues (Or Triage)
  • “Error Triage” Consumer: Instead of just sending to a single DLQ, consider a dedicated consumer that subscribes to your main DLQ topic.
    • This consumer acts as an automated triage.
    • It checks the errorType (from handlePermanentFailure’s metadata).
    • If errorType is “TransientSqlError” or “RetriesExhausted” (but could eventually succeed): It re-publishes the original message back to the portal-event topic with an exponential backoff. It might implement its own max retries (e.g., 50 retries over 24 hours). If it still fails, then it pushes to a “Final DLQ” that truly requires manual intervention.
    • If errorType is “ConcurrencyConflict”, “DataValidationError”, “UnhandledEventType”, or “KnownBugSkip”: It pushes to a separate “Permanent DLQ” topic. This queue is smaller and truly requires human eyes.
  • Why: This handles messages that might eventually self-resolve or that you know can’t be fixed by immediate retries but aren’t necessarily “dead forever.” It reduces the volume of messages requiring immediate human attention.
3. Manual Intervention for “Permanent DLQ” / Complex Bugs (When Devs Get Involved)
  • The “Permanent DLQ” is where true bugs/bad data sit.
  • The same monitoring and alerting from before applies, but now it’s for a much smaller, higher-priority queue.
  • Developers must actively:
    • Analyze: What exactly caused this? Why did it bypass automated retries/filters?
    • Fix: Develop and deploy the bug fix.
    • Reconcile/Re-drive:
      • If the bug fix resolves the issue, use a re-driver tool to re-submit messages from the Permanent DLQ to the portal-event topic.
      • If the bug resulted in data inconsistencies that can’t be fixed by re-driving (e.g., a critical business state was violated), you might need to perform a manual database correction on the affected aggregate(s) (this is the most dangerous and should be avoided if possible).
4. Long-Term Data Reconciliation / Auditing
  • Offline Reconciliation: For critical data, implement daily/weekly batch jobs that compare the state of your read model tables with the authoritative Event Store.
    • If discrepancies are found, they are reported, and a reconciliation process is triggered (either manual or automated). This ensures that even if events were missed or misapplied, data consistency is eventually achieved.
  • Event Replay (When all else fails): If a significant bug causes widespread data corruption or loss of consistency, the ultimate fallback is to:
    1. Deploy the bug fix.
    2. Stop the affected read model consumer.
    3. Clear the affected read model tables.
    4. Replay all historical events from the event_store_t (or long-retention Kafka topics) through the fixed consumer logic. This rebuilds the read model from scratch, reflecting the correct business logic. This is why Event Sourcing is so powerful.

Example Workflow with a Long-Deployment-Cycle Bug

  1. Bug Identified: ScheduleCreatedEvent creates a schedule, but due to a bug in the consumer’s dbProvider.createSchedule method, it tries to insert a duplicate primary key if scheduleId (aggregate ID) exists, and this causes a permanent error in the consumer.
  2. Immediate Containment (Filter/Bypass):
    • A hotfix is applied to the PortalEventConsumerStartupHook (or a dedicated filter SMT) to recognize ScheduleCreatedEvent where scheduleId already exists.
    • For such events, it handlePermanentFailure() the message to a portal-event-dlq-permanent topic (or a KnownBugDLQ). This prevents the main consumer from getting stuck.
  3. DLQ Accumulation & Monitoring: Messages related to this bug pile up in portal-event-dlq-permanent. Alerts are firing.
  4. Development Fix: The development team works on a fix for dbProvider.createSchedule (e.g., changing it to an UPSERT if a “create” event implies “idempotent create/update” or better handling of duplicate primary keys if it truly is an error). This takes weeks.
  5. Deployment: The fix is deployed.
  6. Re-driving: Operations team uses the re-driver tool to:
    • Read messages from portal-event-dlq-permanent.
    • Publish them back to the portal-event topic.
    • The now-fixed consumer processes them correctly.

This approach balances immediate operational stability with eventual consistency, acknowledging the realities of enterprise deployment cycles.

MSAL Light OAuth Integration

Token Exchange

We have a React Single Page Application(SPA) running within a corporate network using Azure AD for SSO. However, we want to use JWT tokens from another OAuth 2.0 provider Light-OAuth to issue the token with additional custom claims for fine-grained Authorization. What is the best way to do the integration?

This is a common and powerful architectural pattern used to separate authentication from authorization, allowing you to use a central identity provider (like Azure AD) for who the user is and a specialized service for what the user can do.

The goal is to use the trusted identity established by Microsoft to “mint” a new, enriched token from your second provider.

The best and most secure way to achieve this is by using the OAuth 2.0 Token Exchange Flow (RFC 8693). This process happens on your backend, ensuring that no sensitive client secrets or complex token logic is exposed in the React SPA.

Let’s break down the architecture and the steps involved.


In this model, your React SPA’s responsibility remains simple: authenticate the user with Microsoft and get a token. It then sends this token to your own backend API. Your backend is the one that communicates with the second OAuth provider to exchange the token.

Here is a diagram of the flow:

+-----------+     +-----------------+     +----------------+
|           | (1) |                 | (2) |                |
| React SPA |---->|  Microsoft      |---->|   React SPA    |
|           |     |  Entra ID (MSAL)|     | (has MS Token) |
+-----------+     +-----------------+     +----------------+
      |
      | (3) Sends MS Token to Backend
      v
+-----------+     +-----------------+     +----------------+
|           | (4) |                 | (5) |                |
| Your      |---->| Second OAuth    |---->|  Your          |
| Backend   |     | Provider        |     |  Backend       |
| API       |     | (Token Exchange)|     | (has New Token)|
+-----------+     +-----------------+     +----------------+
      |
      | (6) Establishes session / returns new token
      v
+-----------+
| React SPA |
| (is now   |
| authorized)|
+-----------+

Why this is the best approach:

  • Security: Your second provider’s client_id and client_secret are kept securely on your backend, never exposed to the browser.
  • Separation of Concerns: The React app only worries about authenticating with Microsoft. Your backend handles the complex authorization logic.
  • Flexibility: You can swap out the second authorization provider without changing your entire frontend authentication flow.
  • Standard-Compliant: The Token Exchange grant type is an official standard designed for this exact “delegation” or “impersonation” scenario.

Step-by-Step Integration Guide

Here’s how to implement this pattern.

Step 1: Configure Your Second OAuth 2.0 Provider

This is the most critical setup step. You need to configure your authorization provider (e.g., Auth0, Okta, Duende IdentityServer, or a custom one) to:

  1. Trust Microsoft Entra ID (Azure AD) as an Identity Provider: It must be able to validate the signature of the token it receives from your backend. This usually involves pointing it to Microsoft’s OIDC discovery endpoint (https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration) to get the public signing keys.
  2. Enable the Token Exchange Grant Type: You’ll need to create a new “client” or “application” within this provider for your backend API. This client must be configured to use the urn:ietf:params:oauth:grant-type:token-exchange grant type.
  3. Define a User Mapping Strategy: The provider needs to know how to link the incoming Microsoft token to a user in its own database. A common practice is to map the oid (Object ID) or sub (Subject) claim from the Microsoft token to a user profile in the second provider. This is how it knows which fine-grained permissions (claims) to add.
  4. Define the Custom Claims: Configure the rules that add the additional claims to the new token when the exchange is successful. For example: “If the incoming user has oid ‘123-abc’, add the claims permissions: ['create:document', 'read:report'].”

Step 2: Update Your React SPA Logic

Your React app’s interaction with MSAL will remain largely the same, with one key difference in what you do after a successful login.

  1. Authenticate and Acquire a Token: Use MSAL as you normally would to log the user in and get an access token for your own backend API.

    // msalConfig.js - Make sure you have a scope for your own backend API
    export const msalConfig = {
      auth: { /* ... */ },
      cache: { /* ... */ },
    };
    
    export const loginRequest = {
      scopes: ["User.Read", "api://<your-backend-client-id>/access_as_user"]
    };
    
  2. Call Your Backend: After getting the token, instead of using it to call various protected resources, you make a single call to a dedicated endpoint on your backend (e.g., /auth/ms/exchange) to initiate the session.

    import { useMsal } from "@azure/msal-react";
    import { loginRequest } from "./msalConfig";
    
    function MyComponent() {
      const { instance, accounts } = useMsal();
    
      const handleLoginAndExchange = async () => {
        try {
          // 1. Get the MSAL token for our backend
          const response = await instance.acquireTokenSilent({
            ...loginRequest,
            account: accounts[0],
          });
          const microsoftAccessToken = response.accessToken;
    
          // 2. Send it to our backend for exchange
          const backendResponse = await fetch('/auth/ms/exchange', {
            method: 'POST',
            credentials: 'include',
            headers: {
              'Authorization': `Bearer ${microsoftAccessToken}`,
              'Content-Type': 'application/json',
            },
          });
    
          if (!backendResponse.ok) {
            throw new Error('Token exchange failed');
          }
    
          // The backend stores the internal tokens in BFF cookies and returns
          // the scopes that the SPA may display for consent.
          const { scopes } = await backendResponse.json();
          console.log("Gateway session established", scopes);
    
        } catch (error) {
          // Handle token acquisition or exchange errors
          console.error(error);
          if (error.name === "InteractionRequiredAuthError") {
             instance.acquireTokenPopup(loginRequest);
          }
        }
      };
      // ...
    }
    

Step 3: Implement the Backend Token Exchange Endpoint

This is where the core logic resides. You’ll create an endpoint that receives the Microsoft token and exchanges it.

  1. Protect the Endpoint: Configure your backend to validate the Bearer token from Microsoft that it receives from your React app. This ensures only authenticated users from your SPA can trigger an exchange.

  2. Implement the Exchange Logic:

        // Endpoint classification happens before this flow. OPTIONS passes to
        // CORS. Mutation endpoints are POST-only; another method returns
        // ERR10008/405 with Allow: POST before this flow starts.
        if (exchange.getRelativePath().equals(config.getExchangePath())) {
            // token exchange request handling.
            if(logger.isTraceEnabled()) logger.trace("MsalTokenExchangeHandler exchange is called.");
    
            String authHeader = exchange.getRequestHeaders().getFirst(Headers.AUTHORIZATION);
            if (authHeader == null || !authHeader.startsWith("Bearer ")) {
                setExchangeStatus(exchange, JWT_BEARER_TOKEN_MISSING);
                return;
            }
            String microsoftToken = authHeader.substring(7);
    
            // --- Validate the incoming Microsoft Token ---
            if(msalJwtVerifier == null) {
                // handle case where config failed to load
                throw new Exception("MsalJwtVerifier is not initialized.");
            }
            try {
                // We only need to verify it, we don't need the claims for much.
                // The second provider will do its own validation and claim mapping.
                // Set skipAudienceVerification to true if the 'aud' doesn't match this BFF's client ID.
                String reqPath = exchange.getRequestPath();
                msalJwtVerifier.verifyJwt(microsoftToken, msalSecurityConfig.isIgnoreJwtExpiry(), true, null, reqPath, null);
            } catch (InvalidJwtException e) {
                logger.error("Microsoft token validation failed.", e);
                setExchangeStatus(exchange, INVALID_AUTH_TOKEN, e.getMessage());
                return;
            }
    
            // --- Perform Token Exchange ---
            String csrf = UuidUtil.uuidToBase64(UuidUtil.getUUID());
            TokenExchangeRequest request = new TokenExchangeRequest();
            request.setSubjectToken(microsoftToken);
            request.setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt");
            request.setCsrf(csrf); // The CSRF for the *new* token we are getting
    
            Result<TokenResponse> result = OauthHelper.getTokenResult(request);
            if (result.isFailure()) {
                logger.error("Token exchange failed with status: {}", result.getError());
                setExchangeStatus(exchange, TOKEN_EXCHANGE_FAILED, result.getError().getDescription());
                return;
            }
    
            // --- The setCookies logic is identical ---
            List<String> scopes = setCookies(exchange, result.getResult(), csrf);
            if(logger.isTraceEnabled()) logger.trace("scopes = {}", scopes);
    
            exchange.setStatusCode(StatusCodes.OK);
            exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json");
            // Return the scopes in the response body
            Map<String, Object> rs = new HashMap<>();
            rs.put(SCOPES, scopes);
            exchange.getResponseSender().send(JsonMapper.toJson(rs));
        } else if (exchange.getRelativePath().equals(config.getLogoutPath())) {
            // logout request handling, this is the same as StatelessAuthHandler to remove the cookies.
            if(logger.isTraceEnabled()) logger.trace("MsalTokenExchangeHandler logout is called.");
            // Logout CSRF is observed or enforced before cookie deletion.
            removeCookies(exchange);
            exchange.setStatusCode(StatusCodes.NO_CONTENT);
            exchange.endExchange();
        } else {
            // This is the subsequent request handling after the token exchange. Here we verify the JWT in the cookies.
            if(logger.isTraceEnabled()) logger.trace("MsalTokenExchangeHandler is called for subsequent request.");
            String jwt = null;
            Cookie cookie = exchange.getRequestCookie(ACCESS_TOKEN);
            if(cookie != null) {
                jwt = cookie.getValue();
                // verify the jwt with the internal verifier, the token is from the light-oauth token exchange.
                JwtClaims claims = internalJwtVerifier.verifyJwt(jwt, securityConfig.isIgnoreJwtExpiry(), true);
                String jwtCsrf = claims.getStringClaimValue(Constants.CSRF);
                // get csrf token from the header. Return error is it doesn't exist.
                String headerCsrf = exchange.getRequestHeaders().getFirst(HttpStringConstants.CSRF_TOKEN);
                if(headerCsrf == null || headerCsrf.trim().length() == 0) {
                    setExchangeStatus(exchange, CSRF_HEADER_MISSING);
                    return;
                }
                // verify csrf from jwt token in httpOnly cookie
                if(jwtCsrf == null || jwtCsrf.trim().length() == 0) {
                    setExchangeStatus(exchange, CSRF_TOKEN_MISSING_IN_JWT);
                    return;
                }
                if(logger.isDebugEnabled()) logger.debug("headerCsrf = " + headerCsrf + " jwtCsrf = " + jwtCsrf);
                if(!headerCsrf.equals(jwtCsrf)) {
                    setExchangeStatus(exchange, HEADER_CSRF_JWT_CSRF_NOT_MATCH, headerCsrf, jwtCsrf);
                    return;
                }
                // renew the token 1.5 minute before it is expired to keep the session if the user is still using it
                // regardless the refreshToken is long term remember me or not. The private message API access repeatedly
                // per minute will make the session continue until the browser tab is closed.
                if(claims.getExpirationTime().getValueInMillis() - System.currentTimeMillis() < 90000) {
                    jwt = renewToken(exchange, exchange.getRequestCookie(REFRESH_TOKEN));
                }
            } else {
                // renew the token and set the cookies
                jwt = renewToken(exchange, exchange.getRequestCookie(REFRESH_TOKEN));
            }
            if(logger.isTraceEnabled()) logger.trace("jwt = " + jwt);
            if(jwt != null) exchange.getRequestHeaders().put(Headers.AUTHORIZATION, "Bearer " + jwt);
            // if there is no jwt and refresh token available in the cookies, the user not logged in or
            // the session is expired. Or the endpoint that is trying to access doesn't need a token
            // for example, in the light-portal command side, createUser doesn't need a token. let it go
            // to the service and an error will be back if the service does require a token.
            // don't call the next handler if the exchange is completed in renewToken when error occurs.
            if(!exchange.isComplete()) Handler.next(exchange, next);
        }
    

What to Avoid: The Anti-Pattern

Do not try to perform two separate, chained OAuth flows in the frontend. This would involve:

  1. User logs in with MSAL.
  2. Your React app gets the MSAL token.
  3. Your React app then initiates a second redirect or popup flow with the other provider, trying to pass the MSAL token as a parameter.

This is a bad idea because:

  • Terrible User Experience: It can lead to multiple redirects, popups, and a confusing login process.
  • Security Risk: It increases the surface area for token handling in the browser and might require you to use less secure flows (like Implicit flow) on the second provider.
  • Complexity: Managing the state of two independent authentication libraries and their tokens in a SPA is extremely difficult and error-prone.

Client Secret

Token exchange specification doesn’t require client_id and client_secret to be sent to the second OAuth 2.0 provider to exchage the token. However, it is highly recommended to pass the client_id and client_secret from the BFF to the second OAuth 2.0 provider. The subject token along is not sufficient.

This is a critical security aspect of the Token Exchange flow. Let’s break down why.

The “Two Questions” Security Model

When your BFF makes the token exchange request, the second OAuth provider needs to answer two fundamental security questions:

  1. WHO IS THE USER? (Authentication of the Subject)

    • This question is answered by the subject_token (the Microsoft token).
    • The provider validates the token’s signature, issuer (iss), expiration (exp), and audience (aud) to confirm that it’s a legitimate token for a valid user from a trusted identity provider (Microsoft).
  2. WHO IS ASKING FOR THIS TOKEN? (Authentication of the Client)

    • This question is answered by the client_id and client_secret.
    • This is crucial. The provider needs to know which application is requesting to act on the user’s behalf. It’s not enough that the user is valid; the application making the request must also be a known, trusted, and authorized client.

Why the Subject Token Alone is a Security Risk

Imagine if only the subject_token were required. Any malicious actor or compromised service that managed to get a user’s Microsoft access token could then send it to your second OAuth provider and exchange it for a new token containing your fine-grained authorization claims. This would allow them to impersonate the user within your system completely.

By requiring the client_id and client_secret, you ensure that only your specific, trusted BFF application is allowed to perform this exchange. The client_secret is the proof that the request is coming from your backend and not some other application.


The Token Exchange Request Body

So, the POST request your MsalTokenExchangeHandler (the BFF) sends to your second provider’s token endpoint will be application/x-www-form-urlencoded and must look like this:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_id=YOUR_BFFS_CLIENT_ID_FOR_THE_SECOND_PROVIDER
&client_secret=YOUR_BFFS_CLIENT_SECRET
&subject_token=THE_MICROSOFT_ACCESS_TOKEN_FROM_THE_SPA
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&scope=permissions_for_the_new_token

Configuration Checklist for your Second OAuth Provider

This means that on your second OAuth 2.0 provider, you must:

  1. Create a Client Registration: Create a new “Application” or “Client” specifically for your light-gateway BFF.
  2. Set Client Type: Configure this client as a Confidential Client (as opposed to a Public Client like a SPA), because it is capable of securely storing a secret.
  3. Generate Credentials: Generate a client_id and a client_secret for this BFF client.
  4. Enable Grant Type: Explicitly enable the urn:ietf:params:oauth:grant-type:token-exchange grant type for this specific client. Your provider’s security policy should only allow trusted, confidential clients to use this powerful grant type.
  5. Store Credentials Securely: Store the generated client_id and client_secret securely in your BFF’s configuration (e.g., in secret.yml or environment variables), where they are not exposed to the outside world.

In short:

  • The subject_token proves who the user is.
  • The client_id and client_secret prove who your BFF is.

Both are required for a secure delegation and token exchange process.

Token Verification

It is necessary to verify the jwt token from Azure AD on both BFF and light-oauth based on the “Zero Trust” principle. They perform the validation for different, but equally important, reasons.

Skipping the validation on the BFF, while technically possible, is a significant security anti-pattern. Let’s break down the distinct roles of each validation step.


1. The BFF’s Responsibility: “Am I Talking to a Legitimate Client?”

The validation performed by your MsalTokenExchangeHandler in the BFF serves as a gatekeeper for your own system. Its purpose is to protect the BFF itself and the downstream services it communicates with.

When the BFF validates the Microsoft token, it’s asking these questions:

  • Is this token even real? (Signature validation).
  • Is it from an identity provider I trust? (Checking the iss or “issuer” claim is from login.microsoftonline.com/...).
  • Is this token actually meant for me? (This is CRITICAL). The BFF must check the aud or “audience” claim. The aud should be the Client ID of your BFF application. This prevents a token that was issued for another API (like the Microsoft Graph API) from being replayed against your BFF to trick it. This is a defense against the “confused deputy” problem.
  • Has it expired? (Checking the exp or “expiration” claim).

Why this is crucial for the BFF:

  • Fail Fast: You immediately reject invalid, expired, or improperly targeted tokens. This is a better user experience and saves system resources.
  • Denial-of-Service (DoS) Protection: If you don’t validate, your BFF becomes a dumb proxy that forwards every piece of junk it receives to your second OAuth provider. An attacker could flood your BFF with garbage tokens, causing it to swamp your authorization server with useless validation and exchange requests, potentially taking it down.
  • Security Boundary: The BFF is the first line of defense. It should never blindly trust any input it receives from the public internet, even from your own SPA.

2. The Second OAuth Provider’s Responsibility: “Can I Issue a New Token for this Subject?”

The validation performed by the second OAuth provider is the authoritative act of delegation. It’s the ultimate source of truth for the new, enriched token. It cannot and must not trust that the BFF has already performed a valid check.

When the second OAuth provider receives the subject_token, it asks all the same questions as the BFF, but for its own security policy:

  • Is this token real and from a trusted issuer? (Signature and iss validation). It must have Microsoft configured as a trusted external identity provider.
  • Is this token meant for a client that is allowed to exchange it? (It might check the aud claim).
  • Is the client making the request (the BFF) authorized to perform a token exchange? (This is validated via the client_id and client_secret you send in the request).
  • How do I map this external user to an internal user? (This is the most important unique step). It will inspect the oid, sub, email, or another claim from the Microsoft token to find the corresponding user in its own database.
  • What new claims should I issue for this user? Based on the mapped internal user, it will apply its authorization rules to mint the new token with fine-grained permissions.

Analogy: A High-Security Building

Think of it like this:

  1. The React SPA is you, the visitor.
  2. The Microsoft Token is your government-issued driver’s license.
  3. The BFF is the receptionist at the front desk of the building. They look at your driver’s license (BFF validation) to make sure it’s not expired and that your name is on the visitor list for that day (aud check). They protect the building from random people just walking in.
  4. The Second OAuth Provider is the guard in front of the secure vault on the 10th floor. When you get to the 10th floor, the guard doesn’t just say, “Oh, the receptionist let you in, so you must be fine.” No, they perform their own, more thorough check of your driver’s license (OAuth provider validation), check their specific access logs (user mapping), and then issue you a special keycard (new enriched token) that only opens the specific safety deposit box you’re allowed to access (fine-grained claims).

You wouldn’t want a security system where the vault guard blindly trusts the front desk. Each layer must perform its own validation.

Conclusion

Do not skip the validation on the BFF.

  • Verify on the BFF to protect your own application, fail fast, and prevent it from becoming a DoS vector.
  • The Second OAuth Provider MUST verify as its core function to securely map the user and issue an authoritative, enriched token.

The verifications are not redundant; they are a fundamental part of a layered, defense-in-depth security strategy.

Single Page Application

There are two endpoints that the SPA should access for both token exchange and logout.

Login

After Microsoft Entra ID SSO, send the ID token to POST /auth/ms/exchange in the standard Authorization: Bearer <id-token> header. Include credentials and do not place the token in the URL or request body. No body is required; a zero-length request is valid when a shared client sets Content-Type: application/json. The response contains the permitted scopes and sets the BFF cookies used for the session.

Logout

Call credentialed POST /auth/ms/logout with X-CSRF-TOKEN read from the readable csrf cookie, then complete the Microsoft logout. Successful backend logout returns 204 No Content and deletion cookies for all cookies owned by the handler.

Exchange and logout are POST-only. A legacy GET or another unsupported method returns 405, ERR10008, and Allow: POST before token-server, cookie, or proxy side effects. Keep explicit OPTIONS routes through a chain with CORS before the auth handler.

User Registration and Onboarding

In light-portal, user management is the foundation of the authentication and authorization to access different services.

There are two different apporaches to create an user entry in the system: Internet and Corporation.

Internet User

This is for Internet users to register and verify via email to a cloud Light Portal instance.

The entry point is the createUser command handle in the user-command service.

Corporation User

This is for corporation users to onboard to a dedicated Intranet Light Portal instance.

The entry point is the onboardUser command handler in the user-comand service.

User Password

In the user_t table, the password is nullable and onboardUser doesn’t have password passed in as the authentication is done through Azure AD and ECIF etc.

Optimistic vs Pessimistic UI

When you create, update, delete an entity on the UI and refresh the list immediately, chances are the newly updated entity doesn’t show up the changes. This is a classic challenge when working with systems that use Event Sourcing and CQRS (Command Query Responsibility Segregation).

  • Command: Your deleteHost request is a Command. It’s sent to the write-model to change the state of the system and publish an event (e.g., HostDeletedEvent).
  • Query: Your fetchData request is a Query. It reads from a separate read-model (the hosts database view/table).
  • Eventual Consistency: There is a delay (usually milliseconds, but it can vary) between the command succeeding and the event consumer updating the read-model.

Your UI is so fast that it’s sending the Query before the read-model has been updated, leading to the stale data problem.

Should we wait a few seconds?

No, please do not use a setTimeout to wait. This is the most important takeaway. It’s an unreliable “magic number” that will cause problems:

  • Bad UX: It forces the user to wait for an arbitrary amount of time, even if the system is fast.
  • Unreliable: If the system is under heavy load, the delay might be longer than your timeout, and the bug will reappear.
  • It’s a “code smell”: It indicates that the UI isn’t correctly handling the nature of the backend architecture.

The Professional Solutions

There are two primary, robust patterns for handling this on the UI. The best choice depends on the desired user experience.


This is the most common and user-friendly approach in modern web applications. You assume the command will succeed and update the UI immediately.

How it works:

  1. User clicks “Delete”.
  2. You immediately remove the item from your local React state. The user sees the item disappear instantly.
  3. You send the deleteHost command to the server in the background.
  4. Crucially: If the command fails for some reason (e.g., validation error, server down), you revert the UI change (add the item back) and show an error message.

This provides the best possible user experience because the UI feels instantaneous.

Here is how you would implement this in your handleDelete function:

  // Delete handler - OPTIMISTIC UI APPROACH
  const handleDelete = useCallback(async (row: MRT_Row<HostType>) => {
    if (!window.confirm(`Are you sure you want to delete host: ${row.original.subDomain}?`)) {
      return;
    }

    // Keep a copy of the current data in case we need to roll back
    const originalData = [...data];

    // 1. Optimistically update the UI
    setData(prevData => prevData.filter(host => host.hostId !== row.original.hostId));
    setRowCount(prev => prev - 1); // Also optimistically update the total count

    // 2. Send the command to the server
    const cmd = {
      host: 'lightapi.net',
      service: 'host',
      action: 'deleteHost',
      version: '0.1.0',
      data: { hostId: row.original.hostId, aggregateVersion: row.original.aggregateVersion },
    };

    try {
      const result = await apiPost({ url: '/portal/command', headers: {}, body: cmd });
      if (result.error) {
        // 3a. On failure, revert the UI and show an error
        console.error('API Error on delete:', result.error);
        alert('Failed to delete host. Please try again.'); // Or use a snackbar
        setData(originalData);
        setRowCount(originalData.length); // Revert the count
      }
      // 3b. On success, do nothing! The UI is already correct.
      // You could trigger a silent background refetch here if you want to be 100% in sync, but it's often not necessary.

    } catch (e) {
      // Also handle network errors
      console.error('Network Error on delete:', e);
      alert('Failed to delete host due to a network error.');
      setData(originalData);
      setRowCount(originalData.length);
    }
  }, [data]); // The main dependency is the 'data' for rollback.

Option 2: Pessimistic UI with State Locking (Simpler, Good UX)

This approach is more straightforward. You “lock” the UI in a loading state until you are certain the operation is complete.

How it works:

  1. User clicks “Delete”.
  2. You show a loading spinner on that specific row or disable the whole table.
  3. Send the deleteHost command.
  4. When the command API call returns a success, you then call fetchData() to get the fresh data. Because the command has completed, it’s much more likely the read model is now consistent. This is essentially what you were trying to do before.

The problem, as you noted, is that even after the command returns, the read model might still not be updated. The optimistic approach neatly sidesteps this entire timing issue. If you must stick to a pessimistic approach, the Optimistic UI is still the superior and often easier pattern to implement correctly.

To fix your current pessimistic implementation, you’d have to implement polling, which is complex:

  1. Send delete command.
  2. On success, start a setInterval to call fetchData every 2 seconds.
  3. In each fetchData response, check if the deleted item is gone.
  4. If it is, clearInterval and stop.
  5. Add a timeout to stop polling after ~10-15 seconds to prevent infinite loops.

As you can see, this is much more complicated than the optimistic update.

Recommendation

Adopt the Optimistic UI pattern (Option 1). It provides the best user experience, is resilient to timing issues caused by eventual consistency, and the implementation is clean and modern. The code provided for the optimistic handleDelete is a drop-in replacement that will solve your problem robustly.

Should you poll peroidically to sync other users’ changes

The short answer is: No, you should still avoid client-side polling for this specific use case. It’s generally the wrong tool for this problem and creates more issues than it solves. The Optimistic UI approach is still preferable, but it needs to be combined with a robust backend and potentially other real-time technologies for a complete solution.

Let’s break down why and explore the professional-grade solutions.


Why Polling is a Bad Fit Here

Your concern is valid: polling does add significant pressure, and it’s inefficient.

  1. High Network Traffic: Every active user would be sending a getHost query every few seconds. If you have 50 users on that page, that’s 10-25 queries per second just from this one component, most of which will return no new data.
  2. Database and Service Layer Load: This traffic directly translates to load on your service and database. Your SELECT query, while indexed, still consumes resources. At scale, this can become a significant performance bottleneck.
  3. Delayed UX: The user experience is still poor. A user makes a change and might have to wait up to X seconds (your polling interval) to see it reflected, which feels sluggish.
  4. Complexity: As we discussed, managing polling logic (starting, stopping, timeouts) on the client adds complexity and potential bugs.

So, while polling can eventually get you the latest data, it’s a brute-force approach with major drawbacks.


The Professional-Grade Solutions for Multi-User Environments

The key is to shift from a “pull” model (client polling) to a “push” model (server notifies the client). This is where real-time technologies shine.

Solution 1: Optimistic UI + Server-Sent Events (SSE) or WebSockets (Best for Real-Time)

This is the gold standard for collaborative applications.

How it Works:

  1. Frontend (Your Optimistic UI):

    • User A deletes a host. Their UI updates instantly (optimistic update). The deleteHost command is sent to the server.
    • User B is looking at the same list. Their screen is unchanged for now.
  2. Backend (The Magic):

    • The command handler processes the deleteHost command and publishes a HostDeletedEvent.
    • An Event Notifier Service listens for this event.
    • Upon receiving the event, this service pushes a notification to all connected clients who are interested in host updates. This is done via Server-Sent Events (SSE) or WebSockets. SSE is often simpler for server-to-client-only communication.
  3. Frontend (Receiving the Push):

    • User B’s browser receives the HostDeletedEvent push notification.
    • The React component’s event listener fires. It can do one of two things:
      • A) Smart Update (Ideal): The event payload contains the hostId that was deleted. The client simply finds that ID in its local data state and removes it. This is hyper-efficient.
      • B) Refetch (Simpler): Upon receiving any host-related event, the client triggers a fetchData() call to get the latest list. This is less efficient than a smart update but still vastly better than polling.
    • User A’s browser also receives the event. It can simply ignore it, as its UI is already up-to-date.

Why this is the best solution:

  • Real-Time: Updates are pushed instantly to all users.
  • Hyper-Efficient: No unnecessary network requests. The server and client only communicate when there’s an actual state change.
  • Scalable: A single event from the backend can update thousands of connected clients simultaneously.
  • Excellent UX: The application feels alive and collaborative.

Solution 2: Optimistic UI + Stale-While-Revalidate (SWR) / react-query with Refetch-on-Focus

This is a powerful and very easy-to-implement pattern that offers a great “80% solution” without needing a full real-time backend setup. Libraries like react-query (now TanStack Query) or Vercel’s swr are built for this.

How it Works:

  1. You replace your manual useState/useEffect/fetchData logic with the useQuery hook from react-query.
  2. User A deletes a host. You perform an optimistic update using the library’s built-in tools.
  3. User B is looking at the list. Nothing happens yet.
  4. Now, User B switches from another browser tab back to your application tab.
  5. react-query automatically detects this “window focus” event and triggers a background refetch of the data.
  6. The UI is seamlessly updated with the latest data (showing User A’s deletion).

Why this is a great solution:

  • Extremely Simple to Implement: You get this behavior for free just by using the library.
  • “Good Enough” Real-Time: Data is refreshed exactly when the user is most likely to need it (when they re-engage with the app).
  • Efficient: Avoids constant polling. It only refetches on specific, user-driven events (window focus, network reconnect, etc.).
  • Handles Caching, Loading States, etc.: These libraries solve many data-fetching headaches for you.

Recommendation & Path Forward

  1. Immediate Step: Stick with the Optimistic UI approach from my previous answer. It correctly handles the single-user eventual consistency problem, which is your most pressing issue. It’s the foundation for everything else.

  2. Next Step (Highly Recommended): Introduce a data-fetching library like TanStack Query (react-query). This will simplify your code and give you the “refetch-on-focus” behavior out of the box, largely solving the multi-user problem with minimal effort.

  3. Long-Term Goal (For True Real-Time): If your application’s core value is real-time collaboration (like a Google Doc or Figma), then plan to add a Server-Sent Events (SSE) or WebSocket layer to your backend to push updates to clients.

In summary: Avoid client-side polling. Implement the optimistic UI pattern now, and for multi-user synchronization, use a purpose-built library like react-query or a real-time backend push technology like SSE.

Soft Delete vs Hard Delete

Soft Delete vs Hard Delete

Here is a classic problem in Event Sourcing, often related to the concept of “soft deletes” or “state transitions” versus “hard deletes” and re-insertions. The core issue is that aggregate_version must be strictly unique for a given aggregate. If you try to re-insert an aggregate at an old version, it fundamentally violates Event Sourcing principles.

Let’s break down the scenario and the best ways to handle it.


The Problem Scenario: Version Conflict on Re-add

Your scenario:

  1. UserHostCreatedEvent (userId=U, hostId=H, aggregate_version=1) -> event_store_t has version 1. user_host_t (projection) has version 1.
  2. UserHostDeletedEvent (userId=U, hostId=H, aggregate_version=2) -> event_store_t has version 2. user_host_t either deletes or marks as inactive.
  3. UserHostCreatedEvent (userId=U, hostId=H, aggregate_version=1) -> CONFLICT! This event says the aggregate (U,H) is at version 1 again, but event_store_t already has version 2 for (U,H).

Root Cause: You cannot “re-add” an aggregate at an old version. An aggregate’s version always strictly increases. The action of “adding back” is not a “first time add” in the event history; it’s a new state transition.


Best Ways to Handle This Kind of Scenario

The solution involves redefining what “add back” means in an Event Sourcing context and how your aggregates and projections handle it.

This is the most common and robust approach. Instead of thinking of “add” and “remove” as discrete CRUD operations on a single record, think of them as state changes of an aggregate instance that always exists.

Aggregate Design (Conceptual UserHostMapping Aggregate):

  • An aggregate representing the state of a (User, Host) relationship (e.g., UserHostMappingAggregate(userId, hostId)).
  • It has a state, e.g., ACTIVE, INACTIVE.
  • The aggregate_id for this aggregate would be a composite ID (e.g., userId + "-" + hostId or a UUID that represents this specific mapping).
  • It has a version (sequence number).

Event Types:

  • UserHostActivatedEvent (userId, hostId, sequence_number)
  • UserHostDeactivatedEvent (userId, hostId, sequence_number)

Scenario with State Transitions:

  1. Add Host to User Mapping (First Time):

    • Command: ActivateUserHostMapping(userId=U, hostId=H, expectedVersion=0) (Expected version 0 because it doesn’t exist yet).
    • Aggregate (U,H): Generates UserHostActivatedEvent (userId=U, hostId=H, sequence_number=1).
    • event_store_t: Saves version 1.
    • user_host_t (projection): INSERTS record (U, H, status=ACTIVE, aggregate_version=1).
  2. Remove Host to User Mapping:

    • Command: DeactivateUserHostMapping(userId=U, hostId=H, expectedVersion=1).
    • Aggregate (U,H): Generates UserHostDeactivatedEvent (userId=U, hostId=H, sequence_number=2).
    • event_store_t: Saves version 2.
    • user_host_t (projection): UPDATES record (U, H) to status=INACTIVE, aggregate_version=2. (Doesn’t delete the row).
  3. Add Back the Same Host to User Mapping:

    • Command: ReactivateUserHostMapping(userId=U, hostId=H, expectedVersion=2). (Expected version 2 because it’s currently INACTIVE at version 2).
    • Aggregate (U,H): Generates UserHostActivatedEvent (userId=U, hostId=H, sequence_number=3).
    • event_store_t: Saves version 3.
    • user_host_t (projection): UPDATES record (U, H) to status=ACTIVE, aggregate_version=3.

Benefits of State Transitions:

  • Strictly Monotonic Versions: The sequence_number for the UserHostMapping aggregate (U,H) always increases (0 -> 1 -> 2 -> 3). No version conflicts.
  • Complete History: The Event Store clearly shows the activation/deactivation cycle.
  • Simpler Projection: The projection (user_host_t) never deletes rows; it only updates their status and version. This makes updates simple (UPDATE ... WHERE aggregate_id = ? AND aggregate_version = ?) and avoids INSERT conflicts on “re-add.”
  • Idempotent Read Model Updates: The consumer logic is straightforward.

Option 2: Unique ID for Each Relationship Instance (Less common for simple toggles)

  • Approach: Instead of (U,H) being one aggregate that changes status, you treat each “active period” of (U,H) as a new, distinct aggregate.
  • aggregate_id: A brand new UUID for each activation of (U,H).
  • Event Types:
    • UserHostCreatedEvent (mappingId=M1, userId=U, hostId=H, sequence_number=1)
    • UserHostDeletedEvent (mappingId=M1, userId=U, hostId=H, sequence_number=2)
    • UserHostCreatedEvent (mappingId=M2, userId=U, hostId=H, sequence_number=1) (for the second time)
  • Projection: The user_host_t table would track these mappingIds, possibly with start_ts and end_ts. When a mapping is terminated, you update its end_ts. When “added back,” you insert a new row with a new mappingId.
  • Complexity: Managing which mappingId is current for (U,H) can be tricky. It’s usually overkill for simple active/inactive toggles.

Option 3: History Table for User Host Mapping

  • Approach: Create a user_host_history_t to keep a history of UserHostMapping.
  • Projection: The user_host_t and user_host_history_t join together for the query with both snapshot and historical views.
  • Complexity: Managing both original and historical tables is overkill in this use case unless you need historical query very frequently.

Go with Option 1: State Transitions for a (User, Host) Aggregate.

Detailed Changes:

  1. Database Schema for user_host_t:

    • Add a status column (e.g., VARCHAR(10) NOT NULL DEFAULT 'ACTIVE').
    • Ensure aggregate_version column exists.
    • Primary key/unique constraint likely remains (host_id, user_id).
    ALTER TABLE user_host_t
    ADD COLUMN status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE',
    ADD COLUMN aggregate_version BIGINT NOT NULL DEFAULT 0;
    
    -- Add a unique constraint if not already present on (host_id, user_id)
    -- ALTER TABLE user_host_t ADD CONSTRAINT pk_user_host PRIMARY KEY (host_id, user_id);
    
  2. Define specific Event Types:

    • UserHostActivatedEvent
    • UserHostDeactivatedEvent
  3. Command Handling Logic (Write Model):

    • When the “add host to user” command comes in:
      • Load the UserHostMapping aggregate (identified by (host_id, user_id)).
      • If not found (expectedVersion 0), generate UserHostActivatedEvent.
      • If found and status=INACTIVE (expectedVersion > 0), generate UserHostActivatedEvent.
      • If found and status=ACTIVE (expectedVersion > 0), reject (already active, idempotent no-op).
    • When the “remove host from user” command comes in:
      • Load the UserHostMapping aggregate.
      • If not found or status=INACTIVE, reject (already inactive/not found).
      • If status=ACTIVE, generate UserHostDeactivatedEvent.
  4. PortalEventConsumer Logic (Read Model Update):

    • For UserHostActivatedEvent:

      • This event means the mapping is now active.
      • Try to UPDATE user_host_t SET status='ACTIVE', aggregate_version=? WHERE host_id=? AND user_id=? AND aggregate_version=?.
      • If 0 rows updated:
        • Check if the record exists (SELECT COUNT(*) ...).
        • If it exists (and version didn’t match), it’s a ConcurrencyException.
        • If it doesn’t exist, it’s the very first time this mapping became active, so INSERT INTO user_host_t (...) VALUES (...).
      • This will handle both initial creation and reactivation as idempotent updates/inserts based on state.
    • For UserHostDeactivatedEvent:

      • This event means the mapping is now inactive.
      • UPDATE user_host_t SET status='INACTIVE', aggregate_version=? WHERE host_id=? AND user_id=? AND aggregate_version=?.
      • If 0 rows updated, it’s either ConcurrencyException or “not found” (already inactive).

This approach treats the user_host_t relationship as a single logical entity (an aggregate instance) that transitions through states (ACTIVE/INACTIVE), ensuring the aggregate_version always progresses monotonically and avoiding the conflict you described.

Command Handler Logic

It is crucial to figure out the db logic between the read model (what the UI sees) and the command model (what the command handler needs to decide). The command handler cannot rely solely on the UI’s expectedVersion in this scenario. It needs to query its own source of truth (the Event Store) to decide if it’s an “initial activation” or a “reactivation.”

Let’s refine the command handling logic for the UserHostMapping aggregate.


Key: The Command Handler Owns the Decision, Using the Event Store

The command handler’s job is to:

  1. Load the aggregate’s current state (by replaying events from event_store_t).
  2. Determine its current status and current version based on that replay.
  3. Compare the expectedVersion from the command with the aggregate’s currentVersion.
  4. Apply business rules to decide what event(s) to generate.

Event Types & Aggregate ID (as per previous recommendation)

  • Aggregate ID: A composite of hostId and userId (e.g., hostId + "_" + userId).
  • Events:
    • UserHostActivatedEvent: Represents the relationship becoming active.
    • UserHostDeactivatedEvent: Represents the relationship becoming inactive.

Step-by-Step Command Handling Logic

Let’s assume your command handler is UserHostMappingCommandHandler and it interacts with a UserHostMappingAggregate.

1. UserHostMappingAggregate (Internal Logic):

This aggregate needs to rebuild its state (currentStatus, currentVersion) from its event stream.

public class UserHostMappingAggregate {
    private final String hostId;
    private final String userId;
    private UserHostMappingStatus currentStatus; // Enum: ACTIVE, INACTIVE, NON_EXISTENT
    private long currentVersion; // Sequence number of the last applied event

    private List<DomainEvent> uncommittedEvents = new ArrayList<>();

    public UserHostMappingAggregate(String hostId, String userId) {
        this.hostId = hostId;
        this.userId = userId;
        this.currentStatus = UserHostMappingStatus.NON_EXISTENT; // Initial state
        this.currentVersion = 0;
    }

    public static UserHostMappingAggregate loadFromEvents(String hostId, String userId, List<DomainEvent> history) {
        UserHostMappingAggregate aggregate = new UserHostMappingAggregate(hostId, userId);
        if (history != null && !history.isEmpty()) {
            history.forEach(aggregate::applyEvent);
        }
        return aggregate;
    }

    private void applyEvent(DomainEvent event) {
        if (event instanceof UserHostActivatedEvent) {
            this.currentStatus = UserHostMappingStatus.ACTIVE;
        } else if (event instanceof UserHostDeactivatedEvent) {
            this.currentStatus = UserHostMappingStatus.INACTIVE;
        }
        this.currentVersion = event.getSequenceNumber(); // Update version based on event
    }

    // --- Command Handling Methods ---

    public void activateMapping(long expectedVersion) {
        // OCC Check (optional here, but good practice if not relying solely on DB constraint)
        if (this.currentVersion != expectedVersion) {
            throw new ConcurrencyException("Concurrency conflict. Expected version " + expectedVersion + ", actual " + this.currentVersion);
        }

        // Business Logic: What state must it be in to activate?
        if (this.currentStatus == UserHostMappingStatus.ACTIVE) {
            // Already active, idempotent no-op or reject as invalid transition
            logger.info("Mapping for user {} host {} is already active. No new event generated.", userId, hostId);
            return;
        }

        // Generate new event
        long nextVersion = this.currentVersion + 1;
        UserHostActivatedEvent event = new UserHostActivatedEvent(
            UUID.randomUUID(), Instant.now(), getAggregateId(), "UserHostMapping", nextVersion, hostId, userId
        );
        uncommittedEvents.add(event);
        applyEvent(event); // Apply to internal state immediately for consistency
    }

    public void deactivateMapping(long expectedVersion) {
        // OCC Check
        if (this.currentVersion != expectedVersion) {
            throw new ConcurrencyException("Concurrency conflict. Expected version " + expectedVersion + ", actual " + this.currentVersion);
        }

        // Business Logic
        if (this.currentStatus != UserHostMappingStatus.ACTIVE) {
            logger.info("Mapping for user {} host {} is not active. Cannot deactivate.", userId, hostId);
            throw new IllegalStateException("Mapping is not active and cannot be deactivated.");
        }

        // Generate new event
        long nextVersion = this.currentVersion + 1;
        UserHostDeactivatedEvent event = new UserHostDeactivatedEvent(
            UUID.randomUUID(), Instant.now(), getAggregateId(), "UserHostMapping", nextVersion, hostId, userId
        );
        uncommittedEvents.add(event);
        applyEvent(event);
    }
    
    // Helper to get the composite aggregate ID
    public String getAggregateId() {
        return hostId + "_" + userId; // Consistent composite ID
    }

    // Getters for external access
    public UserHostMappingStatus getCurrentStatus() { return currentStatus; }
    public long getCurrentVersion() { return currentVersion; }
    public List<DomainEvent> getUncommittedEvents() { return uncommittedEvents; }
    public void markEventsCommitted() { uncommittedEvents.clear(); }

    public enum UserHostMappingStatus {
        ACTIVE, INACTIVE, NON_EXISTENT
    }
}

2. UserHostMappingCommandHandler (Application Service):

This is where the command logic happens. The key is that the command from the UI is now generic (e.g., SetUserHostMappingStatus).

public class UserHostMappingCommandHandler { // This is your application service
    private final EventStoreEventRepository eventStoreRepository; // To load events
    private final OutboxMessageRepository outboxRepository;     // To save new events

    // Constructor injection
    // ...

    public void handleSetUserHostMappingStatus(String hostId, String userId, boolean activate, long expectedVersionFromUI) {
        String aggregateId = hostId + "_" + userId;
        
        // 1. Load aggregate state from Event Store
        List<DomainEvent> history = eventStoreRepository.findByAggregateIdOrderBySequenceNumberAsc(aggregateId)
                                       .stream()
                                       .map(this::deserializeEventStoreEvent) // Deserialize from DB format
                                       .collect(Collectors.toList());
        UserHostMappingAggregate aggregate = UserHostMappingAggregate.loadFromEvents(hostId, userId, history);

        // 2. Perform business logic based on intent (activate) and current state
        if (activate) {
            aggregate.activateMapping(expectedVersionFromUI); // Will generate UserHostActivatedEvent
        } else {
            aggregate.deactivateMapping(expectedVersionFromUI); // Will generate UserHostDeactivatedEvent
        }

        // 3. Persist new events
        List<DomainEvent> newEvents = aggregate.getUncommittedEvents();
        if (!newEvents.isEmpty()) {
            // Your transactional outbox logic (save to Event Store and Outbox)
            eventStoreRepository.saveAll(newEvents.stream().map(this::mapToEventStoreEvent).collect(Collectors.toList()));
            outboxRepository.saveAll(newEvents.stream().map(this::mapToOutboxMessage).collect(Collectors.toList()));
            aggregate.markEventsCommitted();
        }
    }
    
    // Helper methods for serialization/deserialization as shown in previous examples
    // ...
}

3. PortalEventConsumer Logic (Read Model Update):

The consumer updates user_host_t based on the events.

  • For UserHostActivatedEvent:

    // In your PortalEventConsumer (inside processSingleEventWithRetries for this event type)
    Map<String, Object> eventData = extractEventData(eventMap);
    String hostId = (String) eventMap.get(Constants.HOST); // Assuming hostId is a CE extension
    String userId = (String) eventMap.get(Constants.USER); // Assuming userId is a CE extension
    String aggregateId = (String) eventMap.get(CloudEventV1.SUBJECT); // Or extract from eventData if set as such
    long newVersion = getEventSequenceNumber(eventMap);
    
    // SQL: UPSERT is ideal here. If record exists, update status/version. If not, insert.
    // This handles both initial activation (INSERT) and reactivation (UPDATE) idempotently.
    final String upsertSql = "INSERT INTO user_host_t (host_id, user_id, status, aggregate_version, update_user, update_ts) " +
                             "VALUES (?, ?, ?, ?, ?, ?) " +
                             "ON CONFLICT (host_id, user_id) DO UPDATE SET " +
                             "status = EXCLUDED.status, " +
                             "aggregate_version = EXCLUDED.aggregate_version, " +
                             "update_user = EXCLUDED.update_user, " +
                             "update_ts = EXCLUDED.update_ts " +
                             "WHERE user_host_t.aggregate_version < EXCLUDED.aggregate_version"; // Only update if incoming event is newer
    
    try (PreparedStatement statement = conn.prepareStatement(upsertSql)) {
        statement.setObject(1, UUID.fromString(hostId));
        statement.setObject(2, UUID.fromString(userId));
        statement.setString(3, UserHostMappingAggregate.UserHostMappingStatus.ACTIVE.name());
        statement.setLong(4, newVersion);
        statement.setString(5, (String)eventMap.get(Constants.USER)); // From CE extension
        statement.setObject(6, OffsetDateTime.parse((String)eventMap.get(CloudEventV1.TIME)));
        statement.executeUpdate();
    }
    
    • Crucial ON CONFLICT ... WHERE user_host_t.aggregate_version < EXCLUDED.aggregate_version: This makes the projection update idempotent and handles out-of-order delivery. If the database already has a newer version than the incoming event, it simply does nothing (0 rows affected), preventing a stale event from overwriting a more recent state.
  • For UserHostDeactivatedEvent:

    // In your PortalEventConsumer (inside processSingleEventWithRetries for this event type)
    Map<String, Object> eventData = extractEventData(eventMap);
    String hostId = (String) eventMap.get(Constants.HOST);
    String userId = (String) eventMap.get(Constants.USER);
    long newVersion = getEventSequenceNumber(eventMap);
    
    final String updateSql = "UPDATE user_host_t SET status='INACTIVE', aggregate_version=?, update_user=?, update_ts=? " +
                             "WHERE host_id = ? AND user_id = ? AND aggregate_version < ?"; // Only update if incoming event is newer
    
    try (PreparedStatement statement = conn.prepareStatement(updateSql)) {
        statement.setLong(1, newVersion);
        statement.setString(2, (String)eventMap.get(Constants.USER));
        statement.setObject(3, OffsetDateTime.parse((String)eventMap.get(CloudEventV1.TIME)));
        statement.setObject(4, UUID.fromString(hostId));
        statement.setObject(5, UUID.fromString(userId));
        statement.setLong(6, newVersion); // Only update if current DB version < newVersion (from event)
        statement.executeUpdate();
    }
    

How to Figure it Out in the Command Handler (from UI perspective)

The UI will initially query the user_host_t read model.

  • Scenario A: UI queries, no record for (U,H) found.

    • UI infers state is “Non-Existent” or “Inactive”.
    • UI provides expectedVersion = 0 to the command (because the read model had no entry).
    • Command handler: aggregate.currentStatus == NON_EXISTENT. Generates UserHostActivatedEvent (sequence_number=1).
  • Scenario B: UI queries, record (U,H, status=ACTIVE, aggregate_version=1) found.

    • UI provides expectedVersion = 1 to the command.
    • User wants to “remove.”
    • Command handler: aggregate.currentStatus == ACTIVE. Generates UserHostDeactivatedEvent (sequence_number=2).
  • Scenario C: UI queries, record (U,H, status=INACTIVE, aggregate_version=2) found. (This assumes your UI could list inactive items, or an admin UI can see it.)

    • UI provides expectedVersion = 2 to the command.
    • User wants to “add back” / “reactivate.”
    • Command handler: aggregate.currentStatus == INACTIVE. Generates UserHostActivatedEvent (sequence_number=3).
  • Crucial UI Aspect: If the UI doesn’t display inactive items (which is typical for a “list active” view), and the user tries to “add” an item that used to exist but is now inactive, the UI would initially send expectedVersion = 0.

    • Command handler receives expectedVersion = 0, but aggregate is actually INACTIVE at version=2.
    • OCC Conflict! aggregate.currentVersion (2) != expectedVersion (0). Command is rejected.
    • User Experience: “Cannot add. This mapping exists in an inactive state. Please activate it instead.” This forces a clearer UI workflow.

By leveraging state transitions within your aggregate and using ON CONFLICT / WHERE aggregate_version < EXCLUDED.aggregate_version in your projection updates, you ensure strict versioning, idempotent read models, and a consistent business logic flow.

Tenant Migration and Environment Promotion

This document addresses the reality of tenant migration and environment promotion in an Event Sourcing context. You must preserve the event sequence (aggregate_version) while making necessary adjustments (hostId, new userId UUIDs) to fit the target environment.


Design Strategy: The Event Mutator

The best design is to introduce a specific, configurable pipeline stage—an Event Mutator—that runs after deserialization but before the final DB insert.

We’ll define the replacement and enrichment parameters as JSON/YAML structures and create a separate utility to apply the mutations.

1. Mutation Configuration Format

We’ll define the parameters to be a JSON string representing a list of mutation rules.

  • replacement (-r): Find a field with an old value and replace it with a new value.
    • Example: [{"field": "hostId", "from": "UUID_A", "to": "UUID_B"}, {"field": "user_id", "from": "ID_X", "to": "ID_Y"}]
  • enrichment (-e): Find a field and generate a new, unique value for it.
    • Example: [{"field": "id", "action": "generateUUID"}, {"field": "userId", "action": "mapAndGenerate", "sourceField": "originalUserId"}]

2. The EventMutator Class

This class will handle parsing the configuration and applying the changes to the CloudEvent attributes and the data payload.


Refactored Cli.java and New EventMutator Logic

Here is the updated Cli.java and a conceptual EventMutator structure.

A. New Class: EventMutator.java

This class handles the core logic. Since CloudEvents are immutable, any change requires rebuilding the event (CloudEventBuilder.v1(cloudEvent)).

package net.lightapi.importer;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.config.Config;
import com.networknt.utility.UuidUtil;
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import net.lightapi.portal.PortalConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

public class EventMutator {
    private static final Logger logger = LoggerFactory.getLogger(EventMutator.class);
    private final ObjectMapper mapper = Config.getInstance().getMapper();
    
    // Parsed list of rules
    private final List<Map<String, String>> replacementRules;
    private final List<Map<String, String>> enrichmentRules;

    // Map to track generated UUIDs for consistent replacement across events (e.g., old user ID -> new user ID)
    private final Map<String, String> generatedIdMap = new HashMap<>();

    public EventMutator(String replacementJson, String enrichmentJson) {
        this.replacementRules = parseRules(replacementJson);
        this.enrichmentRules = parseRules(enrichmentJson);
    }

    private List<Map<String, String>> parseRules(String json) {
        if (json == null || json.isEmpty()) return Collections.emptyList();
        try {
            return mapper.readValue(json, new TypeReference<List<Map<String, String>>>() {});
        } catch (IOException e) {
            logger.error("Failed to parse mutation rules JSON: {}", json, e);
            throw new IllegalArgumentException("Invalid JSON format for mutation rules.", e);
        }
    }

    /**
     * Applies all replacement and enrichment rules to a single CloudEvent.
     * @param originalEvent The original CloudEvent object.
     * @return The mutated CloudEvent.
     */
    public CloudEvent mutate(CloudEvent originalEvent) {
        CloudEventBuilder builder = CloudEventBuilder.v1(originalEvent);
        Map<String, Object> dataMap = null;
        
        // Deserialize data payload once (if present)
        if (originalEvent.getData() != null && originalEvent.getData().toBytes().length > 0) {
            try {
                dataMap = mapper.readValue(originalEvent.getData().toBytes(), new TypeReference<HashMap<String, Object>>() {});
            } catch (IOException e) {
                logger.error("Failed to deserialize CloudEvent data for mutation. Skipping data mutation.", e);
                // Continue with just extension mutation
            }
        }
        
        // 1. Apply Replacements
        applyReplacements(builder, dataMap);
        
        // 2. Apply Enrichments
        applyEnrichments(builder, dataMap);

        // Rebuild CloudEvent with mutated data if it was changed
        if (dataMap != null && dataMap.containsKey("__MUTATED_DATA__")) {
             builder.withData(originalEvent.getDataContentType().orElse("application/json"), dataMap.get("__MUTATED_DATA__"));
             // Remove the internal flag
             dataMap.remove("__MUTATED_DATA__");
        }
        
        return builder.build();
    }
    
    // --- Private Mutation Helpers ---

    private void applyReplacements(CloudEventBuilder builder, Map<String, Object> dataMap) {
        for (Map<String, String> rule : replacementRules) {
            String field = rule.get("field");
            String from = rule.get("from");
            String to = rule.get("to");
            if (field == null || from == null || to == null) continue;

            // Check CloudEvent Extensions (including known attributes like host, user)
            Object extensionValue = builder.getExtension(field);
            if (extensionValue != null && extensionValue.toString().equals(from)) {
                builder.withExtension(field, to);
                logger.debug("Replaced extension {} from {} to {}", field, from, to);
            } 
            
            // Check CloudEvent Data Payload
            if (dataMap != null && dataMap.containsKey(field) && dataMap.get(field) != null && dataMap.get(field).toString().equals(from)) {
                dataMap.put(field, to);
                dataMap.put("__MUTATED_DATA__", dataMap); // Flag that data was mutated
                logger.debug("Replaced data field {} from {} to {}", field, from, to);
            }
        }
    }
    
    private void applyEnrichments(CloudEventBuilder builder, Map<String, Object> dataMap) {
        for (Map<String, String> rule : enrichmentRules) {
            String field = rule.get("field");
            String action = rule.get("action");
            if (field == null || action == null) continue;
            
            String generatedId = null;

            if ("generateUUID".equalsIgnoreCase(action)) {
                // Generate and cache a new UUID for the whole import run if needed, or always generate new.
                // For simplicity, we assume we generate a new UUID for the field.
                generatedId = UuidUtil.getUUID().toString();
            } else if ("mapAndGenerate".equalsIgnoreCase(action)) {
                String sourceField = rule.get("sourceField");
                String originalId = null;
                
                // Get the original ID from a source field in the data payload (e.g., from an 'oldUserId' field)
                if (dataMap != null && sourceField != null && dataMap.containsKey(sourceField)) {
                    originalId = dataMap.get(sourceField).toString();
                } 
                // Or get from a specific CloudEvent extension/subject
                else if ("subject".equalsIgnoreCase(sourceField) && builder.getSubject() != null) {
                    originalId = builder.getSubject();
                }

                if (originalId != null) {
                    // Check cache for consistency (e.g., ensure old_user_ID_A always maps to new_user_ID_X)
                    generatedId = generatedIdMap.computeIfAbsent(field + ":" + originalId, k -> UuidUtil.getUUID().toString());
                    logger.debug("Mapped original ID {} to new ID {}", originalId, generatedId);
                } else {
                    // Cannot map, fall back to simple UUID generation if allowed
                    generatedId = UuidUtil.getUUID().toString();
                }
            } else if ("aggregateIdMap".equalsIgnoreCase(action) && field.equals("subject")) {
                // This complex logic is for when a related aggregate ID needs to be updated.
                // E.g., when importing a User, the UserCreatedEvent ID is the new Subject/AggregateId.
                // The actual logic for this is too complex for a generic SMT and relies on a separate lookup service.
                // Skip for this simple mutator.
                continue;
            }

            if (generatedId != null) {
                // Mutate CloudEvent Extensions (Subject, ID, etc.)
                if ("id".equalsIgnoreCase(field)) {
                    builder.withId(generatedId);
                } else if ("subject".equalsIgnoreCase(field)) {
                    builder.withSubject(generatedId);
                } else if (builder.getExtension(field) != null) { // Custom extension
                    builder.withExtension(field, generatedId);
                }
                
                // Mutate Data Payload
                if (dataMap != null && dataMap.containsKey(field)) {
                    dataMap.put(field, generatedId);
                    dataMap.put("__MUTATED_DATA__", dataMap); // Flag that data was mutated
                }
                logger.debug("Enriched field {} with new ID {}", field, generatedId);
            }
        }
    }
}

B. Updated Cli.java to Integrate EventMutator

package net.lightapi.importer;

// ... (Existing imports) ...
import com.networknt.config.JsonMapper;
import com.networknt.db.provider.SqlDbStartupHook;
import com.networknt.monad.Result;
import com.networknt.service.SingletonServiceFactory;
import com.networknt.status.Status;
import com.networknt.utility.Constants;
import com.networknt.utility.UuidUtil; // Used in mutator
import io.cloudevents.CloudEvent;
import io.cloudevents.core.builder.CloudEventBuilder;
import io.cloudevents.core.format.EventFormat;
import io.cloudevents.core.provider.EventFormatProvider;
import io.cloudevents.jackson.JsonFormat;
import net.lightapi.portal.EventTypeUtil;
import net.lightapi.portal.PortalConstants;
import net.lightapi.portal.db.PortalDbProvider;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID; // Used in mutator

public class Cli {
    private static final Logger logger = LoggerFactory.getLogger(Cli.class); // Added logger
    public static PortalDbProvider dbProvider;
    public static SqlDbStartupHook sqlDbStartupHook;

    @Parameter(names={"--filename", "-f"}, required = false,
            description = "The filename to be imported.")
    String filename;

    @Parameter(names={"--batchSize", "-b"}, required = false,
            description = "Number of events to import per database transaction batch. Default is 1000.")
    int batchSize = 1000;

    @Parameter(names={"--replacement", "-r"}, required = false,
            description = "JSON array string of replacement rules: [{'field': 'oldHostId', 'from': 'UUID_A', 'to': 'UUID_B'}].")
    String replacement;

    @Parameter(names={"--enrichment", "-e"}, required = false,
            description = "JSON array string of enrichment rules: [{'field': 'userId', 'action': 'mapAndGenerate', 'sourceField': 'oldUserId'}].")
    String enrichment;

    @Parameter(names={"--help", "-h"}, help = true)
    private boolean help;

    public static void main(String ... argv) throws Exception {
        try {
            // ... (Startup initialization remains the same) ...
            Cli cli = new Cli();
            JCommander jCommander = JCommander.newBuilder().addObject(cli).build();
            jCommander.parse(argv);
            // Assuming SingletonServiceFactory and SqlDbStartupHook setup is correct
            dbProvider = (PortalDbProvider) SingletonServiceFactory.getBean(DbProvider.class);
            cli.run(jCommander);

        } catch (ParameterException e) {
            System.err.println("Command line parameter error: " + e.getLocalizedMessage());
            jCommander.usage();
        } catch (Exception e) {
            System.err.println("An unexpected error occurred during startup or import: " + e.getLocalizedMessage());
            e.printStackTrace();
        }
    }

    public void run(JCommander jCommander) throws Exception {
        if (help) {
            jCommander.usage();
            return;
        }

        logger.info("Starting event import with batch size: {}", batchSize);
        if (replacement != null) logger.info("Replacement rules: {}", replacement);
        if (enrichment != null) logger.info("Enrichment rules: {}", enrichment);

        EventFormat cloudEventFormat = EventFormatProvider.getInstance().resolveFormat(JsonFormat.CONTENT_TYPE);
        if (cloudEventFormat == null) {
            logger.error("No CloudEvent JSON format provider found.");
            throw new IllegalStateException("CloudEvent JSON format not found.");
        }

        // --- Instantiate EventMutator ---
        EventMutator mutator = new EventMutator(replacement, enrichment);
        
        List<CloudEvent> currentBatch = new ArrayList<>(batchSize);
        long importedCount = 0;
        long lineNumber = 0;

        try (BufferedReader reader = new BufferedReader(new FileReader(filename))) {
            String line;
            while((line = reader.readLine()) != null) {
                lineNumber++;
                if(line.startsWith("#") || line.trim().isEmpty()) continue;

                try {
                    // Assuming format: "key value" (where key is user_id, value is the full database row JSON)
                    int firstSpace = line.indexOf(" ");
                    if (firstSpace == -1) {
                        logger.warn("Skipping malformed line {} (no space separator): {}", lineNumber, line);
                        continue;
                    }
                    String dbRowJson = line.substring(firstSpace + 1); // <<< Full DB row JSON

                    // 1. Deserialize the nested CloudEvent (The Fix from prior step)
                    Map<String, Object> dbRowMap = Config.getInstance().getMapper().readValue(dbRowJson, new TypeReference<HashMap<String, Object>>() {});
                    String cloudEventJsonFromPayload = (String) dbRowMap.get("payload"); 
                    CloudEvent cloudEvent = cloudEventFormat.deserialize(cloudEventJsonFromPayload.getBytes(StandardCharsets.UTF_8));
                    
                    // 2. Perform Mutation/Enrichment
                    CloudEvent mutatedEvent = mutator.mutate(cloudEvent);

                    // 3. Finalization/Validation (Transfer critical top-level DB fields to Extensions)
                    // Transferring nonce and aggregateVersion from the exported DB row into the CloudEvent's extensions.
                    Object dbNonceObj = dbRowMap.get("nonce");
                    if (dbNonceObj instanceof Number) {
                        mutatedEvent = CloudEventBuilder.v1(mutatedEvent)
                                .withExtension(PortalConstants.NONCE, ((Number)dbNonceObj).longValue())
                                .build();
                    }
                    Object dbAggVersionObj = dbRowMap.get("aggregateVersion");
                    if (dbAggVersionObj instanceof Number) {
                        mutatedEvent = CloudEventBuilder.v1(mutatedEvent)
                                .withExtension(PortalConstants.EVENT_AGGREGATE_VERSION, ((Number)dbAggVersionObj).longValue())
                                .build();
                    }
                    
                    // 4. Add to current batch.
                    currentBatch.add(mutatedEvent);

                    // If batch is full, process it
                    if (currentBatch.size() >= batchSize) {
                        processBatch(currentBatch); 
                        importedCount += currentBatch.size();
                        currentBatch.clear();
                    }

                } catch (Exception e) {
                    logger.error("Error processing line {}: {}", lineNumber, e.getMessage(), e);
                    // Log and continue to process the rest of the file.
                }
            } // end while loop

            // Process any remaining events in the last batch
            if (!currentBatch.isEmpty()) {
                processBatch(currentBatch);
                importedCount += currentBatch.size();
            }

        } catch (IOException e) {
            logger.error("Error reading file {}: {}", filename, e.getMessage(), e);
            throw e;
        } finally {
            logger.info("Import process finished. Total events successfully imported in batches: {}", importedCount);
        }
        logger.info("All Portal Events have been imported successfully from {}. Have fun!!!", filename);
    }

    /**
     * Processes a batch of CloudEvents by inserting them into the database in a single transaction.
     * @param batch The list of CloudEvents to insert.
     */
    private void processBatch(List<CloudEvent> batch) {
        // --- Transaction Management ---
        // The transaction logic is ideally handled inside dbProvider.insertEventStore
        // or by a wrapper method if insertEventStore doesn't handle transactions internally.
        
        Result<String> eventStoreResult = dbProvider.insertEventStore(batch.toArray(new CloudEvent[0]));
        
        if(eventStoreResult.isFailure()) {
            logger.error("Failed to insert batch of {} events. Rollback occurred. Error: {}", batch.size(), eventStoreResult.getError());
            // In a CLI, failing the batch often means stopping the entire import process 
            // to ensure data integrity, as a full rollback on the entire batch has occurred.
            // If you want to continue, you would need complex tracking of failed batches.
            // For now, logging the error is sufficient, and the method returns.
        } else {
            logger.info("Imported batch of {} records successfully.", batch.size());
        }
    }
}

Key Usage Examples for the CLI

When calling the CLI, you pass the mutation rules as a single JSON string (often enclosed in single quotes '...' in the shell):

1. Replace Host ID (Tenant Migration)

You moved from old_host_uuid to new_host_uuid.

java -jar importer.jar -f events.log -r '[{"field": "hostId", "from": "OLD_HOST_UUID", "to": "NEW_HOST_UUID"}]'

2. Replace Host ID and Generate New Aggregate IDs (Full Isolation)

You want to map the old userId to a new userId and generate new eventIds and subject (aggregate ID).

java -jar importer.jar -f events.log \
    -r '[{"field": "hostId", "from": "OLD_HOST_UUID", "to": "NEW_HOST_UUID"}]' \
    -e '[
        {"field": "id", "action": "generateUUID"}, 
        {"field": "subject", "action": "generateUUID"},
        {"field": "originalUserId", "action": "mapAndGenerate", "sourceField": "userId"}
    ]'

(Note: For the user mapping, you would need a custom solution that first reads a mapping table or performs a one-time query to get the originalUserId from a previous step, and then uses the mapping to generate the new ID consistently.)

Product Version Config

When using light-portal to manage the configurations for Apis or Apps. The configuration can be overwritten at different level. On top of platform default, the production level and production version level are utilized very often.

There are two options:

  1. Extract the config files from the product jar and create the events for mapping. This includes all config and config properties in the jar file per product and product version.

Pros:

  • Can be automatically done with a process.
  • Standardized and hardly make mistakes.

Cons:

  • It cannot be customized per organization.
  1. Manually create events for mappings per product and per product version for the properties that is potentially changeable.

Pros:

  • Flexible and customizable per organization.
  • Can be improved in a process.

Cons:

  • May take some time to create and maintain the event file for every release.

Product Version Config Mapping Automation

The portal-view config update page depends on product-version applicability metadata before it can show configurable properties for instance, API, app, and app-api scopes. The metadata is stored in two product release mapping tables:

  • product_version_config_t
  • product_version_config_property_t

The current Rust bootstrap data is generated into import files:

  • event-importer/events/local/09-rust-product-version-configs.json
  • event-importer/events/local/08-rust-product-version-config-properties.json

The same import-file approach can be used for Java products, but it does not scale well if every Java or Rust release requires a hand-maintained set of mapping events across all portal instances. This design proposes a release mapping automation model that keeps the existing event-sourced write path and removes the need to manually recreate mapping files for every product release.

Problem

Product versions are released often. Each new release can introduce a new productVersionId, and the config update page only knows which configs and properties are applicable when mappings exist for that exact product version.

Without automation:

  • new releases have empty config update views until mappings are imported
  • each portal instance must be updated separately
  • Java and Rust products need parallel manual processes
  • copying JSON import files by hand can drift from the actual product config schema
  • support teams cannot safely tell whether an empty page means “no configs” or “missing mappings”

The automation must support two release modes:

  • release all Java products
  • release one or more Rust products

It must also support tenant-specific product versions without copying the same standard config mappings into every tenant.

Current Model

product_version_config_t maps a product version to a config:

host_id + product_version_id + config_id

product_version_config_property_t maps a product version to a config property:

host_id + product_version_id + property_id

The event types already exist:

  • ProductVersionConfigCreatedEvent
  • ProductVersionConfigDeletedEvent
  • ProductVersionConfigPropertyCreatedEvent
  • ProductVersionConfigPropertyDeletedEvent

The command APIs already exist:

  • product/createProductVersionConfig/0.1.0
  • product/deleteProductVersionConfig/0.1.0
  • product/createProductVersionConfigProperty/0.1.0
  • product/deleteProductVersionConfigProperty/0.1.0

The projection handlers insert into the mapping tables through the event processor. The preferred automation path is therefore event-based, not direct SQL.

Product Versioning Policy

The release process must separate three related but different concepts:

release train change != product artifact change != config contract change

A Java release train can have one shared version number for coordination, but that does not mean every product necessarily has a changed config contract. At the same time, a product can legitimately need a new product version even when its own repository did not change. For example, if a shared light-4j module changes and every Java product must be rebuilt to pick up that dependency, each rebuilt artifact is a real product release.

Recommended policy:

  • Create a new product version when the product artifact changes.
  • Treat common library upgrades as product artifact changes for every rebuilt product.
  • Do not create a new product version for a product that is not rebuilt and not redeployed as part of the release.
  • Treat config mapping as a separate decision from product version creation.
  • If the config contract is unchanged, inherit the previous product version’s profile link.
  • If the config contract changed or breakConfig=true, require an explicit profile manifest.

This lets Java keep the operational benefit of release trains while preventing unnecessary mapping maintenance. Rust can continue independent product versioning because Rust products are already released separately.

If the portal needs to show that an unchanged product participated in a Java release train, model that as release-set membership, not as a new product version. A release set can link to the existing productVersionId for unchanged products and to the new productVersionId for rebuilt products.

The release metadata should record why a product version exists:

{
  "releaseReason": "light4j-dependency-upgrade",
  "artifactChanged": true,
  "sourceChanged": false,
  "configChanged": false,
  "breakConfig": false,
  "configMappingPolicy": "inheritProfileFromPrevious"
}

Decision matrix:

CaseProduct VersionMapping Action
Product source changed and config changedcreate new versionexplicit profile manifest
Product source changed but config unchangedcreate new versioninherit previous profile link
Shared Java dependency changed and product rebuiltcreate new versioninherit profile link unless config changed
Product not rebuilt and not redeployedno new versionno mapping action
Breaking config changecreate new versionexplicit profile manifest required

Goals

  • Auto-populate config mappings for every new Java or Rust product release.
  • Preserve event replay, auditability, and projection rebuild behavior.
  • Support all portal hosts with one release operation without per-host mapping event amplification.
  • Avoid hard-coded productVersionId values in reusable release manifests.
  • Support dry-run reporting before events are emitted.
  • Keep manual override and cleanup possible through existing mapping commands.
  • Make generated events idempotent enough for safe retry.
  • Detect missing config and property definitions before a release appears complete.

Non-Goals

  • Do not change the config override hierarchy.
  • Do not write directly to config mapping projection tables.
  • Do not make the config update page infer product applicability by scanning all config properties at runtime.
  • Do not require schema-registry completion before mappings can be automated.
  • Do not force all organizations to use the same product mappings if they need host-specific customization.

Use ConfigProfile as the reusable config contract, then link tenant product versions to the profile.

The existing product_version_config_t and product_version_config_property_t tables are host-scoped because product_version_t is host-scoped. That model works for tenant-specific extensions, but it is expensive for standard product mappings because every host receives a duplicate copy of the same config/property rows.

The profile model separates the global product config contract from the tenant’s product release:

ConfigProfile = standard config contract for a product/runtime/framework line
ProductVersion = tenant-owned release artifact/version
ProductVersionConfigProfile = tenant product version points to standard profile

For example, every tenant can have its own internal lg product version while all of those versions point to the same light-gateway-java-2.3.5 config profile if their config contract is the same.

The existing product-version mapping tables remain useful, but their role changes:

  • config_profile_config_t and config_profile_property_t hold standard global applicability.
  • product_version_config_profile_t links a tenant product version to the standard profile.
  • product_version_config_t and product_version_config_property_t hold tenant-specific additions or legacy direct mappings.

This removes the need for allHosts=true to generate the same mapping events for every tenant. A release creates or updates one profile, then each tenant product version emits one profile-link event.

Schema Proposal

The profile tables are global because config_t and config_property_t are already global definitions.

CREATE TABLE config_profile_t (
    profile_id           UUID PRIMARY KEY,
    profile_name         VARCHAR(255) NOT NULL,
    runtime_family       VARCHAR(32) NOT NULL,
    product_id           VARCHAR(8) NOT NULL,
    light4j_version      VARCHAR(32),
    contract_version     VARCHAR(64) NOT NULL,
    profile_desc         VARCHAR(1024),
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active               BOOLEAN NOT NULL DEFAULT TRUE,
    delete_user          VARCHAR(255),
    delete_ts            TIMESTAMP WITH TIME ZONE,
    update_user          VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

CREATE UNIQUE INDEX config_profile_unique_idx
    ON config_profile_t(runtime_family, product_id, contract_version)
    WHERE active = true;

CREATE TABLE config_profile_config_t (
    profile_id           UUID NOT NULL,
    config_id            UUID NOT NULL,
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active               BOOLEAN NOT NULL DEFAULT TRUE,
    delete_user          VARCHAR(255),
    delete_ts            TIMESTAMP WITH TIME ZONE,
    update_user          VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY(profile_id, config_id),
    FOREIGN KEY(profile_id) REFERENCES config_profile_t(profile_id) ON DELETE CASCADE,
    FOREIGN KEY(config_id) REFERENCES config_t(config_id) ON DELETE CASCADE
);

CREATE TABLE config_profile_property_t (
    profile_id           UUID NOT NULL,
    property_id          UUID NOT NULL,
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active               BOOLEAN NOT NULL DEFAULT TRUE,
    delete_user          VARCHAR(255),
    delete_ts            TIMESTAMP WITH TIME ZONE,
    update_user          VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY(profile_id, property_id),
    FOREIGN KEY(profile_id) REFERENCES config_profile_t(profile_id) ON DELETE CASCADE,
    FOREIGN KEY(property_id) REFERENCES config_property_t(property_id) ON DELETE CASCADE
);

CREATE TABLE product_version_config_profile_t (
    host_id              UUID NOT NULL,
    product_version_id   UUID NOT NULL,
    profile_id           UUID NOT NULL,
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active               BOOLEAN NOT NULL DEFAULT TRUE,
    delete_user          VARCHAR(255),
    delete_ts            TIMESTAMP WITH TIME ZONE,
    update_user          VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY(host_id, product_version_id),
    FOREIGN KEY(host_id, product_version_id)
        REFERENCES product_version_t(host_id, product_version_id) ON DELETE CASCADE,
    FOREIGN KEY(profile_id) REFERENCES config_profile_t(profile_id) ON DELETE RESTRICT
);

Using a separate product_version_config_profile_t link table is preferred over adding config_profile_id to product_version_t because it keeps the product version aggregate smaller and lets profile linking be introduced as a separate event stream. If a future product version needs multiple profiles, the primary key can be extended to (host_id, product_version_id, profile_id) with an order_index column.

ON DELETE RESTRICT on product_version_config_profile_t.profile_id is intentional. A profile cannot be deleted while any tenant product version is linked to it. Operators must first migrate linked product versions to another profile, unlink them, or delete the tenant product versions.

The database constraints only apply to hard deletes. Because projections use soft deletes, command handlers must manually reject profile deletion while active product-version profile links exist, and the ConfigProfileDeletedEvent projection must mark active config_profile_config_t and config_profile_property_t rows inactive.

Recommended event types:

  • ConfigProfileCreatedEvent
  • ConfigProfileDeletedEvent
  • ConfigProfileConfigCreatedEvent
  • ConfigProfileConfigDeletedEvent
  • ConfigProfilePropertyCreatedEvent
  • ConfigProfilePropertyDeletedEvent
  • ProductVersionConfigProfileLinkedEvent
  • ProductVersionConfigProfileUnlinkedEvent

The existing ProductVersionConfigCreatedEvent and ProductVersionConfigPropertyCreatedEvent remain valid for host-specific direct mappings.

Query Resolution

getConfigUpdateProperties should resolve applicable configs/properties from both profile mappings and direct product-version mappings:

-- profile-backed standard mappings
SELECT cpc.config_id
FROM product_version_config_profile_t pvcp
JOIN config_profile_t cp ON cp.profile_id = pvcp.profile_id
JOIN config_profile_config_t cpc ON cpc.profile_id = pvcp.profile_id
WHERE pvcp.host_id = :hostId
  AND pvcp.product_version_id = :productVersionId
  AND pvcp.active = true
  AND cp.active = true
  AND cpc.active = true

UNION

-- tenant-specific direct additions and legacy mappings
SELECT pvc.config_id
FROM product_version_config_t pvc
WHERE pvc.host_id = :hostId
  AND pvc.product_version_id = :productVersionId
  AND pvc.active = true;

The property query follows the same pattern with config_profile_property_t and product_version_config_property_t.

If a tenant must remove a standard profile property for its own product version, the clean path is to assign a different profile for that product version. Direct mapping tables are additive and should not try to represent negative overrides unless a future exclusion table is explicitly added.

Manifest Source

The canonical public manifest source is the lightapi/config-profile-manifests repository:

https://github.com/lightapi/config-profile-manifests

This repository stores portable, product-level ConfigProfile manifests for LightAPI releases. It is intentionally not a generated-event repository. Customer-specific hostId, productVersionId, admin user IDs, generated CloudEvents, tenant overrides, and secrets must stay outside the public repo.

Real release manifests should use this path convention:

java/<product-id>/<product-version>/manifest.json
rust/<product-id>/<product-version>/manifest.json

The repository also contains the manifest schema, example manifests, a local validation script, and a GitHub Actions workflow. Release automation should validate manifests in that repository before using them as input to event-importer --generate-config-profiles.

The manifest itself is portable. It uses logical product, config, and property names, not customer-only database IDs. It defines a config profile once, then links product versions to that profile.

{
  "runtimeFamily": "java",
  "light4jVersion": "2.3.5",
  "profiles": [
    {
      "profileName": "light-gateway-java-2.3.5",
      "productId": "lg",
      "contractVersion": "2.3.5",
      "configs": [
        {
          "configName": "server.yml",
          "properties": "*"
        },
        {
          "configName": "handler.yml",
          "properties": ["enabled", "path"]
        }
      ]
    }
  ],
  "products": [
    {
      "productId": "lg",
      "productVersion": "2.3.5",
      "configProfileRef": "lg|2.3.5"
    }
  ]
}

For Java products, a shared Java dependency upgrade can create new tenant product versions while reusing the same profile if the config contract did not change. If the config contract changed, the release creates a new profile and links rebuilt product versions to it.

If a Java release train includes products that were not rebuilt, those products should be linked to the release set but should not receive new productVersionId values or new mapping events.

Generator Responsibilities

The generator takes:

  • optional hostId, product, or release-set filters for profile links
  • runtime family: java, rust, or both
  • manifest path, normally from lightapi/config-profile-manifests
  • dry-run flag

For each profile entry, it resolves:

  • configId from configName
  • propertyId from configName + propertyName
  • existing profileId from runtimeFamily + productId + contractVersion, or a deterministic new profileId

Then it emits profile events only for missing or changed profile mappings:

  • one ConfigProfileCreatedEvent for each new profile
  • one ConfigProfileConfigCreatedEvent for each profile config
  • one ConfigProfilePropertyCreatedEvent for each profile property
  • in syncProfile replacement mode, one ConfigProfileConfigDeletedEvent or ConfigProfilePropertyDeletedEvent for each active profile mapping that is no longer present in the manifest

Profile deletion or replacement must be explicit. The default sync mode should be additive so a partial manifest cannot accidentally remove a property from every tenant linked to the profile. A delete-capable sync must require replace=true or an equivalent explicit flag and must show affected linked product versions in dry-run output.

For each product entry, it resolves:

  • productVersionId from hostId + productId + productVersion
  • profileId from configProfileRef

Then it emits:

  • one ProductVersionConfigProfileLinkedEvent per tenant product version
  • optional direct ProductVersionConfigCreatedEvent and ProductVersionConfigPropertyCreatedEvent only for tenant-specific additions

If dryRun=true, no events are emitted. The response returns a report:

{
  "releaseSet": "java-2026-06",
  "profiles": [
    {
      "profileName": "light-gateway-java-2.3.5",
      "profileId": "019f...",
      "configsToCreate": 15,
      "propertiesToCreate": 183,
      "alreadyMappedConfigs": 0,
      "alreadyMappedProperties": 0,
      "missingConfigs": [],
      "missingProperties": []
    }
  ],
  "products": [
    {
      "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
      "productId": "lg",
      "productVersion": "2.0.0",
      "productVersionId": "019f...",
      "profileId": "019f...",
      "linkToCreate": true,
      "alreadyLinked": false
    }
  ]
}

Dry run must fail the release when any required product version, profile, config, or property cannot be resolved.

Inheritance From Previous Version

inheritFrom is useful for frequent releases, but it should usually inherit a profile link, not copy rows.

Recommended rules:

  • If the config contract is unchanged, link the new product version to the same profile as the previous product version.
  • If a manifest lists an explicit profile with configs/properties, create or update that profile and link the product version to it.
  • If configMappingPolicy=inheritProfileFromPrevious, copy the previous profile link.
  • If inheritFrom is set and the manifest omits configProfileRef, copy the profile link from the source product version.
  • If both inheritance and add/remove are set, create a new profile derived from the inherited profile, apply the changes, and link to the new profile.
  • If the new product version has breakConfig=true, require an explicit profile manifest. Do not silently inherit.
  • If breakConfig=false, inheritance is allowed, but dry run should still compare the inherited mappings against any known generated config metadata.
  • If configChanged=false, profile-link inheritance is the default mapping policy.
  • If configChanged=true, require either explicit configs or explicit add/remove sections.

Example:

{
  "productId": "api",
  "productVersion": "1.0.2",
  "inheritFrom": {
    "productVersion": "1.0.1"
  },
  "remove": [
    {
      "configName": "old-config"
    }
  ],
  "add": [
    {
      "configName": "new-config",
      "properties": ["enabled", "endpoint"]
    }
  ]
}

This gives release automation a low-maintenance path for patch releases while still allowing breaking releases to declare exact applicability.

Event Idempotency

The generator should produce deterministic event IDs so the same release operation can be retried safely.

Use a stable namespace string such as:

runtimeFamily|productId|contractVersion
profileId|configId
profileId|propertyId
hostId|productVersionId|profileId
hostId|productVersionId|configId
hostId|productVersionId|propertyId

The aggregate subject should match the mapping aggregate identity used by the event model. Profile aggregate subjects do not need hostId. Product-version profile links and direct tenant mappings do need hostId and productVersionId.

Direct IDs are preferred in generated events:

{
  "type": "ProductVersionConfigProfileLinkedEvent",
  "aggregatetype": "ProductVersionConfigProfile",
  "data": {
    "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
    "productId": "gtw",
    "productVersion": "1.0.1",
    "productVersionId": "019f...",
    "profileId": "019f...",
    "profileName": "light-gateway-java-2.3.5",
    "aggregateVersion": 0,
    "newAggregateVersion": 1
  }
}

The human-readable names are still useful for audit and diagnostics, but the projection should not depend on name resolution after the generator has already resolved the IDs.

Command API Option

Add a new product command:

product/syncProductVersionConfigProfiles/0.1.0

Request:

{
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "runtimeFamily": "java",
  "releaseSet": "java-2026-06",
  "manifest": {},
  "allHosts": false,
  "dryRun": true,
  "mode": "syncProfile"
}

Modes:

  • syncProfile: create or update profile mappings from a manifest; additive by default, replacement only with an explicit delete-capable flag
  • linkProfile: link tenant product versions to profiles
  • inheritProfile: link a new product version to the previous version’s profile
  • backfillLinks: link existing product versions to matching profiles
  • verify: report missing profiles, links, configs, or properties without creating events

Profile delete and replacement operations must respect the ON DELETE RESTRICT link. A command cannot delete a profile while any active product_version_config_profile_t row references it; it must first migrate or unlink the affected product versions.

Because projections soft-delete rows instead of issuing hard DELETE statements, the command handler must perform this active-link check explicitly before emitting ConfigProfileDeletedEvent. The projection handler should also defensively skip parent deletion while active links exist and cascade a successful profile soft-delete to active config_profile_config_t and config_profile_property_t rows.

The command handler should not directly update projection tables. It should emit profile and profile-link events.

For large all-host release sets, the command should avoid one giant synchronous transaction. It can either:

  • emit the profile events first because they are host-neutral, then
  • enqueue one profile-link job per host and product

The second step is cheap compared with copying every config property mapping, but it should still be asynchronous for large tenant counts.

Importer Option

The event importer supports a generator mode:

java -jar target/event-importer.jar \
  --generate-config-profiles \
  --manifest java/lg/2.3.5/manifest.json \
  --targetHostId 01964b05-552a-7c4b-9184-6857e7f3dc5f \
  --adminUserId 01964b05-5532-7c79-8cde-191dcbd421b8 \
  --output ./generated \
  --dry-run

For deployment bundles, the generator can write normal JSON import files:

generated/07-config-profiles.json
generated/08-config-profile-properties.json
generated/09-config-profile-configs.json
generated/10-product-version-config-profile-links.json

This is the fastest migration path because it extends the current JSON import process. It also lets teams review the generated events before importing them.

The importer path is best for bootstrap and local environments. The command API path is better for live portal operations where the release needs to create profiles once and link tenant product versions without copying files into each deployment.

Release Flow

Recommended release pipeline:

  1. Determine the product release set.
  2. Classify each product as artifact changed, source changed, config changed, or unchanged.
  3. Generate or update config/property definitions for products whose config contract changed.
  4. Add or update the release manifest in lightapi/config-profile-manifests.
  5. Validate the manifest with the repo validation workflow.
  6. Create or reuse ConfigProfile rows for each config contract.
  7. Create new ProductVersionCreatedEvent rows for every product whose artifact changed.
  8. Run profile and profile-link dry-run for all target hosts.
  9. Fail the release if dry-run reports unresolved product versions, profiles, configs, or properties.
  10. Emit profile events and product-version profile-link events.
  11. Verify config_profile_config_t, config_profile_property_t, and product_version_config_profile_t counts.
  12. Smoke-test getConfigUpdateProperties for at least one instance, API, app, and app-api target for the release.

For patch releases where config does not change, the pipeline can use inheritFrom and verify that the new version links to the same profile as the previous version.

For Java common-library upgrades, all rebuilt Java products should receive new product versions even if their own repositories did not change. If configChanged=false, the mapping generator should inherit mappings from each product’s previous version by reusing the previous profile link.

For breaking config releases, the pipeline should require an explicit manifest and should report added and removed configs/properties in the release note.

Backfill Existing Product Versions

Backfill is needed for product versions that already exist but have no profile link or still depend only on legacy direct mappings.

Backfill should support:

  • one product version
  • all versions of one product
  • all products in one runtime family
  • all active product versions for all hosts

Backfill must be conservative:

  • create only missing active profiles and profile links
  • never delete existing direct product-version mappings automatically
  • report direct mappings that duplicate profile mappings so operators can decide whether to clean them up later
  • report conflicting or inactive config/property definitions
  • keep generated events deterministic

Backfill output should include counts by host, product, product version, profile, and direct legacy mappings so operators can confirm why a config update page was empty before the fix.

Migration from the current tables should be done in three steps:

  1. Create profiles from known Java and Rust manifests or from trusted existing product-version mappings.
  2. Link existing product versions to the correct profile.
  3. Leave existing direct mappings in place until query resolution proves the profile path covers the same configs/properties.

After migration, release automation should stop generating direct product-version mapping events for standard mappings. Direct mapping events remain available for tenant-specific additions.

Host and Tenant Handling

The profile manifest is host-neutral. The generator resolves global profile IDs once, then resolves tenant product-version IDs only for profile links.

For allHosts=true, the generator should query active hosts that have matching product versions and create profile-link events per product version. It should not generate per-host config/property mapping events for standard profile mappings. If a host does not have the target product version, it should be reported as skipped, not failed, unless the release request marks that product version as required for every host.

Host-specific overrides are allowed through optional manifest sections:

{
  "hostOverrides": {
    "01964b05-552a-7c4b-9184-6857e7f3dc5f": {
      "products": [
        {
          "productId": "gtw",
          "directAdd": [
            {
              "configName": "tenant-plugin",
              "properties": ["enabled", "endpoint"]
            }
          ]
        }
      ]
    }
  }
}

The default path should be shared profiles. Host overrides should be rare and visible in dry-run output.

Host overrides should not live in the public lightapi/config-profile-manifests repository. They are tenant-specific operational inputs and should be kept in private deployment overlays or entered through the command API.

If a tenant needs to remove a standard profile property, assign a different profile to that product version. Avoid negative host-specific overrides in the MVP because they make query resolution and audit history harder to reason about.

Observability

The generator or command should publish a structured summary:

  • release set
  • runtime family
  • host count
  • product count
  • generated profile events
  • generated profile config events
  • generated profile property events
  • generated product-version profile-link events
  • skipped existing profile links
  • duplicate direct mappings
  • missing product versions
  • missing profiles
  • missing configs
  • missing properties
  • failed hosts

The config update page empty-state message should reference this operational check: if an instance has no applicable config properties, verify the product version has a profile link or direct config/config-property mappings.

Phased Implementation

Phase 1: Manifest Generator for Importer

  • Create and maintain Java and Rust mapping manifests in lightapi/config-profile-manifests.
  • Validate manifests with the repository schema and workflow.
  • Generate JSON import files for ConfigProfile, profile config/property mappings, and product-version profile links.
  • Use direct IDs in generated events.
  • Add dry-run validation and count reports.
  • Use this path to backfill current local/dev deployments.

Phase 2: Sync Command

  • Add syncProductVersionConfigProfiles.
  • Support dryRun, syncProfile, linkProfile, inheritProfile, backfillLinks, and verify.
  • Emit profile and profile-link events instead of direct SQL.
  • Add RBAC so only product/release admins can run it.

Phase 3: Release Pipeline Integration

  • Call dry-run during Java and Rust release workflows.
  • Fail release on unresolved profiles, product versions, configs, or properties.
  • Emit profile events and profile-link events after product versions are created.
  • Record mapping summary in release artifacts.

Phase 4: Runtime Drift Detection

  • Add scheduled or on-demand verification.
  • Report active product versions with no profile link and no direct config mappings.
  • Report config properties referenced by manifests but missing from config_property_t.
  • Add a portal-view diagnostics link from the config update page.

Open Questions

  • Should ProductVersionCreatedEvent optionally carry a configProfileRef, or should profile linking remain a separate release step?
  • Do we need an organization-level policy to prevent inheritance for selected regulated products?
  • Do we need a profile-clone command for tenant-specific removals, or is manual profile creation enough for the MVP?

Recommendation

Implement the profile schema and Phase 1 importer generator first, then add the sync command for live all-host operations.

The long-term target is release-time automation:

  • product CI generates or validates the profile manifest in lightapi/config-profile-manifests from source metadata instead of relying on hand-maintained JSON
  • product release creates product versions
  • profile dry-run validates configs/properties once
  • profile-link dry-run validates every tenant product version
  • profile and profile-link events are emitted or imported
  • config update page works for the new release without manual follow-up

For Java, manifest generation should eventually come from a Maven plugin that introspects the light-4j config modules or generated config metadata during the build. For Rust, the equivalent should be a Cargo build script or release tool that extracts config structs and their generated metadata. This keeps the manifest aligned with the code and turns manual manifest editing into an exception path.

This keeps the config update page simple and keeps product applicability in the event-sourced product release model without duplicating standard mappings for every tenant.

Release Workflow

Status

Proposed design.

Light Portal should use light-workflow as the durable release orchestrator for Java and Rust releases. The workflow should coordinate repository checkout, preflight validation, build, test, package, ConfigProfile manifest handling, artifact publishing, AI-assisted failure diagnosis, and human approval.

The workflow engine should not execute release commands directly inside the portal service process. Command execution belongs in a sandboxed release runner, with light-workflow owning state, task routing, retries, approvals, and audit history.

Problem

Java releases currently depend on light-bot, while Rust releases are handled through separate command-line and repository-specific steps. This works, but it keeps release knowledge outside the same workflow model used by Light Portal for human tasks, automation tasks, and approval flows.

The release process is stateful and failure-prone:

  • a release can span many repositories,
  • Java and Rust products use different build and publish tools,
  • a failure may require log analysis before the next action is obvious,
  • publish and signing steps require stricter approval and secret handling,
  • ConfigProfile manifests and generated import events must be checked before customers see the release as complete,
  • an operator needs a durable record of what ran, what failed, what was fixed, and who approved publication.

The release workflow should be flexible enough to call existing command-line tools, but controlled enough that it does not become an unrestricted shell inside Light Portal.

Goals

  • Replace the current Java light-bot release path with light-workflow once parity is proven.
  • Support both Java release trains and independent Rust product releases.
  • Run build, test, package, and import-generation commands in sandboxed release runners.
  • Capture command output, exit status, artifacts, and workspace changes as workflow task results.
  • Let an AI agent analyze failed commands and propose or apply bounded fixes when policy allows it.
  • Escalate unclear, risky, or approval-required cases to human tasks.
  • Integrate ConfigProfile manifest validation and event-importer dry-run reporting into the release gate.
  • Keep publish, signing, tag creation, and external customer-visible actions behind explicit approval.
  • Preserve release auditability and reproducibility.

Non-Goals

  • Do not run arbitrary release commands in the Light Portal service process.
  • Do not replace Maven, Cargo, Docker, GitHub CLI, or existing release scripts where they already work.
  • Do not allow an AI agent to publish artifacts, sign releases, rotate secrets, or push final tags without human approval.
  • Do not make generated tenant-specific events public. Public release metadata should be portable manifests, not customer import output.
  • Do not remove light-bot until Java release parity has been demonstrated through several successful workflow-managed releases.

Current State

light-bot is the practical Java release automation path today. It contains working release knowledge and should remain available as a fallback during the migration.

light-workflow is a good orchestration target because it already models durable workflow instances, tasks, branching, context updates, and human task patterns. The current executor supports control-plane task types such as ask, assert, call, set, and switch.

The workflow model also defines run.container, run.script, run.shell, and run.workflow. Those task types are the right DSL surface for release command execution, but the runtime still needs sandbox-backed execution support before release commands can move from scripts into light-workflow.

The ConfigProfile mapping work adds another release concern. Reusable profile manifest files should live in the public lightapi/config-profile-manifests repository. The release workflow should validate those manifests and use event-importer to generate dry-run reports and import events for target portal environments.

Use light-workflow as the host-side orchestrator and delegate effectful release work to sandboxed release runners.

Light Portal
  |
  | start release / approve / inspect task history
  v
light-workflow
  |
  | durable tasks, branching, retries, audit
  v
Sandboxed Release Runner
  |
  | git, mvn, cargo, docker, gh, event-importer
  v
Release Repositories and Registries

The main components are:

  • light-workflow: Owns workflow instance state, task claiming, context, branching, retry policy, approval gates, human task creation, and audit metadata.
  • Release runner: Executes approved commands in a sandbox or controlled worker. It owns checkout directories, build caches, generated files, and command output capture.
  • AI release assistant: Consumes failed command context, classifies the failure, proposes fixes, and optionally creates a bounded patch when policy allows it.
  • Human task UI: Presents failed steps, AI analysis, command logs, proposed actions, and approval options.
  • Release integrations: GitHub, Maven repositories, Cargo crates, Docker registries, config-profile manifests, event-importer, and deployment verification tools.

Execution Boundary

Host execution should be limited to orchestration and approved control-plane calls:

  • ask
  • assert
  • set
  • switch
  • context merge
  • task claiming and completion
  • process state persistence
  • calls to approved internal APIs

Sandbox execution should be required for release effectors:

  • run.shell
  • run.script
  • run.container
  • repository checkout and mutation
  • build, test, and package commands
  • Docker build and image publishing
  • Maven and Cargo publishing
  • GitHub release and tag commands
  • event-importer execution
  • external MCP server processes
  • AI-agent tool execution that can mutate files or repositories

For normal build, test, package, and dry-run work, use one sandbox session per workflow instance. This lets checkout state, dependency caches, generated artifacts, and temporary files survive across related tasks.

For publish, signing, tag creation, and tasks with release secrets, use a fresh per-task sandbox with task-scoped secrets. These tasks should be isolated from the broader build workspace unless policy explicitly allows artifact transfer.

Release Lifecycle

The release workflow should follow this lifecycle.

  1. Create release request.
  2. Resolve release scope.
  3. Run preflight checks.
  4. Prepare the sandbox workspace.
  5. Build and test selected Java and Rust repositories.
  6. Validate ConfigProfile manifests.
  7. Run event-importer dry-run for generated mapping events.
  8. Package artifacts and images.
  9. Diagnose and repair failures when policy allows.
  10. Request human approval for publish.
  11. Publish artifacts, tags, images, and release notes.
  12. Verify published artifacts and generated portal events.
  13. Close the release workflow with a durable summary.

Release Request

The release request should be explicit enough to reproduce the run.

{
  "releaseId": "2026.06.0",
  "releaseType": "java-train",
  "runtimeFamilies": ["java"],
  "repos": [
    {
      "name": "light-4j",
      "url": "https://github.com/networknt/light-4j.git",
      "ref": "master",
      "version": "2.3.5"
    }
  ],
  "configProfileManifest": {
    "repo": "https://github.com/lightapi/config-profile-manifests.git",
    "ref": "main",
    "paths": ["java/light-gateway/2.3.5.json"]
  },
  "portalTargets": [
    {
      "name": "dev",
      "hostId": "host-id-for-dev",
      "dryRunRequired": true
    }
  ],
  "publishPolicy": {
    "requireHumanApproval": true,
    "allowAiPatch": true,
    "maxRepairAttempts": 2
  }
}

Rust product releases use the same shape, but releaseType can be rust-products and the repository list can contain only the selected Rust products.

Preflight Checks

The preflight stage should fail before any publishable side effect.

Required checks:

  • requested release version is valid,
  • target branches and tags do not already conflict,
  • release repositories are reachable,
  • release scripts and tool versions are available in the runner image,
  • portal target credentials are present but not exposed in logs,
  • ConfigProfile manifest files validate against the public schema,
  • event-importer can connect to the target read model for dry-run lookup,
  • no required human approval is missing.

Preflight failures should create a human task directly unless the error is a known repairable workspace issue.

ConfigProfile Gate

ConfigProfile mappings should be part of the release gate, not a manual afterthought.

The workflow should:

  1. Check out lightapi/config-profile-manifests.
  2. Validate every manifest selected by the release request.
  3. Run event-importer --generate-config-profiles --dry-run for each target portal environment.
  4. Persist the dry-run report as a workflow artifact.
  5. Block publish if the report contains missing config or property references.
  6. Require human approval when --replace would delete profile mappings.
  7. Emit or attach generated import events only after approval.

The public manifest repository should contain portable product profile contracts. Tenant-specific generated event files, customer host IDs, and private overrides should remain outside the public repository.

AI Failure Loop

When a command task fails, the release workflow should create a structured failure record and route it to the AI release assistant.

The record should include:

  • workflow instance ID,
  • failed task name and attempt number,
  • command template and arguments,
  • sanitized environment summary,
  • exit code,
  • stdout and stderr excerpts,
  • full log artifact reference,
  • repository status,
  • changed files,
  • relevant test reports or build artifacts,
  • previous repair attempts.

The AI assistant should classify the failure before proposing a fix.

Recommended categories:

CategoryExampleDefault Action
transient infrastructureregistry timeout, GitHub API rate limitretry with backoff
dependency resolutionMaven or Cargo dependency conflictpropose dependency fix
compile failureJava or Rust compiler errorpropose source patch
test failuredeterministic unit test failurepropose source or test fix
release metadataversion, tag, changelog, manifest errorpropose metadata patch
permission or secretdenied publish, missing tokencreate human task
policy violationcommand not approved, network blockedcreate human task
uncertainunclear logs or risky patchcreate human task

If policy allows repair, the AI assistant can:

  • inspect the checked-out repository,
  • propose a patch,
  • apply a patch in the sandbox,
  • rerun the failed command or a narrower verification command,
  • create a branch or pull request for human review.

The AI assistant must not:

  • read or print release secrets,
  • bypass workflow approvals,
  • publish artifacts,
  • sign artifacts,
  • push final tags,
  • change command allowlists,
  • increase its own permission scope.

Retries must be bounded. After the configured retry limit, or after any high-risk classification, the workflow should create a human task.

Human Task Escalation

Human tasks are the safety valve for release automation. A failed or approval-required step should create a task with enough context for a quick decision.

The task should show:

  • release ID and release type,
  • failed workflow step,
  • repository and ref,
  • command result summary,
  • log and artifact links,
  • AI classification and confidence,
  • proposed patch or action,
  • affected products and portal targets,
  • approval history,
  • available actions.

Common actions:

  • retry same step,
  • approve AI patch and rerun,
  • reject AI patch,
  • open generated pull request,
  • skip non-required product,
  • abort release,
  • approve publish,
  • request manual intervention.

Workflow Definition Sketch

The exact DSL can evolve with light-workflow, but release definitions should look like normal workflow definitions with sandbox metadata and run.* tasks.

document:
  dsl: "1.0.3"
  namespace: release
  name: lightapi-release
  version: "0.1.0"
  metadata:
    lightWorkflow:
      security:
        executionProfile: release-sandbox
        sandbox:
          mode: workflow-session
          provider: cubesandbox
          template: lightapi-release-runner

do:
  - validate-config-profile-manifests:
      run:
        shell:
          command: python3
          arguments:
            - scripts/validate-manifests.py
      metadata:
        lightWorkflow:
          artifactPolicy:
            capture:
              - validation-report.json

  - build-java-products:
      run:
        shell:
          command: ./release.sh
          arguments:
            - "${ .release.version }"
      metadata:
        lightWorkflow:
          onFailure:
            call: ai-release-diagnosis

  - config-profile-dry-run:
      run:
        shell:
          command: java
          arguments:
            - "-jar"
            - "event-importer.jar"
            - "--generate-config-profiles"
            - "--manifest"
            - "${ .release.configProfileManifestPath }"
            - "--targetHostId"
            - "${ .portal.hostId }"
            - "--adminUserId"
            - "${ .release.adminUserId }"
            - "--output"
            - "./generated"
            - "--dry-run"

  - approve-release:
      ask:
        assignee: "${ .release.owner }"
        prompt: "Approve publishing release ${ .release.version }"

  - publish-release:
      run:
        shell:
          command: ./publish.sh
          arguments:
            - "${ .release.version }"
      metadata:
        lightWorkflow:
          security:
            sandbox:
              mode: per-task
              reason: release-token-isolation
            secrets:
              - github-release-token
              - maven-publish-token

Command Result Contract

Each sandbox command should return a normalized task result so workflow branching and AI diagnosis do not depend on raw console parsing.

{
  "taskName": "build-java-products",
  "attempt": 1,
  "command": "./release.sh 2.3.5",
  "exitCode": 1,
  "status": "failed",
  "startedAt": "2026-06-07T18:10:00Z",
  "completedAt": "2026-06-07T18:18:30Z",
  "durationMs": 510000,
  "stdoutRef": "artifact://release/2026.06.0/build-java/stdout.log",
  "stderrRef": "artifact://release/2026.06.0/build-java/stderr.log",
  "summary": "Maven test failure in db-provider",
  "changedFiles": [],
  "artifacts": [
    "artifact://release/2026.06.0/build-java/surefire-reports.zip"
  ]
}

The workflow context should store references and summaries, not unbounded logs. Full logs belong in artifact storage with retention and access policy.

Security Requirements

Release automation needs stricter controls than normal background tasks.

  • Commands must come from approved workflow definitions or approved templates.
  • The runner image must be versioned and auditable.
  • Network egress must be policy controlled.
  • Secrets must be scoped to the smallest task that needs them.
  • Logs must be redacted before they are stored or sent to AI analysis.
  • Publish and signing tasks require human approval.
  • AI repair tasks must have bounded retry counts and clear write permissions.
  • Workflow audit records must include effective policy, runner image, command template, artifact references, approvals, and repair attempts.
  • Release artifacts should be reproducible from the recorded repository refs, workflow definition version, runner image, and command results.

Phased Implementation

Phase 1: Runtime Foundation

  • Implement sandbox-backed execution for run.shell, run.script, and run.container.
  • Define the command result contract.
  • Add log capture, artifact storage references, and redaction.
  • Add workflow and task metadata for execution security profiles.
  • Keep publish tasks disabled until approval and secret policy are implemented.

Phase 2: Java Release Parity

  • Model the existing light-bot Java release flow as a workflow definition.
  • Call the existing Java release scripts from sandbox tasks.
  • Compare generated artifacts, tags, release notes, and publish behavior with the current light-bot path.
  • Run several releases with light-bot retained as fallback.

Phase 3: Rust Release Support

  • Add Rust product release workflow definitions.
  • Support Cargo build, test, package, image build, and publish tasks.
  • Allow release requests to select one Rust product or a set of Rust products.
  • Share the same approval and artifact model used by Java releases.

Phase 4: ConfigProfile Release Gate

  • Check out and validate lightapi/config-profile-manifests.
  • Run event-importer dry-run for selected portal targets.
  • Persist dry-run reports as workflow artifacts.
  • Require approval for replacement deletes or missing-reference exceptions.
  • Emit approved import events through the normal event-import path.

Phase 5: AI Repair Loop

  • Add AI failure classification for failed command tasks.
  • Allow bounded AI patch attempts in sandbox workspaces.
  • Create branches or pull requests for human review.
  • Add retry policy and automatic escalation after uncertain or exhausted repairs.

Phase 6: Publish and Verification

  • Add per-task sandbox isolation for signing and publish tasks.
  • Add post-publish verification for Maven, Cargo, Docker, GitHub releases, and portal import events.
  • Add release dashboard and final release summary.
  • Retire light-bot after Java parity and rollback procedures are proven.

Risks and Open Questions

  • The sandbox runner must be reliable enough for long-running release builds.
  • Artifact retention needs a concrete storage backend and access policy.
  • Secret handling must be designed before publish tasks are enabled.
  • The AI repair scope must be narrow enough to prevent accidental broad refactors during release pressure.
  • Cross-repository version coordination needs a clear source of truth.
  • Rollback behavior for partially published releases must be defined per artifact type.
  • The first implementation should decide whether AI patches create pull requests by default or only update the sandbox workspace for operator review.

Recommendation

Moving Java and Rust releases to light-workflow is a good direction, provided the migration treats light-workflow as the orchestrator and uses sandboxed runners for command execution. This gives the release process durable state, human approvals, AI-assisted diagnostics, and a single model for Java, Rust, and ConfigProfile release gates.

The migration should be incremental. Keep light-bot as the Java fallback until the workflow release path has matched it in real releases. Enable AI analysis early, but keep AI-generated changes and all publish actions behind explicit policy and human approval.

Optimistic Concurrency Control (OCC)

In the previous documento optimistic-pessimistic-ui, we have decided to leverage the OCC to prevent multiple users update the same aggregate at the same time from different browser sessions.

With OCC, we have the single point of necessary trust: the read model must be consistent enough to support the OCC check.

The concern here is the core trade-off of CQRS: Eventual Consistency.


The Problem: When Eventual Consistency Breaks OCC

Your system’s flow is:

  1. Read (UI): Reads ReadModel (V=5) from Projection DB.
  2. Write (Command Handler):
    • Command arrives with expectedVersion=5.
    • Handler verifies against Event Store (Source of Truth): EventStore.currentVersion must be 5.
  3. The Stale Read Model Gap (The Problem):
    • Event E6 is processed by the Command Handler and committed to EventStore (V=6).
    • Before the Consumer applies E6 to the Projection DB, the UI reads.
    • UI still reads ReadModel (V=5) (STALE).
    • User submits Command2 (expectedVersion=5).
    • The Conflict: The Command Handler checks EventStore.currentVersion which is now 6. It sees 6 != 5 and throws a ConcurrencyException.

Result: The user is incorrectly told there was a conflict and must refresh, even though their original read was perfectly valid and their change was submitted before any other user’s command. The issue is that the read model was too slow to reflect the change that already happened in the source of truth.


The Solution: Shift the OCC Check to the Event Store’s Version

The best way to handle this and eliminate the dependency on the read model’s consistency is to ensure the UI’s OCC is based on the authoritative version from the Event Store itself.

Here are three practical options for injecting the authoritative version.

The “best” option balances data consistency (critical) against performance and complexity (practical). Given the context of a high-performance CQRS/ES application, here is the evaluation and recommendation.


Evaluation of Options for OCC Version Retrieval

OptionWhere Version is FetchedConsistency StatusPerformance ImpactComplexityEvaluation
1. Join with event_store_t (Pagination Query)Read Model + Event StoreAuthoritative (Best)High (Slows down every page load, large joins are expensive).High (Complex SQL, need to avoid full table scans).POOR (Breaks Read Performance/Scalability).
2. Button Click/Form LoadDedicated Version Service (Event Store)Authoritative (Best)Low/Moderate (1 extra, quick, targeted query per form load).Low/Moderate (Easy to implement service).GOOD (Decouples Read/Write, best UX).
3. Command SubmissionDedicated Version Service (Event Store)Authoritative (Best)Low (1 extra query per command).Low/Moderate (Easy to implement service).GOOD but FLAWED UX (Causes more false failures).

Fetch the authoritative version when the user initiates the edit (button click / form load).

Why Option 2 is the Best Balance:

  1. Highest Consistency & UX: It provides the highest level of consistency without sacrificing the performance of the common “list entities” query. When the user loads the edit form, they are guaranteed to see the latest version. If another user commits a change before the form loads, the user will see the newest data and version, preventing the immediate “false conflict.”
  2. Performance Preservation: The most frequently executed query (queryAllEntitiesWithPagination) remains fast, hitting only the optimized Projection DB. The extra query (VersionLookup) only runs when a user takes the action to edit, which is a rare event compared to listing.
  3. Simplicity: It requires a simple, dedicated, fast endpoint in your backend (e.g., /api/version/role/{id}) that executes the SELECT MAX(sequence_number) ... query against your event_store_t.

Why the Other Options Fail:

  • Option 1 (Join with Pagination Query): Fails Scalability. Joining a wide, paginated projection table with a potentially massive, ever-growing event_store_t table (even with indexes) is a performance killer. It makes every single query slow. You use CQRS to avoid this kind of cross-cutting query.
  • Option 3 (Command Submission): Fails User Experience.
    • User loads data (Version 5).
    • User spends 5 minutes making changes.
    • During those 5 minutes, another user commits V6 and V7.
    • User submits Command (expectedVersion=5).
    • Handler fetches latest version (V7). Conflict: 7 != 5.
    • User is rejected and loses 5 minutes of work.
    • By contrast, Option 2 would have made the user refresh immediately upon clicking ‘Edit’ (because the version check would have failed then), saving the user from losing their work.

Implementation Flow for Option 2 (The Correct Flow)

  1. UI/List View: Populated from Projection.queryEntities(offset, limit, filters). This query is fast and returns the version from Read Model. (The version might be the stale one).
  2. User Action: User clicks “Edit” button for role_id=R1.
  3. Backend Call 1 (Version Check): UI calls a dedicated endpoint: /api/write/version/{aggregate_id} (R1). The backend executes SELECT MAX(aggregate_version) FROM event_store_t WHERE aggregate_id = 'R1'. Returns currentVersion = V.
  4. Version Comparison 1: Compare the V with aggregate_version of the UI form data derived from the list view. If they are the same, no further action.
  5. Backend Call 2: If the form data version is less than the V from event_store_t, UI calls /api/read/role/{id} to get fresh form data from the Read Model.
  6. Version Comprison 2: Compare the V with aggregate_version reload from the Read Model. In most of the case, they should be the same. However, the Read Model might not be updated if there is consumer lag. In this case, an error message will be shown on the UI to inform user to wait several minutes to refresh. If problem persist, the user needs to report to the support team to get the issue resolved.
  7. UI Form: Data is populated. A hidden field is set to aggregateVersion = V.
  8. User Submission: UI sends UpdateCommand(..., expectedVersion=V) to the command endpoint.
  9. Command Handler: Executes OCC check against the Event Store. This check is now authoritative and highly likely to succeed.

Aggregate Version in Projection

Adding aggregate_version in all tables in read models is the most common, reliable, and scalable pattern to implement Optimistic Concurrency Control (OCC) in a CQRS/Event Sourcing system that uses a relational database for its read models.


Confirmation of the OCC Pattern

ComponentResponsibility for OCCDetails
Projection Tables (Read Model)Store the VersionRequired: Must have an aggregate_version column (e.g., BIGINT) on every entity row that represents an Aggregate Root.
Pagination/List Query (UI Read)Retrieve the VersionRequired: The API endpoint for listing entities must include the aggregate_version column in its SELECT statement and return it to the UI.
UI Form (Client)Hold the VersionRequired: The UI must store this retrieved aggregate_version (often in a hidden field) and rename it to expectedVersion for the next command.
Command Handler (Write Model)Perform the CheckRequired: When the command arrives, check: EventStore.actualVersion MUST EQUAL command.expectedVersion.

Summary of Why This is Necessary

  1. Atomicity of the Check: The aggregate_version in the read model serves as the handle for the OCC check. The UI has to pass some authoritative marker of the state it observed.
  2. Decoupling: By having the version in the read model, you avoid performing costly SELECT MAX(sequence_number) queries against the event_store_t for every single row in the pagination result. Instead, you only perform the authoritative version lookup (or the OCC check itself) on the one specific record the user is attempting to modify.
  3. Read/Write Split: This solution maintains the separation of concerns:
    • Read Side: Fast, optimized for retrieval.
    • Write Side: Slow, transactionally consistent, responsible for the final state check.

Final Recommendation:

Yes, we must include aggregate_version in all projected tables that are used as the basis for user updates, and it must be part of the data retrieved by the UI’s list queries.

This is the non-negotiable step to ensuring your access control system prevents the dangerous “Last-Write-Wins” scenario.

Refresh Data for Edit

We need to get the latest data after user click the ‘Edit’ button, there are two ways to get the lastet data: Read model or Replay. Let’s clarify exactly what data consistency level is needed for the “Edit” form.

The answer is: You should read the data from the Read Model (Projection) and retrieve the latest aggregate_version from the Event Store.

You should NOT replay the Event Store to populate the UI form.


Analysis of the Two Read Operations

OperationSourcePurposeConsistency LevelPerformance
Data RetrievalRead Model (role_t Projection)To populate the UI form fields (name, description, etc.).Eventual (It’s the data the user sees).Fast (Single row lookup by PK).
Version RetrievalEvent Store (event_store_t)To provide the authoritative expectedVersion for OCC.Strictly Authoritative (Source of Truth).Fast (Single SELECT MAX(sequence_number) WHERE aggregate_id=? query).
Replay OperationEvent Store (event_store_t)To reconstruct the current state by re-running all events.Source of Truth (Highest fidelity).Slow (Involves reading many rows, deserialization, and business logic execution).

Why Combining Read Model + Version Lookup is Best

The flow for the /api/read/role/{id} endpoint should be:

  1. Retrieve Authoritative Version:

    • Execute: SELECT MAX(sequence_number) AS authoritative_version FROM event_store_t WHERE aggregate_id = ?
    • (This is fast).
  2. Retrieve Data (The actual form fields):

    • Execute: SELECT * FROM role_t WHERE role_id = ?
    • (This is also fast).
  3. Combine and Return:

    • Return the data from the Read Model and replace the aggregate_version in the final JSON with the authoritative_version retrieved in Step 1.
    // Final API Response
    {
      "roleId": "R1",
      "roleDesc": "...", // Data from Read Model
      "updateUser": "...", // Data from Read Model
      "aggregateVersion": [Authoritative_Version_from_ES] // Replaced version from Event Store
    }
    

Reasons for this Approach:

  • Speed (Performance): Replaying the Event Store is computationally expensive and slow. You would never do this for a simple read-to-populate-a-form scenario unless the Read Model was completely missing the data. Using the existing projected data for the form fields is orders of magnitude faster.
  • Decoupling (Read/Write Split): This maintains the CQRS separation. The Read Model is still the source for what is displayed to the user. The Event Store is only queried for the transactional anchor (MAX(sequence_number)).
  • Consistency (High Enough): By replacing the read model’s version (V_stale) with the authoritative version (V_authoritative), you achieve transactional consistency for the critical OCC field, and high eventual consistency for the form data (since the event consumer should be very fast at updating the read model).

What if the Read Model is Out of Sync?

The only time this approach is problematic is if the read model is severely stale (e.g., the consumer has been down for hours). In this case, the form data the user sees might not reflect the most recent events, even though the aggregateVersion is correct.

  • Example: Event E6 added a field is_admin = true. The consumer is down. The Read Model doesn’t have is_admin = true. The UI loads the form, sets aggregateVersion=V6, but the form field for is_admin is missing.
  • Mitigation: The expectation in a healthy CQRS system is that the read model lag is measured in milliseconds, not minutes or hours. If lag is severe, the solution is to fix the consumer and the Eventual Consistency pipeline, not to slow down every read operation by resorting to full Event Replay.

Conclusion: The solution is to mix and match: Read data from the projection, but read the version from the source of truth (Event Store).

Eventual Consistency Trade Off

Availability/consistency trade-off is the most nuanced and important philosophical point in CQRS/Event Sourcing.

It is correct that Option 2 (Version Lookup on Button Click) temporarily breaks pure eventual consistency for the purpose of transactional integrity.

Here is the detailed elaboration on why this is necessary and how it redefines the consistency boundary, rather than fundamentally destroying the trade-off.


1. The Principle of Eventual Consistency (EC)

  • Definition: The system state (Read Model) will eventually equal the Source of Truth (Write Model/Event Store) after a small delay ($\Delta t$).
  • Trade-Off: You trade strong/immediate consistency for high availability and high performance (speed of writes and reads).
  • The Acceptable Lie: The Read Model is allowed to lie for $\Delta t$ seconds.

2. The Unacceptable Lie: Breaking Transactional Integrity

The moment a user wants to perform a write operation, the system must enforce Strong Consistency for that single transaction, regardless of the CQRS pattern.

  • Goal of the Transaction: To guarantee that the command (write) is based on a known, singular, correct state of the Aggregate.
  • The Problem: If we use the stale version from the Projection DB (V_stale), and the Write Model is at V_authoritative, one of two things happens:
    1. If V_authoritative > V_stale (Stale Read): The command is rejected (correctly by the Command Handler’s OCC check). The user is told to refresh.
    2. If we tried to bypass OCC: A new event is generated based on stale data, potentially creating an invalid state (e.g., inventory going negative). This is a data integrity failure.

Conclusion: For the Write Path, you must have Strong Consistency. The Write Path does not participate in the EC trade-off.

3. Why Option 2 is the Best Synthesis (The Redefined Trade-Off)

Option 2 queries Projection AND Event Store) is a controlled and highly localized violation of pure EC that elevates transactional integrity.

OperationConsistency MechanismStatus
A. List ViewEventual Consistency (EC)Hits Projection DB only. Fast. Can be stale. $\checkmark$
B. Button Click/Form LoadRead-Your-Own-Writes Consistency (RYOW) / Strong ConsistencyHits Event Store for Version ONLY. Checks V_stale against V_authoritative. If V_stale < V_authoritative, the read is aborted, forcing a fresh, consistent read for the form. $\checkmark$
C. Command SubmissionOptimistic Concurrency Control (OCC) / Strong ConsistencyHits Event Store for the final check. Guarantees data integrity. $\checkmark$

The “Controlled Violation” Justification

  1. Only for the Edit Form: The slow, authoritative query is only done for one Aggregate when a user chooses to edit it. This minimizes performance impact.
  2. Only for the Version: The authoritative query (SELECT MAX(sequence_number)) is the lightest possible query against the Event Store, ensuring minimum latency for this strong consistency check. It doesn’t incur the cost of full Event Replay or full Projection DB reads.
  3. Redefining the UX Contract: You are establishing a clear contract: “The List View is for quick display (EC). The Edit Form guarantees that the state you see is the last known state, and we checked the Source of Truth just for you (Strong Consistency).”

The Availability Trade-off is NOT Broken:

  • Availability: The Write Model (Command Handler) is still available. The Read Model (Projection) is still available. The system has not halted.
  • Performance: The List View remains fast (available). The Write Path remains fast (available).
  • The Cost: The cost is one extra, fast, authoritative query right before the edit begins, which is a small price to pay for preventing a major data integrity or user experience failure.

Summary

The pattern you are implementing with Option 2 (Projection Data + Event Store Version) is the standard, correct way to implement OCC across the Read-Write boundary in a performant CQRS/ES system. It is a necessary local “tightening” of the eventual consistency model to ensure transactional correctness where it absolutely matters: at the point of data modification.

Policy-Driven Cascade Delete

Portal projection tables use soft deletion for recoverable domain state and hard deletion for non-restorable runtime or credential state. PostgreSQL ON DELETE CASCADE alone cannot implement this model because a parent soft delete is an UPDATE ... SET active = FALSE, not a physical DELETE.

This design keeps database-level cascading, but replaces column-shape inference with an explicit relationship policy. The trigger remains generic: table names and actions live in data, not in PL/pgSQL branches.

Decision

Every relationship traversed from a soft-deleted parent has one declared action:

ActionDelete behaviorRestore behaviorIntended use
SOFT_DELETESet the child active = FALSE and record cascade deletion metadataRestore only rows retired by that parent cascadeRecoverable domain and configuration state
HARD_DELETEPhysically delete matching child rowsNoneCredentials, tokens, authorization codes, and other non-restorable runtime state
IGNORELeave the child unchangedNoneRelationships whose lifecycle is intentionally independent

The action is selected by an explicit policy row. The trigger must not infer a hard delete merely because a child is missing active or delete_ts. Missing or incomplete metadata is a deployment error, not permission to destroy data.

Why the Current Inference Is Unsafe

The current cascade_relationships_v selects a relationship when both tables have delete_ts. The current smart_cascade_soft_delete() function then unconditionally reads and writes all of these child columns:

  • active;
  • delete_ts;
  • delete_user;
  • update_ts;
  • update_user.

That contract is inconsistent. For example, auth_client_token_t has delete_ts but no active. Projecting a ClientDeletedEvent therefore tries to run an invalid dynamic statement and fails with PostgreSQL SQLSTATE 42703. The projection transaction rolls back and the event is captured in the DLQ.

Adding only active to credential tables is not sufficient. Every reader and authorization path would also need to filter it, and restoration could revive credentials that should remain revoked.

Policy Registry

Add a schema-owned table named cascade_relationship_policy_t. It is static database metadata delivered by canonical DDL and forward-only upgrade patches; it is not an event-backed Portal entity and is not editable through Portal UI.

Recommended logical contract:

CREATE TABLE cascade_relationship_policy_t (
    parent_schema       VARCHAR(63) NOT NULL DEFAULT 'public',
    parent_table        VARCHAR(63) NOT NULL,
    child_schema        VARCHAR(63) NOT NULL DEFAULT 'public',
    child_table         VARCHAR(63) NOT NULL,
    constraint_name     VARCHAR(63) NOT NULL,
    delete_action       VARCHAR(16) NOT NULL,
    restore_action      VARCHAR(16) NOT NULL DEFAULT 'NONE',
    policy_description  VARCHAR(1024),
    update_user         VARCHAR(255) NOT NULL DEFAULT SESSION_USER,
    update_ts           TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (
        parent_schema,
        parent_table,
        child_schema,
        child_table,
        constraint_name
    ),
    CHECK (delete_action IN ('SOFT_DELETE', 'HARD_DELETE', 'IGNORE')),
    CHECK (restore_action IN ('RESTORE', 'NONE')),
    CHECK (
        (delete_action = 'SOFT_DELETE' AND restore_action = 'RESTORE')
        OR (delete_action IN ('HARD_DELETE', 'IGNORE') AND restore_action = 'NONE')
    )
);

The registry is release-owned and authoritative: installation stages the full canonical inventory, upserts it, and deletes rows absent from that inventory in the same transaction. There is deliberately no runtime enabled switch. To suspend traversal, a release must classify the relationship as IGNORE with a reviewed rationale; an ad hoc disabled row would make replay behavior depend on mutable environment state.

The migration removes the earlier enabled column. Although the canonical view is dropped and recreated in the same transaction, this is a breaking metadata change for external SQL consumers that selected the column directly; reporting queries and dashboards must remove that dependency before rollout.

The foreign-key constraint name is part of the key because the same parent and child tables can have multiple relationships with different column mappings. The resolved relationship view must join this registry to the live PostgreSQL catalog and obtain the composite parent/child column arrays from the referenced constraint. A stale policy whose constraint no longer exists must fail schema validation.

Resolved Relationship Contract

Replace the current inference-only view with a policy-resolved view, retaining the useful catalog-derived foreign-key mapping. Each policy row exposes at least:

  • parent and child schema/table;
  • constraint name and OID;
  • ordered parent and child column arrays;
  • delete_action and restore_action;
  • booleans for every required soft-delete column;
  • the foreign key’s PostgreSQL delete action.

Validation rules:

  1. The named foreign-key constraint must exist and match the configured parent and child tables.
  2. SOFT_DELETE requires the parent and child to have active, delete_ts, delete_user, update_ts, and update_user.
  3. HARD_DELETE requires an ON DELETE CASCADE foreign key. This makes the physical child lifecycle explicit in both the policy and relational schema. Every foreign key that references the hard-deleted child must also use ON DELETE CASCADE; otherwise a retained downstream row could abort the parent soft-delete after earlier child mutations have already run.
  4. IGNORE performs no child mutation.
  5. A partially implemented soft-delete contract is rejected by the schema gate.
  6. Every public foreign key whose parent has the core active and delete_ts lifecycle markers must be classified, regardless of the child’s columns. Column shape validates the selected action but never removes a relationship from discovery, so active-only and columnless children cannot disappear from the reviewed inventory.
  7. Unclassified candidate relationships fail the gate rather than acquiring a default destructive action.
  8. For a SOFT_DELETE child, delete_user must be wide enough for every possible per-FK ownership token: 14 + 33n characters for n soft parent relationships. Unbounded text types satisfy this rule automatically.

Consequently, adding an FK whose parent has active and delete_ts can block a deployment when the parent lacks the remaining audit columns or the FK has no canonical classification. That failure is intentional. Operators must complete the parent contract or add a reviewed policy; they must not bypass validation. The transactional installer leaves the prior validated trigger set intact.

The policy table is the authority. Column shape validates an action; it does not select the action.

Generic Trigger Behavior

Create a generic smart_cascade_delete() function and recreate trg_cascade_soft_ops to call it. The old smart_cascade_soft_delete() function can be removed after no triggers depend on it.

When a parent transitions from active = TRUE to active = FALSE, the function processes its policies in deterministic order:

SOFT_DELETE
  UPDATE child
     SET active = FALSE,
         delete_ts = cascade timestamp,
         delete_user = exact per-FK cascade token set,
         update_ts = cascade timestamp,
         update_user = database user
   WHERE foreign-key columns match OLD parent values
     AND (active = TRUE OR already cascade-owned)

HARD_DELETE
  DELETE FROM child
   WHERE foreign-key columns match OLD parent values

IGNORE
  no operation

All statements execute inside the projection transaction. If any action fails, the parent and all previously processed children roll back together.

The delete path must be idempotent:

  • an independently inactive soft child is unchanged;
  • an already cascade-owned child records an additional parent FK token once;
  • a missing hard child is a successful no-op;
  • replaying the same parent event does not recreate or reactivate children.

Restore

When a parent transitions from active = FALSE to active = TRUE, only SOFT_DELETE relationships with restore_action = 'RESTORE' participate. Only children carrying the exact token for the restored parent FK participate. Restoration removes only that token; a child becomes active only when its token set is empty. This keeps a child with two inactive parents inactive until both parents have been restored, in either restoration order. Children retired independently never acquire a cascade token and stay inactive.

HARD_DELETE children are never restored. New tokens or credentials require new domain commands and events after the parent is active again.

This is intentionally asymmetric: a reversible parent state transition can permanently revoke a hard-delete child. That behavior is limited to explicitly reviewed credentials and runtime authorization state, where restoration would be a security defect. It is never inferred from missing columns.

The implementation uses PARENT_CASCADE: followed by comma-separated MD5 tokens derived from the parent schema, parent table, and FK constraint. Restore removes the exact relationship token; it does not use a broad table-name match that could erase another active cascade owner.

Rows retired before this migration carry the historical PARENT_CASCADE_<parent_table>_<timestamp> marker. Restore recognizes that exact literal table prefix, while all new deletions use per-FK tokens. This compatibility path is required until every legacy cascade-owned row has either been restored or retired through the new policy.

Initial Authentication Policies

The implementation inventory must verify exact constraint names before seeding rows. The intended actions for the known authentication relationships are:

ParentChildActionReason
auth_client_tauth_provider_client_tSOFT_DELETERecoverable client/provider configuration
auth_client_tauth_ref_token_tHARD_DELETEThe row contains a full bearer JWT and dereference must be revoked
auth_client_tauth_client_token_tHARD_DELETERevoke non-restorable client credentials
auth_provider_client_tauth_code_tHARD_DELETEAuthorization codes must not survive retirement
auth_provider_client_tauth_refresh_token_tHARD_DELETERefresh tokens must be revoked, not restored
auth_provider_client_tauth_session_tHARD_DELETEProvider-client retirement revokes non-restorable authorization sessions
auth_provider_tauth_provider_key_tIGNOREPreserve keys across reversible host-driven retirement; runtime lookup requires an active provider and host
host_tauth_code_tHARD_DELETERevoke tenant authorization codes even when host_id differs from auth_host_id
host_tauth_ref_token_tHARD_DELETERevoke persisted bearer JWTs on host retirement
host_tauth_refresh_token_tHARD_DELETERevoke tenant refresh tokens even when host_id differs from auth_host_id
host_tauth_session_tHARD_DELETERevoke tenant sessions even when host_id differs from auth_host_id
user_tauth_code_tHARD_DELETEUser deactivation is a revocation boundary; redemption does not recheck user_t.active
user_tauth_refresh_token_tHARD_DELETEUser deactivation must revoke issued refresh credentials
user_tauth_session_tHARD_DELETEUser deactivation must revoke active authorization sessions

These rows belong in the migration, not in conditional branches inside the trigger.

Fifty-nine IGNORE relationships are also deliberate. The self-references on customer_t.referral_id and employee_t.manager_id describe hierarchy, not lifecycle ownership. The user_host_t relationships preserve recoverable customer and employee identity details while a host membership is inactive; membership eligibility is governed by user_host_t.active. Hard-deleting those identity rows would make a later membership reactivation incomplete. These exceptions are canonical policy decisions, not temporary disabled cascades.

Twenty-five of the IGNORE rows cover active-only children that do not implement the complete five-column soft-delete audit contract. Their lifecycle remains command-owned or independently retained; the validator makes that decision visible rather than silently dropping them from candidate discovery.

Twenty-nine more IGNORE rows cover children with neither active nor delete_ts, including immutable snapshots, audit evidence, retained message history, and status-driven operational records. The three columnless auth_session_t relationships are deliberately different: they are HARD_DELETE revocation boundaries through provider-client, tenant-host, and user ownership. The auth_code_t and auth_refresh_token_t session foreign keys also use ON DELETE CASCADE, so credentials issued under a different client or user within the same session cannot block session revocation.

Provider signing keys are the remaining deliberate exception. A host-driven provider retirement is reversible, so it preserves keys and restores the provider without forcing key regeneration. Both Java and Rust authentication lookups join the provider and host and require both to be active, preventing an inactive key from being used. A direct AuthProviderDeletedEvent remains a destructive domain operation and explicitly deletes the provider keys in the projection handler.

The host_t references from auth_code_t and auth_refresh_token_t are hard revocation boundaries. In the Master-OAuth-Host flow their host_id is the tenant while auth_host_id owns the provider-client relationship, so the transitive provider-client cascade cannot revoke them. The equivalent user_t relationships are also HARD_DELETE because neither redemption path rechecks user_t.active.

Event-Sourcing Semantics

The parent domain event remains the only event required for database-level cascading. Child mutations are derived projection state and do not create additional events. This preserves these properties:

  • the event store remains the canonical source of parent intent;
  • parent and child projection changes are atomic;
  • replay produces the same child state under the same policy version;
  • a projection failure is retained in the DLQ without mutating the canonical event payload.

Because policy changes can alter replay results, policy rows are release-owned schema metadata. Historical patch files are immutable; changes require a new forward-only patch and regression qualification against both fresh and upgraded schemas.

Trigger Installation

Install the parent trigger only on tables with the complete parent contract: active, delete_ts, delete_user, update_ts, and update_user. A trigger is useful only when at least one non-IGNORE policy names that table as a parent.

Installation must:

  1. validate all policy rows;
  2. reject unclassified candidate relationships;
  3. drop/recreate the trigger deterministically on eligible parents;
  4. verify that no trigger still calls the retired function;
  5. run safely more than once.

Operational Recovery

For a projection event already in the DLQ:

  1. Deploy the canonical DDL/patch and recreate the generic triggers.
  2. Verify the policy row and live resolved relationship.
  3. Verify that the failed parent row remains at its pre-event aggregate version.
  4. Use Replay original for the unchanged canonical event.
  5. Confirm the parent version/active state, child revocation or retirement, and failure resolution.

Do not edit the DLQ payload or manually advance the parent projection.

Alternatives Rejected

Hard-coded table branches in the trigger

This keeps policy hidden in procedural code, requires a function edit for every new relationship, and makes destructive behavior difficult to inventory.

Infer hard delete from missing columns

An incomplete migration is indistinguishable from intentional hard-delete semantics. Treating absence as authorization to delete can destroy recoverable domain data.

Add active to every child

This is unsafe unless every read and authorization path also filters active. It also allows restoration of credentials that should be permanently revoked.

Application-level table lists

Application handlers can implement the behavior, but duplicated relationship lists drift across processors. The database already owns the foreign-key graph and executes projection transactions, so a validated database policy is the smaller consistency boundary.

Acceptance Criteria

  • No trigger contains hard-coded domain table names.
  • Every public foreign key from a parent with active and delete_ts has an explicit SOFT_DELETE, HARD_DELETE, or IGNORE policy.
  • Soft actions cannot install unless both tables satisfy the complete column contract.
  • Hard actions cannot install unless the referenced FK is ON DELETE CASCADE.
  • Client deletion succeeds with zero or many client-token rows and physically removes those rows.
  • Provider/client retirement revokes authorization codes and refresh tokens.
  • Tenant-host retirement revokes authorization codes and refresh tokens when host_id differs from auth_host_id.
  • Host-driven provider retirement preserves signing keys while active-provider and active-host lookup guards prevent their use until restoration.
  • Restore reactivates only children retired by the matching soft cascade and waits for every parent cascade token to clear; it never recreates hard-deleted state.
  • Fresh-install and historical-upgrade schemas resolve to identical policies, views, functions, and triggers.
  • Original DLQ events replay successfully after deployment without payload mutation.

Query Active Rows

Since we use soft deletes for most tables in the read model, we need to apply an active = true filter to our queries.

For single-table queries, this is straightforward—we can simply add AND active = true to the query. However, for join queries involving multiple tables, the active = true condition must be applied consistently across all participating tables, ideally in an automatic manner.

There are two approaches we can take on top of the current database provider implementation:

Active in filters

    @Override
    public Result<String> queryRolePermission(int offset, int limit, String filtersJson, String globalFilter, String sortingJson, String hostId) {


        boolean isActive = true; // Default to true (active records only)

        // Iterate safely to find and remove the 'active' filter to handle it manually
        if (filters != null) {
            Iterator<Map<String, Object>> it = filters.iterator();
            while (it.hasNext()) {
                Map<String, Object> filter = it.next();
                if ("active".equals(filter.get("id"))) {
                    Object val = filter.get("value");
                    if (val != null) {
                        isActive = Boolean.parseBoolean(val.toString());
                    }
                    it.remove(); // Remove from list so dynamicFilter doesn't add it again
                    break;
                }
            }
        }

        StringBuilder activeSql = new StringBuilder();
        if (isActive) {
            // Strict consistency: A record is only "active" if all related entities are active
            activeSql.append(" AND rp.active = true");
            activeSql.append(" AND r.active = true");
            activeSql.append(" AND ae.active = true");
            activeSql.append(" AND av.active = true");
        } else {
            // Soft-deleted view: Usually we only care that the specific record itself is inactive
            activeSql.append(" AND rp.active = false");
        }

    }	

Pros

  • No need to change the signature, UI and service layer.

Cons

  • Need to iterate all filters to find the active flag per call.

Active as a seperate parameter

    @Override
    public Result<String> queryRolePermission(int offset, int limit, String filtersJson, String globalFilter, String sortingJson, boolean active, String hostId) {
        
        StringBuilder activeSql = new StringBuilder();
        if (active) {
            // Strict consistency: A record is only "active" if all related entities are active
            activeSql.append(" AND rp.active = true");
            activeSql.append(" AND r.active = true");
            activeSql.append(" AND ae.active = true");
            activeSql.append(" AND av.active = true");
        } else {
            // Soft-deleted view: Usually we only care that the specific record itself is inactive
            activeSql.append(" AND rp.active = false");
        }


    }	

Pros

  • Logic is simple in the query.

Cons

  • Need to change the service layer and UI to add an additional parameter.

Conclusion

We recommend proceeding with Option 2. While it requires an initial refactor of the Service and UI layers, it provides strict type safety and cleaner code.

Reasoning:

  • Code Reuse: Option 1 requires repeating the filter iteration logic inside every DAO method. Option 2 keeps DAO methods clean.

  • Semantics: The active status affects multiple table joins (Data Integrity), distinguishing it from standard column filters. It should be an explicit argument.

  • Maintainability: Option 2 decouples the Database layer from the UI’s JSON structure. If the UI changes how it sends the active status, we only change the extraction logic in the Controller, not every SQL query method.

Distributed Scheduler Design

Introduction

The Distributed Scheduler is a robust, highly available component of the light-portal architecture that manages the periodic execution of tasks across a cluster of application instances. It ensures that scheduled tasks are executed exactly as defined, even in a distributed environment, by using a database-backed leader election and locking mechanism.

Architecture

The scheduler follows a Leader-Follower pattern to prevent redundant executions and ensure consistency.

  1. Leader Election: All scheduler instances compete for a global lock in the scheduler_lock_t table.
  2. Lock Heartbeat: The leader periodically updates its heartbeat to maintain ownership. If the leader fails, another instance will eventually claim the lock after a timeout.
  3. Polling Loop: Only the leader performs the polling of the schedule_t table for due tasks.
  4. Task Execution: When a task is due, the scheduler generates the corresponding event into the event_store_t and outbox_message_t tables and updates the next_run_ts for the next occurrence.

Database Schema

schedule_t

Stores the definitions and state of all scheduled tasks.

CREATE TABLE schedule_t (
    schedule_id          UUID NOT NULL,
    host_id              UUID NOT NULL,
    schedule_name        VARCHAR(126) NOT NULL,
    frequency_unit       VARCHAR(16) NOT NULL, -- e.g., 'MINUTES', 'HOURS', 'DAYS'
    frequency_time       INTEGER NOT NULL,
    start_ts             TIMESTAMP WITH TIME ZONE NOT NULL,
    next_run_ts          TIMESTAMP WITH TIME ZONE NOT NULL,
    event_topic          VARCHAR(126) NOT NULL,
    event_type           VARCHAR(126) NOT NULL,
    event_data           TEXT NOT NULL,
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active               BOOLEAN NOT NULL DEFAULT TRUE,
    PRIMARY KEY(schedule_id)
);
CREATE INDEX idx_schedule_active_next_run ON schedule_t (active, next_run_ts);

scheduler_lock_t

Facilitates distributed locking and leader election.

CREATE TABLE scheduler_lock_t (
    lock_id              INT PRIMARY KEY, -- Static ID for the global scheduler lock
    instance_id          VARCHAR(255) NOT NULL, -- ID of the holding instance
    last_heartbeat       TIMESTAMP WITH TIME ZONE NOT NULL
);

Implementation Details

Leader Election and Heartbeat

Instances attempt to acquire the lock by updating the last_heartbeat if the existing heartbeat has expired (e.g., more than 60 seconds ago).

UPDATE scheduler_lock_t 
SET instance_id = ?, last_heartbeat = CURRENT_TIMESTAMP 
WHERE lock_id = 1 AND (instance_id = ? OR last_heartbeat < ?);

Polling Mechanism

The leader queries for tasks where next_run_ts <= CURRENT_TIMESTAMP and active = true.

SELECT * FROM schedule_t 
WHERE active = true AND next_run_ts <= CURRENT_TIMESTAMP 
ORDER BY next_run_ts ASC 
LIMIT ?;

Next Run Timestamp Calculation

After a task is executed, the next_run_ts is incremented based on the frequency_unit and frequency_time.

  • Interval-based: Adds the specified amount of time to the next_run_ts.
  • Drift Correction: To prevent cumulative drift, the calculation is based on the original start_ts or the previous next_run_ts rather than the actual execution time.

Execution Flow

  1. Leader polls for due tasks.
  2. For each task:
    • Starts a database transaction.
    • Inserts the specified event into the event store and outbox message.
    • Updates next_run_ts in schedule_t.
    • Commits the transaction.
  3. The event is then picked up and processed by the Event Consumer (Kafka or Postgres).

Conclusion

The Distributed Scheduler provides a reliable and scalable way to handle periodic activities within the light-portal, ensuring that tasks are executed predictably and exclusively by a single active leader at any given time.

PostgreSQL Pub/Sub Design

Introduction

The PostgreSQL Pub/Sub mechanism provides an alternative to Kafka for event distribution within the light-portal architecture. It is designed for smaller deployments or environments where Kafka is not available, offering a reliable, low-latency, and strictly ordered event delivery system using native PostgreSQL features.

Architecture

The system utilizes a hybrid Polling + LISTEN/NOTIFY approach to achieve both high reliability and low latency.

1. Logical Partitioning

To support horizontal scalability and ensure ordered processing for multi-tenant environments, the system uses logical partitioning based on the host_id.

  • Events are distributed across a fixed number of partitions (e.g., 8 or 16).
  • Partition index = abs(hashtext(host_id::text)) % total_partitions.
  • Each partition has its own progress tracker in consumer_offsets.

2. Contiguous Offset Claiming

Within each partition, the consumer claims a batch of events using gapless logical offsets (c_offset).

3. Real-time Wake-up

To minimize latency without high-frequency polling, the system uses the PostgreSQL LISTEN/NOTIFY mechanism.

  • A database trigger on the outbox_message_t table issues a NOTIFY event_channel whenever new messages are inserted.
  • Consumers use LISTEN event_channel to subscribe to these real-time signals.
  • The consumer loop calls pgConn.getNotifications(timeout) to wait for signals. This allows the consumer thread to sleep efficiently and wake up immediately when work is available, while still falling back to a poll-based check if no notification is received within the waitPeriodMs.

Database Schema

log_counter

Manages the global version/offset for the outbox.

CREATE TABLE log_counter (
    id INT PRIMARY KEY,
    next_offset BIGINT NOT NULL DEFAULT 1
);
INSERT INTO log_counter (id, next_offset) VALUES (1, 1);

consumer_offsets

Tracks the progress of each consumer partition.

CREATE TABLE consumer_offsets (
    group_id VARCHAR(255),
    topic_id INT, -- 1 for global outbox
    partition_id INT, -- Logical partition index
    next_offset BIGINT NOT NULL DEFAULT 1,
    PRIMARY KEY (group_id, topic_id, partition_id)
);

outbox_message_t (Modified)

Stores the events to be published.

ALTER TABLE outbox_message_t ADD COLUMN c_offset BIGINT UNIQUE;
CREATE INDEX idx_outbox_offset ON outbox_message_t (c_offset);

Triggers and Functions

Enables the NOTIFY mechanism.

CREATE OR REPLACE FUNCTION notify_event() RETURNS TRIGGER AS $$
BEGIN
    PERFORM pg_notify('event_channel', 'new_event');
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER event_trigger
AFTER INSERT ON outbox_message_t
FOR EACH STATEMENT EXECUTE FUNCTION notify_event();

Implementation Details

Offset Reservation

When inserting events, the system locks the log_counter row to reserve a range of offsets:

UPDATE log_counter SET next_offset = next_offset + ? WHERE id = 1 RETURNING next_offset - ?;

Competing Consumer Pattern

To support multiple instances within the same consumer group, logical offsets are “claimed” in batches using an atomic UPDATE ... RETURNING statement. This ensures that each event is processed exactly once by one member of the group.

WITH counter_tip AS (
    SELECT (next_offset - 1) AS highest_committed_offset FROM log_counter WHERE id = 1
),
to_claim AS (
    SELECT group_id, next_offset, 
           LEAST(batch_size, GREATEST(0, (SELECT highest_committed_offset FROM counter_tip) - next_offset + 1)) AS delta
    FROM consumer_offsets 
    WHERE group_id = ? AND topic_id = 1 
    FOR UPDATE
),
upd AS (
    UPDATE consumer_offsets c SET next_offset = c.next_offset + t.delta
    FROM to_claim t 
    WHERE c.group_id = t.group_id AND c.topic_id = 1
    RETURNING t.next_offset AS start_offset, (c.next_offset - 1) AS end_offset
)
SELECT start_offset, end_offset FROM upd;

Transactional User-Based Batching

To ensure that events generated from the same user are handled atomically and in order, the consumer employs a grouping strategy within its processing cycle:

  1. Fetch Batch: Read raw payloads from outbox_message_t for the assigned partition range.
  2. Filter and Group:
    • Filter messages by the partition hash: abs(hashtext(host_id::text)) % ? = ?.
    • Group the filtered messages by host_id and user_id.
  3. Process by User:
    • For each (host_id, user_id) group, execute all events in a single database transaction.

Handling Large Atomic Transactions (Batch Extension)

If a business activity (e.g., “instance clone”) generates more events than the configured batchSize, these events should still be processed in a single transaction to maintain system consistency.

The consumer handles this via Atomic Batch Extension:

  1. After fetching the initial batch (e.g., 100 events), the consumer peeks at the next available event in the outbox.
  2. If the next event belongs to the same user_id as the last event in the batch, the consumer continues fetching consecutive events for that user until the transaction boundary is found.
  3. The consumer_offsets are then atomically updated to reflect the true end of the extended batch.
  4. This ensures that even if 120 events were generated, all 120 are processed in a single transaction, regardless of the batchSize limit.

This approach ensures that even if events are processed in parallel across different partitions, events belonging to the same user are always handled in the same transaction, maintaining consistency across subsystems.

Transaction ID and Dead Letter Queue

Transaction ID

To provide precise boundaries for atomic transactions, the system uses a transaction_id column in the outbox_message_t table:

ALTER TABLE outbox_message_t ADD COLUMN transaction_id UUID;

When events are persisted to the outbox, all events generated within a single business transaction are assigned the same transaction_id (a UUID generated once per batch in EventPersistenceImpl.insertEventStore()).

This eliminates ambiguity when grouping events:

  • Without transaction_id: Events are grouped by host_id:user_id, which may incorrectly group unrelated transactions from the same user.
  • With transaction_id: Events are grouped by their exact transaction boundary, ensuring atomic processing of related events only.

Dead Letter Queue (DLQ)

When event processing fails, the system implements a granular fallback mechanism to prevent the entire batch from being blocked:

Schema

CREATE TABLE IF NOT EXISTS dead_letter_queue (
  group_id VARCHAR(255),
  host_id UUID,
  user_id UUID,
  c_offset BIGINT,
  transaction_id UUID,
  payload JSONB,
  exception TEXT,
  created_dt TIMESTAMP DEFAULT NOW()
);

Processing Flow

  1. Normal Processing: The consumer attempts to process all events in a claimed batch within a single database transaction.

  2. Batch Failure Detection: If any event in the batch fails (e.g., constraint violation, business logic error), the entire transaction is rolled back.

  3. Fallback Mode: The consumer switches to processBatchWithFallback():

    • Re-claims the same offset range.
    • Groups events by transaction_id.
    • For each transaction group:
      • Creates a JDBC Savepoint.
      • Attempts to process all events in that transaction.
      • On success: Continues to the next transaction.
      • On failure:
        • Rolls back to the Savepoint.
        • Inserts all events from the failed transaction into dead_letter_queue.
        • Logs the error with the transaction_id for debugging.
  4. Commit: After processing all transactions (successful or moved to DLQ), the consumer commits the transaction, advancing the offset.

Benefits

  • Isolation: Only the failing transaction is moved to DLQ; other transactions in the batch proceed normally.
  • Atomicity: All events belonging to a single business transaction are either processed together or moved to DLQ together.
  • No Blocking: The consumer never gets stuck on a single bad event.
  • Debuggability: The DLQ table preserves the full context (payload, exception, transaction_id) for manual investigation and replay.

Configuration

The consumer is configured via db-event-consumer.yml and runs in a Java 21 Virtual Thread. This ensures that the frequent Thread.sleep (during retries) and the blocking pgConn.getNotifications() (waiting for wake-ups) do not tie up native system threads, making the consumer extremely lightweight.

# Postgres pub/sub event processor configuration
# Consumer group id and it is default to user-query-group. Please only change it if you
# know exactly what you are doing.
groupId: ${db-event-consumer.groupId:user-query-group}
# The batch size when polling from the database for events. It is not fixed and will be
# adjusted if there are more than 100 events belong to the same transaction.
batchSize: ${db-event-consumer.batchSize:100}
# The number of total partitions. It should be the same number of portal-query instances.
totalPartitions: ${db-event-consumer.totalPartitions:1}
# Partition id starting from 0 to totalPartitions - 1 to assign each portal query instance.
partitionId: ${db-event-consumer.partitionId:0}
# The poll interval from the Postgres database to process the events from outbox_message_t.
waitPeriodMs: ${db-event-consumer.waitPeriodMs:1000}

Clean Shutdown

To ensure resources are released cleanly when the application stops, a ShutdownHookProvider is implemented:

  • DbEventConsumerShutdownHook: Sets the done flag to stop the consumer loop and shuts down the ExecutorService. This ensures that the application doesn’t hang on exit and that the database connections are properly returned to the pool.

Conclusion

This native PostgreSQL implementation provides a robust alternative to Kafka, leveraging standard relational database features to maintain strict event ordering and delivery guarantees with minimal infrastructure overhead.

Comparison: Leader Election vs. Competing Consumer (Claiming)

The light-portal architecture employs two different distributed coordination strategies: Leader Election for the Scheduler and Competing Consumers (Offset Claiming) for the PostgreSQL Pub/Sub. Each approach is optimized for its specific use case.

Summary Table

FeatureLeader Election (scheduler_lock_t)Host Partitioning (consumer_offsets)
Primary GoalExclusive Control (Safety)Horizontal Scalability (Throughput)
MechanismCentralized “lock” with heartbeat.Logical partitioning via host_id hash.
ParallelismNone (Single active instance).High (N partitions, N consumers).
Database LoadVery Low (Heartbeat only).Moderate (Per-partition updates).
FailoverDetection delay (Timeout-based).Instant (One processor per partition).
ComplexitySimple.Moderate (Hashing + Batching).

1. Leader Election (Used in Distributed Scheduler)

Why it’s used for the Scheduler:

The “work” done by the scheduler is extremely lightweight: it simply checks if a task is due, inserts a one-line event into the outbox, and updates the next run time. However, the cost of double execution (starting the same job twice) is high.

  • Efficiency: Having one leader prevents multiple instances from redundant polling of the schedule_t table, which reduces database contention.
  • Safety: It provides a simple guarantee that only one controller is making decisions about what triggers and when.
  • Scaling: Since the scheduler doesn’t do the actual “heavy lifting” (the work is done by event consumers), the leader bottleneck is rarely an issue.

2. Host-Based Partitioning (Used in Postgres Pub/Sub)

Why it’s used for Event Processing:

Event processing is the “Data Plane” of the system. By partitioning based on host_id, we emulate Kafka’s partitioning behavior within PostgreSQL.

  • Ordered Processing: Ensures all events for a specific host (or user) are processed by the same partition sequence, avoiding race conditions on multi-tenant data.
  • Throughput: Multiple consumers can process different partitions in parallel. 8 partitions = 8 instances working concurrently.
  • Implicit Load Balancing: Distributes thousands of hosts across a fixed number of partitions.
  • Resiliency: Each partition’s progress is independent. A failure in one host/partition doesn’t block others.

Conclusion: Which is “Better”?

Neither is universally better; they are complementary:

  • Leader Election is better for orchestration and control: Where you need a single “brain” to make consistent decisions and volume is manageable.
  • Competing Consumers is better for workload distribution: Where you need to process a high volume of independent tasks as quickly as possible.

In light-portal, we use the Scheduler (Leader) to reliably “kick off” tasks by emitting events, and the Pub/Sub (Competing Consumers) to at-scale process those events.

Kafka Event Processor

Overview

The Kafka Event Processor (PortalEventConsumerStartupHook) consumes events from Kafka topics that are populated by Debezium CDC from the outbox_message_t table. It provides robust event processing with transaction-level granularity and Dead Letter Queue (DLQ) support.

Architecture

The processor uses a two-phase processing strategy with automatic fallback to ensure both performance and reliability:

  1. Optimistic Batch Processing: Attempts to process all transactions in a single database transaction for maximum throughput
  2. Granular Fallback: On failure, switches to individual transaction processing with JDBC Savepoints to isolate failures

Transaction ID Header

Events published to Kafka include a transaction_id header added by Debezium’s HeaderFrom transform. This UUID groups all events that were generated within a single business transaction, enabling:

  • Precise transaction boundaries: Events are grouped by their actual transaction, not just by user/host
  • Atomic DLQ handling: Failed transactions are moved to DLQ as a complete unit
  • Backward compatibility: Falls back to Kafka key-based grouping for events without the header

Debezium Configuration

The transaction_id header is added via the Debezium connector configuration:

{
  "transforms": "unwrap,addTransactionIdHeader,timestamp_converter,...",
  
  "transforms.addTransactionIdHeader.type": "org.apache.kafka.connect.transforms.HeaderFrom$Value",
  "transforms.addTransactionIdHeader.fields": "transaction_id,transaction_ordinal,transaction_count",
  "transforms.addTransactionIdHeader.headers": "transaction_id,transaction_ordinal,transaction_count",
  "transforms.addTransactionIdHeader.operation": "copy"
}

All three headers are mandatory when canonical capture is enabled. The command side writes the zero-based ordinal and complete member count into every outbox row in the same database transaction. The Kafka processor refuses to advance offsets unless the poll contains exactly ordinals 0..transaction_count-1 with a consistent transaction ID and count. Deploy the outbox migration and connector header change before enabling Kafka capture for a rollout scope.

Processing Flow

Phase 1: Optimistic Batch Processing

// 1. Group events by transaction_id from headers
Map<String, List<ConsumerRecord>> transactionBatches = groupByTransactionId(records);

// 2. Process all transactions in one DB transaction
Connection conn = ds.getConnection();
conn.setAutoCommit(false);

for (Map.Entry<String, List<ConsumerRecord>> entry : transactionBatches.entrySet()) {
    for (ConsumerRecord record : entry.getValue()) {
        updateDatabaseWithEvent(conn, record.getValue());
    }
}

conn.commit();
commitOffset(records);

Benefits:

  • High throughput with single database transaction
  • Minimal overhead for the common success case

Phase 2: Fallback with Savepoints

If the batch processing fails, the processor switches to granular mode:

Connection conn = ds.getConnection();
conn.setAutoCommit(false);

for (Map.Entry<String, List<ConsumerRecord>> entry : transactionBatches.entrySet()) {
    String transactionId = entry.getKey();
    List<ConsumerRecord> txRecords = entry.getValue();
    
    Savepoint sp = conn.setSavepoint("TX_" + transactionId.hashCode());
    try {
        for (ConsumerRecord record : txRecords) {
            updateDatabaseWithEvent(conn, record.getValue());
        }
        // Success - continue to next transaction
        
    } catch (Exception e) {
        // Rollback only this transaction
        conn.rollback(sp);
        
        // Send to DLQ
        produceDLQ(txRecords, e);
    }
}

// Commit all successful transactions
conn.commit();
commitOffset(allRecords);

Benefits:

  • Isolation: Only failing transactions are moved to DLQ
  • Atomicity: All events in a transaction are processed together or fail together
  • No Blocking: Consumer continues processing subsequent transactions
  • Progress Guarantee: Offsets are committed for all records (successful + DLQ’d)

Dead Letter Queue (DLQ)

DLQ Topic

Failed transactions are sent to a DLQ topic: {original-topic}-dlq

Each DLQ message includes:

  • Key: Original Kafka key (user_id)
  • Value: Original event payload
  • TraceabilityId: Exception stack trace for debugging

DLQ Producer Configuration

The DLQ producer is configured via DeadLetterProducerStartupHook and must be enabled in the consumer config:

# kafka-consumer.yml
deadLetterEnabled: true
deadLetterTopicExt: -dlq

Monitoring and Recovery

  1. Alerting: Set up monitoring on the DLQ topic for new messages
  2. Investigation: Inspect DLQ messages to identify root cause (bad data, code bug, constraint violation)
  3. Fix: Deploy code fix or correct data inconsistency
  4. Replay: Use a re-driver application to republish events from DLQ back to the original topic

Transaction Grouping Logic

The processor extracts transaction_id from Kafka record headers:

private String extractTransactionId(ConsumerRecord<Object, Object> record) {
    Map<String, String> headers = record.getHeaders();
    if (headers != null) {
        return headers.get("transaction_id");
    }
    return null;
}

Fallback for Legacy Events: If no transaction_id header is present (old events before the header was added), the processor falls back to using the Kafka key for grouping:

String transactionId = extractTransactionId(record);
if (transactionId == null) {
    transactionId = (String) record.getKey(); // Backward compatibility
}

Error Handling Strategy

Permanent vs Transient Errors

The processor treats all exceptions during fallback processing as permanent errors that warrant DLQ routing. This includes:

  • Database constraint violations (unique, foreign key, not null)
  • Deserialization errors (malformed JSON, schema mismatch)
  • Business logic errors (validation failures, state inconsistencies)

Rationale: If an event fails during fallback (after the initial batch attempt failed), it’s unlikely to succeed on retry without intervention.

Health Monitoring

The processor sets healthy = false on critical failures, which triggers Kubernetes health probes to restart the pod:

  • Consumer instance not found
  • Framework exceptions during polling
  • Fatal errors in fallback processing (after DLQ attempt)

Configuration

Consumer configuration in kafka-consumer.yml:

# Kafka consumer properties
topic: portal-event
groupId: user-query-group
keyFormat: string
valueFormat: string

# DLQ configuration
deadLetterEnabled: true
deadLetterTopicExt: -dlq

# Polling configuration
waitPeriod: 1000  # ms to wait between polls when no records

Comparison with DB Event Consumer

FeatureKafka ConsumerDB Consumer
Event SourceKafka topic (via Debezium CDC)Direct PostgreSQL polling
Transaction IDFrom Kafka headersFrom outbox_message_t.transaction_id column
GroupingMap<String, List<ConsumerRecord>>Map<String, List<EventData>>
DLQ TargetKafka DLQ topicPostgreSQL dead_letter_queue table
Offset ManagementKafka consumer offsetsPostgreSQL consumer_offsets table
Fallback MechanismJDBC SavepointsJDBC Savepoints

Both implementations share the same core DLQ philosophy: isolate failures at the transaction level to prevent blocking the entire consumer.

Best Practices

  1. Idempotent Processing: Ensure updateDatabaseWithEvent() logic is idempotent to handle potential reprocessing
  2. Monitor DLQ: Set up alerts for DLQ topic activity
  3. Version Events: Use schema versioning to handle event evolution gracefully
  4. Test Failure Scenarios: Regularly test DLQ routing with intentional failures
  5. DLQ Retention: Configure appropriate retention for DLQ topics to allow investigation and replay

Rust Event Importer Design

Overview

The importer repository is the Rust replacement for the current Java event-importer CLI. It must preserve the operational workflows that are already used for portal migrations:

  1. Import a JSON array of CloudEvents into event_store_t, outbox_message_t, and pending notification_t records.
  2. Convert a global snapshot JSON file into an ordered JSON array of CreatedEvents that can be imported by the same event import path.

The Rust version should be a standalone command-line tool. It should not start a service, and it should not depend on Java runtime configuration. It should connect directly to PostgreSQL and use the same event-store contract as light-portal.

Existing Java Behavior To Preserve

The Java event-importer exposes two modes.

Import Mode

Default mode:

event-importer --filename events.json

Supported flags:

  • --filename, -f: JSON array of CloudEvents.
  • --replacement, -r: JSON array of replacement rules.
  • --enrichment, -e: JSON array of enrichment rules.

Per event, the current importer:

  1. Parses the input event as JSON.
  2. Applies replacement and enrichment rules with EventMutator.
  3. Deserializes the mutated JSON as a CloudEvent.
  4. Reads or defaults aggregateversion.
  5. Adds missing aggregatetype from event type.
  6. Recomputes subject from event data when the subject is missing or replacement rules were applied.
  7. Skips duplicate (subject, aggregateversion) pairs inside the input batch.
  8. Skips events when the target database already has the same or a higher aggregate version for that subject.
  9. Reserves a fresh nonce from user_t for the event user.
  10. Inserts one event at a time so one bad event does not abort the whole file.

The insert path writes the event to:

  • event_store_t
  • outbox_message_t
  • notification_t with PENDING status

Snapshot Conversion Mode

Conversion mode:

event-importer --convert \
  --filename snapshot.json \
  --targetHostId 01964b05-552a-7c4b-9184-6857e7f3dc5f \
  --adminUserId 01964b05-5532-7c79-8cde-191dcbd421b8 \
  --output events.json

The current converter:

  1. Reads snapshot JSON with a top-level tables object.
  2. Sorts snapshot tables topologically from database FK metadata.
  3. Skips runtime/projection-owned tables.
  4. Maps each table to a CreatedEvent type.
  5. Merges projection-owned child data into parent event payloads where required:
    • auth_provider_key_t into auth_provider_t
    • auth_client_owner_t into auth_client_t
    • user_host_t, customer_t, and employee_t into user_t
    • api_endpoint_t and endpoint scopes into api_version_t
  6. Rewrites the source host id to the target host id recursively.
  7. Emits CloudEvent-compatible JSON with:
    • new event id
    • target host
    • admin user
    • nonce placeholder
    • subject derived from event type and data
    • aggregatetype
    • aggregateversion set to 1

The generated JSON is then imported through import mode.

Goals

  • Preserve Java CLI compatibility for existing scripts and runbooks.
  • Preserve event-store, outbox, notification, nonce, offset, and transaction semantics.
  • Keep migration behavior deterministic and testable with golden files.
  • Make the Rust implementation easier to deploy as a single static binary.
  • Keep snapshot conversion and import logic in one repository, but isolate them into testable modules.
  • Support large migration workflows with stdin/stdout piping, bounded-memory conversion options, aggregate-version caching, and configurable batch imports.

Non-Goals

  • No online REST service in the first version.
  • No schema migration management.
  • No reconciliation or diff-based promotion logic.
  • No attempt to replay projections directly. Import writes events and outbox rows; existing consumers rebuild projections.

Command Line Interface

Use clap with two subcommands while also accepting Java-compatible flags.

Preferred Rust CLI:

importer import --filename events.json
importer convert --filename snapshot.json --target-host-id ... --admin-user-id ... --output events.json
importer convert --filename snapshot.json --target-host-id ... --admin-user-id ... --output - \
  | importer import --filename -

Compatibility CLI:

importer --filename events.json
importer --convert --filename snapshot.json --targetHostId ... --adminUserId ... --output events.json

Import Options

  • --filename, -f: required event list JSON file.
  • --filename -: read event JSON from stdin.
  • --replacement, -r: JSON string or @file containing replacement rules.
  • --enrichment, -e: JSON string or @file containing enrichment rules.
  • --dry-run: parse, mutate, validate, and report without writing.
  • --fail-fast: stop on the first failed event. Default is continue.
  • --batch-size: number of events per transaction. Default is 1 for Java compatibility. Snapshot imports should use a larger value, such as 500, after validation passes.
  • --summary-json: print machine-readable summary.

Convert Options

  • --convert, -c: compatibility flag for conversion mode.
  • --filename, -f: required snapshot JSON file.
  • --targetHostId, --target-host-id, -t: required target host id.
  • --adminUserId, --admin-user-id, -u: required user id stamped on events.
  • --output, -o: optional output file. If absent or -, write JSON to stdout and diagnostics to stderr.
  • --schema-source embedded|database: default embedded. Embedded mode uses a checked-in dependency graph generated from portal DDL. Database mode uses PostgreSQL metadata and is useful for parity checks and dependency-graph refresh validation.

Configuration

Use environment-first configuration:

DATABASE_URL=postgres://postgres:secret@localhost:5432/configserver importer import -f events.json

Optional config file support can mirror local compose usage:

database:
  url: postgres://postgres:secret@localhost:5432/configserver
  max_connections: 3

Precedence:

  1. CLI flags
  2. Environment variables
  3. Config file
  4. Defaults

Use a config resolver such as figment or config so this precedence is centralized and testable instead of open-coded through the CLI.

Rust Crates And Observability

Recommended crates:

  • clap: CLI parsing for subcommands and compatibility flags.
  • serde and serde_json: strongly typed rule/config parsing plus flexible event payload handling.
  • sqlx: PostgreSQL access with explicit SQL.
  • tracing and tracing-subscriber: structured logs with event indexes, aggregate ids, table names, and transaction ids attached as fields.
  • anyhow for internal error propagation and miette for user-facing validation/conversion diagnostics.
  • figment or config: CLI/env/file/default configuration merging.
  • uuid, time, and indexmap: deterministic IDs, timestamps, and stable output ordering where needed.

Logs must go to stderr when stdout is used for generated JSON. Long-running imports should log periodic progress with totals for imported, skipped, and failed events.

Module Design

Proposed Rust modules:

src/
  main.rs
  cli.rs
  config.rs
  db.rs
  event/
    mod.rs
    cloud_event.rs
    event_type.rs
    mutator.rs
    normalize.rs
  import/
    mod.rs
    aggregate_cache.rs
    batch.rs
    importer.rs
    report.rs
  snapshot/
    mod.rs
    converter.rs
    dependency_graph.rs
    stream.rs
    table_rules.rs
    topology.rs
    row_merge.rs
  sql/
    mod.rs
    event_store.rs
    nonce.rs
    offset.rs
    notification.rs

cli

Parses both the new subcommand form and the legacy Java flags. The CLI layer should only validate argument presence and resolve files. It should not contain event mutation, conversion, or SQL logic.

db

Owns the connection pool and transaction helper. Use sqlx with PostgreSQL. Queries should be explicit SQL, not generated dynamically except for metadata inspection in snapshot conversion.

event::cloud_event

Represents CloudEvents as structured JSON plus typed helpers for required fields and extensions. The implementation may use a Rust CloudEvents SDK if it matches the portal payload exactly. If not, use a serde_json::Value backed model so the serialized payload stays byte-compatible with existing Java CloudEvents.

Required fields/extensions:

  • id
  • source
  • type
  • time
  • subject
  • specversion
  • datacontenttype
  • data
  • host
  • user
  • nonce
  • aggregatetype
  • aggregateversion

The importer must preserve unknown CloudEvent extensions in the stored payload and metadata. The metadata JSON should exclude the core portal extensions just like Java EventPersistenceImpl excludes host, user, nonce, aggregatetype, and aggregateversion.

event::event_type

Rust parity for EventTypeUtil.

Responsibilities:

  • Derive aggregate type from event type suffixes.
  • Derive aggregate id from event type and event data.
  • Keep table-to-event overrides aligned with GlobalSnapshotPersistenceImpl.

This module is a high-risk drift point. Every new portal event type that can be exported or imported must have a test case here.

event::mutator

Rust parity for EventMutator.

Rules should be parsed into strongly typed serde structs during startup. Bad rule JSON should fail before the importer opens the input event file or starts a long-running import.

Replacement rules:

[
  {"field":"hostId","from":"OLD_HOST_UUID","to":"NEW_HOST_UUID"}
]

Behavior:

  • If from and to are UUID-looking strings, recursively replace string occurrences anywhere in the event JSON.
  • If field is present, recursively replace exact field values matching from.
  • Accept legacy aliases fieldName, fromValue, and toValue.

Enrichment rules:

[
  {"field":"id","action":"generateUUID"},
  {"field":"originalUserId","action":"mapGenerate","sourceField":"userId"}
]

Behavior:

  • generateUUID: generate a new UUID for the target field.
  • mapGenerate: use a stable in-memory map keyed by field + source value.
  • Accept Java README-style aliases field and mapAndGenerate in addition to Java implementation names fieldName and mapGenerate. This keeps old scripts and docs working even though the Java implementation is stricter than the README examples.

The first Rust release should accept unversioned Java-compatible arrays and versioned rule documents. The normalized internal representation should always include a schema version so future rule changes are explicit.

Versioned rule document example:

{
  "schemaVersion": 1,
  "replacement": [
    {"field":"hostId","from":"OLD_HOST_UUID","to":"NEW_HOST_UUID"}
  ],
  "enrichment": [
    {"field":"id","action":"generateUUID"}
  ]
}

Import Flow

read file
  -> parse JSON array from file or stdin
  -> for each raw event:
       mutate JSON
       normalize CloudEvent
       derive/default aggregate version
       derive missing aggregate type
       recompute subject if needed
       skip duplicate input aggregate version
       skip existing target aggregate version using cache-backed lookup
       reserve nonce
       insert event + outbox + pending notification in one transaction
       update summary

Aggregate Version Rules

Input may contain either aggregateversion as a CloudEvent extension or aggregateVersion as a raw compatibility field. Normalize to aggregateversion.

Default is 1 when missing.

Skip rules:

  • Skip duplicate (subject, aggregateversion) inside the input file.
  • Cache target max versions in memory by aggregate_id.
  • On first sight of an aggregate id, query target MAX(aggregate_version) and populate the cache.
  • After a successful insert, update the cached max version.
  • Skip if target max version is greater than or equal to the event version.

This avoids an N-query import path for aggregates with many events in the same file. The cache is scoped to one importer run and is safe because the importer still relies on database constraints as the final authority.

Nonce Rules

Nonce must be reserved from user_t in the same transaction as the event insert:

UPDATE user_t
SET nonce = nonce + 1
WHERE user_id = $1
RETURNING nonce

Do not trust imported nonce values. They are placeholders only.

Offset And Transaction Rules

For each transaction, reserve outbox offsets from log_counter:

UPDATE log_counter
SET next_offset = next_offset + $1
WHERE id = 1
RETURNING next_offset - $1

For import mode, keep Java’s isolation behavior by default: one event per transaction and one transaction_id per event. When --batch-size is greater than 1, reserve enough offsets for the batch and write the batch in one transaction with one transaction_id.

If a batched transaction fails because of a validation or constraint error, roll back the batch and retry its events one at a time unless --fail-fast is set. This keeps fast-path imports efficient without losing the Java tool’s ability to identify the bad event and continue later events.

Insert SQL

Write both event rows in one transaction:

INSERT INTO event_store_t
  (id, host_id, user_id, nonce, aggregate_id, aggregate_version,
   aggregate_type, event_type, event_ts, payload, metadata)
VALUES
  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
INSERT INTO outbox_message_t
  (id, host_id, user_id, nonce, aggregate_id, aggregate_version,
   aggregate_type, event_type, event_ts, payload, metadata, c_offset,
   transaction_id)
VALUES
  ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12, $13)

Also insert a pending notification row:

INSERT INTO notification_t
  (id, host_id, user_id, nonce, event_class, event_json, event_ts, process_ts,
   status, error, aggregate_id, aggregate_type, aggregate_version,
   event_partition, event_offset, transaction_id)
VALUES
  ($1, $2, $3, $4, $5, $6::jsonb, $7, now(), 'PENDING', NULL,
   $8, $9, $10, NULL, NULL, $11)
ON CONFLICT (host_id, id) DO NOTHING

The pending notification keeps admin visibility consistent with Java imports and the event processor cleanup flow.

Constraint Collisions And Idempotency

Do not add ON CONFLICT DO NOTHING to event_store_t or outbox_message_t. Their primary keys and unique constraints are part of the import safety model. The importer should catch PostgreSQL unique-violation errors, inspect the existing row, and categorize the result:

  • Exact duplicate event id and aggregate version with equivalent payload: report as skipped exact duplicate.
  • Existing aggregate id/version with different payload, event type, or metadata: report as failed conflict.
  • Event id collision pointing at a different aggregate: report as failed conflict.
  • Outbox insert collision: roll back the transaction and report as failed conflict unless the corresponding event-store row proves this was an exact duplicate import.

The notification_t insert is different. It may use ON CONFLICT DO NOTHING because it is a derived admin visibility record and should not make an otherwise valid idempotent event import fail.

Snapshot Conversion Flow

read snapshot
  -> parse top-level object from file or stdin
  -> read tables map
  -> compute table order
  -> build child-row lookup maps
  -> for each table in order:
       skip runtime/projection-owned tables
       derive CreatedEvent type
       merge child row payloads when needed
       recursively replace source host with target host
       set aggregateVersion/newAggregateVersion compatibility fields
       derive subject
       emit CloudEvent JSON
  -> write array to output or stdout

Memory Strategy

The simple implementation can parse the full snapshot into serde_json::Value when the input is known to fit comfortably in memory. This is easiest for parity work and golden tests, but it should be documented as roughly multiple times the JSON file size because parsed JSON objects, strings, and maps add overhead.

For production-sized global snapshots, implement a streaming converter:

  1. Stream the tables object from the input file.
  2. Materialize only rows required for child merge rules, such as auth_client_owner_t, auth_provider_key_t, user_host_t, customer_t, employee_t, api_endpoint_t, and api_endpoint_scope_t.
  3. Stream parent table rows in dependency order and write output events incrementally.
  4. Preserve JSON array syntax by writing [ once, then comma-separated events, then ].

If strict dependency ordering requires parent rows after later child tables, the converter may use temporary spill files under a configured work directory. The default design should avoid holding gigabyte-scale snapshots as a single JSON tree.

Snapshot Format

Expected top-level fields:

  • exportVersion
  • sourceHostId
  • exportScope
  • exportTs
  • tables

Each table entry contains rows, where row keys are already camelCase.

Table Ordering

Embedded mode should use a checked-in table dependency graph generated from the portal DDL. Database mode should query PostgreSQL metadata and perform the same Kahn topological sort used by Java:

  • parent tables before child tables
  • only dependencies between tables present in the snapshot matter
  • if a cycle is found, append remaining tables deterministically and warn

The embedded graph should be the default so CI, offline conversion, and local testing do not require a running PostgreSQL instance. Add a validation command or test that compares the embedded graph with database metadata to catch DDL drift.

Table Skip Rules

Keep these sets centralized in snapshot::table_rules.

Auth runtime state skipped in both export/conversion logic:

auth_session_audit_t
auth_session_t
auth_refresh_token_t
auth_code_t
auth_ref_token_t
auth_client_token_t

Projection-owned/runtime tables skipped in conversion:

employee_t
customer_t
notification_t
user_host_t
user_crypto_wallet_t
auth_provider_key_t
auth_client_owner_t
api_endpoint_t
api_endpoint_scope_t
private_conversation_t
private_message_t
private_message_state_t
agent_memory_*
agent_session_history_t
snapshot_*

The exact skip sets must stay aligned with GlobalSnapshotPersistenceImpl. A test should compare the Rust list against a fixture extracted from the Java source until the Java importer is retired.

Table-To-Event Overrides

Most tables map from snake case to PascalCase plus CreatedEvent, but several tables require explicit overrides:

environment_property_t             -> ConfigEnvironmentCreatedEvent
instance_api_property_t            -> ConfigInstanceApiCreatedEvent
instance_app_property_t            -> ConfigInstanceAppCreatedEvent
instance_app_api_property_t        -> ConfigInstanceAppApiCreatedEvent
instance_file_t                    -> ConfigInstanceFileCreatedEvent
deployment_instance_property_t     -> ConfigDeploymentInstanceCreatedEvent
instance_property_t                -> ConfigInstanceCreatedEvent
product_property_t                 -> ConfigProductCreatedEvent
product_version_property_t         -> ConfigProductVersionCreatedEvent
value_locale_t                     -> RefLocaleCreatedEvent
relation_type_t                    -> RefRelationTypeCreatedEvent
relation_t                         -> RefRelationCreatedEvent
auth_client_t                      -> ClientCreatedEvent
wf_definition_t                    -> WorkflowDefinitionCreatedEvent

Child Row Merge Rules

Some tables are not standalone aggregates. Their rows must be embedded into the parent event payload.

Required merge rules:

  • auth_provider_key_t: group by providerId; attach key payloads to the matching auth_provider_t row.
  • auth_client_owner_t: group by ownerId; merge owner data into the matching auth_client_t row so ClientCreatedEvent has the replay contract it needs.
  • user_host_t, customer_t, employee_t: group by userId; merge dependent user state into UserCreatedEvent.
  • api_endpoint_t and api_endpoint_scope_t: group endpoint payloads by apiVersionId; attach to ApiVersionCreatedEvent.

This is the most important conversion parity area. Missing merge logic can produce an apparently valid event list that later fails replay or silently loses projection state.

Host Rewrite

When sourceHostId exists, recursively replace all exact string occurrences of the source host id with targetHostId before deriving subject.

This order is required. If the subject is derived before replacement, old-host aggregate ids survive and can collide on (aggregate_id, aggregate_version).

Reports And Exit Codes

Import summary should include:

{
  "file": "events.json",
  "total": 100,
  "imported": 97,
  "skippedDuplicateInput": 1,
  "skippedExistingTarget": 1,
  "failed": 1
}

Exit code policy:

  • 0: completed; no failed events
  • 2: completed with skipped events only
  • 3: completed with failed events and --fail-fast was not set
  • 4: validation/config/connection error
  • 5: conversion failed

The Java tool currently exits successfully after per-event failures because it logs and continues. The Rust tool should preserve continue behavior by default, but explicit exit codes make automation safer.

Testing Strategy

Unit Tests

  • CLI parsing for new and compatibility forms.
  • Replacement and enrichment rules, including alias support.
  • Versioned rule document parsing and fail-fast validation.
  • CloudEvent normalization and metadata extraction.
  • Aggregate type and aggregate id derivation.
  • Aggregate max-version cache behavior.
  • Table skip rules.
  • Table-to-event override mapping.
  • Child row merge rules.

Golden File Tests

Use committed fixtures from event-importer:

  • events/bootstrap/*.json
  • events/local/*.json
  • representative snapshot JSON files

Golden assertions:

  • Java-converted snapshot and Rust-converted snapshot produce equivalent event arrays after ignoring nondeterministic fields (id, time).
  • Rust import dry-run summary matches expected counts.
  • convert --output - | import --filename - works without an intermediate events file.
  • Host rewrite happens before subject derivation.
  • auth_client_t conversion includes owner payload.
  • PII token tables convert only when event/replay support exists.

Database Integration Tests

Use disposable PostgreSQL. H2 is not recommended for Rust because the runtime target is PostgreSQL-specific SQL and JSONB.

Test cases:

  1. Import one event and verify event_store_t, outbox_message_t, and notification_t.
  2. Import duplicate input aggregate version and verify skip count.
  3. Import when target already has equal/higher aggregate version and verify skip.
  4. Verify nonce increments from user_t.
  5. Verify outbox offsets are gapless for successful inserts.
  6. Verify failed event rolls back without blocking later events.
  7. Verify unique-constraint collisions are categorized as exact duplicate or failed conflict.
  8. Verify --batch-size imports multiple events in one transaction and falls back to one-event isolation when a batch fails.
  9. Convert a snapshot and import the resulting event list into an empty DB.
  10. Compare embedded table dependency order against live PostgreSQL metadata.

Rollout Plan

Phase 1: Rust Project Skeleton

  • Add Cargo project in importer.
  • Add CLI, config loading, logging, and database connection.
  • Add dry-run import that parses and normalizes events.

Phase 2: Import Parity

  • Implement mutator and aggregate utilities.
  • Implement event-store/outbox/notification transaction.
  • Add duplicate/existing skip logic and aggregate max-version cache.
  • Add --batch-size with failed-batch fallback to one-event isolation.
  • Validate against existing events/bootstrap and events/local files.

Phase 3: Snapshot Conversion Parity

  • Port table ordering, skip rules, event overrides, and merge rules.
  • Add embedded table dependency graph generated from portal DDL.
  • Generate ordered CloudEvent JSON.
  • Support --filename - and --output - so conversion can pipe directly into import.
  • Compare Rust output with Java output using golden fixtures.
  • Add streaming conversion or bounded-memory spill-file support before using the tool for multi-gigabyte snapshots.

Phase 4: Operational Hardening

  • Add release build and static binary packaging.
  • Add wrapper scripts equivalent to importer.sh and converter.sh.
  • Add README migration notes.
  • Deprecate Java event-importer after the Rust importer passes the same snapshot conversion and import scenarios.

Design Decisions

  • Include an embedded table dependency graph in the first release and keep database metadata mode as a validation/refresh path.
  • Implement --batch-size during import parity work. Default to 1 for Java behavior, but support larger batches for initial snapshot imports.
  • Support stdin/stdout in both modes so operators can run importer convert ... --output - | importer import --filename -.
  • Formalize replacement/enrichment as versioned serde schemas while accepting Java-compatible unversioned arrays for migration compatibility.

Event Replay

Status

This document defines the revised target design for event replay during the early development stage of light-portal. The implementation still contains legacy-backfill, staged-rollout, feature-gate, mandatory-encryption, and allowlist machinery that does not yet match this design. That code will be reviewed separately after this design is accepted.

The design deliberately optimizes for a new development deployment:

  • replay works as soon as the code and replay database tables are deployed;
  • only failures observed after deployment are replay candidates;
  • historical DLQ rows are not migrated or replayed;
  • one small configuration provides an execution circuit breaker and identifies exceptional event types that must not be replayed;
  • event validity and replay safety are enforced in code;
  • Light Gateway controls which roles may call each replay endpoint;
  • application-level payload encryption is not required for replay correctness.

Motivation

The portal has two projection processors:

  • DbEventConsumerStartupHook reads outbox_message_t, tracks progress in consumer_offsets, and writes failed events to the PostgreSQL dead_letter_queue table.
  • PortalEventConsumerStartupHook in user-query reads Debezium-published Kafka records, commits Kafka consumer offsets, and publishes failed records to the configured Kafka DLQ topic.

Both processors ultimately call PortalDbProvider.handleEvent(conn, event). They isolate a failed transaction and advance to later independent work, but a DLQ row alone is not a safe replay instruction:

  • one row may be only one member of a multi-event business transaction;
  • aggregate versions or graph revisions may require earlier failed transactions first;
  • replay can race with newer live projection work;
  • rewinding a shared source offset reprocesses unrelated transactions.

Event replay therefore operates on complete canonical failure transactions, not individual notification or DLQ rows. It invokes the same projection executor as live processing without rewinding PostgreSQL or Kafka offsets.

Goals

  • Capture every new failed projection transaction in a replayable canonical form.
  • Preserve the original transaction boundary, event order, payload, identity, aggregate version, graph revision, and source coordinates.
  • Distinguish exact replay after a processor fix from controlled repair of an event whose business data is permanently invalid.
  • Validate replay-required metadata when commands append events to event_store_t and outbox_message_t.
  • Support PostgreSQL and Kafka through the same planning and execution model.
  • Derive ordinary replay policy from validated event metadata instead of maintaining a large event allowlist.
  • Permit an explicit exclusion list for exceptional, non-idempotent, or externally side-effecting projection events.
  • Use the same projection handler path for live processing and replay.
  • Keep unrelated hosts, aggregates, and graph roots processing while a failed scope is repaired.
  • Require an immutable plan, a reason, authorization, and a distinct approver before execution.
  • Start replay capture, APIs, and the asynchronous worker by default after the schema is present.
  • Keep configuration small enough that a developer can understand the entire replay setup from one screen.

Non-Goals

  • Historical or legacy dead_letter_queue rows are not imported. They remain diagnostic history only.
  • The replay planner is not an event editor. It can use the original payload or reference a separately validated and approved repair; it cannot create or modify that repair.
  • A repair never overwrites the original event or changes event IDs, transaction membership, aggregate identity/version, graph revision, or source coordinates.
  • Replay does not replay successful history to rebuild an empty projection.
  • Replay does not make external side effects or non-idempotent handlers safe.
  • Replay does not replace event import, promotion, snapshot restore, or a full projection rebuild.
  • The initial design does not require an object store, application-managed encryption keys, production rollout stages, canary allowlists, or legacy migration jobs.

Operating Model

command validation
       |
       v
event_store_t + outbox_message_t
       |
       v
PostgreSQL or Kafka live projection processor
       |
       +---------------- success ----------------> projection tables
       |
       +---------------- failure
                              |
                              v
                  canonical failure transaction
                              |
                              v
                     classify failure
                        /       \
                       v         v
             exact replay    propose repair
                       \         /
                        v       v
                  plan -> approve -> execute
                              |
                              v
                    asynchronous replay worker
                              |
                              v
                       projection tables

The normal Pub/Sub configuration selects the live processor. Event replay must not introduce another source-selection property. PostgreSQL deployments use the database source metadata; Kafka deployments use the Kafka source metadata.

All of these components are standard, always-active platform behavior after the replay schema and services are deployed:

  1. command-side event validation;
  2. canonical failure capture;
  3. replay query and command APIs;
  4. the built-in approval state machine;
  5. the asynchronous replay worker.

Replay and repair are different operations. Exact replay is appropriate when the event was valid and the projection handler was defective or temporarily unable to process it. Repair is appropriate only when the persisted business data itself is invalid and exact replay would deterministically fail again.

There are no independent capture, planning, rollout, source, projection, consumer-group, or host switches in the development configuration. One enabled property is retained solely as the replay-execution circuit breaker.

Event Append Contract

Replayability begins when a command appends events, not after an event fails. The command path must reject invalid transactions before committing event_store_t or outbox_message_t.

Transaction invariants

Every appended transaction must satisfy:

  • transaction_id is present and uses the canonical UUID representation;
  • transaction_count is positive and identical on every member;
  • transaction_ordinal is contiguous from zero through transaction_count - 1;
  • every member has the same host and transaction identity;
  • event IDs are present and unique;
  • the transaction does not contain duplicate ordinals;
  • event_store_t and outbox_message_t are committed atomically;
  • the persisted source order is deterministic.

Database constraints enforce uniqueness and basic ranges. Java validation enforces cross-row completeness before commit. Tests must prove that a partial transaction cannot become visible to either live processor.

Event invariants

Every event must contain a structurally valid CloudEvent envelope, including:

  • event ID and event type;
  • host identity;
  • schema/specification version;
  • event timestamp;
  • parseable data;
  • aggregate ID, aggregate type, and positive aggregate version for aggregate events;
  • root instance ID and positive graph revision for graph-ordered events;
  • any handler-specific identity required to make the projection idempotent.

The command path also validates the event data against the registered schema for that exact event type and schema version. It enforces required properties, property types, bounded values, and domain invariants that can be evaluated without projection state. A failure returns a command validation error and appends nothing; it is not a DLQ or replay candidate.

The validation registry is shared by command append, live projection, failure capture, planning, and replay. A handler must not interpret an event as one policy during live processing and another policy during replay.

Every appended event records the exact registry and repair-schema version used to validate it. Later live projection, capture, planning, repair, and replay use that pinned version; a deployment may add a new version but must not remove a version while an event, open failure, repair, or plan references it. Unknown registry entries fail closed on portal/internal append. An external Kafka event with an unknown entry is captured as diagnostic, non-executable evidence rather than interpreted under the current default. This prevents a registry edit from retroactively changing the meaning of already-committed events.

Projection handler contract

Replayable projection handlers must:

  • perform database projection work through the caller’s transaction;
  • be idempotent for the original event ID and ordering metadata;
  • use monotonic aggregate-version or graph-revision checks when ordered;
  • avoid network calls, email, message publication, payment submission, or any other non-transactional external side effect;
  • produce the same projection outcome in LIVE and REPLAY modes.

An unordered event whose handler cannot satisfy this contract belongs in excludedEventTypes. An aggregate- or graph-ordered handler cannot be excluded: doing so would create a projection gap that cannot be replayed or safely waived. Such a handler must be made transactionally idempotent, normally by moving its external effect behind an outbox, before it can carry ordering metadata.

Replay Eligibility Policy

Shared append-validation registry

Every portal or internal append is validated against the exact event type and event schema version in event-replay-policy-v2. The registry declares the transaction/order policy, dependency metadata, data validator, replay policy, repair disposition and optional repair-schema version. It is code-owned and versioned; it is not another operator switch in event-replay.yml.

The common append boundary validates the complete transaction before reserving an outbox offset or inserting event, outbox, or notification rows. Interactive commands, graph/clone operations, global snapshot imports, and scheduler commands all use this boundary. A successful append pins eventschema and replaypolicy CloudEvent extensions, plus repairschema when applicable, and persists those versions in event_store_t and outbox_message_t. Canonical failure capture copies the pins into the failure transaction and member rows so planning and replay load the referenced version instead of reinterpreting an old event with the latest registry.

Unknown portal/internal event types, malformed transactions, mixed host/user identity, non-contiguous member order, invalid aggregate or graph metadata, and schema-invalid data fail closed and commit no append-side writes. An unknown or structurally invalid external Kafka event can be retained as diagnostic failure evidence, but it is marked non-executable and cannot become a replay candidate.

PORTAL_OBJECT_V1 is the shared baseline validator for current event schema version 1: it proves the CloudEvent data is a parseable JSON object and combines that with the registered envelope, identity, ordering, dependency, and size checks. It does not duplicate every command handler’s required-field and domain rules. Those rules still run before ordinary command event construction. Any event declared SCHEMA_REPAIR must instead name a concrete typed repair/data schema; R9 qualification rejects a repairable event that still relies only on the structural baseline. This distinction prevents the baseline name from being misread as complete per-event domain validation.

The Kafka consumer invokes the same external validation before live projection. A registered, valid portal event remains executable. A structurally valid but unknown or excluded event is captured as canonical diagnostic evidence whose policy rejects planning. A malformed member makes the complete transaction non-executable; it is recorded through the bounded failure notification and configured legacy diagnostic path without creating a canonical replay candidate, and its offset may then advance rather than poison-loop forever.

The default policy is derived from validated metadata:

Event evidenceDerived policyBehavior
Root instance ID and graph revisionGRAPH_ROOTOrder by root and require contiguous graph revisions.
Aggregate ID, type, and versionAGGREGATE_VERSIONOrder by aggregate and require monotonic versions.
Complete transaction without stronger ordering metadataTRANSACTION_ONLYReplay the complete transaction as one unit.
Explicitly excluded event typeNOT_REPLAYABLEKeep diagnostic failure evidence but reject planning.
Missing or contradictory required metadataInvalid eventReject at append; if received from an external Kafka producer, do not create an executable candidate.

This removes the need to list hundreds of ordinary replayable events. A new event is replayable when its append contract and projection handler satisfy the derived policy. Exceptional events are excluded explicitly.

For example, UserDeletedEvent contains aggregate identity and version metadata. Its projection handler must use the monotonic aggregate-version guard; it then derives AGGREGATE_VERSION without a dedicated allowlist entry.

An exclusion is an exact event-type match. Unknown patterns, substrings, and wildcards are not accepted because they make policy review ambiguous. Configuration reload also rejects an exclusion whose registered policy is GRAPH_ROOT or AGGREGATE_VERSION. Registry validation rejects an ordered NOT_REPLAYABLE policy for the same reason. Exclusion is therefore available only for transaction-only or explicitly unordered events and cannot brick an ordered scope.

Minimal Configuration

The complete development-facing event-replay.yml is:

# Execution circuit breaker. This does not disable capture, queries, planning,
# approval, or durable replay state.
enabled: ${event-replay.enabled:true}

# Exact event type names whose projection handlers are not safe to replay.
excludedEventTypes: ${event-replay.excludedEventTypes:}

enabled defaults to true and controls execution only. Event validation, canonical failure capture, candidate and status queries, planning, approval, and durable replay state remain active when it is false. An empty exclusion list means every event satisfying the append and projection-handler contracts derives its replay policy from its metadata.

Limits, lease durations, plan expiry, retry counts, approval requirements, and safe error sizes use reviewed application defaults. They do not need operator properties during early development. They can become advanced production configuration later without changing the public replay contract.

The worker identity is derived from the deployed service identity plus a per-process instance identifier. Developers do not configure a separate replay client ID merely to start the worker.

Gateway endpoint roles remain in Light Gateway access-control configuration, not event-replay.yml.

Execution circuit breaker

An emergency may require replay execution to pause while diagnosis continues. Examples include a projection-handler regression, database overload, an incompatible rolling deployment, repeated worker failures, or an authorization incident. These conditions do not justify disabling event validation or failure capture.

Execution pause is controlled by changing event-replay.enabled to false in the config server and pushing the change to every hybrid-command and hybrid-query instance. Config-server change history provides the actor, timestamp, and change evidence. Pausing:

  • makes the execute endpoint reject new execution requests with REPLAY_EXECUTION_PAUSED;
  • prevents workers from claiming new replay requests;
  • does not interrupt an already-running database transaction unsafely;
  • does not stop command-side event validation or canonical failure capture;
  • does not hide candidates, plans, attempts, or status APIs;
  • does not prevent creating or approving a plan;
  • does not delete or alter durable replay state;
  • does not require a service restart.

Scheduled requests remain durable while paused; approved but unscheduled plans remain available only until their immutable expiry. An in-flight transaction finishes or rolls back under its existing database fence. Changing enabled back to true and pushing the configuration resumes worker claims and queued, non-expired work. Removing gateway permission may prevent new operator requests, but it is not a substitute for pausing an already-approved worker queue.

EventReplayConfig must be a reloadable light-4j module. Config reload clears the cached event-replay document, and command handlers read the current value before every execute transition. The wake-up dispatcher and claimant read the current value before starting or claiming work rather than retaining only the startup snapshot. Like AuditConfig, it compares the cached configuration-map identity on each load()/current() access and rebuilds its immutable snapshot after ConfigReloadHandler clears the cache. No replay-specific callback or light-4j framework change is required. A transition from false to true schedules a drain when it is observed by the next notification, command/status read, or periodic recovery scan. With no other activity, queued approved work resumes within the 60-second recovery interval. The effective execution state is exposed through health/status together with the config-server version or generation, reload timestamp, and process instance ID. The deployment health aggregator reports the expected replicas, their effective values and generations, and whether they agree. A multi-instance pause is complete only when that single fleet view reports every expected command/query replica at the same pushed generation with enabled=false; missing or stale replicas keep the pause state unconfirmed.

Shared Projection Transaction Executor

PostgreSQL live processing, Kafka live processing, and replay call one shared executor:

ProjectionResult execute(
    Connection connection,
    ProjectionTransaction transaction,
    ProjectionExecutionMode mode
) throws Exception;

ProjectionExecutionMode is LIVE or REPLAY. It may change telemetry and audit context but must not select a different projection handler. Exact replay passes the original CloudEvent. Repair replay passes a transaction materialized by the approved repair resolver before the executor is called; the executor itself never edits an event.

The executor:

  1. validates transaction membership and event order;
  2. calls PortalDbProvider.handleEvent(conn, event) for every member;
  3. completes graph-revision and aggregate-version bookkeeping;
  4. completes clone or other transactional projection outcomes;
  5. records notification outcomes through the caller’s connection;
  6. leaves commit or rollback to the caller.

The same transaction must contain projection writes, ordering metadata, replay attempt outcome, and failure resolution.

Canonical Failure Capture

Canonical capture applies only to failures observed after the feature is deployed. The legacy DLQ remains visible for diagnostics but is not queried by the replay candidate API and is never backfilled.

PostgreSQL processor

When a complete projection transaction fails:

  1. roll back projection writes to the transaction savepoint;
  2. construct a canonical envelope from the complete ordered transaction;
  3. persist the failure transaction and all event members;
  4. update the notification status to DLQ;
  5. commit canonical failure capture and claimed source progress atomically;
  6. continue with the next independent transaction.

If canonical capture fails, source progress does not advance. A failed event must not be committed past unless the payload needed for replay is durable.

The existing PostgreSQL dead_letter_queue write may remain temporarily for diagnostic compatibility, but replay correctness depends only on canonical failure capture for new failures.

Kafka processor

When a complete Kafka projection transaction fails:

  1. roll back projection writes;
  2. persist the canonical transaction with original keys, headers, topic, partition, offsets, transaction identity, count, and order;
  3. commit the source Kafka offsets only after canonical persistence commits;
  4. publish to the external Kafka DLQ independently when that integration is configured.

If canonical persistence fails, the source offset is not committed and Kafka redelivers the records. Capture is idempotent by content fingerprint.

This is intentionally an at-least-once boundary across PostgreSQL persistence and Kafka offset commit, not a distributed transaction. PostgreSQL capture must commit first; Kafka may redeliver after a crash, and the deterministic fingerprint must collapse that delivery into the existing failure. Reversing or parallelizing this order is forbidden because it can lose the only durable replay payload.

External Kafka producers that omit transaction count/order or required event metadata are rejected as executable replay candidates. The system does not guess transaction boundaries.

Canonical Failure Model

One canonical failure represents one complete logical transaction. It records:

  • host, projection, and consumer group;
  • original transaction ID and ordered member count;
  • original source processor and coordinates;
  • content fingerprint;
  • dependency scopes derived from event metadata;
  • bounded error code and message;
  • first and latest failure timestamps;
  • lifecycle status: OPEN, RESOLVED, or WAIVED.

Each ordered event member records:

  • ordinal, event ID, and event type;
  • aggregate identity and version when present;
  • graph root and revision when present;
  • original source coordinates, key, and headers when applicable;
  • original payload or a durable payload reference;
  • payload format and SHA-256 digest.

The content fingerprint is deterministic over projection identity, transaction identity, ordered event IDs, and ordered payload digests. A redelivery at a different source offset observes the same logical failure rather than creating another candidate.

Once an ordered failure is canonically captured, new commands for the affected aggregate or graph scope are blocked until exact replay or repair restores projection continuity. This bounds further accumulation behind a known poison event, but it cannot guarantee that N+1 is never appended: projection and capture are asynchronous, so commands may append during the interval between the original append and committed failure capture. The block is therefore a prompt, eventually-visible guard after capture, not a synchronous projection cursor.

Before classification, blocked commands return AGGREGATE_PROJECTION_BLOCKED with a safe failure reference. Once a validated repair proposal classifies the failure as invalid data, they return AGGREGATE_REPAIR_REQUIRED; the operator uses the repair flow instead of resubmitting through the stale projection UI. Waiver may close the operator action for diagnostic or unordered failures. A failure with an open AGGREGATE or GRAPH_ROOT scope is rejected with INVALID_REPLAY_STATE; it must be exact-replayed or repaired so the ordered projection gap is closed.

This is a deliberate consistency-over-availability decision. One failed ordered transaction can deny commands for that scope until a fix and exact replay or an approved repair succeeds. There is no generic break-glass that advances ordering metadata without applying the missing projection. Health and alerts report blocked-scope count, age, host, projection, and safe failure ID; crossing the reviewed duration threshold is an operator incident. The existing barrier release can recover worker isolation but cannot pretend an ordered gap is resolved or make later versions safe.

The command guard reads normalized OPEN rows in event_failure_scope_t using the partial command-path index. It matches host plus AGGREGATE (aggregateType:aggregateId) or GRAPH_ROOT scope, so another host, aggregate, or root continues normally. Before a repair proposal it returns AGGREGATE_PROJECTION_BLOCKED; once a validated proposal is awaiting approval or approved it returns AGGREGATE_REPAIR_REQUIRED. The administrative replay status reports the blocked-scope count, oldest blocked timestamp and age. The reviewed code default is 900 seconds; an oldest blocked scope at or above that age sets blockedScopeIncident=true. This threshold is intentionally an internal runtime default, not additional development configuration.

notification_t is the latest user-facing status, not the replay ledger. The candidate APIs read canonical failure tables only.

Payload Storage and Encryption

Application-level encryption is not required for replay correctness and is not mandatory in the early-development design.

Canonical and repaired payloads are stored as immutable bytes (BYTEA for the plain database representation). The SHA-256 content digest is computed over exactly those stored canonical bytes. Replay verifies and parses those same bytes; it never recomputes a digest from a JSONB value or re-serialized object.

event_store_t and outbox_message_t currently store JSONB, which normalizes representation and is not a stable raw-byte archive. A canonical failure may therefore reference those rows for identity and audit, but not as the sole digest-bound payload unless a future schema also stores the versioned canonical bytes. For current PostgreSQL capture, serialize through the versioned canonical JSON encoder once and copy the resulting bytes into the canonical member before source progress commits. Kafka capture stores the received value bytes. Repair creation likewise materializes and stores corrected canonical bytes once.

In either case:

  • the payload is immutable after capture;
  • a SHA-256 digest over the stored bytes is stored and verified before execution;
  • the UI, list APIs, logs, metrics, and audit records never expose the payload, Kafka key, headers, or event JSON;
  • the baseline schema revokes payload-column access from PUBLIC; production deployments use dedicated non-owner projection/replay roles with explicit column-scoped grants because owners and explicit table grants bypass that baseline;
  • normal database and volume encryption at rest protect the development deployment.

Optional envelope encryption or object storage may be added for production when retention, PII, regulatory, or storage requirements justify it. Enabling that option must not change planning, fingerprints, ordering, or projection behavior, and its key configuration must not be required for ordinary development startup. A secure representation must retain the stable digest of the canonical plaintext bytes separately from any digest of randomized ciphertext or object-storage bytes; ciphertext digests are storage-integrity evidence and must never define the corrected transaction fingerprint.

Repair Model

Repair is an append-only amendment to a canonical failed transaction. It is not an update to event_store_t, outbox_message_t, a Kafka record, or the canonical failure payload. The original event remains available with its original digest for audit and diagnosis.

The minimum repair persistence model is:

  • event_repair_t: repair ID, host, target failure, lifecycle status, reason, requester, approver, timestamps, original transaction fingerprint, and corrected transaction fingerprint;
  • event_repair_event_t: repair ID, original event ID and ordinal, original digest, corrected data or durable reference, corrected digest, schema version, and the names of changed fields.

Repair lifecycle states are AWAITING_APPROVAL, APPROVED, APPLIED, CANCELLED, and REJECTED. Rows and corrected payloads become immutable when the proposal enters AWAITING_APPROVAL; a change requires a new repair ID and new approval.

Repair proposal contract

A repair proposal always targets one complete canonical failure transaction. It may correct the data of one or more members, but it preserves:

  • event IDs, event types, transaction ID, count, order, host, and source coordinates;
  • aggregate ID, aggregate type, and aggregate version;
  • graph root and graph revision;
  • unchanged transaction members byte-for-byte.

Editable fields come from an event-type-specific repair schema. The UI does not provide a generic CloudEvent or JSON editor. The server exposes only authorized, schema-approved business fields, applies field-level redaction, and revalidates the complete corrected transaction with the same schema and domain validators used by command append. Envelope, identity, authorization, and ordering fields are server controlled.

The repair command requires an explicit changeShape discriminator. With SINGLE_EVENT_FIELDS, changes is {field: value} and the complete transaction must have exactly one member for the requested repair schema. With PER_EVENT_FIELDS, changes is keyed by immutable event ID and each value is that member’s typed field object. The server never infers event scoping from whether a business-field value happens to be an object. This is not a raw JSON editor: every event ID must belong to the target transaction, every field must be declared by the pinned repair schema, and the server reconstructs the complete CloudEvent from its immutable canonical envelope. The metadata query returns event IDs, ordinals, digests, changed field names, actors, and lifecycle timestamps, but no original or corrected payload values.

R5 delivers the complete persistence, fingerprint, approval, and API framework, but its only executable repair-schema implementation is the isolated contract fixture (event-replay-contract-fixture-repair-v1). No production portal event is repairable merely because this framework exists. Concrete per-event typed schemas and validators are added to the versioned registry and proven through the UI/API flow by the R9 qualification gate; until then, non-fixture schema requests fail closed with REPAIR_SCHEMA_VALIDATION_FAILED.

Every event policy explicitly declares one repair disposition: SCHEMA_REPAIR, FIX_AND_EXACT_REPLAY_ONLY, or NOT_REPAIRABLE_UNORDERED. SCHEMA_REPAIR names a versioned repair schema and the registry coverage gate requires that schema to exist. FIX_AND_EXACT_REPLAY_ONLY is allowed for an ordered event only as an explicit decision: invalid external data keeps the scope blocked until a deployment makes the original event processable or adds a new repair-schema version. NOT_REPAIRABLE_UNORDERED cannot be used for an ordered policy. There is no implicit empty repair schema.

The proposal stores both payload digests and a bounded audit summary of changed field names. Payload values do not appear in audit messages, logs, metrics, or ordinary replay APIs. The requester cannot approve the repair.

Approval and rejection use one command endpoint with an explicit APPROVE or REJECT decision and an expected corrected-transaction fingerprint. The provider locks the immutable proposal and performs a compare-and-set from AWAITING_APPROVAL; approval records the independent reviewer, while rejection is terminal. There is no caller cancellation endpoint. A waiver or other valid terminal resolution cancels any AWAITING_APPROVAL or APPROVED repair in the same database transaction, and later repair reads reconcile a proposal if they observe that its target failure is already terminal.

Repair planning and execution

An approved repair is input to the planner, not output from it. A repair plan binds the repair ID, approval, original fingerprint, corrected fingerprint, schema version, dependency closure, and projection preconditions into the plan hash. Any change makes the plan stale.

The R5 loadApproved result is a verified snapshot for planning integration, not execution authority. R6 execution must lock the repair and target failure in the canonical failure-then-repair order, recheck both lifecycle states, and reverify original/corrected fingerprints and stored digests in the execution transaction before applying corrected bytes.

The replay worker installs the normal scope barrier and materializes the corrected transaction using the original immutable envelope plus the approved corrected data. The transaction executes at its original logical aggregate version or graph revision through the shared projection handler. This avoids trying to insert another (aggregate_id, aggregate_version) into event_store_t and avoids generating version N+1 while the projection is still at N-1.

Projection writes, ordering metadata, repair status APPLIED, replay attempt completion, and failure status RESOLVED with resolution code RESOLVED_BY_REPAIR commit atomically. Failure leaves the repair approved and retryable and keeps the scope quarantined. Waiver does not apply a repair and never advances projection metadata.

Approved repairs are permanent canonical history. Exact replay of that failure and any future projection rebuild must resolve the original event through the approved repair record and verify both fingerprints. A deployment that loses the repair tables cannot deterministically rebuild repaired projections and must fail closed rather than fall back to the poison payload.

The shared canonical repair resolver accepts both the initial APPROVED materialization and an APPLIED materialization bound by resolved_by_repair_id. Exact replay of an already repaired failure binds the applied repair ID and fingerprints into a new immutable plan, revalidates the original and corrected digests under the normal execution locks, and leaves the terminal repair/failure lifecycle unchanged after projection. Original failure members referenced by an applied repair are exempt from payload and failure metadata retention so unchanged transaction members remain available to this resolver. A future rebuild must call this same resolver rather than create a parallel correction mechanism.

Planning

The UI selects canonical failure transaction IDs. Selecting one member always selects its complete transaction.

The planner:

  1. loads complete immutable failure transactions;
  2. loads any explicitly selected, approved repairs;
  3. verifies original and corrected payload digests and availability;
  4. rejects excluded event types;
  5. derives graph, aggregate, or transaction-only scopes;
  6. adds required failed dependency transactions;
  7. deterministically orders the dependency graph;
  8. records projection preconditions and isolation scope;
  9. creates an immutable plan hash and expiry.

Supported selection strategies are:

  • EXACT: use exactly the selected complete transactions when no earlier dependency is missing;
  • DEPENDENCY_CLOSURE: add required failed predecessors automatically.

There is no unbounded Replay All operation. Bulk selection is bounded by application defaults and always produces a preview before approval.

Execution rejects a stale plan when failure content, dependency state, projection versions, repair approval, schema version, or payload digests change after planning. The planner cannot accept inline corrected data.

Plan expiry continues after approval. APPROVED may transition to EXPIRED, and execute compares the current time with the immutable expiresAt before scheduling. Pausing execution does not extend the TTL; an expired approved plan requires a new plan and approval instead of executing unexpectedly after a long pause.

Approval and Authorization

Light Gateway is the role-based authorization boundary for all replay service IDs. Its endpoint rules map deployment-defined role names to the JWT role claim. Replay code does not hard-code admin, host-admin, replay-admin, or any other role name.

The minimum authorization model is one authorized role and two distinct users:

  • user A creates the replay plan;
  • user B, authorized for the same host, approves the exact plan hash;
  • an authorized user requests execution after approval.

The early-development deployment therefore assumes that two test identities can be created in the host. There is no single-user or development-mode bypass: such a bypass would make the same artifact behave differently when promoted and would weaken the audit evidence this feature exists to provide.

Existing admin or host-admin roles may be assigned to every replay endpoint, or a deployment may create one dedicated role. host-admin remains host scoped; endpoint permission never bypasses token-host validation.

The built-in state machine records requester, approver, executor, reason, plan hash, and timestamps. It rejects requester-as-approver. It does not require light-workflow or create a manual task. A future workflow integration may drive the same transitions without weakening these invariants.

API Contract

Replay remains in the existing user-query and user-command services:

OperationTypeService ID
List replay candidatesQuerylightapi.net/user/listEventReplayCandidate/0.1.0
Get failure transactionQuerylightapi.net/user/getEventReplayFailure/0.1.0
Create immutable planCommandlightapi.net/user/createEventReplayPlan/0.1.0
Get plan/statusQuerylightapi.net/user/getEventReplay/0.1.0
Approve planCommandlightapi.net/user/approveEventReplay/0.1.0
Execute approved planCommandlightapi.net/user/executeEventReplay/0.1.0
Cancel before executionCommandlightapi.net/user/cancelEventReplay/0.1.0
Waive explicit failure transactionsCommandlightapi.net/user/waiveEventReplayFailure/0.1.0
Release a quarantined barrierCommandlightapi.net/user/releaseEventReplayBarrier/0.1.0
Get a repair proposalQuerylightapi.net/user/getEventReplayRepair/0.1.0
Create a validated repair proposalCommandlightapi.net/user/createEventReplayRepair/0.1.0
Approve a repair proposalCommandlightapi.net/user/approveEventReplayRepair/0.1.0

All request bodies are host-scoped and bounded. Host and actor identity come from trusted token/audit context, not caller-supplied authorization fields. The approve-repair command accepts APPROVE or REJECT. CANCELLED is a system transition when the target failure reaches another terminal outcome; there is no separate repair-cancel endpoint, so the public contract remains exactly twelve endpoints.

Waiver remains a two-person operation without adding a thirteenth endpoint. The requester first calls waiveEventReplayFailure with the exact failure IDs; the response is AWAITING_APPROVAL and includes a waiverRequestId plus the computed downstream impact. A different user approves by calling the same endpoint with that waiverRequestId, the exact failure IDs, and the expected downstream blocked failure IDs. Neither step advances projection metadata.

This is an intentional v2 narrowing of the former waiver surface. Deployments upgrading from v1 must not expect previously permitted ordered-failure waivers to remain available: any still-open ordered failure now requires exact replay or an approved repair.

V2 inheritance is closed, not catch-all. Only sections named in inheritsFrom.inheritedSections carry forward from v1. The shared LIVE/REPLAY execution modes, validation-mode semantics, Kafka DLQ evidence contract, replay policies, and failure/barrier/audit state remain inherited. V1 featureGates, mandatory encryption, required objectStore, operator-facing limits, and fixed retentionDays are explicitly superseded and must not be merged into v2.

Isolation and Execution

Replay must not race with newer live work for the same ordered scope.

Preferred barriers are:

  • GRAPH_ROOT for graph-revision events;
  • AGGREGATE for aggregate-version events;
  • TRANSACTION_ONLY isolation when the transaction has no stronger ordering scope.

A complete transaction may touch several aggregates or graph roots. Planning derives the union of every member’s ordering scopes, checks dependency continuity for each scope, sorts the lock keys canonically, and acquires all scope locks before executing any member. A gap or exclusion in one scope makes the whole transaction non-executable; replay never applies the transaction to only the unaffected scopes. Live work intersecting any member scope is deferred as the same complete transaction. Canonical lock ordering prevents two cross-scope transactions from deadlocking each other.

Aggregate ordered-scope keys have one canonical encoding: aggregateType + ":" + aggregateId. Append validation uses the CloudEvent subject as aggregateId; canonical capture persists that same subject as event_failure_event_t.aggregate_id. Capture, dependency extraction, planning, barrier installation, and execution all call the shared encoder rather than reconstructing the key independently. This keeps the command guard and replay barrier byte-identical for the same aggregate.

The worker installs a fenced barrier, waits for current work in that scope to finish, and then applies the approved items through the shared executor. Live transactions intersecting the barrier are deferred as complete transactions; unrelated scopes continue normally. After repair, deferred transactions drain in source order before the barrier is removed.

Plain-payload deferred isolation

R6 closes the temporary R2 plain-codec limitation. The deferred table now has an exact-byte payload_plain representation whose SHA-256 digest and byte count are database constrained. A live transaction intersecting an active barrier is durably recorded as DEFERRED in the supported DATABASE_PLAIN representation before its source position advances. Unrelated later transactions can therefore continue, while deferred work still drains in source order before barrier release. Deferred bytes are immutable and direct column access is restricted in the same way as canonical plain failure bytes.

Replay requests use row locks, monotonic fencing tokens, leases, and advisory scope locks. A lease provides liveness and abandoned-work recovery; it never overrides a database lock or permits two workers to execute the same item.

The replay execution components are registered only in hybrid-query and start automatically after the replay schema is available. The execute API durably schedules work and returns; it does not run projection SQL on the HTTP thread.

Worker wake-up and idle behavior

event_replay_request_t is the durable work queue. In the same database transaction that changes an approved request to INSTALLING_BARRIER, the execute command calls pg_notify('event_replay_ready', replayRequestId). A PostgreSQL notification is delivered only after commit, so a listener cannot wake for work that later rolls back. The notification is only a wake-up hint; the durable request row remains the source of truth.

Replay configuration follows the standard light-4j lazy reload contract used by AuditConfig: ConfigReloadHandler clears the registered module’s cached document, and the next EventReplayConfig.current() observes the new map identity and rebuilds the configuration. A replay notification observes the new value immediately through requestDrain; when no notification or request arrives, the 60-second recovery scan is the bounded config-observation path. An observed false -> true transition schedules a coalesced drain. This avoids both a one-second claim loop and a replay-specific change to the shared light-4j config-reload framework.

Each hybrid-query replica uses a lightweight virtual thread blocked on LISTEN event_replay_ready. It does not run a one-second claim loop. The listener may use a dedicated PostgreSQL connection or a shared internal notification dispatcher that multiplexes application channels. A blocked virtual thread consumes no polling CPU. When notified, it submits a drain task that claims durable requests through the existing SKIP LOCKED, fencing, and lease protocol. Several replicas may wake for the same notification, but only one can claim a request.

The current implementation reserves one connection from the application data source for the lifetime of each query replica. Size the pool for peak ordinary query/projection concurrency plus this listener connection; a pool size of one is invalid for a query replica. Some PostgreSQL driver versions may pin the listener virtual thread’s carrier while getNotifications waits. This is bounded to the single listener and is not a reason to fan out one listener per channel.

LISTEN requires session affinity. The listener connection must reach PostgreSQL directly or through a session-pooling endpoint; PgBouncer transaction or statement pooling is unsupported for this connection even when ordinary queries use it. Startup sends a uniquely identified self-test notification and requires the listener to observe it within a bounded interval. Failure marks listener health degraded and reports the connection/pool mode; the 60-second scan preserves correctness but must not mask a permanently broken notification path.

PostgreSQL notifications are not durable. On startup, after listener reconnect, and once every 60 seconds while execution is enabled, each replica performs a recovery scan for an executable request. The 60-second interval is a reviewed application default, not another development configuration property. After a worker drains the available requests, it returns to the blocked listener. This reduces an idle replica from one empty query per second to at most one recovery query per minute while preserving immediate normal execution.

While event-replay.enabled=false, notifications may still wake the listener, but requestDrain intentionally drops those wake-up hints and no drain task may claim work. Durable request rows are not dropped. Changing it back to true is observed by the next notification or other config access, or within 60 seconds by the periodic recovery scan. Kafka deployments use the same PostgreSQL replay control plane and therefore use this wake-up mechanism as well; it is independent of the live Pub/Sub source.

Replay worker operational status

Each hybrid-query replica exposes the following replica-local administrative endpoint:

GET /adm/event-replay/status

The endpoint returns application/json. It is an observation endpoint only: it does not list replay candidates, create or approve a plan, or start replay execution. Its purposes are to confirm that a replica loaded the expected execution-pause configuration, verify that its PostgreSQL LISTEN/NOTIFY worker is healthy, and provide enough evidence to confirm a fleet-wide pause.

A healthy response has the following shape:

{
  "status": "HEALTHY",
  "effectiveEnabled": true,
  "configGeneration": "6b68d2...",
  "configReloadTimestamp": "2026-07-23T18:20:31.123Z",
  "processInstanceId": "019...",
  "listenerConnectionRequirement": "DIRECT_OR_SESSION_POOLING",
  "dedicatedListenerConnections": 1,
  "listenerConnected": true,
  "selfTestPassed": true,
  "detail": "LISTEN/NOTIFY self-test passed",
  "lastConnectedTimestamp": "2026-07-23T18:19:02.456Z",
  "lastNotificationTimestamp": "2026-07-23T18:20:10.789Z",
  "lastRecoveryScanTimestamp": "2026-07-23T18:20:02.456Z",
  "reconnectCount": 0,
  "notificationCount": 4,
  "recoveryScanCount": 1,
  "drainRunCount": 5
}

The fields have these meanings:

FieldMeaning
statusDispatcher lifecycle or health: STARTING, SELF_TESTING, HEALTHY, DEGRADED, or STOPPED.
effectiveEnabledEffective value of event-replay.enabled on this replica. false pauses execution and claiming only.
configGenerationSHA-256-derived identity of the effective replay configuration. Replicas with the same intended config must report the same generation.
configReloadTimestampTime this process last observed the current configuration generation.
processInstanceIdUnique identity of this running query replica, used to distinguish reports across restarts and replicas.
listenerConnectionRequirementRequired PostgreSQL connection mode. The value is DIRECT_OR_SESSION_POOLING; transaction pooling cannot preserve LISTEN session affinity.
dedicatedListenerConnectionsNumber of JDBC connections permanently reserved by this replica’s listener; currently 1.
listenerConnectedWhether the dedicated PostgreSQL listener session is currently connected.
selfTestPassedWhether the session-affinity LISTEN/NOTIFY self-test passed on the current listener session.
detailHuman-readable lifecycle or degradation detail, including the failure type when degraded.
lastConnectedTimestampMost recent successful listener connection time, or null before the first connection.
lastNotificationTimestampMost recent received replay-ready notification time, or null if none has been received.
lastRecoveryScanTimestampMost recent periodic durable-work recovery scan time, or null before the first scan.
reconnectCountNumber of listener reconnection attempts after listener failures.
notificationCountNumber of PostgreSQL replay-ready notifications received by this process. Notifications are coalescible wake-up hints, not the durable work record.
recoveryScanCountNumber of periodic recovery scans used to find work after a lost notification or listener failure.
drainRunCountNumber of coalesced worker drain runs scheduled by startup, notification, resume, reconnect, or recovery. It is not expected to equal notificationCount.

Before the dispatcher is installed, the endpoint reports status=STARTING, listenerConnected=false, selfTestPassed=false, and the configuration and connection-requirement fields. Listener timestamps and counters are added once the dispatcher exists. A DEGRADED status commonly means that the listener connection failed, its self-test failed, or a transaction-pooling proxy broke session affinity. The detail field identifies the observed condition.

For fleet-wide pause confirmation, an operator or aggregator polls every target query replica and verifies all of the following:

  • every expected processInstanceId is represented by a fresh response;
  • every response has effectiveEnabled=false;
  • every response has the intended configGeneration; and
  • there are no missing or stale replicas.

A missing or stale response never confirms a pause. Listener health is separate from pause confirmation: a replica can report DEGRADED while still finding durable work through the 60-second recovery scan.

For that reason, listener degradation does not fail the service’s normal liveness endpoint. Restarting an otherwise healthy query service would disrupt unrelated APIs without repairing an unsupported connection-pooling mode. The administrative status endpoint must instead be monitored separately. It exposes no event payload or PII, but it reveals internal execution state, so the gateway or deployment ingress must protect it with the same administrative access controls used for other /adm routes.

Request states are:

PLANNING -> READY -> AWAITING_APPROVAL -> APPROVED
         -> INSTALLING_BARRIER -> RUNNING -> SUCCEEDED
                                        \-> FAILED
READY/AWAITING_APPROVAL/APPROVED -> CANCELLED
READY/AWAITING_APPROVAL/APPROVED -> EXPIRED

Attempts are append-only. Success resolves the canonical failure in the same transaction as projection writes and attempt completion. A failed replay does not create a new DLQ loop; it records another attempt against the same failure.

Notification and UI

The Event Admin page provides:

  • a list of open canonical replay candidates;
  • transaction member count, event types, ordering scope, error, and failure time;
  • explicit distinction between canonical candidates and legacy DLQ notifications;
  • dependency-closure preview;
  • plan hash, expiry, requester, approver, status, and attempts;
  • approval, execution, cancellation, waiver, and quarantine controls according to gateway authorization.
  • a separate Repair action when exact replay would repeat an invalid-data failure;
  • a schema-driven repair form that exposes only authorized editable business fields and clearly states that the complete failed transaction is affected;
  • repair status, changed field names, original/corrected digests, requester, approver, and linked replay plan without exposing payload values.

A legacy notification may remain visible in the lower notification table but must not be described as replayable. The empty candidate state explains that only newly captured canonical failures appear there.

Event Admin repair interaction

Event Admin presents two intentionally separate recovery paths. Replay original creates an exact/dependency replay plan after a processor defect is fixed. Repair creates an append-only amendment when replaying the persisted business data unchanged would fail again. Selecting Repair always identifies the complete failed transaction as the unit; the form may correct one or more members but cannot split transaction membership.

Repair forms are event-type and schema-version entries in Forms.json. Each entry mirrors the server repair schema and exposes only its declared business fields. The UI does not request original field values, so replacement inputs start blank. It has no generic JSON editor and cannot edit CloudEvent envelope, identity, ordering, key, header, or storage fields. A single repairable member uses SINGLE_EVENT_FIELDS; multiple compatible members use explicit event-ID-keyed PER_EVENT_FIELDS. The server still validates the pinned event policy, schema version, change shape, and every field; the UI schema is not a security boundary.

The form identifies which event type and how many transaction members are editable. All other members remain byte-identical while still participating in the complete transaction replay. If more than one form definition matches a mixed transaction, Event Admin fails closed instead of choosing by catalog order; the operator must use a server-supported unambiguous repair schema or fix the processor and replay the original transaction.

The initial supported product contract deliberately permits exactly one repair schema version per failed transaction. UserUpdatedEvent schema version 1 is the first deployed typed-repair policy and binds USER_UPDATED_V1 to user-updated-repair-v1. A transaction may contain multiple compatible members and use PER_EVENT_FIELDS; members with a non-repair disposition remain byte-identical. If members resolve to two different SCHEMA_REPAIR versions, both the provider and UI reject the proposal with REPAIR_SCHEMA_VALIDATION_FAILED. Supporting that case later requires a versioned multi-schema contract, approval fingerprint, persistence model, executor, and UI; catalog order is never a selection rule.

USER_UPDATED_V1 append validation mirrors the already-published user-command updateUserByIdRequest schema; it must not add UUID, length, or userType restrictions that the command contract does not enforce. The repair schema may be narrower for operator-entered replacement values and currently limits userType to C or E plus the documented per-field lengths. Email, entity ID, user ID, and host ID are deliberately not editable repair fields. Corruption in those identity-adjacent values is not a schema-repair case: the source must be corrected through an authorized domain workflow or quarantined for explicit reconciliation.

PORTAL_OBJECT_V1 is intentionally only a structural JSON-object validator. It cannot qualify an event for typed repair. Every deployed SCHEMA_REPAIR entry must instead name a concrete data validator, a concrete repair schema, an exact editable-field set, and a matching schema-driven Forms.json entry. The synthetic contract fixture remains in contract and test resources only; it is absent from runtime policy and form catalogs.

After creation, the browser discards replacement values and displays metadata only: repair state, changed field names, original/corrected transaction fingerprints, per-member original/corrected digests, reason, requester, reviewer/approver, timestamps, and linked replay request. repairId is a host-scoped URL/local-state key so a second user can open the proposal. The repair query resolves the latest linked replay request and status from durable plan-item metadata; linkage never depends solely on one browser’s state. The requester is shown that another authorized user must approve or reject it, but the UI never interprets role names. Gateway endpoint policy plus backend host, actor, fingerprint, state, and requester-not-approver checks authorize every operation.

Repair approval and replay-plan approval are independent. Only an APPROVED repair can create a repair-bound replay plan; the resulting immutable plan hash must then be approved through the normal replay workflow before execution. CANCELLED, REJECTED, STALE_PLAN, and REPAIR_FINGERPRINT_MISMATCH are terminal for the displayed artifact and tell the operator to refresh/re-plan rather than blindly retry.

When a replay reports SUCCEEDED with projectionCommitted=true, Event Admin refreshes canonical candidates, repair metadata, and the legacy notification table. It also emits portal:event-replay-applied with only hostId, replayRequestId, and optional repairId; a mounted business view uses that event to invalidate and reload its projection query. No payload or changed field value is included in the event.

Raw payloads, complete event JSON, Kafka keys, headers, and database payload references are never returned to the browser. A repair endpoint may return only the explicitly authorized and redacted business fields declared by the repair schema.

Failure Handling

  • Invalid command-side transaction: reject the command before event-store or outbox commit.
  • Persisted event has permanently invalid data: once a validated repair proposal classifies the failure, reject exact planning with EVENT_REPAIR_REQUIRED; block later commands in the ordered scope with AGGREGATE_REPAIR_REQUIRED; require an approved repair.
  • External Kafka transaction is incomplete: do not create an executable candidate and do not commit the source offset when canonical evidence cannot be persisted safely.
  • Excluded unordered event type: retain diagnostic failure metadata and report EVENT_NOT_REPLAYABLE during planning. Reject ordered exclusions at policy/configuration validation.
  • Payload missing or digest mismatch: reject planning or execution with PAYLOAD_UNAVAILABLE or PAYLOAD_DIGEST_MISMATCH.
  • Dependency gap: report the exact missing aggregate version, graph revision, or transaction and keep the plan non-executable.
  • Worker crashes before commit: database rollback leaves the item pending; lease recovery starts a new fenced attempt.
  • Worker crashes after commit: projection result and attempt outcome are already atomic; recovery observes completion.
  • Replay handler still fails: stop the ordered plan, record the attempt, and keep the affected scope quarantined.
  • Plan expires or becomes stale: require a new plan and approval.
  • Repair fails validation: store no executable repair and report bounded field errors without changing the original failure.
  • Repair execution fails: retain the approved repair, record the attempt, and keep the ordered scope quarantined for retry or cancellation.
  • Emergency execution pause: push event-replay.enabled=false to all command and query instances, verify their effective health/status, and stop new execute transitions and worker claims while validation, capture, query APIs, planning, approval, and durable state remain available.

Security and Privacy

  • Light Gateway authorizes every replay endpoint.
  • Every query and mutation validates token host against the requested host.
  • Requester and approver must be distinct users.
  • Repair requester and repair approver must also be distinct users; approval of a replay plan does not implicitly approve a repair.
  • The reason and immutable plan hash are audited.
  • Payload digests are verified immediately before projection execution.
  • Repair APIs enforce event-type-specific editable fields and never accept caller-controlled envelope, tenant, identity, or ordering metadata.
  • Payloads, keys, headers, event JSON, and direct storage locations never appear in API lists, browser state, logs, metrics, or audit details.
  • Canonical schema and upgrade assets revoke every plain payload column from PUBLIC. This is the current repository-managed baseline; table owners and roles with explicit table grants remain privileged.
  • Dedicated non-owner database roles and column-scoped grants are production hardening, not part of the early-development activation contract. A production deployment that requires database-level service separation must provision those roles and credentials outside this schema before promotion.
  • Application-level encryption is an optional production hardening control, not a prerequisite for development replay.

Observability

Recommended bounded-cardinality metrics are:

  • canonical failures captured, open, resolved, repaired, and waived;
  • capture failures by safe error code;
  • replay plans and attempts by status;
  • repair proposals and executions by safe status and event type;
  • replay transaction and event counts;
  • planning, approval-wait, execution, and barrier duration;
  • stale plans, dependency gaps, excluded events, and payload mismatches;
  • blocked ordered scopes by age bucket and safe failure code;
  • capture rate, stored payload bytes, capacity-watermark state, and source backpressure activations;
  • active barriers, deferred transactions, quarantined scopes, and abandoned attempts;
  • worker heartbeat and claim failures;
  • replay listener connection/reconnection state, notification wake-ups, and recovery-scan claims.

Logs include request ID, failure ID, projection, consumer group, attempt, counts, and safe result code. They exclude payloads and unbounded exception messages.

Always-on capture uses reviewed internal soft/hard capacity defaults even though they are not development-facing configuration. A failure storm crossing the soft threshold raises health/alerts. At the hard threshold, the affected source stops before advancing past a failure whose replay bytes cannot be stored; it does not discard payloads or silently downgrade to legacy DLQ-only behavior. Blocked-scope age and failure-rate alerts make the resulting availability impact visible during a bad deployment.

Deployment Behavior

The schema migration creates the canonical failure, repair, replay request, item, attempt, lease, barrier, deferred-work, and audit tables. After both the schema and updated services are present:

  1. replay validation, capture, APIs, and worker startup are active;
  2. hybrid-query starts canonical capture and the replay worker;
  3. hybrid-command accepts plan and state-transition commands;
  4. new failed transactions appear in Event Admin;
  5. gateway endpoint roles determine who may operate them.

user-command and user-query are source repositories/modules; hybrid-command and hybrid-query are their deployed service bundles. This document uses the module names for code ownership and the hybrid names for runtime instances.

Deployment order remains schema, shared light-portal artifacts, hybrid-command, then hybrid-query. Command-side schema validation is active when command deploys, but the open-failure scope block is inert until the query deployment is capturing canonical failures. Operators must not claim the block is fleet-effective until capture health is green on every query replica.

Repair tables are required replay history, not an optional UI feature. Startup must verify them together with the canonical failure and replay tables.

event-replay.enabled defaults to true. Config-server reload applies changes without restart. hybrid-command enforces the current value at execute time; every hybrid-query worker enforces it before claiming work. Health/status reports the effective value for each instance.

The schema migration also installs the event_replay_ready notification contract. hybrid-query establishes its listener before its startup recovery scan so work committed during startup cannot be stranded. Listener loss marks health degraded and reconnects with bounded backoff; the recovery scan remains the correctness fallback.

Startup fails with a clear schema error when required replay tables are absent. It must not silently downgrade to a partially working mode.

There is no rollout table, rollout stage, source/host allowlist, legacy backfill job, or requirement to enable capture, planning, and execution separately.

The R8 convergence migration removes the former rollout-audit and legacy backfill checkpoint/issue tables from upgraded databases and fresh-install DDL. The old dated Phase 11 migration remains in source as historical evidence only; it is followed by the repeatable R8 removal migration and is not a supported runtime or installation surface. The compiled runtime likewise has no backfill entry point, rollout model, change-ticket check, PKCS12 key provider, encrypted payload codec, object-store client, or configurable worker identity. Existing encrypted/object payload columns remain schema history, but the runtime accepts only exact-byte DATABASE_PLAIN payloads. Those columns are retained solely as a non-destructive upgrade and rollback boundary; they are not a reserved or advertised future storage mode, and rows using DATABASE or OBJECT are not executable by the R8 runtime. During R9, deployment qualification must count every historical non-plain row. A later destructive migration may remove the columns and dead constraint branches only after that count is zero across every deployment and the pre-R8 rollback window has closed.

External Kafka DLQ compatibility remains available independently of replay execution. Kafka-source failures still publish through the normally configured Kafka producer to the code-owned topic template, retry, age, and acknowledgement defaults in EventReplayRuntimePolicy.KafkaDlq; no retired replay rollout, encryption, or object-store property controls it. PostgreSQL dead_letter_queue diagnostics and notification status also remain. The disabled durable external publication-outbox experiment is not required for canonical replay correctness; canonical capture remains authoritative.

Validation Plan

Append validation

  • Reject missing transaction IDs, duplicate ordinals, inconsistent counts, non-contiguous membership, and cross-host members.
  • Reject aggregate and graph events missing their required ordering metadata.
  • Reject event data that fails its registered event-type/schema-version validator, and prove no event-store, outbox, notification, or failure row is committed.
  • Prove event_store_t and outbox_message_t commit atomically.
  • Prove a newly registered projection handler declares or derives a replay policy and satisfies the idempotency contract.
  • Prove every policy declares a repair disposition and every SCHEMA_REPAIR policy resolves to a versioned schema.
  • Prove a pinned registry/schema version remains usable for an already-appended event after a newer version is deployed, and referenced versions cannot be removed.
  • Reject ordered NOT_REPLAYABLE policies and configuration that excludes a graph- or aggregate-ordered event.
  • Smoke-test registry misconfiguration and prove portal/internal appends fail closed without reserving offsets or writing partial rows.

Failure capture

  • Fail one PostgreSQL transaction and verify one complete canonical candidate is committed with the notification and source progress.
  • Fail canonical persistence and prove PostgreSQL or Kafka source progress does not advance.
  • Redeliver the same transaction and verify idempotent failure observation.
  • Crash after PostgreSQL Kafka-failure capture commits but before Kafka offset commit; verify redelivery resolves to the same fingerprint and failure row.
  • Round-trip equivalent JSON through JSONB with different key ordering and whitespace; verify replay still hashes the unchanged canonical BYTEA, not the re-serialized JSONB.
  • Verify historical legacy DLQ rows are ignored by candidate queries.

Policy and planning

  • Verify graph events derive GRAPH_ROOT.
  • Verify events such as UserDeletedEvent derive AGGREGATE_VERSION.
  • Verify ordinary complete unordered transactions derive TRANSACTION_ONLY.
  • Verify an exact excluded unordered event type is rejected as EVENT_NOT_REPLAYABLE, and ordered exclusions are rejected at reload.
  • Select one member and verify the complete transaction is planned.
  • Verify dependency closure, deterministic order, stale-plan rejection, and payload-digest enforcement.
  • Approve a plan, let its immutable TTL expire, and verify execute rejects it; pausing execution must not extend the TTL.
  • Verify inline corrected data is rejected and only an approved immutable repair ID can change the materialized replay input.

Repair

  • Fail an aggregate event at version N because of invalid data while its projection remains at N-1; after canonical capture commits, verify ordinary submission is blocked with AGGREGATE_REPAIR_REQUIRED. Separately race a command with asynchronous capture and document that an N+1 append may win before the block becomes visible.
  • Verify a repair cannot change envelope, identity, transaction membership, aggregate version, graph revision, or fields absent from the repair schema.
  • Verify original and corrected digests, changed field names, reason, requester, and distinct approver are durable while payload values remain out of API, audit, log, and metric output.
  • Apply an approved repair and verify projection writes, ordering metadata, repair status, attempt completion, and failure status RESOLVED with resolution code RESOLVED_BY_REPAIR commit atomically.
  • Re-run exact replay and a projection rebuild and verify both resolve the approved repair deterministically instead of processing the poison payload.
  • Verify waiver does not apply the repair or advance projection metadata.
  • Verify waiver cannot unblock an ordered scope while its projection metadata still has a version or revision gap.

Execution and concurrency

  • Verify an intersecting barrier is durably DEFERRED with exact plain bytes before source progress and drains in source order after barrier release.
  • Process the same transaction through LIVE and REPLAY and compare every projection and ordering row.
  • Verify requester/approver separation and plan-hash binding.
  • Verify unrelated scopes continue while a graph or aggregate barrier is active.
  • Replay a complete transaction spanning multiple aggregate/root scopes; verify canonical lock ordering and that a gap in one scope prevents every member from executing.
  • Verify deferred work drains in source order.
  • Kill a worker before and after commit and prove one committed outcome.
  • Verify an execute commit emits event_replay_ready, wakes the listener, and begins execution without waiting for the recovery interval.
  • Drop a notification and restart or reconnect the listener; verify the startup or 60-second recovery scan claims the durable request.
  • Run several hybrid-query replicas, wake all of them, and prove SKIP LOCKED plus fencing permits one committed execution.
  • Leave the system idle and verify there is no one-second claim loop and no more than one scheduled recovery query per minute per replica.
  • Push event-replay.enabled=false and verify every command/query instance reports the effective value, config generation, reload timestamp, and instance ID without restart; verify the fleet aggregator refuses to confirm pause while a replica is missing or stale.
  • Verify the execute endpoint returns REPLAY_EXECUTION_PAUSED and workers stop new claims without stopping capture, planning, approval, status, or an in-flight transaction.
  • Push event-replay.enabled=true and verify queued approved work resumes immediately without waiting for the periodic scan or duplicating an attempt.
  • Run the listener through a direct/session-pooled connection and verify the startup self-test. Route it through transaction pooling and verify health is degraded while the recovery scan still preserves correctness.
  • Cross the internal capture soft and hard watermarks; verify alerting and that source progress stops before an uncaptured replay payload is lost.

Security and UI

  • Verify gateway roles can be configured independently for all twelve endpoints.
  • Verify a host-scoped user cannot inspect or mutate another host.
  • Verify no payload, event JSON, key, header, or storage reference reaches the browser, log, metric, or audit record.
  • Verify legacy DLQ notifications and canonical candidates have unambiguous UI wording.

Qualification and Deployment

Deploy in dependency order: portal-db, light-portal, user-command, user-query, gateway endpoint policy, portal-view, and finally the config mirrors. Run the R9 gate against a disposable PostgreSQL database before each environment promotion. The gate composes R0-R8, exercises the database and Kafka-source semantic paths, verifies the real repair policy and UI form, and builds the command, query, UI, and documentation artifacts.

Before promotion, run the read-only storage inventory against every target database. DATABASE_PLAIN and tombstoned DELETED failure rows are supported; deferred and corrected repair rows must be DATABASE_PLAIN. Any historical DATABASE or OBJECT row is recorded as non-executable rollback-boundary evidence. The inventory exits non-zero when such a row exists, does not rewrite it automatically, and the legacy columns must not be dropped until the count is zero everywhere and the pre-R8 rollback window is closed.

For PostgreSQL pub/sub, qualification proves capture, planning, approval, barrier installation, repair-aware execution, resolution, and deferred drain. For Kafka pub/sub, it additionally proves validation before projection, canonical capture before source-offset commit, idempotent redelivery, retained source coordinates, no live-topic republish, and no consumer-offset rewind. Gateway rules remain deployment-defined independently for all twelve replay endpoints, with two distinct authenticated users used for repair approval and replay-plan approval.

The deployable evidence and exact commands are maintained in the R9 qualification record under the implementation repository. /adm/event-replay/status is polled on every query replica after deployment to confirm listener health, effective execution state, config generation, reload timestamp, and instance identity; a missing or stale replica never confirms a fleet pause.

Future Production Hardening

The following may be added later as advanced capabilities without changing the core replay contract:

  • application-level envelope encryption and key rotation;
  • immutable object storage and payload lifecycle policies;
  • configurable retention and legal holds;
  • deployment-specific non-owner database roles and column-scoped payload grants;
  • operator tuning of the mandatory baseline failure-storm capacity controls;
  • staged rollout and canary scopes for an existing production deployment;
  • legacy migration tooling if historical replay becomes a requirement;
  • rollback dry-run support for handlers proven safe for transactional dry run;
  • light-workflow manual approval tasks;
  • production-specific limits and break-glass policy.

The non-negotiable contracts are complete transaction membership, deterministic ordering, immutable original and repair payload digests, code-level event validation, a planner that cannot edit data, shared live and replay projection behavior, host-scoped authorization, distinct-user approval, database-enforced fencing, durable repair history, and durable failure capture before source progress.

Event Replay Operations Runbook

This runbook covers the Phase 10 health, capacity, retention, and reconciliation controls for both database and Kafka projection processors. Preserve the canonical failure, replay attempt, barrier, and retention evidence while responding. Never rewind a shared source offset as an incident shortcut.

Health states

HEALTHY means the canonical archive has capacity and external publication has no known terminal failure. DEGRADED_EXTERNAL_PUBLICATION means the Kafka DLQ publication path is degraded but the PostgreSQL canonical archive remains authoritative; live source progress can continue while the outbox remains below its hard limit. HARD_STOP means archive, deferred-work, or database free-space limits can no longer guarantee durable capture. The affected source must not advance until capacity is restored.

The dashboard uses only global gauges. Do not add host IDs, event types, transaction IDs, failure IDs, topic names, partitions, or replay request IDs as metric labels.

Alert actions

Archive, object, or deferred quota warning

  1. Check event_replay.inline_bytes, object_bytes, deferred_bytes, object_store_backlog_bytes, and highest_host_quota_utilization.
  2. Verify the object store is healthy and immutable versioning/object lock are still enabled.
  3. Drain publication or deferred backlog before raising a limit.
  4. Do not delete an open failure payload. Use the Event Admin follow-up plan for quarantined work.

At a hard limit, keep source progress stopped. Restore disk/object capacity, run one bounded cleanup iteration, refresh health, and resume only after the hard condition clears.

Database free-space floor

  1. Confirm the filesystem measured by databaseCapacity.dataPath is the PostgreSQL data filesystem.
  2. Stop unrelated disk growth and add capacity if needed.
  3. Do not manually truncate replay tables or audit evidence.
  4. Allow the retention worker to remove only eligible resolved payloads and aged metadata. Resume processors only after database_free_ratio exceeds the hard boundary.

Publication TERMINAL_FAILED

  1. Treat PostgreSQL canonical failure rows as authoritative; do not replay from the external DLQ merely because publication failed.
  2. Repair Kafka connectivity/topic/ACL configuration.
  3. Preserve the terminal row until its outcome has been copied to immutable audit evidence. The retention worker performs that copy before deletion.
  4. Confirm backlog age and terminal-failure gauges fall after recovery.

Stuck fallback pause acknowledgement

  1. Inspect worker heartbeat and acknowledged epoch for the affected projection.
  2. Identify the stuck transaction or partition without releasing the barrier.
  3. Restart only the unhealthy worker if its current transaction is confirmed rolled back.
  4. Use RELEASE_WITH_GAP only under the separate two-person break-glass procedure; it is not a repair and leaves the failure open.

Old barrier or quarantine

  1. Open Event Admin and confirm the owner failure, barrier epoch, deferred bytes, and immutable attempt history.
  2. Build a dependency-closure follow-up plan containing the owner failure.
  3. Execute the exact approved hash. Do not delete deferred rows or change the barrier owner manually.

Repeated STALE_PLAN or LEASE_LOST

  1. Stop approving the stale hash and create a new plan from current graph and source metadata.
  2. For lease loss, verify the old transaction released its row/advisory locks.
  3. Confirm the abandoned attempt and higher fencing token are present before retry. Never edit fencing tokens or leases directly.

Payload-key unavailable

  1. Restore the referenced historical KEK alias from the approved key backup; do not rotate or replace the key ID in archived rows.
  2. Validate decryption with a dry run before execution.
  3. Keep the failure open if the key cannot be recovered. Do not waive solely to hide key loss.

Cleanup or object reconciliation failed

  1. Check event_replay.cleanup_failures and reconciliation_failures plus the worker error type. Payload and identifiers are deliberately absent from logs.
  2. Repair database/object connectivity and retry. Cleanup batches are resumable and use SKIP LOCKED.
  3. An object is deleted as orphaned only when it uses the managed replay/v1/ prefix, is older than the configured grace period, and has no exact locator/version reference in PostgreSQL.
  4. Retention evidence is append-only. Never delete or modify event_replay_retention_log_t.

Rollout and rollback

Keep operations.enabled false until canonical capture, database filesystem measurement, object-store listing support, dashboards, and alerts are verified. Enable one query node first. Multiple nodes are safe because cleanup candidates are locked with FOR UPDATE SKIP LOCKED.

To roll back the worker, set operations.enabled false and restart. Do not roll back schema additions or remove retention evidence. Capture, planning, and execution gates remain independent.

Event Replay Backfill and Rollout Runbook

This runbook activates event replay in bounded stages. The shipped configuration remains rollout.mode: DISABLED with every feature gate off. Production activation requires an approved change ticket, an allowlisted host, two human operators, and retained rollout evidence.

Safety Rules

  • Apply the additive schema before deploying code that reads replay tables.
  • Use ALLOWLIST for every canary. ALL is an expansion action, not a shortcut.
  • Never remove a host from the allowlist while it owns an active request, barrier, pause, deferred transaction, or quarantine.
  • Disable executionEnabled before rolling back worker or command code.
  • Do not drop replay tables during application rollback.
  • Do not rewind database/Kafka offsets, edit graph revisions, or delete canonical failures to make a canary pass.
  • Keep legacy DLQ publication until every external consumer accepts the canonical compatibility envelope.

Configuration Boundary

The global feature gate and rollout boundary must both allow an operation. A database capture canary for one host is configured as follows:

featureGates:
  captureEnabled: true
  kafkaPublicationEnabled: false
  planningEnabled: false
  rollbackDryRunEnabled: false
  executionEnabled: false
  breakGlassReleaseEnabled: false

rollout:
  mode: ALLOWLIST
  sourceProcessors: DATABASE
  projectionNames: portal-projection
  consumerGroups: user-query-group
  hostIds: 10000000-0000-0000-0000-000000000001
  changeTicket: CHG-000000

Add KAFKA, portal-query, and the Kafka group only at the Kafka capture stage. Read, plan, dry-run, and execution APIs reject requests outside this exact boundary. The worker claim query independently matches the host, projection, and consumer group before it can claim an existing request.

Ordered Rollout

  1. Schema: Apply the fresh DDL or all dated patches through Phase 11 twice in a disposable database, then once in the target environment. Record SCHEMA/VERIFIED evidence.
  2. Dormant code: Deploy both adapters, worker, APIs, UI, and operations hooks with all gates off and rollout.mode: DISABLED.
  3. Database capture: Enable captureEnabled for one database projection group and one non-production host. Compare canonical transaction membership, digests, and errors with dead_letter_queue.
  4. Kafka capture: Add KAFKA and enable kafkaPublicationEnabled. Prove canonical commit precedes offset commit and publication outage loses no canonical failure.
  5. Backfill: Run bounded database backfill batches. Resolve or explicitly accept every open event_replay_backfill_issue_t row before expansion.
  6. Read-only: Enable planningEnabled; verify metadata-only candidate, failure, plan, and status responses in Event Admin.
  7. Dry run: Enable rollbackDryRunEnabled only for reviewed graph events. Projection rows and source offsets must remain unchanged.
  8. Non-production execution: Enable executionEnabled for one host. Exercise lease loss, pod termination, publication and object-store outages, deferred capacity, and cleanup concurrency.
  9. Production canary: Allow one approved production host. Require separate requester and approver identities and record the exact plan hash.
  10. Expansion: Add handlers and hosts only after handler-specific replay, outage, and idempotency evidence is attached to the change ticket.
  11. Runbook cutover: Remove offset rewind and manual graph-ledger edits from the supported procedure.
  12. Legacy decision: Decide separately whether dead_letter_queue remains retained or becomes a compatibility view. Phase 11 does not delete it.

Bounded Legacy Backfill

Backfill is an explicit CLI job and is never an application startup hook. It uses a composite (offset, host, group, transaction) cursor, locks one durable checkpoint, and refuses incomplete DLQ/outbox membership.

export EVENT_REPLAY_BACKFILL_JDBC_URL='jdbc:postgresql://db/portal'
export EVENT_REPLAY_BACKFILL_DB_USER='replay-operator'
export EVENT_REPLAY_BACKFILL_DB_PASSWORD='...'
export EVENT_REPLAY_BACKFILL_JOB_NAME='legacy-db-2026-07'
export EVENT_REPLAY_BACKFILL_CHANGE_TICKET='CHG-000000'
export EVENT_REPLAY_BACKFILL_WORKER_ID='[email protected]'
export EVENT_REPLAY_BACKFILL_BATCH_SIZE='100'
export EVENT_REPLAY_BACKFILL_MAX_BATCHES='10'

mvn -q -pl db-provider exec:java \
  -Dexec.mainClass=net.lightapi.portal.db.replay.EventReplayBackfillMain \
  -Dexec.args=--apply

Use the reviewed external light-4j configuration containing replay keys, object-store settings, capacity floors, rollout scope, and feature gates. An evidence defect is recorded with its specific code, including TRANSACTION_INCOMPLETE, PAYLOAD_UNAVAILABLE, or UNSUPPORTED_PAYLOAD_VERSION; restore provable source evidence or leave the item non-executable. Storage, database, crypto, and other transient failures fail the batch without advancing the checkpoint, so the same item is retried after the dependency is restored.

There is no coordinate-only Kafka backfill command. Legacy Kafka envelopes are eligible only when transaction identity, complete membership, member order, and source coordinates are independently proven; otherwise keep the DLQ evidence and classify the item as non-executable.

Canary and Motivating Incident

Capture the database offset and projection baseline before replay:

implementation/light-portal/scripts/event-replay-canary-snapshot.sh capture \
  "$POSTGRES_URL" "$HOST_ID" user-query-group /secure/replay-before.json

Then:

  1. Confirm accepted graph revision is ahead of projected revision and planInstanceClone/0.1.0 is absent.
  2. List the canonical ConfigInstanceApiUpdatedEvent failure.
  3. Create a DEPENDENCY_CLOSURE plan and verify every missing earlier revision and complete transaction was added.
  4. Run VALIDATE_ONLY, then ROLLBACK_DRY_RUN if allowed.
  5. Have a second operator approve the exact plan hash and execute it.
  6. Confirm projected revision is contiguous and the endpoint rule is present.
  7. Verify no database offset regressed and no request/barrier remains:
implementation/light-portal/scripts/event-replay-canary-snapshot.sh verify \
  "$POSTGRES_URL" "$HOST_ID" user-query-group /secure/replay-before.json

Capture Kafka offsets before and after with the platform Kafka admin tool. Every after offset must be at least its baseline; --reset-offsets is prohibited.

Preflight and Immutable Evidence

The control script checks active isolation, terminal publication failures, and unresolved backfill issues for the target stage. It inserts append-only evidence containing the reviewed config digest.

implementation/light-portal/scripts/event-replay-rollout-control.sh \
  "$POSTGRES_URL" "$HOST_ID" portal-query user-query-group \
  PRODUCTION_CANARY PRECHECK_PASSED "$CHANGE_TICKET" "$ACTOR" event-replay.yml

Record VERIFIED only after dashboards, APIs, offsets, audit, and alerts are reviewed. Stage ROLLBACK refuses to proceed while requests, barriers, pauses, or deferred work remain.

Rollback

  1. Stop new execute requests and set executionEnabled: false.
  2. Run the ROLLBACK preflight. If blocked, retain the current worker and give every active scope an operator-owned recovery plan.
  3. Disable dry-run, planning, Kafka publication, and capture in that order.
  4. Preserve canonical tables, payload keys, objects, attempts, audit, backfill issues, and rollout evidence.
  5. Keep the legacy compatibility path until source progress is verified.
  6. Record ROLLBACK/ROLLED_BACK with the deployed config digest.

Acceptance Checklist

  • Database and Kafka canaries prove archive-before-progress ordering.
  • Soft spill and every hard capacity stop are exercised.
  • Publication/object outages, lease expiry, pod crash, stuck pause, deferred quarantine, and cleanup races have evidence.
  • Metrics contain no host, event, transaction, request, or failure labels.
  • The motivating incident is repaired without offset rewind or ledger edits.
  • Operations accepts alerts, retention, recovery, and rollback procedures.
  • Security accepts encryption, authorization, two-person approval, immutable audit, and break-glass evidence.

Configuration Snapshot Design

This document describes the design and implementation of the configuration snapshot feature in the light-portal.

Overview

A configuration snapshot captures the state of an instance’s configuration at a specific point in time. It includes all properties, files, and relationships defined for that instance, merging overrides from various levels (Product, Environment, Product Version) into a “burned-in” effective configuration.

Snapshots are created in two scenarios:

  1. Deployment Trigger: Automatically created when a deployment occurs (to capture the state being deployed).
  2. User Trigger: Manually created by a user via the UI (e.g., to save a milestone).

Data Model

Snapshot Header (config_snapshot_t)

Captures metadata about the snapshot.

  • snapshot_id: UUID
  • snapshot_type: Type of snapshot (e.g., DEPLOYMENT, USER_SAVE)
  • instance_id: Target instance
  • host_id: Tenant identifier
  • deployment_id: Link to deployment (if applicable)
  • product_version: Locked product version at time of snapshot
  • service_id: Locked service ID

Snapshot Content

Snapshot data is normalizing into shadow tables that mirror the runtime configuration tables. These tables differ from the runtime tables by including a snapshot_id and lacking some runtime-specific fields.

Key tables include:

  • snapshot_instance_property_t
  • snapshot_instance_file_t
  • snapshot_deployment_instance_property_t
  • snapshot_product_version_property_t
  • snapshot_environment_property_t
  • … (others for APIs, Apps, etc.)

Effective Configuration (config_snapshot_property_t)

A flattened, merged view of all properties for the snapshot. This table represents the “final” configuration values used by the instance.

  • Calculated by merging properties from all levels (Deployment > Instance > Product Version > Environment > Product) based on priority.

Backend Implementation

Stored Procedure (create_snapshot)

Located in portal-db/postgres/sp_tr_fn.sql. This procedure performs the heavy lifting:

  1. Validates the instance and retrieves scope data (product, environment, etc.).
  2. Creates the snapshot header record.
  3. Copies raw data from active runtime tables to snapshot tables (e.g., instance_property_t -> snapshot_instance_property_t).
  4. Merges properties from all levels into config_snapshot_property_t.
    • Handles list/map merging (aggregation).
    • Handles scalar overriding (last update wins/priority tiers).

Persistence Layer (ConfigPersistenceImpl.java)

Provides the Java interface to calls the stored procedure:

  • createConfigSnapshot: Calls CALL create_snapshot(...).
  • getConfigSnapshot: Retrieves snapshot headers with filtering/sorting.
  • updateConfigSnapshot: Updates metadata (description).
  • deleteConfigSnapshot: Deletes a snapshot and its cascaded data (if cascade delete is set up in DB, otherwise manual cleanup might be needed).

Front End Implementation

Config Snapshot Page (ConfigSnapshot.tsx)

  • Displays a list of snapshots for a selected instance.
  • Supports filtering by current, ID, date, etc.
  • Actions:
    • Create: Navigates to /app/form/createConfigSnapshot.
    • Update: Fetches fresh data and navigates to update form.
    • Delete: Calls deleteSnapshot command.

Gap Analysis & Missing Components

The following components are currently MISSING or incomplete:

  1. Command Handlers:

    • CreateConfigSnapshot handler (for User Trigger) is missing in config-command.
    • DeleteConfigSnapshot handler is missing in config-command.
    • GetFreshConfigSnapshot handler is missing (required for the “Update” action in UI).
  2. Deployment Integration:

    • CreateDeployment.java (in deployment-command) does NOT call createConfigSnapshot.
    • The automatic snapshot creation on deployment is currently not implemented.
  3. API Definition:

    • The createConfigSnapshot and deleteConfigSnapshot endpoints need to be defined in the schema/routing if they are not already.

Action Plan

  1. Implement Command Handlers:

    • Create CreateConfigSnapshot handler in config-command that invokes ConfigPersistence.createConfigSnapshot.
    • Create DeleteConfigSnapshot handler in config-command.
    • Create GetFreshConfigSnapshot handler in config-query.
  2. Integrate with Deployment:

    • Modify CreateDeployment.java (or the platform handler it invokes) to call ConfigPersistence.createConfigSnapshot immediately after a successful deployment job is submitted or completed.
  3. Review Idempotency:

    • Ensure create_snapshot handles re-runs gracefully (Idempotency is partially handled by UUID generation, but business logic should prevent duplicate snapshots for the exact same state if needed).

Config Clone

OAuth 2.0 State Parameter Design

This document outlines the design, generation, and flow of the state parameter within the LightAPI OAuth 2.0 architecture.

Overview

The state parameter is an opaque value used by the client to maintain state between the request and callback. In the OAuth 2.0 Authorization Code Flow, its primary and critical function is to prevent Cross-Site Request Forgery (CSRF) attacks.

Workflow

The flow involves three parties:

  1. Client: The application requesting access (e.g., Light Portal).
  2. Authorization Server UI: The front-end login interface (e.g., Login View).
  3. Authorization Service: The backend service validating credentials and issuing codes.

Step-by-Step Flow

  1. Generation (Client Side)

    • The User initiates a login action on the Client.
    • The Client generates a cryptographically strong random string (the state).
    • The Client stores this state locally (e.g., in a secure, HTTP-only cookie or Session Storage) bound to the user’s current session.
    • The Client redirects the browser to the Authorization Server UI (login-view), appending the state as a query parameter.
    GET https://login.lightapi.net/?client_id=...&response_type=code&state=xyz123...
    
  2. Preservation (Authorization Server UI)

    • The Authorization Server UI (login-view) loads and parses the query parameters.
    • It must not modify or validate the state. Its sole responsibility is preservation.
    • When the user submits credentials (username/password) or selects a social provider, the UI passes the state exactly as received to the backend Authorization Service.
  3. Authorization (Authorization Service)

    • The backend service authenticates the user.
    • Upon success, it generates an Authorization Code.
    • It constructs the redirect URL back to the Client.
    • It must append the exact same state value received from the UI to this redirect URL.
    HTTP/1.1 302 Found
    Location: https://portal.lightapi.net/authorization?code=auth_code_abc&state=xyz123...
    
  4. Verification (Client Side)

    • The Client receives the callback request.
    • It extracts the state from the URL parameters.
    • It retrieves the stored state from its local session.
    • It compares the two values:
      • Match: The request is valid. Proceed to exchange the code for a token.
      • Mismatch: The request is potentially malicious (CSRF likely). Reject the request and show an error.

Security Requirements

  • Uniqueness: The state must be unique per authentication request.
  • Entropy: It must be a cryptographically random string (high entropy) to be unguessable.
  • Binding: It must be bound to the user’s specific browser session on the client side.

Responsibility Matrix

ComponentResponsibilityAction
Portal (Client)OwnerGenerate, Store, Verify.
Login View (UI)CarrierReceive, Preserve, Forward.
Auth ServiceEchoReceive, Echo back in Redirect.

References

Auth Client Secret Regeneration

Problem

An OAuth auth client receives a client_id and client_secret when it is created. The clear text client_secret is intentionally a one-time value. The database projection stores only a verifier value in auth_client_t.client_secret so the clear secret cannot be recovered later.

This is secure, but it creates an operational problem. Users can miss the one-time response, close the page, or forget to copy the secret into their deployment system. Once that happens, the portal needs a way to issue a new secret without weakening the storage model.

Current State

The current create flow in oauth-command generates both values in CreateClient:

  • clientId: generated UUID.
  • clientSecret: generated random/base64 UUID value.
  • clientSecretEncrypted: generated with HashUtil.generateStrongPasswordHash(clientSecret).

The read model stores the verifier in auth_client_t.client_secret. The update flow does not update client_secret, which is correct because normal client metadata updates should not rotate credentials.

The Auth Client page currently supports:

  • creating a client through the create form,
  • creating tokens for an existing client,
  • updating client metadata,
  • deleting a client.

Options

Option 1: Delete And Recreate The Client

This works only as a workaround and should not be the product design.

Problems:

  • It changes client_id, so every downstream service, runtime config, token request, and automation script must be updated.
  • It creates avoidable downtime because the old credential is removed before the new one can be distributed.
  • It loses the continuity of the auth client record and makes audit history harder to read.
  • It can leave related state confusing, especially provider-client mappings, client tokens, and owner relationships.
  • It trains users to use a destructive operation for a credential-management problem.

Option 2: Add A Regenerate Secret Action

This is the recommended option.

The client record and client_id remain stable. Only the secret verifier is replaced. The clear text value is returned once in the command response and is never stored in a recoverable form.

Benefits:

  • Keeps existing client ownership, provider link, service references, and audit continuity.
  • Avoids unnecessary delete/recreate events.
  • Matches common OAuth client-management behavior.
  • Enables a focused UI flow with explicit warning, confirmation, copy action, and audit trail.

Decision

Add a dedicated “Regenerate Secret” action on the Auth Client page.

Do not reuse delete/recreate as the normal path. Do not add a recoverable encrypted-secret store. The portal should continue treating client secrets as one-time credentials.

Command API

Add a new command action:

lightapi.net/oauth/regenerateClientSecret/0.1.0

Suggested request:

{
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "clientId": "019e6235-1966-7322-bbcd-1cb432b5bb88",
  "aggregateVersion": 11,
  "ownerPositionId": "optional-position-id",
  "reason": "optional user supplied reason"
}

aggregateVersion is required. Secret regeneration modifies the existing Client aggregate, so the command must use the same optimistic concurrency pattern as other update commands. If the submitted version is stale, the command should fail with a refresh/retry response instead of silently rotating a secret against an older client view.

Suggested response:

{
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "clientId": "019e6235-1966-7322-bbcd-1cb432b5bb88",
  "clientSecret": "new-one-time-secret",
  "aggregateVersion": 12,
  "rotatedTs": "2026-05-28T14:22:00Z"
}

The clear clientSecret is response-only. It must not be included in the event payload, logs, audit payloads, read-model query responses, or notification payloads.

Event Design

Add a new event type:

ClientSecretRegeneratedEvent

Use the existing Client aggregate. The aggregate id should be derived from hostId and clientId, the same way ClientUpdatedEvent is derived.

Event data should contain only non-recoverable secret material:

{
  "hostId": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
  "clientId": "019e6235-1966-7322-bbcd-1cb432b5bb88",
  "clientSecretEncrypted": "PBKDF2 verifier",
  "reason": "optional user supplied reason"
}

The command handler should keep two payloads:

  • event payload: safe to persist and replay,
  • response payload: includes the one-time clear secret.

This separation is important. If the current create-client event still includes the clear clientSecret, harden CreateClient at the same time so the clear secret is returned to the UI but not stored in event_store_t.

Projection Behavior

Add persistence handling for ClientSecretRegeneratedEvent.

The projection should update only credential-related fields:

UPDATE auth_client_t
SET client_secret = ?,
    update_user = ?,
    update_ts = ?,
    aggregate_version = ?
WHERE host_id = ?
  AND client_id = ?
  AND active = TRUE
  AND aggregate_version < ?;

update_user and update_ts should come from standard CloudEvent metadata rather than from user-editable event data. The event payload can include an optional reason, but the actor performing the rotation must be taken from the authenticated command context and persisted through the event metadata used by the existing projection framework.

No database schema change is required for the first implementation. The existing auth_client_t.client_secret column can continue storing the PBKDF2 verifier. The existing update_user, update_ts, and aggregate_version fields are enough to show that the client changed.

Optional future projection fields:

  • secret_update_user
  • secret_update_ts
  • secret_version

Only add these if the UI needs to display secret rotation metadata separately from normal client metadata updates.

UI Design

Add a row action on the Auth Client page:

Regenerate Secret

Enable it only when the current user can modify the client. Use the same owner and oauth-client-admin rules as update/delete.

Recommended flow:

  1. User clicks the row action.
  2. Portal shows a confirmation dialog explaining that the old secret will stop working for future client authentication.
  3. User confirms.
  4. Portal calls regenerateClientSecret.
  5. Portal shows a modal with:
    • clientId,
    • new clientSecret,
    • copy buttons for each field,
    • a “copied” acknowledgement before closing.
  6. Portal refreshes the table row after the dialog closes.

The modal must make it clear that the secret is shown once. After it closes, the secret cannot be recovered. If the user loses it again, they must regenerate again.

Token And Runtime Impact

Regenerating the client secret changes future client authentication. Existing issued access tokens remain valid until their normal expiration unless token revocation is implemented separately.

OAuth servers may cache client credential records to avoid database lookups on every token request. The implementation must make the cache behavior explicit:

  • Prefer subscribing to ClientSecretRegeneratedEvent and evicting the affected (hostId, clientId) credential entry immediately.
  • If event-driven eviction is not available, use a short and documented cache TTL so the new secret starts working and the old secret stops working within an acceptable window.
  • Add an integration test or operational runbook check for the cache behavior, because the command can succeed while token requests still use a stale cached verifier.

Current Java oauth-kafka behavior does not require client-secret cache invalidation. Its token handler validates client secrets through PortalDbProvider.queryClientByClientId, and its signing handler uses ClientUtil.queryClientByClientId, which delegates to the same provider method. The current AuthPersistenceImpl.queryClientByClientId implementation performs a direct SQL lookup from auth_client_t. The CacheStartupHookProvider entries in oauth-kafka config are commented out and there is no active client credential cache in that service path.

If a future oauth-kafka deployment enables a client credential cache, it must evict the affected (hostId, clientId) entry on ClientSecretRegeneratedEvent. It should also evict on client delete and any future event that changes the stored verifier or active state.

If the secret is being rotated because of compromise, the UI should guide the user to review existing client tokens and revoke long-lived tokens if needed. This should be a separate action, not an implicit side effect of secret regeneration.

Secret regeneration should also emit an owner/admin notification. The event should not include the clear secret, but it should notify the client owner and, where appropriate, host or organization admins that a credential was rotated. This gives the owner a chance to detect unexpected rotations or client takeover attempts.

Security Requirements

  • Generate the secret with the same or stronger entropy as create-client.
  • Store only a verifier generated by HashUtil.generateStrongPasswordHash.
  • Never persist the clear secret in event_store_t, auth_client_t, logs, notifications, or audit detail payloads.
  • Return the clear secret only in the immediate command response.
  • Require write scope and the same ownership checks as update/delete.
  • Allow regeneration only for active clients.
  • Treat repeated clicks as separate rotations. If the response is lost, the previous clear secret cannot be recovered; the user must regenerate again.

Implementation Checklist

oauth-command:

  • Add RegenerateClientSecret command handler.
  • Add regenerateClientSecretRequest and action metadata to spec.yaml.
  • Require aggregateVersion and reject stale commands with a refresh/retry error.
  • Generate clientSecret and clientSecretEncrypted.
  • Build an event payload without the clear secret.
  • Customize the response to include the clear secret once.
  • Add handler tests that assert the event data excludes clientSecret.

light-portal:

  • Add CLIENT_SECRET_REGENERATED_EVENT to PortalConstants.
  • Update EventTypeUtil so the event maps to the Client aggregate id.
  • Add PortalDbProvider dispatch for the new event type.
  • Add AuthPersistence.updateClientSecret.
  • Add persistence tests for monotonic replay and active-client checks.
  • Add a side effect or notification processor entry so the client owner and relevant admins are notified when a secret is regenerated.

light-oauth:

  • Verify whether client credential lookup is cached.
  • If cached, evict (hostId, clientId) on ClientSecretRegeneratedEvent or document and test the maximum TTL for stale secret acceptance.
  • Confirm old secret rejection and new secret acceptance after cache invalidation or TTL expiry.

oauth-kafka:

  • Keep the current direct DB-backed client credential lookup, or add event-driven cache invalidation before enabling a client credential cache.
  • If caching is introduced, evict (hostId, clientId) on ClientSecretRegeneratedEvent and client deletion.
  • Add a regression test or operational check proving old secret rejection and new secret acceptance without waiting for process restart.

oauth-query:

  • Prefer masking or omitting clientSecret from query responses. Query APIs should not return the stored verifier as if it were a usable secret.

portal-view:

  • Add the row action and confirmation/result modal to AuthClient.tsx.
  • Reuse ownership checks already used by update/delete.
  • Add copy-to-clipboard handling and a copied acknowledgement.
  • Refresh the table after a successful rotation.

light-portal-doc:

  • Add user help for the Auth Client page explaining one-time secret display and regeneration.

Test Plan

  • Create client still returns a one-time secret and stores only a verifier.
  • Regenerate secret returns a new one-time secret and updates only the verifier.
  • Regenerate secret with a stale aggregateVersion fails and asks the user to refresh.
  • Old secret fails client authentication after regeneration.
  • New secret succeeds client authentication after regeneration.
  • OAuth server cache invalidation or TTL behavior is verified.
  • Existing metadata, owner mapping, provider mapping, and client_id remain unchanged.
  • Unauthorized users cannot regenerate secrets for clients they do not own.
  • Client owner or admin notification is emitted without leaking the secret.
  • Replaying an older regeneration event does not overwrite a newer verifier.
  • The UI does not expose stored verifier values from query responses.

Event Promotion Design: State-Based Reconciliation with Composite Keys

Overview

Traditional event sourcing replication involves copying raw events from one environment to another. However, this fails when the target environment has diverged (e.g., hotfixes), causing aggregateVersion conflicts. Additionally, strict global UUID constraints can prevent reusing the same ID across environments (Tenants). Finally, partial promotions can fail if parent dependencies (referential integrity) are missing in the target.

To resolve this, we adopt a State-Based Reconciliation approach (Semantic Replay) combined with Composite Keys for identity and Recursive Dependency Resolution for integrity.

Core Strategy: State-Based Reconciliation

Workflow

  1. Export (Lower Environment):
    • Query the current state (Snapshot) of the entity from the Lower Environment (LE).
    • Produce a “Canonical State Snapshot” (JSON).
  2. Import & Diff (Higher Environment):
    • Read the LE Snapshot.
    • Query the current state of the representative entity in the Higher Environment (HE).
    • Compare:
      • New? -> Generate XxxCreatedEvent.
      • Changed? -> Calculate Delta -> Generate XxxUpdatedEvent.
      • Same? -> No-op.

Advantages

  • Conflict Immunity: No aggregateVersion conflicts; we always append new events.
  • Self-Healing: Automatically synchronizes diverged states.

Identity Strategy: Composite Keys

The Problem: Global UUID Uniqueness

In a multi-tenant system shareing a single database, a standard Primary Key UUID (e.g., user_id) is globally unique. This prevents us from having “User Steve” with UUID 123 in both the “Dev Tenant” and “Prod Tenant” if the DB enforces strict uniqueness on that column.

The Solution: Composite Keys (host_id + aggregate_id)

We scope all identity by the Tenant ID (host_id).

  1. Schema Change:

    • Primary Keys: Change from PK(id) to PK(host_id, id).
    • Uniqueness: Change unique constraints (e.g., email) from UK(email) to UK(host_id, email).
    • Event Store: Change unique constraint from UK(aggregate_id, version) to UK(host_id, aggregate_id, version).
  2. Promotion Benefit:

    • Dev Tenant: host_id=DEV, user_id=123
    • Prod Tenant: host_id=PROD, user_id=123
    • Matching entities is trivial (compare id directly).

Data Integrity: Recursive Dependency Resolution

The Problem: Missing Dependencies

Promoting a child entity (e.g., API Configuration) fails if its parent (e.g., API Instance) does not exist in the target environment (Higher Env).

The Solution: Deep Promotion (Recursive Bundling)

The exporter must be “Topology Aware”.

  1. Dependency Metadata: Every Entity Type must declare its dependencies.

    • ApiConfig depends on ApiInstance.
    • ApiInstance depends on GatewayInstance.
    • GatewayInstance depends on Host.
  2. Export Workflow (Recursive): When a user selects ApiConfig-123 for promotion:

    • System checks ApiConfig-123 -> Parent ApiInstance-456.
    • System checks ApiInstance-456 -> Parent GatewayInstance-789.
    • Export Package: Includes [GatewayInstance-789, ApiInstance-456, ApiConfig-123] (Ordered by dependency).
  3. Import Workflow (Ordered): The Importer processes the list in order:

    1. GatewayInstance: Exists in Prod? Yes. (Skip).
    2. ApiInstance: Exists in Prod? No. Action: Create ApiInstance.
    3. ApiConfig: Exists in Prod? No. Action: Create ApiConfig.

Dry Run Technical Implementation

Purpose

To guarantee the promotion will succeed without actually modifying the Higher Environment (Production).

  • Logic: The Importer queries the DB (read-only) to fetch the current state of all entities in the package.
  • Result: It calculates the “Diff Plan” purely in memory.
  • Output: “Plan: Create API Instance (New), Update API Config (Diff)”.
  • Pros: Very fast, zero DB locks.
  • Cons: Does not verify deep database constraints (e.g., complex triggers or check constraints) that only trigger on write.
  • Logic:
    1. Start a Database Transaction: connection.setAutoCommit(false);
    2. Simulate Execution: Perform the actual SQL Inserts and Updates generated by the Plan.
      • Insert ApiInstance
      • Insert ApiConfig
    3. Check for Errors: If any SQL Exception occurs (e.g., FK violation, unique constraint violation), catch it.
    4. Rollback: Regardless of success or failure, always call connection.rollback().
  • Output: “Validation Successful: The detailed plan is valid and safe to execute.” OR “Validation Failed: FK Violation on Table X”.
  • Pros: 100% certainty that the data is valid according to the database schema.
  • Cons: Slightly heavier key locks, but acceptable for admin operations.

Recommendation

Use Option 1 (App Simulation) for the UI preview to show the user “what will happen”. Use Option 2 (Transaction Rollback) immediately when the user clicks “Promote” (as a pre-flight check) or as an explicit “Verify” button to ensure deep integrity.

Sibling Deletion: Handling Orphaned Items

The User Case

When promoting a collection of items (e.g., “10 Config Properties” in HE vs “8 in LE”), simply creating or updating the 8 matching items from LE is insufficient. We must identify the 2 extra items in HE that likely need to be deleted to match the LE state.

Design Pattern: Scoped Reconciliation

To handle this, the import logic must be aware of the “Parent Scope” of the entities being promoted.

  1. Export (Snapshot with Siblings):

    • When promoting ApiConfig-123, we fetch ALL associated properties for that config in LE.
    • LE Snapshot: Properties = {P1, P2, ... P8} (Total 8).
  2. Import (Set Difference Logic):

    • Query ALL associated properties for ApiConfig-123 in HE.
    • HE State: Properties = {P1, P2, ... P8, P9, P10} (Total 10).
    • Logic: HE_Only = HE_Set - LE_Set => {P9, P10}.
  3. User Decision (Interactive Mode):

    • The Dry Run Plan reports:
      • Updates: 8 items synced (P1..P8).
      • Deuntions (Potential): 2 items exist in Prod but not Dev (P9, P10).
    • Default Action: Do nothing (Safe Mode).
    • Option: “Sync Deletes” -> Checkbox to delete extras?
    • Strict Mode: Mirror exact state (Automatically schedule ConfigPropertyDeletedEvent for P9, P10).

Implementation Checklist

  • Exporter must include the full list of children IDs when exporting a parent container.
  • Importer must realize that for “One-to-Many” relationships, it has to fetch the full target set to detect orphans.

UI and Service Design

Entity Dependency Graph

The exporter must be “Topology Aware”. When exporting an entity, all parent and child dependencies are included. Starting with instance_t as the primary promotable entity:

host_t
└── instance_t
    ├── instance_property_t
    ├── instance_file_t
    ├── instance_api_t
    │   ├── instance_api_property_t
    │   └── instance_api_path_prefix_t
    ├── instance_app_t
    │   ├── instance_app_property_t
    │   └── instance_app_api_t
    │       └── instance_app_api_property_t
    └── deployment_instance_t
        └── deployment_instance_property_t

Promotion Modes

Two promotion modes are supported:

  1. Cross-Instance (JSON): Export entity snapshots as JSON files, then import them into a different environment/database instance. Used when source and target are in separate databases.
  2. Same-Instance (Data Table): Use promotion_t and promotion_item_t tables for tracking promotions between hosts within the same database. Source and target hosts share the same database.

Database: Promotion Tracking Tables

These tables are for same-instance promotions to track promotion jobs and their items.

CREATE TABLE promotion_t (
    promotion_id         UUID NOT NULL,
    source_host_id       UUID NOT NULL,
    target_host_id       UUID NOT NULL,
    entity_type          VARCHAR(64) NOT NULL,   -- 'instance', 'rule', 'api', etc.
    promotion_status     VARCHAR(16) NOT NULL,   -- 'Planned', 'DryRun', 'Executed', 'Failed', 'RolledBack'
    plan_summary         JSONB,                  -- The diff plan generated by dry run
    created_by           UUID NOT NULL,
    aggregate_version    BIGINT DEFAULT 1 NOT NULL,
    active               BOOLEAN NOT NULL DEFAULT TRUE,
    delete_user          VARCHAR(255),
    delete_ts            TIMESTAMP WITH TIME ZONE,
    update_user          VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY(promotion_id)
);

CREATE TABLE promotion_item_t (
    promotion_id         UUID NOT NULL,
    item_id              UUID NOT NULL,
    entity_type          VARCHAR(64) NOT NULL,    -- 'instance', 'instance_property', etc.
    entity_id            VARCHAR(255) NOT NULL,   -- The ID of the entity being promoted
    action               VARCHAR(16) NOT NULL,    -- 'CREATE', 'UPDATE', 'DELETE', 'NOOP'
    source_snapshot      JSONB,                   -- State in source (LE)
    target_snapshot      JSONB,                   -- State in target (HE) for diff
    diff_summary         JSONB,                   -- Field-level diff
    execution_status     VARCHAR(16) DEFAULT 'Pending', -- 'Pending', 'Success', 'Failed'
    error_message        TEXT,
    update_user          VARCHAR(255) DEFAULT SESSION_USER NOT NULL,
    update_ts            TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    PRIMARY KEY(promotion_id, item_id),
    FOREIGN KEY(promotion_id) REFERENCES promotion_t(promotion_id) ON DELETE CASCADE
);

Service API Contracts

All promotion services are implemented in the user-command module (net.lightapi.portal.user.command.handler) alongside the existing ExportPortalEvent and ImportPortalEvent handlers.

Export Snapshot (Query)

Exports the current state of selected entities and all their children as a canonical JSON snapshot.

  • Service: user
  • Action: exportSnapshot
  • Request Data:
    • sourceHostId (UUID) – The host to export from.
    • entityType (String) – e.g., "instance".
    • entityIds (Array<String>) – IDs of entities to export.
    • includeChildren (Boolean) – Recursively include child entities.
    • includeSiblings (Boolean) – Include full sibling sets for orphan detection.
  • Response: Canonical State Snapshot JSON containing all entities ordered by dependency depth, with nested children. The nested format is preferred over flat-with-references because the tree depth is bounded (max 4 levels for instance_t), making it self-contained and easy to process depth-first during import.
{
  "exportVersion": "1.0.0",
  "sourceHostId": "...",
  "exportTs": "2026-03-09T20:00:00Z",
  "entities": [
    {
      "entityType": "instance",
      "entityId": "...",
      "data": { },
      "children": {
        "instance_property": [ ],
        "instance_file": [ ],
        "instance_api": [
          {
            "data": { },
            "children": {
              "instance_api_property": [ ],
              "instance_api_path_prefix": [ ]
            }
          }
        ],
        "instance_app": [
          {
            "data": { },
            "children": {
              "instance_app_property": [ ],
              "instance_app_api": [
                {
                  "data": { },
                  "children": {
                    "instance_app_api_property": [ ]
                  }
                }
              ]
            }
          }
        ],
        "deployment_instance": [
          {
            "data": { },
            "children": {
              "deployment_instance_property": [ ]
            }
          }
        ]
      }
    }
  ]
}

Import Dry Run (Command)

Performs an application-layer simulation (Option 1) to calculate the diff plan without modifying the database.

  • Service: user
  • Action: importDryRun
  • Request Data:
    • targetHostId (UUID) – The host to import into.
    • snapshot (Object) – The exported canonical snapshot JSON.
  • Response: Diff plan with summary counts and per-item actions.
{
  "promotionId": "...",
  "summary": { "create": 5, "update": 3, "noop": 2, "orphan": 1 },
  "items": [
    {
      "entityType": "instance",
      "entityId": "...",
      "action": "UPDATE",
      "diff": { "instance_name": { "from": "old-name", "to": "new-name" } }
    },
    {
      "entityType": "instance_property",
      "entityId": "...",
      "action": "CREATE",
      "diff": null
    }
  ]
}

Import Execute (Command)

Executes supported promotion snapshots, applying changes to the target host through event sourcing.

The current implementation supports global migration snapshots whose payload contains a top-level tables object. These snapshots are imported through the existing event-based global import pipeline, which converts table rows into ordered events and writes them to event_store_t and outbox_message_t.

Selective entity promotion snapshots whose payload contains a top-level entities array are not executable yet. The dry-run planner can still calculate the diff plan, but importExecute must reject this snapshot shape until the selective entity event materializer is implemented. Returning PLANNED items from importExecute is not a valid execution result and the UI must not treat that response as a successful promotion.

  • Service: user
  • Action: importExecute
  • Request Data:
    • targetHostId (UUID) – The host to apply changes to.
    • snapshot (Object) – The canonical snapshot. Executable today only when it contains tables.
    • promotionId (UUID, optional) – Reserved for selective entity execution.
    • orphanAction (String) – Reserved for selective entity execution: "keep" | "delete" | "sync".
  • Response: For global snapshots, the global import result such as { "imported": 42, "total": 42 }. For selective entity snapshots, a validation error until the selective execution path is implemented.

Selective entity execution requires a new materialization layer:

  1. Translate each dry-run CREATE, UPDATE, and optional orphan DELETE item into the matching domain event.
  2. Preserve dependency order from the exported snapshot.
  3. Write generated events through the same event-store/outbox transaction pattern used by the global import pipeline.
  4. Return per-item execution status only after the event write succeeds or fails.

UI Pages

All pages are located under portal-view/src/pages/promotion/ and accessible via a top-level “Promotion” sidebar menu with children: Export, Import, History.

PromotionExport.tsx (/app/promotion/export)

A 3-step wizard guiding the user through the export process:

  1. Select Source & Type: User picks a source host from a dropdown and selects the entity type (starting with “Instance”).
  2. Select Entities: A MaterialReactTable loads entities for the selected host with checkbox selection. Supports filtering, sorting, and pagination.
  3. Preview & Export: Two options:
    • Download JSON – Downloads the canonical snapshot as a .json file for cross-instance promotion.
    • Promote to Host – Select a target host and navigate to the Import page with the snapshot pre-loaded for dry run.

PromotionImport.tsx (/app/promotion/import)

Handles the import and execution workflow:

  1. Select Import Source: Upload a JSON file, or receive a snapshot from the Export page via navigation state.
  2. Dry Run Preview: After selecting a target host and clicking “Run Dry Run,” displays the diff plan:
    • New items (green) – Will be created.
    • Changed items (yellow) – Will be updated, with expandable field-level diffs.
    • Same items (gray) – No action needed.
    • Orphaned items (red) – Exist in target but not in source.
  3. Execute: For selective entity snapshots, execution is disabled by the backend until event materialization is implemented. For global migration snapshots, the page bypasses the selective dry-run plan and calls globalSnapshotImport directly.

PromotionHistory.tsx (/app/promotion/history)

A standard MaterialReactTable listing past promotions with columns: Source Host, Target Host, Entity Type, Status (color-coded chip), Created By, Timestamp, Promotion ID. Row action: View Details (navigates to diff view).

PromotionDiffView.tsx (/app/promotion/diff)

Displays detailed promotion metadata (source/target hosts, status, timestamps) and a table of all promotion items with expandable field-level diffs showing source vs. target values and per-item execution status.

Implementation Phases

  1. Phase 1 – UI Foundation: Create promotion pages, sidebar menu entry, route registration. (Completed)
  2. Phase 2 – Backend Services: Implement exportSnapshot, importDryRun, and validation for importExecute. (Partially completed: selective execution is blocked until event materialization is implemented.)
  3. Phase 3 – Same-Instance Promotion: Integrate promotion tracking tables, add “Promote to Host” flow, orphan detection, and selective event materialization.
  4. Phase 4 – Additional Entity Types: Add selective export and dry-run support for additional entity types. (Partially completed: config, rule, schema, api, and other entity snapshots are supported for export/dry-run; selective execution still waits on Phase 3 event materialization and dependency ordering remains entity-specific.)
  5. Phase 5 – Global Migration Export: Implement dynamic table discovery for full-database migration. (Completed; see below.)

Global Migration Export

Motivation

The entity-level promotion (ExportSnapshot) is designed for selective promotion — the user picks specific entities (e.g., 3 instances) and promotes them from a lower environment to a higher one. For that use case, the export produces a rich nested JSON with children and dependencies, which requires hand-crafted exportXxxSnapshot() methods per entity type.

However, a full database migration has fundamentally different requirements:

  • Scope: ALL entities across ALL entity types — not a user-selected subset.
  • Maintainability: When new tables are added to the system, the migration should work automatically without code changes.
  • Simplicity: A flat per-table export is sufficient since all data is exported together (no missing dependency risk).

Design: Dynamic Table Discovery

Instead of maintaining a manual list of entity types and per-type export methods, the Global Migration Export uses PostgreSQL DatabaseMetaData to automatically discover and export all projection tables.

How It Works

  1. Discover all tables ending in _t in the public schema via DatabaseMetaData.getTables().
  2. Skip infrastructure tables that should never be exported:
    • event_store_t — immutable event log (events will be regenerated on import)
    • outbox_message_t — transient consumer outbox
    • consumer_offsets — operational state
    • consumer_lock — operational lock
    • promotion_t, promotion_item_t — promotion tracking (environment-specific)
  3. For each discovered table:
    • Inspect column metadata to detect if the table has host_id and active columns.
    • If active column exists: SELECT * FROM table_t WHERE active = TRUE [AND host_id = ?].
    • If no active column: SELECT * FROM table_t [WHERE host_id = ?].
    • Convert each row to Map<String, Object> with camelCase key names.
  4. Record a consistency marker: SELECT MAX(id) FROM event_store_t at the start of the export transaction to stamp the snapshot with the lastEventId.
  5. Use REPEATABLE READ transaction isolation for consistency across all tables (PostgreSQL MVCC ensures a frozen-in-time view even if events are being processed concurrently).

Data Consistency Strategy

Querying projection tables directly is safe because:

  • PostgreSQL MVCC: REPEATABLE READ provides a consistent snapshot at transaction start time. Concurrent event processing does not affect the exported data.
  • Atomic event application: Each event is applied via handleEvent() within its own transaction, so partial aggregate states are never visible.
  • lastEventId marker: The export records the maximum event ID at transaction start, providing an auditable consistency boundary without the cost of event replay.

Why not replay events from event_store_t?

  • The projection tables are the replayed event result — re-replaying is redundant.
  • handleEvent() has 120+ event type cases — duplicating that logic in an in-memory replayer is impractical.
  • Event replay would not unlock any consistency benefit beyond what MVCC already provides.

Output Format

{
  "exportVersion": "1.0",
  "sourceHostId": "N2CMw0HGQXeLvC1wBfln2A",
  "lastEventId": "abc123...",
  "exportTs": "2026-04-09T20:00:00Z",
  "tables": {
    "config_t": {
      "count": 5,
      "rows": [
        { "configId": "...", "configName": "...", "configPhase": "...", ... },
        ...
      ]
    },
    "user_t": {
      "count": 12,
      "rows": [
        { "userId": "...", "email": "...", "firstName": "...", ... },
        ...
      ]
    },
    "role_t": { ... },
    "instance_t": { ... },
    ...
  }
}

Key differences from the per-entity promotion export:

AspectPer-Entity Promotion (ExportSnapshot)Global Migration (ExportGlobalSnapshot)
ScopeUser-selected entitiesAll active entities
StructureNested (parent/children/dependencies)Flat per-table
New table supportRequires code changesAutomatic via DatabaseMetaData
Use caseLower env → Higher envFull database migration
OutputEntity-centric JSONTable-centric JSON
Import mechanismSame-instance via promotion_t or Cross-instance via JSONCross-instance via JSON only

Import: Event-Based Migration (Refined in Phase 2.5)

To ensure maximum compatibility and maintain the integrity of the event-sourced system, the global import process follows a 3-step pipeline:

Source DBExport (Flat JSON) → Convert to Events (Ordered JSON) → Import (Target DB)

1. Snapshot-to-Events Conversion

An intermediate step (ConvertSnapshotToEvents) transforms the flat table-centric snapshot into an ordered JSON array of CloudEvents. This format is 100% compatible with the existing event-importer CLI tool (matching the 00-bootstrap.json structure).

2. Topological Sequencing (Dependency Awareness)

Since a full migration often involves complex relationships, the converter is “Relationship Aware.” It uses DatabaseMetaData.getImportedKeys() to dynamically discover parent→child dependencies.

  • Topological Sort: It implements Kahn’s algorithm to order events such that parent entities (e.g., Org, Host, User, Role) are processed before their children (e.g., UserHost, RoleUser, AuthProviderClient).
  • Dynamic: This approach handles new tables and FK constraints automatically without requiring code changes to a “hard-coded” dependency list.
3. Batch Replay & Reconciliation

The import handler performs a batch insertion of these generated events into event_store_t and outbox_message_t within a single transaction.

  • Nonce Re-calculation: Nonces are re-calculated on the target system during import to ensure uniqueness.
  • Automatic Projections: Inserting into the outbox triggers the DbEventConsumerStartupHook to rebuild all materialized projection tables on the target system.

Service API Contract

  • Export:

    • Handler: GlobalSnapshotExport (user-query)
    • Service ID: lightapi.net/user/exportGlobalSnapshot/0.1.0
    • Request: { "sourceHostId": "...", "entityTypes": [...] }
    • Response: Canonical snapshot JSON (flat tables)
  • Convert (New):

    • Handler: ConvertSnapshotToEvents (user-query)
    • Service ID: lightapi.net/user/convertSnapshotToEvents/0.1.0
    • Request: { "snapshot": "...", "targetHostId": "...", "adminUserId": "..." }
    • Response: JSON array of ordered CloudEvents (event-importer compatible)
  • Import:

    • Handler: GlobalSnapshotImport (user-command)
    • Service ID: lightapi.net/user/importGlobalSnapshot/0.1.0
    • Request: { "targetHostId": "...", "snapshot": "...", "entityTypes": [...] }
    • Response: { "imported": 42, "total": 42 }

Implementation Phases (Updated)

  1. Phase 1 – UI Foundation: Create promotion pages, sidebar menu entry. (Completed)
  2. Phase 2 – Global Export: Implement dynamic table discovery via JDBC metadata. (Completed)
  3. Phase 2.5 – Global Migration Step: Implement Topological Sorting and Snapshot-to-Events conversion for CLI compatibility. (Completed)
  4. Phase 3 – Entity Promotion (Selective): Implement recursive bundling for user-selected entities (e.g., Instance export).
  5. Phase 4 – Same-Instance Tracking: Integrate promotion_t tracking for in-DB moves. (Not completed; history/detail handlers currently return placeholder data until the promotion tables and persistence are added.)

Deployment Workflow

Light Portal manages product, API, application, instance, runtime configuration, and deployment metadata for multiple tenants. The deployment workflow extends that model so a user can deploy a configured instance to a Kubernetes cluster from the Instance Admin page.

The goal is to provide a production-like deployment path for small businesses and enterprise tenants without requiring Light Portal to have direct network access to every customer cluster.

Problem

Each API or application repository can contain a k8s/ folder with Kubernetes deployment templates. The templates contain variables in the following format:

${key:defaultValue}

For each configured portal instance, Light Portal can generate a values.yml document that contains deployment-time values such as image URL, namespace, replica count, service ports, config references, resource limits, ingress host, and rollout options.

When a user clicks the Deployment button for an instance, the system should:

  1. Resolve the target instance and deployment environment.
  2. Generate or fetch the instance deployment values.yml.
  3. Send a deployment command to a deployer that can access the target Kubernetes cluster.
  4. Render the final Kubernetes manifests from the repository templates.
  5. Validate and apply the manifests.
  6. Track rollout status and return deployment results to Light Portal.

The recommended default is to run a small Rust deployer inside each target Kubernetes cluster.

Light Portal
  |
  | deployment request / status query
  v
Light Controller
  |
  | outbound WebSocket session / MCP tool call
  v
In-cluster Rust Deployer Pod
  |
  | Kubernetes API via in-cluster ServiceAccount
  v
Customer Kubernetes Cluster

This is similar to the agent model used by GitOps and cloud management systems: the cluster-local agent connects outbound to the control plane and performs cluster operations using tightly scoped Kubernetes RBAC.

Why In-Cluster Deployer

Running the deployer inside the cluster should be the default for production.

Kubernetes Authentication

An in-cluster deployer can use Kubernetes in-cluster configuration. The Rust service can use kube-rs and call the equivalent of default client discovery. Kubernetes mounts a ServiceAccount token into the pod, so no external kubeconfig file needs to be copied, stored, rotated, or exposed.

Least-Privilege RBAC

The deployer should run as a dedicated ServiceAccount with only the permissions needed for the namespaces and resources it manages. If a deployer is compromised, the blast radius is limited by Kubernetes RBAC.

For a small-business deployment, the first version can bind the deployer to a dedicated namespace. For managed enterprise environments, the portal can create one deployer per cluster or per tenant namespace.

Firewall Traversal

Many customer clusters are behind firewalls or corporate networks. An in-cluster deployer can open an outbound WebSocket connection to Light Controller. This avoids inbound firewall rules and allows Light Portal to manage deployments without direct access to the Kubernetes API server.

Operational Simplicity

Customers do not need to run a separate VM or keep a standalone deployment process alive. They install the deployer with one Kubernetes YAML file or Helm chart, and Kubernetes restarts it if it fails.

Deployment Transports

The deployment system should support two transports.

Controller-Mediated WebSocket

This is the preferred transport for private customer environments.

  1. The deployer pod starts inside the customer cluster.
  2. It registers with Light Controller over an outbound WebSocket.
  3. The controller authenticates the deployer and records its tenant, cluster, environment, capabilities, and current status.
  4. Light Portal sends deployment commands to the controller.
  5. The controller forwards the command to the deployer using MCP-style tool calls over the existing session.
  6. The deployer streams status back through the controller.

This mode works when Light Portal cannot reach the customer environment.

Direct Deployer URL

This is useful for local MicroK8s, managed clusters, and environments where Light Portal can reach the deployer directly.

The deployer URL can be stored in deployment configuration or config server metadata. Light Portal or the workflow engine can call the deployer’s API/MCP endpoint directly.

Direct mode should be treated as an optimization, not the primary model for customer-managed private networks.

Deployer Responsibilities

The deployer is intentionally narrow. It should not own tenant configuration or business workflow decisions. It executes deployment instructions and reports results.

The deployer should support these actions:

  • render: Fetch templates and values, render manifests, and return a manifest summary.
  • dryRun: Render manifests and validate them against the Kubernetes API without applying changes.
  • deploy: Apply manifests and wait for rollout status.
  • redeploy: Re-apply manifests and trigger rollout if needed.
  • undeploy: Delete resources created by the deployment.
  • status: Return current Kubernetes resource and rollout status.
  • logs: Return recent pod logs for the deployed instance.
  • rollback: Redeploy a previous Light Portal deployment snapshot.

The first implementation should include dryRun, deploy, undeploy, and status.

Rollback should be implemented through Light Portal deployment history, not native Kubernetes rollout undo. Native Kubernetes rollback only reverts the Deployment pod template and does not reliably revert associated ConfigMaps, Secrets, or deployment values. A Light Portal rollback should redeploy a previous immutable deployment snapshot so pods, config, environment variables, and related resources return to the same known state.

Deployment Request

A deployment request should be 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
valuesRef:
  source: config-server
  path: /deployments/petstore-dev/values.yml
template:
  repoUrl: https://github.com/lightapi/petstore-api.git
  ref: main
  path: k8s
options:
  dryRun: false
  waitForRollout: true
  timeoutSeconds: 300

The request should be created by Light Portal and persisted as deployment history before it is sent to the deployer.

Values File

The values.yml is instance-specific. It should contain all values needed to render Kubernetes templates for one deployment target.

image:
  repository: ghcr.io/lightapi/petstore-api
  tag: 1.0.0
deployment:
  replicas: 2
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    limits:
      cpu: 500m
      memory: 512Mi
service:
  port: 8080
ingress:
  enabled: true
  host: petstore-dev.example.com
config:
  snapshotId: petstore-dev-20260427
  configServerUrl: https://config.lightapi.net
template:
  repoUrl: https://github.com/lightapi/petstore-api.git
  ref: main
  path: k8s

The deployer can receive the values inline or fetch them from config server using the valuesRef in the deployment request.

Config Server should be the authoritative source of truth for deployment values. At deployment time, Light Portal should create an immutable snapshot of both the deployment values.yml and the runtime configuration values.yml. That snapshot is the deployment evidence. If a deployment fails or must be audited later, the team must be able to reconstruct exactly which values were used even if the current config has changed.

Light Portal should persist the snapshot reference and hash in deployment history. It should not rely only on a mutable config path.

Template Rendering

The initial template format can use simple placeholders:

image: ${image.repository}:${image.tag}
replicas: ${deployment.replicas:1}

The renderer should support nested keys and defaults. If a key is missing and no default is provided, rendering should fail.

The deployer should render manifests in memory and avoid writing generated YAML to disk unless debug mode is explicitly enabled.

Longer term, the deployer can support additional renderers:

  • Built-in ${key:default} renderer for simple service templates.
  • Kustomize for standard Kubernetes overlays.
  • Helm for teams that already maintain charts.

The built-in renderer should be deterministic and small. It should not evaluate arbitrary code.

Do not use raw string replacement or regex replacement against raw YAML text. YAML is indentation sensitive, and multi-line values, certificates, JSON strings, and embedded config blocks can break when substituted as plain text.

The preferred first renderer is a constrained internal AST renderer:

  1. Parse each template document with serde_yaml into serde_yaml::Value.
  2. Recursively traverse the YAML value tree.
  3. Resolve placeholders only inside string scalar values.
  4. Replace ${key:default} with values from the structured deployment values.
  5. Serialize the YAML value back to YAML or convert it directly to Kubernetes dynamic objects.

This avoids most quoting, escaping, and indentation bugs because YAML parsing and serialization remain responsible for formatting. It also keeps the renderer small and prevents arbitrary code execution.

The implementation must include tests for ConfigMap multi-line blocks, JSON strings, certificate-shaped values, and Secret references before production use.

Kubernetes Execution

The Rust deployer should prefer kube-rs and the Kubernetes API over shelling out to kubectl.

Benefits:

  • no kubectl binary dependency
  • structured errors
  • easier dry-run and rollout status handling
  • better control over authentication and namespaces
  • safer request construction

kubectl can remain a diagnostic or fallback mode, but it should not be the default production implementation.

The deployer should use Kubernetes server-side dry run for validation:

dryRun=All

For apply, use server-side apply when possible so the deployer has a clear field manager identity.

The field manager must be explicit, for example:

fieldManager=light-deployer

Using a stable field manager is important for coexistence with other Kubernetes controllers. For example, a Horizontal Pod Autoscaler may own Deployment replica changes. Server-side apply helps the deployer avoid accidentally overwriting fields owned by other managers.

For rollout status, the deployer should use the Kubernetes watch API rather than only polling logs. The portal user experience should show resource status transitions such as:

Pending -> ContainerCreating -> Running -> Ready

Streaming watch events through the deployer gives Light Portal a precise deployment timeline similar to a CI/CD job log while still preserving structured Kubernetes state.

Security Model

Security is the central design constraint because this component can mutate a customer cluster.

Authentication

The deployer must authenticate to Light Controller or Light Portal before it can receive commands. Recommended options:

  • mTLS for deployer-to-controller registration
  • signed JWT enrollment token for first registration
  • short-lived command tokens issued by Light Portal

The deployer should have a stable deployerId and should report cluster, namespace, version, and capability metadata during registration.

Authorization

Light Portal must verify that the requesting user can deploy the target instance, environment, and tenant. The deployer must also enforce local constraints:

  • allowed namespaces
  • allowed repository hosts and repository names
  • allowed image registries
  • allowed Kubernetes resource kinds
  • allowed actions

The deployer should reject commands outside its configured policy even if the portal sends them.

RBAC

For namespace-scoped deployments, prefer Role and RoleBinding over ClusterRole and ClusterRoleBinding.

Version 1 should allow only application-level resource kinds:

  • Deployment
  • Service
  • Ingress
  • ConfigMap
  • Secret

Version 1 should explicitly block cluster-scoped and control-plane resources, including:

  • Namespace
  • ClusterRole
  • ClusterRoleBinding
  • CustomResourceDefinition
  • admission webhooks

This keeps the default deployer RBAC narrow and supports least-privilege customer installations.

Example namespace-scoped installation:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: light-portal-deployer
  namespace: petstore-dev
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: light-portal-deployer
  namespace: petstore-dev
rules:
  - apiGroups: ["", "apps", "networking.k8s.io"]
    resources: ["deployments", "services", "ingresses", "configmaps"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: light-portal-deployer
  namespace: petstore-dev
subjects:
  - kind: ServiceAccount
    name: light-portal-deployer
    namespace: petstore-dev
roleRef:
  kind: Role
  name: light-portal-deployer
  apiGroup: rbac.authorization.k8s.io

Secrets should be handled carefully. Avoid logging rendered manifests that contain secret values. Prefer references to existing Kubernetes Secrets, External Secrets, Sealed Secrets, or config-server secret references resolved inside the deployer.

The Rust implementation must also avoid logging raw Kubernetes apply payloads. When using tracing or log, never log full kube-rs request objects, patches, or serialized manifests for Secret resources. Kubernetes Secret values are base64 encoded, not encrypted, and will leak credentials if written to pod stdout.

Deployment Pod

The deployer can be installed as a Kubernetes Deployment.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: light-portal-deployer
  namespace: petstore-dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: light-portal-deployer
  template:
    metadata:
      labels:
        app: light-portal-deployer
    spec:
      serviceAccountName: light-portal-deployer
      containers:
        - name: deployer
          image: ghcr.io/lightapi/light-portal-deployer:0.1.0
          env:
            - name: LIGHT_CONTROLLER_WS_URL
              value: wss://controller.lightapi.net/deployer/ws
            - name: DEPLOYER_ID
              value: petstore-dev-microk8s
            - name: DEPLOYER_TOKEN
              valueFrom:
                secretKeyRef:
                  name: light-portal-deployer-credentials
                  key: token
            - name: ALLOWED_NAMESPACES
              value: petstore-dev

Portal Workflow

The Instance Admin Deployment button should not synchronously run deployment logic in the browser request. It should create a deployment request and trigger an asynchronous workflow.

Recommended flow:

  1. User clicks Deployment for an instance.
  2. Portal validates authorization.
  3. Portal resolves instance, environment, product version, image, config snapshot, and template repository.
  4. Portal creates a deployment request row/event.
  5. Portal snapshots deployment values and runtime values.
  6. Portal or workflow engine runs dryRun.
  7. If the target environment requires approval, workflow waits for human approval.
  8. Workflow calls deploy.
  9. Deployer streams events: render complete, dry-run complete, apply started, pod phase changes, rollout progressing, rollout complete or failed.
  10. Portal updates deployment history and status.
  11. User can inspect rendered manifest summary, rollout status, pod status, and logs.

This fits the agentic workflow model. The workflow can ask the user to approve the rendered changes before applying them.

Approval should be configurable at the environment level. Development and test environments can allow automatic deployment. Production environments should normally require manual approval through Light Portal or an agentic workflow ask task.

Status And Audit

Light Portal should persist deployment history.

Suggested fields:

  • deploymentId
  • hostId
  • instanceId
  • environment
  • clusterId
  • namespace
  • action
  • status
  • requestUser
  • deployerId
  • templateRepoUrl
  • templateRef
  • templatePath
  • valuesHash
  • valuesSnapshotId
  • runtimeValuesHash
  • runtimeValuesSnapshotId
  • manifestHash
  • templateCommitSha
  • resourceSummary
  • imageRepository
  • imageTag
  • startedTs
  • completedTs
  • errorMessage

The deployer should return enough detail to reproduce the deployment intent without storing secrets.

Light Portal should store only the rendered manifest hash, Git commit SHA, and a redacted resource summary. It should not store full rendered YAML in the database because rendered manifests can contain environment variables, connection strings, or credentials.

Example resource summary:

[
  {"kind": "Deployment", "namespace": "petstore-dev", "name": "petstore"},
  {"kind": "Service", "namespace": "petstore-dev", "name": "petstore"}
]

Multi-Tenant Considerations

Small-business cloud service means multiple tenants may share Light Portal but deploy to separate clusters or namespaces.

Rules:

  • Tenant identity must be present in every deployment request.
  • A deployer must be bound to one tenant boundary. In most installations, that means one tenant namespace or a tightly controlled set of namespaces owned by that tenant.
  • Do not share one deployer across unrelated tenants.
  • Namespace policy must be enforced both by portal authorization and deployer local policy.
  • Deployment history must be filtered by hostId.
  • A compromised deployer must not be able to receive commands for another tenant.

Failure Handling

The deployer should classify failures:

  • template repository fetch failure
  • values file fetch failure
  • render failure
  • manifest validation failure
  • Kubernetes API authorization failure
  • apply failure
  • rollout timeout
  • health check failure
  • controller WebSocket disconnected
  • deployer registration rejected

Each failure should include a safe message and diagnostic metadata. Secret values must be redacted.

For controller-mediated deployments, the deployer must have a resilient WebSocket lifecycle. If Light Controller restarts or the network drops, the deployer should not crash. It should reconnect with exponential backoff and jitter, re-register after reconnecting, and resume accepting commands only after the controller confirms the deployer session.

First Implementation

The first implementation should target local MicroK8s and direct feedback in Light Portal.

Phase 1:

  • Create Rust deployer service.
  • Run it inside MicroK8s.
  • Support direct API mode for local testing.
  • Implement render, dryRun, deploy, undeploy, and status.
  • Use kube-rs and in-cluster ServiceAccount authentication.
  • Support built-in ${key:default} rendering.
  • Add deployment request and deployment history tables/events.
  • Add Instance Admin deployment request flow.

Phase 2:

  • Add controller-mediated WebSocket registration.
  • Expose deployer operations as MCP tools through the controller.
  • Stream deployment progress and Kubernetes watch events to Light Portal.
  • Implement exponential backoff reconnect and re-registration.
  • Add approval step through agentic workflow.

Phase 3:

  • Add Helm/Kustomize renderer support if needed.
  • Add rollback support.
  • Add multi-cluster inventory and deployer health view.
  • Add deployment policy and quota enforcement.

Resolved Design Decisions

  • Config Server is the authoritative source of truth for values. Each deployment stores immutable deployment and runtime values snapshot references plus hashes.
  • Light Portal stores rendered manifest hash, template Git commit SHA, and redacted resource summary. It does not store full rendered manifests by default.
  • Regulated environments can add an opt-in enterprise artifact mode that stores the full rendered manifest in encrypted object storage with strict retention. Full manifests should stay out of the relational database.
  • Deployment approval is configured at the environment level. Production should require approval by default.
  • Deployers are installed per tenant boundary and should not be shared across unrelated tenants.
  • Version 1 allows only application-level resources: Deployment, Service, Ingress, ConfigMap, and Secret.
  • The first renderer should be a constrained internal AST renderer based on serde_yaml, not raw text replacement.
  • The direct deployer URL mode should expose MCP immediately, using the same internal tool implementation that controller-mediated WebSocket mode will use later.
  • Rollback is a redeploy of a previous Light Portal deployment snapshot, not a native Kubernetes rollout undo.

Open Questions

  • Which object storage providers should enterprise artifact mode support first?
  • What retention policies should be available for encrypted rendered manifest artifacts?
  • Should direct MCP use streamable HTTP only, or should it also expose SSE for long-running deployment progress events?
  • Should rollback require the same environment-level approval policy as deploy?

Recommendation

Use an in-cluster Rust deployer as the default production model. The deployer should connect outbound to Light Controller and execute deployment commands via MCP-style tools. Direct deployer URL mode is useful for MicroK8s and managed environments but should be secondary. The MCP tool implementation should be shared by both transports from the beginning.

Use kube-rs instead of shelling out to kubectl for the production execution path. Keep the deployer small, policy-bound, and auditable. Let Light Portal own deployment intent and history, while the deployer owns safe cluster-local execution.

Light OAuth and OAuth Kafka AgentCore OIDC Discovery

Problem

Issue https://github.com/lightapi/portal-service/issues/44 asks whether portal-service/apps/light-oauth can support AWS AgentCore JWT inbound authorization.

The current Rust light-oauth service and the Java oauth-kafka service can mint RS256 JWT access tokens and serve provider keys from:

GET /oauth2/{providerId}/keys

That is enough for internal services that are configured with an explicit jwksUrl, but it is not enough for AWS AgentCore or AWS API Gateway HTTP JWT authorizers. Those integrations discover the issuer metadata first, then use the published jwks_uri to fetch signing keys.

The linked AWS AgentCore document requires a discovery URL ending in /.well-known/openid-configuration, and validates configured audiences, clients, scopes, and required claims against the JWT. The API Gateway debugging document shows the same class of failure: without a valid OIDC discovery endpoint, AWS cannot create or use the JWT authorizer correctly. The Authgear OIDC guide summarizes the metadata fields expected by OIDC clients, including issuer, authorization_endpoint, token_endpoint, jwks_uri, response_types_supported, and signing algorithms.

Current Rust Behavior

The Rust service currently has these relevant routes:

POST /oauth2/{providerId}/code
POST /oauth2/{providerId}/token
GET  /oauth2/{providerId}/keys

The service has static token issuer and audience settings:

jwtIssuer: ${jwt_issuer}
jwtAudience: ${jwt_audience}

Default values are URNs:

jwt_issuer: "urn:com:networknt:oauth2:v1"
jwt_audience: "urn:com.networknt"

Generated access tokens currently include:

iss: configured issuer
aud: configured audience
cid: client id
scp: array of scopes

The service does not currently publish:

  • /.well-known/openid-configuration
  • /oauth2/{providerId}/.well-known/openid-configuration
  • an external/public issuer URL
  • OIDC-compatible client_id and scope token claims
  • a discovery document that maps the issuer to the existing JWKS endpoint

Current Java Behavior

The Java implementation in oauth-kafka has the same public OAuth shape:

GET  /oauth2/{providerId}/code
POST /oauth2/{providerId}/code
POST /oauth2/{providerId}/token
GET  /oauth2/{providerId}/keys
GET  /oauth2/{providerId}/deref/{token}
POST /oauth2/{providerId}/signing

The route mapping lives in:

src/main/resources/config/handler.yml

The handler list and local values live in:

src/main/resources/config/values.yml

The current JWKS handler is:

src/main/java/com/networknt/oauth/handler/ProviderIdKeysGetHandler.java

It queries the provider by id, returns the jwk JSON from the database, and returns 404 when the provider cannot be found. It does not publish discovery metadata.

The Java token handler is:

src/main/java/com/networknt/oauth/handler/ProviderIdTokenPostHandler.java

Its token claim helpers currently emit Light-specific claims:

cid: client id
scp: array of scopes

The signing endpoint already emits client_id for signed custom payloads:

src/main/java/com/networknt/oauth/handler/ProviderIdSigningPostHandler.java

However, that endpoint still needs the same reserved-claim behavior if it is used for AgentCore-facing tokens, because its custom payload is applied after the initial client_id claim.

The Java OpenAPI document also only exposes /{providerId}/keys; it has no discovery route:

src/main/resources/config/openapi.yaml

Gaps

1. Missing OIDC Discovery

AWS AgentCore expects a discovery URL matching:

^.+/\.well-known/openid-configuration$

Both light-oauth and oauth-kafka only expose /oauth2/{providerId}/keys. AWS does not know how to discover that provider-specific JWKS URL unless the OAuth service publishes a metadata document with jwks_uri.

2. Issuer Is Not a Public HTTPS URL

The default issuer is a URN. AgentCore discovery expects the discovery URL to point to an issuer URL, and the decoded token iss must match the issuer metadata. API Gateway JWT authorizers have the same practical requirement.

For enterprise deployments, the issuer should be the externally reachable URL seen by AWS, not the container DNS name or localhost address.

3. Token Claims Do Not Match AgentCore Names

AgentCore validates:

  • aud against allowedAudience
  • client_id against allowedClients
  • scope against allowedScopes

Current Rust and Java token flows expose the client as cid and scopes as scp. That is useful for existing Light consumers but does not satisfy AWS claim names by default.

4. Provider and Tenant Addressing Is Ambiguous

The existing JWKS route is provider-scoped. OIDC discovery commonly uses the issuer base URL plus /.well-known/openid-configuration, but light-oauth supports multiple providers. We need an explicit rule for how a discovery URL selects a provider.

5. Public URL Construction Is Not Configurable

The service runs behind gateways, Docker networks, and potentially AWS-facing domains. Discovery metadata must publish public URLs such as:

https://oauth.example.com/oauth2/{providerId}/keys

It must not publish internal URLs such as:

https://light-oauth:6881/oauth2/{providerId}/keys

6. JWKS and Signing Key Consistency Needs a Test Contract

Tokens are signed with rows from auth_provider_key_t, while /keys returns the provider jwk from auth_provider_t. The implementation should guarantee that the JWT header kid is present in the returned JWKS for the same provider. That guarantee matters more once external AWS services cache the discovery and JWKS responses.

Goals

  • Let AWS AgentCore use Rust light-oauth or Java oauth-kafka as a JWT bearer token issuer.
  • Publish OIDC-compatible discovery metadata for each provider in both implementations.
  • Keep existing /oauth2/{providerId}/keys and Light-specific cid/scp claims working.
  • Avoid exposing internal Docker or Kubernetes service names in public metadata.
  • Keep issuer, audience, and discovery URLs deterministic across environments.
  • Add tests that prove discovery, JWKS, and signed token claims line up.

Non-Goals

  • Do not implement full OIDC identity-provider behavior in the first phase.
  • Do not add dynamic client registration.
  • Do not replace existing explicit jwksUrl verification used by internal services.
  • Do not remove Light-specific token claims.
  • Do not solve AgentCore outbound OAuth credential providers in this change.

Add provider-scoped OIDC discovery to Rust light-oauth and Java oauth-kafka, and make token output compatible with both Light and AWS AgentCore.

Routes

Add the provider-scoped route first:

GET /oauth2/{providerId}/.well-known/openid-configuration

This avoids ambiguity because the route contains the provider identifier. The issuer for this route should be:

{publicIssuerBaseUrl}/oauth2/{providerId}

The discovery URL becomes:

{publicIssuerBaseUrl}/oauth2/{providerId}/.well-known/openid-configuration

The JWKS URI becomes:

{publicIssuerBaseUrl}/oauth2/{providerId}/keys

Optionally add a root route for a configured default provider:

GET /.well-known/openid-configuration

Only enable the root route when defaultProviderId is configured. Otherwise, return 404 to avoid publishing metadata for the wrong tenant or provider.

Discovery Document

Return application/json and a compact OIDC-compatible document:

{
  "issuer": "https://oauth.example.com/oauth2/AZZRJE52eXu3t1hseacnGQ",
  "authorization_endpoint": "https://oauth.example.com/oauth2/AZZRJE52eXu3t1hseacnGQ/code",
  "token_endpoint": "https://oauth.example.com/oauth2/AZZRJE52eXu3t1hseacnGQ/token",
  "jwks_uri": "https://oauth.example.com/oauth2/AZZRJE52eXu3t1hseacnGQ/keys",
  "response_types_supported": ["code"],
  "grant_types_supported": [
    "authorization_code",
    "password",
    "refresh_token",
    "client_credentials",
    "urn:ietf:params:oauth:grant-type:token-exchange"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic",
    "client_secret_post"
  ],
  "scopes_supported": ["portal.r"],
  "claims_supported": [
    "iss",
    "aud",
    "exp",
    "iat",
    "nbf",
    "jti",
    "client_id",
    "scope",
    "cid",
    "scp"
  ],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

id_token_signing_alg_values_supported is included for compatibility because many discovery consumers expect it, even if light-oauth does not issue ID tokens yet. The design should document this as discovery compatibility metadata, not as a promise that ID-token grant flows are complete.

Configuration

Add explicit public URL configuration:

oidcDiscoveryEnabled: ${oidc_discovery_enabled:true}
publicIssuerBaseUrl: ${public_issuer_base_url}
defaultProviderId: ${default_provider_id:}

Example local values:

public_issuer_base_url: "https://localhost:6882"
default_provider_id: "AZZRJE52eXu3t1hseacnGQ"

Example enterprise values:

public_issuer_base_url: "https://oauth.customer.example.com"
default_provider_id: "AZZRJE52eXu3t1hseacnGQ"

When publicIssuerBaseUrl is configured, generated token iss should default to:

{publicIssuerBaseUrl}/oauth2/{providerId}

Keep jwtIssuer for backward compatibility. If both are set, use a strict rule:

  1. If jwtIssuer is set to a non-default value, keep using it and make discovery issuer equal to that value.
  2. If jwtIssuer is absent or equal to the current default URN, use the provider-scoped public issuer URL.
  3. Log a startup warning if discovery is enabled but the issuer is not an HTTPS URL, unless running in local development.

Token Claim Compatibility

Extend JwtClaims without removing existing fields:

cid: existing Light client id claim
scp: existing Light scope array claim
client_id: OIDC/AWS client id claim
scope: OIDC/AWS space-delimited scope claim

For a client token, emit:

{
  "client_id": "019c9273-2663-7a9e-82f4-94f9f5f79c3a",
  "scope": "portal.r",
  "cid": "019c9273-2663-7a9e-82f4-94f9f5f79c3a",
  "scp": ["portal.r"]
}

For user grants, also emit a stable sub value. Prefer the portal user id if the token represents a user; otherwise use the client id for client credentials tokens. Keep the existing uid and uty claims.

Reserved claim names from request extra_claims must not override:

iss, aud, exp, iat, nbf, jti, kid, client_id, scope, cid, scp, sub

If an AgentCore runtime is configured with required custom claims, support them through existing client custom_claim configuration or a new allowlisted static claim configuration. For example, a customer that wants Cognito-like access token semantics could configure:

{
  "token_use": "access"
}

Do not hard-code Cognito-specific claims globally unless the Light token contract explicitly adopts them.

Scope Source

The token endpoint already resolves requested scope against the configured client scope. Discovery can publish a conservative scopes_supported value:

  • Use a configured oidcScopesSupported list when set.
  • Otherwise publish the union of active client scopes for the provider.
  • If querying client scopes is not added in phase 1, omit scopes_supported or publish a configured static list.

For AgentCore, the critical runtime behavior is that the token includes the space-delimited scope claim expected by allowedScopes.

JWKS Response

Keep:

GET /oauth2/{providerId}/keys

Add response headers:

Content-Type: application/jwk-set+json
Cache-Control: public, max-age=300

Five minutes is a reasonable starting cache TTL. It limits repeated AWS fetches while keeping key rotation practical. If existing clients depend on application/json, application/jwk-set+json remains JSON-compatible; test the known internal verifier before changing this header.

Add tests that assert:

  • a token signed for provider P has a kid
  • /oauth2/P/keys returns a JWKS containing that kid
  • discovery jwks_uri returns that same key set

AgentCore Configuration Example

An AgentCore runtime should be configured with the provider-scoped discovery URL:

{
  "customJWTAuthorizer": {
    "discoveryUrl": "https://oauth.customer.example.com/oauth2/AZZRJE52eXu3t1hseacnGQ/.well-known/openid-configuration",
    "allowedClients": ["019c9273-2663-7a9e-82f4-94f9f5f79c3a"],
    "allowedAudience": ["urn:com.networknt"],
    "allowedScopes": ["portal.r"]
  }
}

The token must then contain:

{
  "iss": "https://oauth.customer.example.com/oauth2/AZZRJE52eXu3t1hseacnGQ",
  "aud": "urn:com.networknt",
  "client_id": "019c9273-2663-7a9e-82f4-94f9f5f79c3a",
  "scope": "portal.r"
}

If the customer wants allowedAudience to be the AgentCore runtime or an API identifier instead of urn:com.networknt, make jwtAudience environment specific and align it with the AgentCore authorizer configuration.

Implementation Plan

Phase 1: Discovery Metadata

  • Rust: add publicIssuerBaseUrl, oidcDiscoveryEnabled, and defaultProviderId to ServerConfig.
  • Java: add publicIssuerBaseUrl, oidcDiscoveryEnabled, defaultProviderId, and optional oidcScopesSupported to OAuthConfig.
  • Rust: add provider-scoped discovery route in apps/light-oauth/src/main.rs.
  • Java: add ProviderIdOpenIdConfigurationGetHandler and register it in handler.yml and values.yml.
  • Java: add the provider-scoped discovery path to openapi.yaml and explicitly mark it as a public endpoint (security: []) so the endpoint remains public if JwtVerifyHandler is included in the route chain.
  • Build discovery URLs from the public issuer base URL and provider id.
  • Return 404 if discovery is disabled or the provider does not exist.
  • Add tests for discovery JSON shape and URL construction.
  • Update local/dev config examples with a public issuer base URL.

Phase 2: AgentCore Claim Compatibility

  • Rust: add client_id, scope, and sub to JwtClaims.
  • Java: update ProviderIdTokenPostHandler claim builders: mockCcClaims, mockBsClaims, and mockAcClaims.
  • Keep cid and scp in both implementations.
  • Add a reserved-claim guard for flattened/custom claims in both implementations.
  • Java: decide whether ProviderIdSigningPostHandler should use put-if-absent behavior for reserved claims, or document that the signing endpoint is for trusted callers that control the full JWT payload.
  • Add tests that decode a generated token and assert AgentCore claim names.
  • Add a sample AgentCore authorizer configuration to docs/config notes.

Phase 3: JWKS and Rotation Contract

  • Add tests proving the token kid is available in /keys.
  • Decide whether /keys should return application/jwk-set+json immediately or stay application/json for one release.
  • Add Cache-Control with a short TTL.
  • Add an operational check that warns if the current signing key is missing from the published provider JWKS.
  • Java: keep ProviderIdKeysGetHandler behavior aligned with the Rust /keys endpoint, including status codes and cache headers.

Phase 4: Optional Root Discovery

  • Add GET /.well-known/openid-configuration only when defaultProviderId is configured.
  • Make the root metadata identical to the provider-scoped metadata for the default provider.
  • Document that multi-provider enterprise deployments should prefer provider-scoped discovery URLs.

Java Implementation Notes

The Java implementation should stay structurally close to the existing oauth-kafka handler model.

Add a new handler:

src/main/java/com/networknt/oauth/handler/ProviderIdOpenIdConfigurationGetHandler.java

Register it in handler.yml:

- path: '/oauth2/{providerId}/.well-known/openid-configuration'
  method: 'GET'
  exec:
    - default
    - openidConfigurationGet

Note: Ensure that this endpoint is marked with security: [] in openapi.yaml so that the endpoint remains public if JwtVerifyHandler is included in the route chain (which may happen in enterprise overrides).

Register the handler alias in values.yml:

- com.networknt.oauth.handler.ProviderIdOpenIdConfigurationGetHandler@openidConfigurationGet

Extend oauth.yml and OAuthConfig:

oidcDiscoveryEnabled: ${oauth.oidcDiscoveryEnabled:true}
publicIssuerBaseUrl: ${oauth.publicIssuerBaseUrl:}
defaultProviderId: ${oauth.defaultProviderId:}
oidcScopesSupported: ${oauth.oidcScopesSupported:}

Use Config.getInstance().getJsonObjectConfig(OAuthConfig.CONFIG_NAME, OAuthConfig.class) or the local equivalent pattern already used by the token handler to load this configuration.

The discovery handler should:

  • read {providerId} from exchange.getQueryParameters()
  • return 404 when discovery is disabled or the provider lookup fails
  • build issuer, token_endpoint, authorization_endpoint, and jwks_uri from publicIssuerBaseUrl plus /oauth2/{providerId}
  • return application/json
  • avoid using Host or X-Forwarded-* headers as the default source of the public issuer URL

For token claims, change Java helper methods as follows:

mockCcClaims:
  cid, scp, client_id, scope, sub=clientId

mockBsClaims:
  cid, scp, client_id, scope, sub=clientId

mockAcClaims:
  uid, uty, cid, scp, client_id, scope, sub=userId

Keep existing Java tests for legacy claims, and add new tests that decode the JWT and assert client_id, scope, and sub.

Validation Checklist

For a customer-facing AgentCore setup, validate:

curl -k https://oauth.customer.example.com/oauth2/{providerId}/.well-known/openid-configuration
curl -k https://oauth.customer.example.com/oauth2/{providerId}/keys

Then decode a minted token and confirm:

  • iss equals discovery issuer
  • discovery URL ends with /.well-known/openid-configuration
  • discovery jwks_uri is externally reachable by AWS
  • JWT header kid exists in the JWKS
  • aud matches AgentCore allowedAudience
  • client_id matches AgentCore allowedClients
  • scope contains each required AgentCore allowedScopes entry
  • token is signed with RS256
  • certificate chain for the public issuer URL is trusted by AWS

For API Gateway HTTP authorizer deployments, enable the equivalent of FailOnWarnings so discovery failures fail deployment loudly.

Security Notes

  • Do not derive public issuer URLs from untrusted request headers by default. Use explicit configuration. If proxy headers are supported later, trust them only behind a configured gateway.
  • Prefer HTTPS public issuer URLs. Local development can allow localhost and self-signed certificates, but enterprise AgentCore setup should use a public CA trusted by AWS.
  • Do not let custom token claims override reserved claims.
  • Keep short-lived access tokens for AgentCore invocation unless the customer has a specific long-lived service token use case.
  • Keep client secrets out of browser flows. Use backend-mediated token exchange or confidential clients where needed.
  • CORS: While AgentCore calls the discovery endpoint server-to-server, if any SPAs need to read this metadata, ensure that the provider-scoped and optional root discovery paths are placed on a handler chain that includes cors (since cors is not in the default chain by default in oauth-kafka), and ensure cors.yml allows GET on these paths.

Resolved Questions

  • Should jwtAudience remain a single string, or should light-oauth support multiple audiences in aud for AgentCore plus existing Light services? Resolution: Support either a string or an array of strings for aud, but keep the default as the existing single string. The current Rust issuer and verifier are string-shaped and may fail to decode tokens if aud becomes an unconditional array. Update the verifiers and tests to support an array before enabling multi-audience output by default.
  • Should auth_client_t.client_id remain the only client identifier, or do we need an external client alias for customers that cannot use UUID client ids in AWS configuration? Resolution: Keep it as the only identifier for Phase 1 to reduce scope. If AWS AgentCore restricts UUID formats, a client alias feature can be proposed in Phase 2.
  • Should the service expose OAuth 2.0 Authorization Server Metadata at /.well-known/oauth-authorization-server in addition to OIDC discovery? Resolution: No, OIDC discovery (openid-configuration) is sufficient for AgentCore and most standard OIDC consumers.
  • Should discovery include only configured scopes, or query active client scopes dynamically per provider? Resolution: Use a static configured list (oidcScopesSupported) for Phase 1. Querying active scopes dynamically could introduce performance overhead for discovery.
  • Should key rotation update auth_provider_t.jwk transactionally with auth_provider_key_t, or should /keys be generated directly from auth_provider_key_t? Resolution: They must be updated transactionally or /keys should generate its payload directly from auth_provider_key_t. Serving mismatched JWKS metadata will break token verification. Generating directly from auth_provider_key_t is the most reliable design. The dynamic JWKS must include every active public verification key that can validate currently valid tokens (including current, previous rotation keys, and long-lived keys if long-lived tokens are still issued). It must never expose private key material.
  • GitHub issue: https://github.com/lightapi/portal-service/issues/44
  • AWS AgentCore OAuth and JWT inbound auth: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-oauth.html
  • AWS API Gateway OIDC JWT authorizer debugging: https://loige.co/debugging-api-gateway-http-oidc-jwt-authorizer/
  • OIDC discovery field overview: https://www.authgear.com/post/well-known-openid-configuration/

OpenAPI Endpoint Parameter Mapping Design

This document outlines the design for passing OpenAPI parameter mapping details (path parameters, query parameters, headers, cookies, and body) from SpecUtil to the mcp-router in light-fabric. This enables the MCP router to correctly invoke the backend REST APIs based on flat tool call arguments provided by AI agents.


Context & Motivation

When the OpenAPI specification is parsed by SpecUtil.java, endpoints are registered in the Light Portal database, and a flat toolSchema is generated to represent the input schema.

For example, given a GET request with query parameters (like /offers) or path parameters (like /customers/{customerId}), the parameters are flattened into a single JSON schema structure:

{
  "type": "object",
  "properties": {
    "segment": { "type": "string", "description": "Customer segment filter." },
    "state": { "type": "string", "description": "Region or province filter." }
  }
}

The AI agent invokes this tool by passing a flat map of arguments:

{
  "segment": "premium",
  "state": "ON"
}

Currently, the mcp-router in light-fabric does not know where each argument belongs (e.g., whether it should be placed in the URL path, query string, headers, or request body). For GET requests, it defaults to appending all arguments as query parameters. For POST/PUT/PATCH requests, it defaults to placing all arguments in the JSON body.

This leads to several failures:

  1. Path parameters (e.g. {customerId}) are not substituted in the URL path.
  2. Header parameters and Cookies are completely lost or put in the wrong place.
  3. Mixed requests (e.g. a POST with a URL path parameter and a JSON body) cannot be assembled correctly.

Design Requirements

  • Accuracy: The router must place every argument in the exact location (path, query, header, cookie, or body) defined by the OpenAPI specification.
  • Efficiency / Cleanliness: The solution must not increase token usage for the LLM agents. Gateway-specific routing details should remain hidden from public tool schemas exposed to agents.
  • Backward Compatibility: If no mapping metadata is provided, the router should fall back to its existing default routing rules.

Design Options for Storing Parameter Locations

We evaluated two options for conveying parameter mapping information from the Spec parser to the gateway router:

Option A: Schema-Level Annotations (toolSchema)

Inject custom attributes (such as "x-in": "query", "x-in": "path") directly into the JSON Schema properties:

{
  "type": "object",
  "properties": {
    "customerId": {
      "type": "string",
      "x-in": "path"
    }
  }
}
  • Pros: Self-contained schema where each field is annotated with its location.
  • Cons: Increases the payload size of inputSchema sent to the LLM agent via tools/list, leading to wasted token count and exposing gateway-internal routing details to the agent.

Store the parameter locations in the private toolMetadata payload, which is saved in the database and loaded by the gateway, but is filtered out and never sent to the LLM agent.

{
  "routing": {
    "domain": "Offers",
    "sourceProtocol": "openapi"
  },
  "parameters": {
    "customerId": "path",
    "segment": "query",
    "X-Trace-Id": "header",
    "body": "body"
  }
}
  • Pros:
    • Keeps the public toolSchema clean and minimal.
    • Saves LLM token costs.
    • Consistently places all gateway-internal routing decisions inside the private toolMetadata structure.
  • Cons: Slightly split parsing (schema for validation, metadata for execution), but since the gateway already deserializes both, this has negligible overhead.

Detailed Solution

We will implement Option B. The design requires updates to two components: SpecUtil.java (spec parsing) and mcp.rs (routing execution in the Rust gateway).

1. Spec Parser Changes (SpecUtil.java)

When parsing an OpenAPI spec in SpecUtil.java, we will build a parameters location map of type Map<String, String> mapping each parameter name to its location:

  • path -> "path"
  • query -> "query"
  • header -> "header"
  • cookie -> "cookie"
  • Request Body -> "body" (mapped from the unified schema body property for body-capable HTTP methods)

This map will be attached to routingExtras during metadata enrichment under the "parameters" key, resulting in the following toolMetadata structure:

{
  "routing": {
    "domain": "Offers",
    "sourceProtocol": "openapi",
    "parameters": {
      "segment": "query",
      "state": "query",
      "customerId": "path"
    }
  },
  "safety": {
    "read_only": true,
    "destructive": false
  }
}

2. Rust Gateway Router Changes (mcp.rs)

The mcp.rs module in light-pingora will be updated as follows:

  1. Extract Parameter Locations: When caching or loading tools, the router will deserialize the parameters map from tool_metadata.routing.parameters.
  2. Argument Placement: When executing an HTTP tool call, the router will partition the arguments map into:
    • Path Map: Key-value pairs where location is "path".
    • Query Map: Key-value pairs where location is "query".
    • Header Map: Key-value pairs where location is "header".
    • Cookie Map: Key-value pairs where location is "cookie".
    • Body Val: The argument corresponding to the key mapped to "body". If no explicit body mapping is defined but the HTTP method allows a body (POST/PUT/PATCH), any arguments not explicitly mapped to path/query/header/cookie will be packed into the JSON request body.
  3. Build Outbound Request:
    • Path Substitution: Iterate through the path map and replace {key} placeholders in the tool URL path.
    • Query Serialization: Append the query map properties to the target URL’s query string using URL-encoding.
    • Header Injection: Append header map values as HTTP headers.
    • Cookie Injection: Format cookie map values into the Cookie header.
    • Body Serialization: Attach the JSON body payload to the outbound HTTP request.

Concrete Examples

Example 1: GET /offers (Query Filters)

Original OpenAPI Specification

  /offers:
    get:
      operationId: searchOffers
      parameters:
        - name: segment
          in: query
          schema:
            type: string
        - name: state
          in: query
          schema:
            type: string

Generated Database Artifacts

  • toolSchema:
    {
      "type": "object",
      "properties": {
        "segment": { "type": "string" },
        "state": { "type": "string" }
      }
    }
    
  • toolMetadata:
    {
      "routing": {
        "domain": "Offers",
        "sourceProtocol": "openapi",
        "parameters": {
          "segment": "query",
          "state": "query"
        }
      }
    }
    

Tool Call Arguments

{
  "segment": "premium",
  "state": "ON"
}

Outgoing REST Call

GET /offers?segment=premium&state=ON HTTP/1.1
Host: backend-service

Example 2: GET /customers/{customerId} (Path Parameter)

Original OpenAPI Specification

  /customers/{customerId}:
    get:
      operationId: getCustomerProfile
      parameters:
        - name: customerId
          in: path
          required: true
          schema:
            type: string

Generated Database Artifacts

  • toolSchema:
    {
      "type": "object",
      "properties": {
        "customerId": { "type": "string" }
      },
      "required": ["customerId"]
    }
    
  • toolMetadata:
    {
      "routing": {
        "domain": "Customers",
        "sourceProtocol": "openapi",
        "parameters": {
          "customerId": "path"
        }
      }
    }
    

Tool Call Arguments

{
  "customerId": "CUST-1001"
}

Outgoing REST Call

GET /customers/CUST-1001 HTTP/1.1
Host: backend-service

Example 3: PUT /customers/{customerId}/preferences (Mixed Path & Body)

Original OpenAPI Specification

  /customers/{customerId}/preferences:
    put:
      operationId: updateCustomerPreferences
      parameters:
        - name: customerId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                channel:
                  type: string
                consent:
                  type: boolean

Generated Database Artifacts

  • toolSchema:
    {
      "type": "object",
      "properties": {
        "customerId": { "type": "string" },
        "body": {
          "type": "object",
          "properties": {
            "channel": { "type": "string" },
            "consent": { "type": "boolean" }
          }
        }
      },
      "required": ["customerId", "body"]
    }
    
  • toolMetadata:
    {
      "routing": {
        "domain": "Customers",
        "sourceProtocol": "openapi",
        "parameters": {
          "customerId": "path",
          "body": "body"
        }
      }
    }
    

Tool Call Arguments

{
  "customerId": "CUST-1001",
  "body": {
    "channel": "portal",
    "consent": true
  }
}

Outgoing REST Call

PUT /customers/CUST-1001/preferences HTTP/1.1
Host: backend-service
Content-Type: application/json

{"channel":"portal","consent":true}

Portal View Help

This is the fallback help page for portal-view.

Use this section when a page, form, or task does not yet have a more specific help page. The contextual help design expects the portal UI to link here when a specific helpPath is missing.

Common Starting Points

  • Pages explain what a screen is used for and which actions are available.
  • Forms explain when to submit a command and what happens after submission.
  • Tasks explain multi-step workflows that span pages and forms.
  • Concepts explain reusable portal ideas such as ownership, hosts, and API versioning.

Portal View Page Help

This section contains page-level help for portal-view.

Page help should explain what the screen is for, who can access it, which records are visible, and which common actions are available.

Available page guides:

Task Center

Use Task Center to start guided portal workflows that span multiple existing pages and forms.

Task Center does not replace the underlying pages. It gives each workflow a single starting point, carries useful context in the URL, and links to the forms or admin pages needed to finish the work.

Where Task Context Is Stored

Task context is stored in the user’s browser sessionStorage, not in the portal database. The saved values are local to the current browser session and are used to restore Recent Tasks, skipped checklist steps, and the context chips shown on task pages.

Task Center uses session storage keys such as:

  • portal-view.taskContext.<taskId>
  • portal-view.taskSkippedSteps.<taskId>
  • portal-view.recentTaskContexts
  • portal-view.recentPages

Because this state is browser-local, it is not shared across users, devices, or different browser sessions. Clearing browser session storage, using Clear Context, or completing a task removes the local task context and the task no longer appears in Recent Tasks.

Common areas:

  • search for tasks, pages, forms, or entity context
  • filter tasks by category
  • continue tasks shown in Recent Tasks
  • open context-aware suggestions when the URL already contains entity IDs
  • open a task detail page to review required and optional steps

Recent Tasks and Suggested Tasks are shown separately from the category list. When there is no search query, tasks already shown in those sections are hidden from the category cards so the same task does not appear twice on the page.

Task details show each workflow step, progress status, related pages, and actions for opening the next page or form. When a task opens another page, the portal carries task context through the URL so the destination can prefill or highlight the relevant record when supported.

Reference Table Admin

The Reference Table Admin page allows you to create and manage reference-data tables for your portal view.

Global vs. Host-Specific Tables

Reference tables can be defined at two levels:

  1. Global Reference Tables: These are built-in or system-wide tables that have no specific hostId assigned (the Host ID column will display as “Global”). They provide a baseline set of reference data accessible to all hosts.
  2. Host-Specific Reference Tables: These are tables created by and assigned to a specific host (displaying the host’s ID in the Host ID column). They contain reference data that is isolated and only accessible to that particular host.

Tip

Expanding Global Tables: You can define a host-specific reference table using the exact same table name as an existing global reference table. By doing this, you can expand or override the global reference table entries with your own host-specific values, tailoring the reference data to your host’s needs without affecting other hosts.

Usage in Forms.json

The primary consumer of reference tables in portal-view is the React Schema Form component, which dynamically renders dropdowns, radios, and multi-select fields based on Forms.json.

In portal-view/src/data/Forms.json, fields that require dynamic reference data specify a URL pointing to the backend API.

Example of a standard dropdown:

{
  "key": "user_type",
  "type": "select",
  "titleMap": {
    "url": "/r/data?name=user_type"
  }
}

Example of a cascading (dependent) dropdown using the host context and a parent field value:

{
  "key": "province",
  "type": "select",
  "titleMap": {
    "url": "/r/data?name=province&host={0}&rela=country-province&from={1}"
  }
}

The /r/data API

The /r/data endpoint is exposed by the portal-service (specifically in apps/portal-service) to serve reference data securely and efficiently to the frontend.

It accepts several important query parameters to shape the response:

  • name: The tableName of the reference table you want to fetch (e.g., country, user_type).
  • host: The hostId of the current user’s workspace. Passing this parameter ensures that any host-specific reference values (overrides/expansions) are merged seamlessly with the global values.
  • rela: The ID of a relationship mapping (Reference Relation). This is used for dependent datasets (e.g., country-province).
  • from: The actual selected value of the parent field in the relationship (e.g., if country was set to US, from=US would fetch only provinces belonging to the US).

The portal-service securely resolves these requests, fetches the necessary values from the underlying ref-query service, evaluates relationships, and returns a key-value map compatible with react-schema-form’s titleMap or dynaselect.

Example: Global vs. Host-Specific Expansion (createInstance form)

A perfect example of how global and host-specific tables interact is found in the Create Instance form, specifically between the environment and envTag fields.

1. The Global Field (environment)

The environment field is designed to ONLY show the standard global environments. It does not pass the host parameter to the API.

{
  "key": "environment",
  "type": "dynaselect",
  "multiple": false,
  "action": {
    "url": "/r/data?name=environment"
  }
}

2. The Expanded Field (envTag)

The envTag field allows you to select a specific environment tag. This field passes the hostId to the API, meaning the resulting dropdown will combine the global environments WITH any host-specific environment tags you have defined.

{
  "key": "envTag",
  "type": "dynaselect",
  "multiple": false,
  "action": {
    "url": "/r/data?name=environment&host={0}",
    "params": [
      "hostId"
    ]
  }
}

Viewing Both Tables

To see both the global and your host-specific reference tables in action:

  1. Go to the Reference Table Admin page.
  2. In the column filters, type environment under the Table Name column.
  3. You will see two results:
    • One row with an empty (or “Global”) Host ID. This is the global table.
    • One row with your specific Host ID. This is where you can add custom tags that will only appear in your envTag dropdown.

List Rule

The List Rule page allows administrators to view, search, and manage YAML rules associated with specific service API endpoints.

This page is designed for users to add existing rules to the endpoint for request access control (req-acc) and response filter (res-fil).

The rule setup on this page associates one or more rules with the current endpoint. These CEL-backed rules are invoked from either the access-control handler for API access or the mcp-router handler for MCP access to enforce fine-grained authorization by matching the user security profile with the endpoint security definitions.

For more details on light rule and security configuration, please refer to the following resources:

Key Features

  • Filter Active Rules: Toggle search filters to view active/inactive rules.
  • Add Rule to Endpoint: Easily attach a rule to the selected endpoint.
  • Delete Rules: Remove rule configurations from endpoints.

Rule Admin

The Rule Admin page allows administrators to create and manage YAML rules with CEL condition expressions and actions.

For most users, they just need to pick up rules pre-defined in this page. We have defined some global rules to share with all tenants. Each tenant can create host-specific rules that can be used with the tenant.

When creating a new rule, the default condition security profile is strict. If standard profile is selected, a workflow task will be assigned to the rule-admin role for approval.

References

User Session

Use User Session to review and revoke OAuth sessions for your own account.

The User Session pages are self-service pages. They use the signed-in user’s identity from the authenticated session, and the backend self-service actions apply the current user scope again. Browser table filters are only usability controls; they are not the authorization boundary.

Available views:

  • My Sessions shows active login sessions by default. Change the status filter if you need to review older revoked or expired sessions.
  • My Refresh Tokens shows active refresh-token-backed sessions for your account.
  • My Session Audit shows login, refresh, failure, and revocation events for your account.

Common actions:

  • open audit history for a session
  • open refresh tokens for a session
  • revoke one of your sessions
  • revoke a refresh-token-backed session

Revoking your current browser session signs you out after the revoke succeeds. If the portal cannot identify whether the selected session is the current browser session, it still warns that the action may sign you out.

Administrators should continue to use OAuth Admin for host-wide session, refresh-token, and audit review.

API Catalog

Use API Catalog to browse APIs that are ready for consumer discovery.

The catalog is backed by the same API records, categories, and tags used by API Admin and the API create/update forms. It is intended for browse and discovery, not bulk administration.

Common filters:

  • search text for API name, id, and description
  • categories for stable browse buckets
  • grouped tags for capability, protocol, lifecycle, security, runtime, domain, consumer, operations, and integration facets
  • active or inactive status
  • sort and card/list view options

Catalog cards show a compact operational summary:

  • active API version count and latest version
  • endpoint count across active versions
  • runtime bindings through instance APIs
  • access-control coverage from active endpoint rules

Common actions:

  • open API details and versions
  • open endpoints for the latest active version
  • create a new API version
  • update API metadata when you own the API or have API administrator access
  • continue related publish, MCP onboarding, or access-control tasks

Service Endpoint

The Service Endpoint page lists the endpoints generated for an API version. Use it to review endpoint metadata and configure endpoint-level access control.

Endpoint-Level Access Control

Access rules, permissions, row filters, and column filters are stored against individual endpoints. Bulk operations on this page write the same endpoint-level records used by the existing per-endpoint pages.

The page does not define API-version-level inherited defaults. After a bulk update, each affected endpoint has its own materialized access-control records.

Bulk Access

Select one or more endpoint rows and choose Bulk Access to apply one access-control operation to all selected endpoints.

Supported operation groups include:

  • endpoint rule assignment
  • role, group, position, and attribute permissions
  • role, group, position, and attribute row filters
  • role, group, position, and attribute column filters

The default conflict mode is Skip Existing, which avoids changing matching records that already exist. Use Overwrite Existing only when the selected endpoints should receive the submitted configuration.

Access Overview

Choose Access Overview to review the final endpoint-level configuration for the current API version.

The overview shows:

  • endpoint rule assignments grouped by rule type
  • permissions by principal type
  • row filters by principal type
  • column filters by principal type
  • summary counts for endpoints with missing configuration, permissions, row filters, and column filters

Use the missing-only filter to find endpoints that still have no access configuration after a bulk update.

Per-Endpoint Adjustments

Use the row action icons when one endpoint needs a specific exception. The per-endpoint pages remain the detailed editors for rule lists, permissions, row filters, and column filters.

Workflow Catalog

Use Workflow Catalog to browse workflow definitions that can be discovered or started from Marketplace.

Visible records include workflow definitions published to the catalog and workflow definitions you can already access through ownership or position scope. Workflow administrators can see all workflow definitions for the current host.

Common filters:

  • search text for namespace, name, and version
  • workflow categories
  • grouped workflow tags
  • active or inactive status
  • sort and card/list view options

Catalog cards show workflow metadata, publication state, categories, tags, and a short definition preview. Use the details drawer to inspect the workflow id, owner metadata, taxonomy, and read-only YAML preview without leaving the catalog.

Common actions:

  • start a workflow from the selected definition
  • open the details drawer
  • edit workflow definitions you own or administer
  • create a new workflow definition
  • open Workflow Admin for table-based management

Publishing a workflow to the catalog is controlled by the workflow definition’s catalogVisible setting. Publishing makes the workflow discoverable in Marketplace, but editing and deleting remain restricted to owners, owner positions, workflow administrators, and administrators.

Schema Catalog

Use Schema Catalog to browse reusable schema contracts from Marketplace.

The catalog is backed by schema registry records, categories, and tags. It is intended for discovery and inspection, not bulk schema administration.

Visible records include published global schemas, published schemas for the current host, and draft or retired schemas that you own or can administer.

Common filters:

  • search text for schema id, name, description, source, and owner metadata
  • schema type, starting with JSON Schema
  • schema status, such as draft, published, and retired
  • schema categories
  • grouped schema tags
  • active or inactive status
  • sort and card/list view options

Catalog cards show a compact contract summary:

  • schema id, name, latest published version, and type
  • spec version, source, status, and scope
  • schema alias and external URL when external access is enabled
  • categories and tags
  • whether the schema body is available for preview
  • whether the schema can drive config-backed form generation

Common actions:

  • open the schema details drawer
  • preview JSON Schema source
  • copy a schema reference
  • copy an external schema URL when available
  • create a new schema version when you have permission
  • edit draft metadata and taxonomy when you own or administer the schema
  • open Schema Admin for table-based management

LLM Model Control Plane

Use /app/genai/LlmModelControlPlane to assemble the configuration that Portal publishes to a light-gateway instance. Authoring records are usable as soon as they are created. Portal keeps active as a backend-managed soft-delete flag; it is not an operator workflow field.

Portal validates record shape and cross-record consistency during commands and again on Publish. It does not call the provider or resolve API keys. Provider connectivity is tested through the live gateway after publication, where the runtime secret is available.

Create records in this order:

  1. Model catalog entry
  2. Registration
  3. Provider Account
  4. Network Zone, only for an approved private provider
  5. Provider Endpoint
  6. Provider Deployment
  7. Provider Credential reference
  8. Public Alias
  9. Alias Route
  10. Pricing
  11. Optional Policy and Policy Binding, only when a workload uses policy-based governance or Alias selection
  12. Publish, review, and create the gateway snapshot

If Publish reports a problem, return to the named tab, correct that record, and publish again.

NVIDIA Nemotron embeddings demo

The hosted NVIDIA demo uses:

  • Provider: nvidia
  • Physical model: nvidia/nemotron-3-embed-1b
  • Protocol: openai_embeddings
  • Base URL: https://integrate.api.nvidia.com/v1
  • Endpoint authentication: BEARER
  • Secret reference: env:NVIDIA_API_KEY
  • Network profile: PUBLIC_TLS
  • Termination: NATIVE

Do not enter the NVIDIA key in Portal. Inject NVIDIA_API_KEY into the target light-gateway process.

Model contract

Set Model operations to ["embed"]. The declared capabilities are the static contract used by publication and gateway compilation:

{
  "operations": ["embed"],
  "embedding": {
    "maxBatchItems": 1,
    "maxInputTokensPerItem": 4096,
    "maxAggregateInputTokens": 4096,
    "supportedDimensions": [2048],
    "supportedEncodings": ["float"],
    "maxResponseBytes": 16777216,
    "space": {
      "spaceId": "nvidia-nemotron-3-embed-1b-float-v1",
      "revision": 1,
      "dimension": 2048,
      "normalization": "l2",
      "distanceMetric": "cosine",
      "documentInputTransformVersion": "document-v1"
    }
  }
}

The embedding-space identity is immutable. A model, dimension, normalization, distance metric, or document-transform change requires a new space identity and newly indexed vectors.

Account and Endpoint

Create the Account with a stable quota group such as nvidia-free-embedding-demo. Create the Endpoint under that Account with the protocol, URL, authentication, and network values above. apiKeyHeader must be empty for BEARER.

Safe non-secret headers may be configured, but Authorization, cookies, proxy credentials, and secret-looking values are rejected.

Deployment

Select the Nemotron Registration, NVIDIA Account, and NVIDIA Endpoint. Preserve the exact protocol, physical model, and base URL. Use a stable revision and runtime-capacity declaration. A functional demo may use one Deployment for both Knowledge Base lanes; production isolation requires distinct physical capacity, not merely different names.

Credential

Use purpose ENDPOINT, select the NVIDIA Endpoint, and set Secret Reference to env:NVIDIA_API_KEY. Effective Time must be current or earlier. Expiration Time is optional. Portal stores and publishes the reference only.

Aliases and Routes

Create kb-index and kb-query as embedding-only internal workload Aliases. For each Alias:

  • operations: ["embed"]
  • require expected embedding space: true
  • lane: kb_index or kb_query
  • required embedding space: exactly the six-field space object above
  • bound workload principal: the intended Knowledge Base workload

Create one priority-zero Route from each Alias to the Nemotron Deployment. The command rejects missing, cross-host, environment-mismatched, protocol-mismatched, or embedding-space-incompatible references.

Pricing

Create an embed Pricing record for the Deployment. Embedding pricing accepts an input rate and forbids an output rate. A zero demo rate is valid when that is the intended accounting contract.

When to use Policies and Bindings

Policies and Bindings are optional governance records. They are not required to create a Provider connection, make a Route eligible, publish an Alias, or test an embedding model through the live gateway.

Use a Policy when the host needs reusable governance intent such as:

  • which subject or operation classes should be allowed;
  • request or period budget limits;
  • content logging, cache, or PII handling intent; or
  • a narrow allowlist of provider-specific request extensions.

Policy objects are extensible authoring data. A key affects runtime behavior only when the publication mapping and target gateway support that key. Do not assume that storing an arbitrary JSON property automatically enforces it.

Use a Binding to assign a Policy to a concrete subject. Supported subject namespaces are AGENT, CLIENT, PRINCIPAL, and PRODUCT_PROFILE. A Binding may also identify a Public Alias to limit the assignment to that Alias.

Create them in this order:

  1. Create the Policy with a stable governance-oriented name.
  2. Create a Binding that selects the Policy and supplies the exact Subject Type and Subject Id used by the consuming system.
  3. Select a Public Alias when the assignment is Alias-specific.
  4. For an AGENT that resolves its default model through policy, set Agent Default on exactly one active Alias Binding for that Policy and Agent.
  5. Publish again and verify that the generated configuration contains only the policy behavior supported by the selected gateway version.

Do not use a Policy or Binding to:

  • store an API key or other provider secret;
  • replace Provider Account, Endpoint, Credential, Deployment, Route, or Pricing configuration;
  • replace an internal Alias’s workload-principal restriction; or
  • make an otherwise incompatible Deployment eligible for an Alias.

For the initial NVIDIA kb-index and kb-query demo, skip both tabs unless an Agent or another subject must select those Aliases through a Model Policy. The Aliases’ INTERNAL_WORKLOAD identity, embedding-space contract, Routes, and Pricing are sufficient for direct workload-based publication and live gateway testing. If policy-based selection is added later, create a dedicated embedding Policy using operation embed, then bind it to the exact subject and Alias; do not reuse a generation-only chat Policy.

See Create Model Policy and Create Policy Binding for field-level examples and Agent Default rules.

Network Zones

Network Zones are required only for private TLS or explicitly approved private plaintext endpoints. A public hosted NVIDIA Endpoint does not use a Network Zone. Empty Network Zones are normal and should not be deleted merely because the NVIDIA demo does not need one.

Publish and test

Publish performs final validation across Accounts, Endpoints, Deployments, Credentials, Aliases, Routes, Pricing, and static model capabilities. A valid publication contains declared capabilities and secret references, never raw keys.

After the gateway loads the snapshot, validate the live path with the documented curl-first embeddings request. This confirms DNS, TLS, authentication, provider protocol, model availability, response shape, dimension, and embedding-space expectations from the customer runtime.

Follow Validate LLM Embeddings Through The Live Gateway for the checked curl helper, optional Rust wrapper, safe evidence, and corrective workflow.

Common failures

  • Route references are incompatible: ensure Alias and Registration use the same environment, the Deployment uses openai_embeddings, and the complete embedding-space objects match.
  • No eligible Credential: check Endpoint purpose, effective/expiration timestamps, and the secret reference format.
  • Raw provider secrets rejected: use env:NVIDIA_API_KEY, never the key.
  • No active records: no non-deleted rows exist for that tab; create one only when the workflow requires it.
  • Policy appears to have no effect: confirm that an active Binding selects the intended subject and optional Alias, and that the publication mapping and gateway version implement the authored policy keys.
  • Publication rejected: correct the record named by the Publish validation result and publish a new candidate.

Knowledge Bases

Use /app/genai/KnowledgeBases to configure governed retrieval from approved content. The page separates build limits, retrieval behavior, and embedding identity because they have different owners and lifecycles.

  1. Select the target environment and create or choose an ingestion policy.
  2. Create or choose a retrieval profile.
  3. A platform administrator publishes an eligible embedding Alias and creates its immutable embedding profile.
  4. Create the tenant Knowledge Base and assign the embedding profile.
  5. Open the Knowledge Base, add sources pinned to reviewed commits, and sync.
  6. Inspect the completed run and documents, then promote a READY generation.
  7. Bind an Agent and select the retrieval profile that should govern searches.
  8. Test the authorized light-knowledge REST or MCP endpoint. The Portal Retrieval Playground is not released yet.

Knowledge Bases section

This table lists the logical collections of content. Desired state is the administrator’s requested lifecycle state. Effective state is the worker projection. Active BASE is the immutable generation currently visible to retrieval; a newly built READY candidate is not searchable until promotion.

Choose Create tenant KB, give it a stable name and description, and select an active embedding profile or assign one later under Settings. Opening the name enters the workspace for sources, builds, bindings, and diagnostics.

Ingestion policies section

An ingestion policy supplies hard ceilings for source processing: documents, chunks, source and stored bytes, embedding tokens, billed cost, wall time, and concurrency. A zero spend ceiling is valid for a genuinely free embedding route; the gateway still rejects any request whose calculated billed cost is greater than zero.

  • A global policy is reusable by every tenant and is maintained by a platform Knowledge Base administrator.
  • A tenant policy belongs to the selected host and is visible only there.

Use Create policy for a new limit set. Portal generates the UUID; sources select policies by name. You may maintain several policies for different corpus sizes. Deactivation is refused while a non-deleted source refers to the policy. An inactive policy remains visible and can be edited to reactivate it.

Retrieval profiles section

A retrieval profile defines bounded query behavior independently of how the index was built. Agent bindings select one active profile. Global profiles are reusable defaults; tenant profiles support tenant-specific behavior.

The fields mean:

  • Strategy: lexical, vector, hybrid, or graph-assisted retrieval.
  • Lexical/vector candidates: maximum candidate pools gathered before fusion. Top K limits the final results.
  • Token budget: maximum retrieved context supplied downstream.
  • Failure policy: fail the request, or return safe partial results when an optional retrieval component fails.
  • Maximum Knowledge Bases: upper bound for multi-base retrieval, from one through four.
  • Lexical evidence required: requires lexical support in the result set.
  • Segment candidate multiplier: bounds candidate expansion across immutable BASE and DELTA segments.
  • Context expansion before/after: includes bounded neighboring passages.

RRF is the fixed fusion method. Use Create retrieval profile, select tenant or global ownership, and start with Balanced hybrid unless the use case requires different bounds. Portal generates the UUID. Deactivation is refused while an active Agent binding uses the profile.

Embedding profiles section

An embedding profile freezes the vector-space identity used to build and query an index: protected public Alias, space ID and revision, dimension, normalization, distance metric, and input transforms. It does not duplicate live model conformance or lifecycle status.

Only a platform administrator creates these global profiles. In Create global embedding profile, select an eligible public Alias; Portal derives the Alias owner, public Alias ID, expected space, and qualification digest. Do not invent the digest or UUID. Assign the profile when creating a Knowledge Base or later under Settings.

Knowledge Base workspace

Overview

Shows desired/effective state, active generation, pointer version, source count, and document count. Use it as a quick readiness summary.

Sources

Creates and syncs approved content inputs. For Git/Markdown:

  • Display name: a short operator-facing label.
  • Approved repository URI: an HTTPS clone URI, for example https://github.com/networknt/light-fabric.git.
  • Immutable commit: the full 40- or 64-character commit SHA. Branches and tags move, so they are not reproducible source identities.
  • Ingestion policy: an active global or tenant policy selected by name.

A mixed source-and-documentation repository is allowed. The connector applies the configured Markdown include/exclude policy, ignores other file types and symlinks, does not execute hooks, and rejects submodules. The default Portal form includes **/*.md and an empty exclude list. Click Sync after creating or updating a source. The worker resolves all active source and policy records from the database and builds one complete BASE for the Knowledge Base.

Documents

Lists immutable document versions written by successful builds. An empty-state message before the first completed build is expected.

Sync Runs

Tracks accepted, running, succeeded, failed, paused-budget, and cancelled runs. The page polls while a run is non-terminal. For a failure, use the displayed error code and message rather than clicking Sync repeatedly; fix the underlying configuration and submit a new sync.

Index Generations

Shows immutable generations and operational evidence. A successful build creates a READY candidate. Review it, then choose Promote for retrieval to atomically update the active pointer. Rebuild, compaction, embedding migration, rollback, and retention controls also appear here when applicable.

Incremental

Displays upload, classified-change, stable-passage-anchor, compaction, and anti-entropy diagnostics. These records explain how later DELTA work relates to the BASE without exposing source text.

Agent Bindings

Connects an Agent to this Knowledge Base. Enter the Agent UUID, choose an active retrieval profile by name, and decide whether missing Knowledge evidence must fail the turn. The service validates tenant ownership and profile activity; a foreign or inactive profile cannot be bound.

Access Policy

Shows source trust, ACL reconciliation, freshness, transition, and connector evidence. Use Simulate access with a normalized USER, GROUP, or ORGANIZATION subject before relying on mirrored source permissions. Retrieval fails closed when permission evidence is stale, incomplete, ambiguous, or unresolved.

Retrieval Playground

The Portal retrieval-test workflow is currently not released, so the question field is intentionally disabled. Test the promoted generation through the authorized light-knowledge REST /v1/knowledge/retrieve endpoint or MCP knowledge.search tool and review its audit evidence.

Quality

Collects generation and production evidence used to judge promotion, migration, rollback, backup, anti-entropy, and purge behavior. Provider latency and retrieval-quality qualification remain explicit operational gates.

Settings

Assigns the desired immutable embedding profile and manages Knowledge Base lifecycle. Changing embedding space requires a governed migration rather than silently rebuilding into an incompatible vector space. Physical purge is not released; deactivation remains available.

Authentication and errors

Knowledge queries require portal.r; mutations require portal.w. The Portal does not use portal.knowledge.r or portal.knowledge.w. JWT security runs before handlers, so a valid token without the action scope produces a structured AUTH_TOKEN_SCOPE_MISMATCH response that the UI displays.

For Knowledge Base operation failed, inspect the returned code and the gateway/service logs. Common causes are a missing access-control route, an old GenAI command/query deployment that lacks a new handler, an unapplied database patch, an unavailable service, or an object outside the selected tenant.

Create LLM Model

Use the Create LLM Model form to add a physical provider model to the LLM global Model Catalog shared by every host. Open it from Marketplace > LLM Model Catalog and choose Create. Only platform catalog administrators should have permission to use this command.

This guide applies only to /app/form/createLlmModel. For registrations, deployments, aliases, policies, and publication, see the LLM Model Control Plane guide.

Required Fields

FieldDescription
Global CatalogRead-only true; catalog Models are platform-global and are not owned by the selected host.
Provider TypeProvider identifier, such as openai.
Physical Model IdThe model identifier recognized by the provider.
Model FamilyThe provider’s model family or product family.
Context Token LimitMaximum context size. Enter an integer greater than zero.
Output Token LimitMaximum generated output size. Enter an integer greater than zero.

Model Version is optional. active is backend-managed: create and update keep the model active, while the delete command soft-deletes it.

Structured Fields

Modalities, Operations, and Declared Capabilities are stored as typed arrays or objects. Do not enter JSON or YAML as a quoted string.

Modalities and Operations

These fields open on the Form tab. Add one string per array item. You can also use the JSON or YAML tab, for example:

["text", "image"]
- generate
- embed

Declared Capabilities

This open-ended object starts on the JSON tab. The Form tab is unavailable because the current schema does not prescribe capability property names. Enter an object whose keys and values describe the provider model, for example:

{
  "streaming": true,
  "tools": true
}

The equivalent YAML is:

streaming: true
tools: true

After changing JSON or YAML, choose Apply. Apply parses the draft, checks its root type and schema constraints, and updates the form model only when it is valid. Choose Reset to discard the draft and restore the last valid value. The Create action remains blocked while a structured draft is invalid or has not been applied.

Categories and Tags

Categories and tags are optional. The selectors show active global taxonomy values registered for the llm_model entity type. Host-specific taxonomy cannot be assigned to a global model.

NVIDIA Nemotron embedding example

For the light-knowledge demo, use these values for the NVIDIA catalog Model:

FieldValue
Provider Typenvidia
Physical Model Idnvidia/nemotron-3-embed-1b
Model Familynemotron-3-embed
Model VersionLeave empty unless the provider publishes a stable version identifier
Context Token Limit4096
Output Token Limit1 because the current generic schema requires a positive value; embedding calls do not generate output tokens
Modalities["text"]
Operations["embed"]

Use this Declared Capabilities object:

{
  "operations": ["embed"],
  "embedding": {
    "maxBatchItems": 1,
    "maxInputTokensPerItem": 4096,
    "maxAggregateInputTokens": 4096,
    "supportedDimensions": [2048],
    "supportedEncodings": ["float"],
    "maxResponseBytes": 16777216,
    "space": {
      "spaceId": "nvidia-nemotron-3-embed-1b-float-v1",
      "revision": 1,
      "dimension": 2048,
      "normalization": "l2",
      "distanceMetric": "cosine",
      "documentInputTransformVersion": "document-v1"
    }
  }
}

NVIDIA currently documents only the native 2048-dimensional output for this hosted model. Document indexing must use passage semantics and retrieval must use query semantics. The catalog capability declaration records that contract; the gateway provider adapter must still send the correct provider request. See the NVIDIA NIM support matrix.

Create the Record

Review the values and choose Create LLM Model. The form sends the createLlmModel command and preserves modalities and operations as arrays and declaredCapabilities as an object. After a successful command, the browser returns to GenAI Admin > LLM Models.

Common Problems

  • Create is blocked after editing JSON or YAML: choose Apply to commit the draft, or Reset to discard it.
  • JSON or YAML error: correct the highlighted syntax and choose Apply again. The last valid value remains unchanged.
  • Required-field validation: provide every required field and use positive integers for both token limits.
  • No categories or tags are available: confirm that global taxonomy values are active and registered for llm_model.
  • 403 on Create: confirm access to the lightapi.net/genai/createLlmModel/0.1.0 command endpoint with the required write scope and role permission.

Skill Workspace

Use Skill Workspace to review and assemble one GenAI skill after the skill record has been created.

A skill contains the reusable instruction content that an agent can use. The workspace shows the skill metadata, taxonomy, linked tools, linked workflows, and test entry points in one place.

Opening The Workspace

Open the workspace from the GenAI Skills page by selecting the workspace action for a skill row.

The workspace needs a skillId. If the page is opened from a task or another GenAI page, the portal carries that context through the URL so related actions can prefill the current skill.

Header Actions

Common actions:

  • Back: return to the source page that opened the workspace.
  • Tool: create a structured skill-to-tool link for the current skill.
  • Workflow: create a structured skill-to-workflow link for the current skill.
  • Edit Skill: update the skill metadata, parent skill, taxonomy, version, and content markdown.
  • Help: open this guide in the portal documentation.

Overview Tab

Use the Overview tab to confirm the skill identity and routing metadata.

The Skill panel shows:

  • skill name
  • version
  • parent skill id
  • active state

The Routing panel shows human-readable category and tag labels. These labels come from the taxonomy tables. The update form stores the selected categoryIds and tagIds, while the workspace displays the resolved labels.

The Description panel is a short human-readable summary of what the skill is for.

Tools Tab

Use the Tools tab to review the executable tools linked to the skill.

Each row shows:

  • tool name
  • tool id
  • access level
  • link configuration

Add tool links with the Tool button in the header. Tool links are structured records; they are not parsed from the skill’s markdown content.

Workflow Tab

Use the Workflow tab to review workflows linked to the skill.

Each row shows:

  • workflow name or workflow definition id
  • workflow version
  • workflow role
  • start mode
  • row actions

Available row actions:

  • validate workflow tool links
  • open the workflow editor
  • start the workflow

Validation checks whether the linked workflow can resolve the tool references needed by the skill workflow connection.

Preview Tab

Use the Preview tab to inspect the skill’s contentMarkdown and composition.

contentMarkdown is the instruction body for the skill. It should describe the skill’s goal, operating rules, and expected output format. It is not the source of truth for executable tool, workflow, or endpoint references.

The Composition panel summarizes how many tools and workflows are linked and which workflow is treated as primary.

Test Tab

Use the Test tab to start the primary workflow linked to the skill.

The Start Primary Workflow button is enabled only when the skill has at least one linked workflow. The primary workflow is the workflow with role primary; if no primary role exists, the workspace uses the first linked workflow.

  1. Create the skill with a clear name, description, content markdown, taxonomy, and optional parent skill.
  2. Open the Skill Workspace.
  3. Add the tools the skill is allowed to use.
  4. Add the workflow that should execute or validate the skill.
  5. Validate workflow tool links.
  6. Preview the skill content and composition.
  7. Start the primary workflow for a manual test.
  8. Edit the skill if metadata, taxonomy, or instructions need adjustment.

Troubleshooting

If categories or tags are missing, edit the skill and confirm taxonomy values are selected.

If a tool or workflow is missing, add the structured link from the workspace. Do not rely on a markdown References section to create executable links.

If workflow validation fails, open the workflow editor and confirm the workflow uses tools that are linked to the skill.

If the Test tab is disabled, link a workflow to the skill first.

API Admin

Use API Admin to create, review, update, and retire APIs owned by your team or visible to your administrator role.

This page is owner-aware. Regular users should see only APIs they own or can access through their position. API administrators can see all APIs for the host.

Common actions:

  • create a new API
  • update API metadata
  • open API versions
  • link the API into onboarding or marketplace tasks

API Detail

Use API Detail to review API versions and version-specific integration details.

This page helps users move from a business API record to the concrete API versions that can be linked to instances, MCP tools, marketplace listings, or access control rules.

Common actions:

  • create an API version
  • update version metadata
  • review endpoint and scope details
  • start related task flows from the selected API version

App Admin

Use App Admin to manage client applications that own OAuth clients and instance application links.

This page is owner-aware. Regular users should see only apps they own or can access through their position. App administrators can see all apps for the host.

Common actions:

  • create a client app
  • update app metadata
  • open OAuth clients for the app
  • link the app to an instance

OAuth Client

Use OAuth Client to create and manage OAuth clients for applications, APIs, or instances.

This page is owner-aware. Regular users should see only OAuth clients they own or can access through their position. OAuth client administrators can see all OAuth clients for the host.

Common actions:

  • create an OAuth client
  • update client metadata
  • regenerate a client secret
  • review scopes and token-exchange settings
  • open client tokens

Client Secrets

When a client is created, the page returns the generated clientId and clientSecret. Copy and store the secret immediately. The portal stores only a password verifier for future authentication; it cannot show the original clear secret again.

If the secret is lost, use the Regenerate Client Secret row action. The action creates a new secret, replaces the stored verifier, and shows the new clear secret one time. Copy it before closing the dialog.

Regenerating a secret affects future client authentication. Existing access tokens remain valid until they expire, but new token requests must use the new secret after the event is processed.

OAuth Client Token

Use OAuth Client Token to create and review long-lived client tokens.

Tokens are sensitive. Users should create tokens only for clients they own or are authorized to manage. Administrators can review all client tokens for the host when their role allows it.

Common actions:

  • create a client token
  • review token metadata
  • delete or rotate tokens according to operational policy

Instance Admin

Use Instance Admin to manage service instances for the current host.

This page is owner-aware. Regular users should see only instances they own or can access through their position. Instance administrators can see all instances for the host.

Common actions:

  • create an instance
  • update instance metadata
  • review linked APIs and apps
  • open runtime endpoints and configuration links

Clone an Instance

Instance Clone creates a new instance from the active, projected configuration of an existing instance. Open Instance Admin and select the Clone action on an instance you own. Administrators may clone any visible instance. A read-only source cannot be cloned.

The page reloads and authorizes the source on the server. Values passed from the Instance Admin row are navigation hints only and are not trusted as the clone source.

What is cloned

The plan can copy the following active instance graph:

  • the instance definition, tags, and categories;
  • instance APIs, path prefixes, apps, and app/API links;
  • overridden configuration properties at instance, API, app, app/API, and deployment-instance scope;
  • selected instance files;
  • selected deployment definitions and their overridden properties;
  • an optional current configuration snapshot.

Runtime instances, OAuth clients, authorization sessions/tokens, deployment jobs and operational status, audit history, notifications, and other transient records are not cloned. Create the target OAuth client after the clone reaches a successful terminal status.

Target identity

Enter a unique target instance name and environment tag. Environment defaults to the environment tag. Service and product version default to the source when left blank. The preview shows the resolved (host, service, environment) snapshot lookup tuple.

For the portal BFF workflow, use separate environment tags and instances:

Environment tagConfiguration sourceTypical instance
locportal-config-loclocal portal BFF
devportal-config-devdevelopment portal BFF
demolight-portal-installinstall/demo portal BFF

Keeping these instances separate prevents one environment’s gateway redirect, cookie, host, or service configuration from being reused by another.

Masked configuration values

Every overridden property is masked initially. Choose one action per stable scope/property selector:

  • COPY copies the source value server-side without revealing it;
  • REPLACE validates and stores the replacement entered on the page;
  • OMIT leaves the target override absent so effective lower-precedence configuration can apply.

Reveal returns one property value through a separate audited request. It never reveals file or certificate content. Revealed values are cleared when a selector changes or the plan is refreshed, and they are not included in the plan fingerprint.

COPY and REPLACE are checked against the current property schema. OMIT is checked against the resulting effective target configuration. If a source value no longer satisfies the current schema, replace or omit it and plan again.

Optional resources

Files and deployment definitions are excluded by default. Enable them and select individual IDs explicitly. Confirm certificate copying before planning any selected certificate/file set. File content remains server-side and is not returned in the preview.

Creating a current snapshot is also optional. When selected, the snapshot event is written last and successful completion is reported as SNAPSHOT_READY.

Plan and execute

Select Plan Clone to validate authorization, target uniqueness, current schemas, selected resources, limits, and source projection parity. The preview shows warnings plus exact event and serialized-byte counts.

Any target, property, file, deployment, certificate-confirmation, or snapshot change invalidates the preview and disables Clone until planning succeeds again. Clone submits the complete plan once. A browser timeout does not mean the clone failed and must not be followed by another submission; use Refresh status with the original request instead.

Status and recovery

The status values are:

  • ACCEPTED: the atomic event/outbox transaction committed and is waiting for projection;
  • PROJECTED: the target projected successfully without a requested snapshot;
  • SNAPSHOT_READY: the target and requested snapshot projected successfully;
  • FAILED_DLQ: projection rolled back and the transaction was moved to the dead-letter queue.

While accepted, the page polls one request at a time with capped backoff and pauses when the tab is hidden or the browser is offline. A transient status failure displays Still processing and offers a manual refresh; it does not resubmit the clone.

For FAILED_DLQ, record the clone request ID and safe error code and contact an administrator. Do not create another clone request until the original DLQ transaction is diagnosed. A repaired replay must use the original transaction metadata.

After PROJECTED or SNAPSHOT_READY, use Open Instance, Open Configuration, or Create OAuth Client to continue setup.

Runtime Instance

Use Runtime Instance to review runtime endpoints for services.

Runtime instances describe where a service is running and how the portal can reach it for deployment, gateway, or operational workflows.

Common actions:

  • create a runtime endpoint
  • update endpoint status and connection details
  • review active runtime records for an instance or service

Instance API

Use Instance API to link API versions to service instances.

This relationship tells the portal which API version is served by which instance and is used by gateway, MCP, configuration, and access-control tasks.

Common actions:

  • link an API version to an instance
  • review existing instance API links
  • open path prefixes or MCP tool mappings

Instance API Path Prefix

Use Instance API Path Prefix to manage route prefixes for an API version linked to an instance.

Path prefixes help gateways and tools route traffic to the correct API surface.

Common actions:

  • add a path prefix
  • update a path prefix ownership position
  • review prefixes for an instance API link

Instance App

Use Instance App to link client apps to service instances.

This relationship is used when an application needs to interact with a deployed instance and related APIs.

Common actions:

  • link an app to an instance
  • review app links for an instance
  • open app API relationship records

Instance App API

Use Instance App API to link an instance app relationship to an instance API relationship.

This page connects which app can use which API on a specific service instance.

Common actions:

  • create an instance app API link
  • review existing links
  • open configuration for the relationship

Schedule Admin

Use Schedule Admin to create and manage scheduled portal events.

This page is owner-aware. Regular users should see only schedules they own or can access through their position. Schedule administrators can see all schedules for the host.

Common actions:

  • create a schedule
  • update schedule timing or event data
  • delete schedules no longer needed

Workflow Definition

Use Workflow Definition to create and manage workflow definitions.

Workflow definitions describe repeatable processes that can be started manually or triggered by other portal events.

Common actions:

  • create a workflow definition
  • update workflow YAML
  • start or review related workflow execution records

Workflow Editor

Use Workflow Editor to create, validate, test, version, and publish workflow definitions. The Help button on the editor opens this page in a new tab.

Define the workflow contract

The form fields write directly into the YAML definition. For a new workflow, start with:

  • DSL Version, Namespace, Name, Version, Title, and Summary for the top-level document object
  • Evaluation Language for evaluate.language; workflow-backed MCP tools currently require cel
  • Input Schema and Output Schema for inline JSON Schema documents
  • categories and tags selected from the portal reference tables

For example, enabling Input Schema creates this structure in the YAML editor:

input:
  schema:
    format: json
    document:
      type: object
      additionalProperties: false
      required:
        - customerId
      properties:
        customerId:
          type: string

Add workflow tasks

Use the Step Palette to select a task type, enter a task name, and add it to the workflow. New definitions use the do task list. The editor inserts each task into the existing task list instead of creating a competing steps section.

The palette includes Ask, Assert, HTTP, OpenAPI, JSON-RPC, OpenRPC, gRPC, MCP, Rule, Agent, child Workflow, Fork, Switch, Condition, Set, Export, and Wait starters. A Condition starter is a named switch skeleton, and an Export starter combines a minimal set task with its task-level export mapping. Step IDs keep their entered letter case, so names such as loadCustomerContext round-trip unchanged.

Use the YAML editor for task-specific settings that are not exposed by a form. The Visual Graph reflects the task structure and lets you inspect the resulting flow. Parallel work is represented by a fork task with named branches.

After inserting or selecting a fork in the Steps list or Visual Graph, use the Fork Branches panel to rename its branches or add another branch. New branches start with a minimal set task that can be replaced with the intended call or other task configuration. Branch names must be unique and may contain letters, numbers, underscores, and hyphens. The editor keeps at least two branches in a fork.

For an endpoint call, select Endpoints in Reference Type and choose an eligible capability. The list includes granted and requestable endpoint Tools and labels each one with its access state. The editor generates an executable call: http task with a logical lightapi:// URI plus the exact Tool ID, capability, version, LightAPI digest, and environment pin. It never stores an environment URL in workflow YAML.

You may insert requestable or pending Tools and save the draft. Select Request Tool Access, review the grouped exact pins and usage locations, enter a justification, and submit the request. The built-in Grant Tools to Workflow process assigns one approval task to the genai-admin role. The first administrator who claims it can approve or reject the complete Tool set from the normal Human Tasks page. Approval is atomic: if any Tool changed or became ineligible, no grants are created and the request becomes stale.

The editor polls only while an approval is pending and displays the request and approval workflow instance IDs. After the transaction commits, the status changes to GRANTED and testing becomes available. A rejection or stale request remains visible so the author can update the draft and submit a new request. Tool Admin’s Workflow Access dialog is intentionally read-only except for revoking existing grants.

Validate and test

Validate first parses the YAML in the browser and then performs server-side draft validation. Missing grants are warnings, so an incomplete draft can be saved while approval is pending. Test and Publish Version use execution validation instead and fail closed unless every referenced Tool ID, capability, version, digest, and requested environment matches an active grant.

Use Test to start a fully granted draft with test input and inspect processes, tasks, assignments, audit records, and final output in the Runtime Test panel.

Import, save, and publish

  • Import loads a YAML, YML, or JSON workflow file into the editor.
  • Export downloads the current definition as YAML.
  • Save stores the current draft.
  • Publish Version makes a saved version immutable and available according to its catalog and ownership settings.
  • Create New Version copies a published version into a new editable draft.

Publishing a definition is separate from making it visible in the Workflow Catalog. Enable catalog visibility only when the workflow should be discoverable by other authorized portal users.

Portal View Form Help

This section contains form-level help for generated and custom portal-view forms.

Form help should explain when to use the form, what happens after submit, important required fields, important optional fields, ownership behavior, and common validation problems.

Create Provider Account

Use /app/form/createProviderAccount to create the non-secret provider billing and quota identity that an LLM Deployment will use. This is not a Portal user account and it does not store an API key, access token, password, or provider endpoint.

Open the form from Administration > GenAI Admin > LLM Models > Accounts by choosing Create provider account.

Before You Begin

Create an Account before creating a Deployment that refers to it. Decide which provider, billing owner, and quota pool the new Account represents. If one host uses multiple provider subscriptions, projects, or cost centers, create a separate Account for each boundary that must be managed independently.

Form Fields

FieldRequiredDescription
Host IdYesThe selected host. The form supplies this read-only value.
Account NameYesAn operator-friendly name, such as openai-production. It must be unique for the selected provider within the host.
Provider TypeYesSelect the provider type used by the related Deployments, such as openai.
Billing PrincipalYesThe organization, project, subscription, or cost center responsible for provider charges. Enter an identifier or name, never a credential.
Quota Group IdYesA stable identifier for the provider capacity or quota pool shared by related Deployments.
Capacity MetadataNoA JSON object containing non-secret provider capacity information. It defaults to an empty object.

The backend creates providerAccountId. The form does not ask you to supply that identifier. The active field is also backend-managed and is not part of the form.

Billing Principal

Use billingPrincipal to identify who is financially responsible for usage. The exact value depends on your organization and provider. Examples include:

  • genai-platform-cost-center
  • azure-subscription-production
  • aws-account-llm-platform
  • provider-project-customer-support

This field is governance and audit metadata. Do not enter a provider API key, secret value, bearer token, password, or authorization header.

Quota Group Id

Use quotaGroupId to name the provider capacity pool shared by deployments, for example openai-production-capacity. Keep the value stable and use the same intended quota-group identity when configuring the related Deployment and gateway publication.

Deployments in the same published quota group share the corresponding gateway capacity identity. Changing an Account later does not automatically rewrite an already published gateway snapshot.

Capacity Metadata

capacityMetadata is an optional open-ended object for non-secret capacity annotations. The editor supports JSON and YAML. For example:

{
  "serviceTier": "production",
  "approvedRpm": 1000,
  "approvedTpm": 2000000
}

The equivalent YAML is:

serviceTier: production
approvedRpm: 1000
approvedTpm: 2000000

Use an empty object when no metadata is needed:

{}

After editing JSON or YAML, choose Apply. The Create action remains blocked while the structured draft is invalid or has unapplied changes. Capacity metadata is currently retained for control-plane governance; the gateway does not use arbitrary metadata properties to resolve credentials or select routes.

Create the Account

Review the values and choose Create Provider Account. The form sends the lightapi.net/genai/createLlmProviderAccount/0.1.0 command. After a successful command, the browser returns to Administration > GenAI Admin > LLM Models. The new Account can then be selected by a provider Deployment.

NVIDIA free embedding demo

For the NVIDIA hosted nemotron-3-embed-1b demo, use:

{
  "accountName": "nvidia-free-embedding-demo",
  "providerType": "nvidia",
  "billingPrincipal": "nvidia-build-api-demo",
  "quotaGroupId": "nvidia-free-embedding-demo",
  "capacityMetadata": {
    "serviceTier": "free-demo",
    "sharedExternalQuota": true
  }
}

billingPrincipal and quotaGroupId are operator-assigned governance names; they are not the NVIDIA API key. If your NVIDIA account exposes a stable organization or project identifier, use that identifier for Billing Principal instead. Keep all secret material on the Credentials tab.

Common Problems

  • Provider Type is empty: confirm the model_provider reference values are configured and available to the Portal environment.
  • Required-field validation: provide Account Name, Provider Type, Billing Principal, and Quota Group Id.
  • Capacity Metadata error: enter an object rather than an array or quoted JSON string, correct any JSON/YAML syntax error, and choose Apply.
  • Duplicate account: choose a different Account Name. A host cannot contain two Accounts with the same Provider Type and Account Name.
  • 403 on Create: confirm access to the lightapi.net/genai/createLlmProviderAccount/0.1.0 command endpoint and the required write permission.

For account relationships and gateway usage, see the LLM Model Control Plane guide.

Create Network Zone

Use /app/form/createLlmNetworkZone to create an administrator-owned outbound allowlist for private provider Endpoints. A Network Zone is not required for a public HTTPS provider.

Fields

FieldRequiredDescription
Host IdYesRead-only host that owns the Zone and any Endpoint that selects it.
Zone NameYesStable operator-friendly name.
Allowed DNS NamesYesJSON array of private provider DNS names. Use [] only when CIDRs carry the complete allowlist.
Allowed CIDRsYesJSON array of approved private IPv4 or IPv6 ranges. Use [] only when DNS names carry the complete allowlist.
Allowed PortsYesNon-empty JSON array of integer ports from 1 through 65535.
Allow Private TLSYesPermit PRIVATE_TLS Endpoints in this Zone.
Allow Private PlaintextYesPermit explicitly acknowledged PRIVATE_PLAINTEXT; keep disabled unless an administrator accepts the risk.

Example for a private TLS provider:

{
  "zoneName": "private-embedding-provider",
  "dnsNames": ["embedding.internal.example.com"],
  "cidrs": ["10.42.0.0/16"],
  "allowedPorts": [443],
  "allowPrivateTls": true,
  "allowPrivatePlaintext": false
}

Choose Apply after editing each structured array. Portal generates the Network Zone Id and aggregate version.

NVIDIA hosted endpoint

Do not create a Network Zone for https://integrate.api.nvidia.com/v1. Its Endpoint uses PUBLIC_TLS, so the Endpoint must leave Network Zone and trust-bundle fields empty. Network Zone rows are real administrator configuration, not one placeholder row per Account.

Update Network Zone

Use /app/form/updateLlmNetworkZone to revise a private-provider outbound allowlist. Host Id, Network Zone Id, and Aggregate Version are read-only.

Editable fields have the same meaning as the Create form: Zone Name, allowed DNS names, CIDRs, ports, and private TLS/plaintext flags. Choose Apply after changing a structured array.

Changing a Zone does not rewrite a running gateway snapshot. Review every Endpoint that references the Zone, publish a new candidate, and verify private connectivity.

The hosted NVIDIA endpoint uses PUBLIC_TLS and should not reference a Network Zone. Do not repurpose or delete an unrelated private Zone merely to configure the NVIDIA demo.

Create Provider Endpoint

Use /app/form/createLlmProviderEndpoint to define reusable provider transport, authentication mode, network profile, and client-refresh settings for one Provider Account. Endpoint fields never contain raw credentials.

Authentication contract

Endpoint AuthenticationAPI Key HeaderMeaning
NONEEmptyProvider call has no Endpoint credential.
BEAREREmptyGateway resolves the Endpoint Credential and sends it as a bearer token.
API_KEYauthorization or x-api-keyGateway resolves the Endpoint Credential and sends it using the selected lowercase header name.

API_KEY requires API Key Header; NONE and BEARER must omit it. Do not put an API key in Safe Non-secret Headers, Base URL, or any authentication field.

Network profile contract

  • PUBLIC_TLS requires an https URL and no Network Zone or private trust reference.
  • PRIVATE_TLS requires https, a Network Zone, a managed trust-bundle reference, and its resolved SHA-256 digest.
  • PRIVATE_PLAINTEXT requires http, a Network Zone, authentication NONE, no trust bundle, and explicit plaintext-risk acknowledgement.

Termination is NATIVE for the ordinary gateway provider client or LIGHT_GATEWAY_SIDECAR for an explicitly managed sidecar transport.

NVIDIA Nemotron example

Select the nvidia-free-embedding-demo Provider Account and use:

{
  "endpointName": "nvidia-free-embeddings",
  "providerProtocol": "openai_embeddings",
  "baseUrl": "https://integrate.api.nvidia.com/v1",
  "headers": {},
  "endpointAuthMode": "BEARER",
  "apiKeyHeader": null,
  "networkProfileMode": "PUBLIC_TLS",
  "networkTermination": "NATIVE",
  "networkZoneId": null,
  "trustBundleReference": null,
  "poolIdleTimeoutMs": 30000,
  "clientRefreshIntervalMs": 300000,
  "plaintextRiskAcknowledged": false
}

The Resolved Trust SHA-256 field is read-only and remains empty for this public TLS Endpoint. Create env:NVIDIA_API_KEY later on the Credentials tab; never paste its value here.

Portal generates Provider Endpoint Id and Aggregate Version. After creating a compatible Deployment and Credential, publish the configuration and verify the runtime path through the target gateway.

Update Provider Endpoint

Use /app/form/updateLlmProviderEndpoint to revise a provider transport profile. Host Id, Provider Endpoint Id, Resolved Trust SHA-256, and Aggregate Version are read-only.

The authentication and network invariants from Create Provider Endpoint still apply. In particular, BEARER must leave API Key Header empty, while API_KEY requires lowercase authorization or x-api-key.

For the hosted NVIDIA embedding Endpoint, preserve:

  • Protocol openai_embeddings;
  • Base URL https://integrate.api.nvidia.com/v1;
  • authentication BEARER with no API Key Header;
  • profile PUBLIC_TLS with no Zone or trust bundle; and
  • termination NATIVE.

Use the Credential update/rotation workflow to change NVIDIA_API_KEY; do not change Endpoint fields to carry a new secret. An Endpoint update affects a gateway only after a new valid publication is applied.

Create Provider Deployment

Use /app/form/createProviderDeployment to bind an approved LLM Registration, Provider Account, and Provider Endpoint to an exact callable model runtime. Open it from Administration > GenAI Admin > LLM Models > Deployments.

A Deployment contains runtime identity, capacity, readiness, and non-secret transport metadata. Provider credentials remain separate.

Before you begin

Create these records first under the same host:

  • the global catalog Model and environment-specific Registration;
  • the Provider Account; and
  • the Provider Endpoint.

The Registration, Account, Endpoint, provider type, protocol, physical model, and base URL must describe the same provider path.

Fields

FieldRequiredDescription
Host IdYesRead-only host that owns the Deployment.
LLM RegistrationYesEnvironment-specific approval of the catalog Model.
Provider AccountYesBilling, quota, and capacity owner.
Deployment NameYesHost-unique operator name.
Provider TypeYesProvider identity, such as nvidia.
Provider ProtocolYesExact wire contract: openai_chat, openai_responses, openai_embeddings, or anthropic_messages.
Physical Model IdYesExact upstream model string.
Base URLYesHTTPS compatibility URL. Copy it exactly from the selected Endpoint.
Provider EndpointYesReusable transport/authentication profile.
Deployment Revision IdYesStable operator revision for this callable runtime configuration.
Physical Runtime IdYesStable identity of the external service, process, or GPU runtime.
Capacity Domain IdYesCapacity/isolation domain. Protected query and index lanes must not share one.
Runtime CapacityYesJSON object with all five positive bounded-capacity fields shown below.
Readiness PolicyYesIMMEDIATE or WARM_BEFORE_ELIGIBLE.
Expected Sidecar IdentityNoSidecar profile/digest object only; leave empty for a native hosted Endpoint. Never include credentials.
RegionNoOptional provider placement/residency label.
Transport BoundsNoAdditional non-secret transport annotations; use {} when none are approved.

Runtime Capacity requires exactly usable positive bounds. A suitable demo starting point is:

{
  "maxParallelRequests": 32,
  "maxQueuedRequests": 32,
  "coldStartTimeoutMs": 30000,
  "streamSetupTimeoutMs": 10000,
  "requestTimeoutMs": 30000
}

Choose Apply after editing Runtime Capacity, Expected Sidecar Identity, or Transport Bounds.

NVIDIA Nemotron embedding example

Select the loc NVIDIA Nemotron Registration, Account nvidia-free-embedding-demo, and Endpoint nvidia-free-embeddings. Then use:

{
  "deploymentName": "nvidia-nemotron-3-embed-1b-loc",
  "providerType": "nvidia",
  "providerProtocol": "openai_embeddings",
  "physicalModelId": "nvidia/nemotron-3-embed-1b",
  "baseUrl": "https://integrate.api.nvidia.com/v1",
  "deploymentRevisionId": "nvidia-free-embedding-demo/r1",
  "physicalRuntimeId": "nvidia/integrate-api/free-embeddings",
  "capacityDomainId": "nvidia-free-embedding-demo",
  "runtimeCapacity": {
    "maxParallelRequests": 32,
    "maxQueuedRequests": 32,
    "coldStartTimeoutMs": 30000,
    "streamSetupTimeoutMs": 10000,
    "requestTimeoutMs": 30000
  },
  "readinessPolicy": "IMMEDIATE",
  "expectedSidecar": null,
  "region": null,
  "transportBounds": {}
}

The form still requires Base URL and Provider Protocol even though the selected Endpoint already stores them. Copy the exact values; a mismatch creates an internally inconsistent legacy/deployment record.

Protected Knowledge Base lanes

kb-index and kb-query are separate protected workload lanes. For a production deployment, create distinct index/query Deployments with different Deployment Revision and Capacity Domain identities, and use provider Accounts/quota that supply real capacity isolation. Merely giving two records different strings does not create physical isolation when both consume the same free external quota.

For a functional local demo, one hosted Deployment can prove transport and embedding correctness, but it must not be represented as production lane isolation evidence.

Portal validates the declared protocol, model, endpoint, runtime bounds, and cross-record references. Test actual provider access through the published gateway, where the runtime secret is available.

After creation, provision the Credential, Pricing, Alias, and Route, then publish a new gateway candidate. Portal generates Provider Deployment Id and Aggregate Version; active remains backend-managed.

Common problems

  • Protocol rejected: use openai_embeddings, not openai or nvidia.
  • Base URL rejected: use the exact HTTPS base URL without /embeddings and without a key or query string.
  • Endpoint missing: create the Provider Endpoint first under the same host.
  • Runtime Capacity rejected: supply all five positive integer fields and choose Apply.
  • Provider mismatch: the Registration, Account, Endpoint, and Deployment must all describe NVIDIA.
  • Raw secret rejected: credentials belong only in an external secret store referenced from the Credentials tab.

Update Provider Deployment

Use /app/form/updateProviderDevelopment to edit an existing provider Deployment. Open it from Administration > GenAI Admin > LLM Models > Deployments by choosing the row’s edit action.

The route name is updateProviderDevelopment, but the form updates a provider Deployment through updateLlmProviderDeployment.

Read-Only Fields

FieldExampleDescription
Host Idselected-host-idHost that owns the Deployment.
Provider Deployment Id7ee18d9d-...Stable Deployment identifier referenced by Credentials, Pricing, and Alias Routes.
Aggregate Version4Current optimistic-concurrency version. A stale version is rejected.

Do not remove or alter these values. The active field is backend-managed and is not included in the form.

Editable Fields

FieldExampleDescription
LLM Registrationdev — groq / llama-3.3-70b-versatileHost-scoped approval; its label combines environment, provider, and physical model.
Provider AccountOpenAI ProductionHost-scoped billing and quota owner.
Deployment Nameopenai-gpt4o-ca-prodUnique operator-friendly name within the host.
Provider TypegroqProvider identity. Changing it reloads Physical Model Id options.
Provider Protocolopenai_embeddingsExact gateway wire contract: openai_chat, openai_responses, openai_embeddings, or anthropic_messages.
Physical Model Idgpt-4oExact upstream model served by the endpoint.
Base URLhttps://api.openai.com/v1HTTPS provider base endpoint without credentials.
Provider Endpointnvidia-free-embeddingsReusable transport/authentication profile. Protocol and Base URL must remain consistent with it.
Deployment Revision Idnvidia-free-embedding-demo/r1Operator revision of this exact callable configuration.
Physical Runtime Idnvidia/integrate-api/free-embeddingsStable external service/process identity.
Capacity Domain Idnvidia-free-embedding-demoRuntime capacity domain; protected lanes must not share one.
Runtime Capacity{"maxParallelRequests":32,...}Required positive parallel, queue, cold-start, stream-setup, and request timeout bounds.
Readiness PolicyIMMEDIATEIMMEDIATE or WARM_BEFORE_ELIGIBLE.
Expected Sidecar IdentityEmptyProfile/digest only for a managed sidecar; leave empty for a native hosted Endpoint.
Regionca-central-1Optional placement or residency region. Leave it empty for a global endpoint.
Transport Bounds{"requestTimeoutMs":60000}Optional non-secret transport metadata object.

Registration and Account selectors list non-deleted labels under the selected host. Provider Type comes from model_provider; Physical Model Id comes from the provider-to-model reference relation; Region comes from the host’s region reference data.

The form does not edit quotaGroupId. The selected Provider Account owns that value, and Portal derives it through the existing Account relationship. Changing the Account changes the value used by the next publication, but it does not rewrite an already published gateway snapshot.

Structured Fields

Runtime Capacity, Expected Sidecar Identity, and Transport Bounds support JSON and YAML. Choose Apply after editing. For example:

{
  "connectTimeoutMs": 5000,
  "requestTimeoutMs": 60000
}

Transport-bound properties remain control-plane annotations unless the publication contract explicitly maps them to supported gateway settings.

Identity Changes

The provider type, provider protocol, physical model, and endpoint form the callable identity. If the provider endpoint or physical model changes, publish the updated configuration and test connectivity through the tenant gateway. The selected Account’s provider type must match the Deployment.

An update does not bypass Credential, Pricing, Alias Route, or publication requirements. Publish performs the final cross-record review.

For the hosted NVIDIA Deployment, preserve Provider Type nvidia, Protocol openai_embeddings, Physical Model Id nvidia/nemotron-3-embed-1b, Base URL https://integrate.api.nvidia.com/v1, and Endpoint nvidia-free-embeddings. Rotate env:NVIDIA_API_KEY through Credentials rather than changing Endpoint or Deployment fields.

Save The Update

Choose Update Provider Deployment. The form sends lightapi.net/genai/updateLlmProviderDeployment/0.1.0 with providerDeploymentId and aggregateVersion, then returns to Administration > GenAI Admin > LLM Models after success.

An update changes the Portal control-plane record; it does not rewrite a previously published gateway snapshot. Publish the intended new configuration and test it through the tenant gateway before expecting supported runtime behavior.

Common Problems

  • Stale aggregate version: reopen the form from the Deployments tab and apply the change to the latest record.
  • Registration or Account is unavailable: confirm it is not deleted and belongs to the selected host.
  • Base URL is rejected: use a complete HTTPS URL without secrets.
  • Structured edit is blocked: correct the Transport Bounds JSON/YAML draft and choose Apply, or choose Reset to restore the last valid value.
  • Provider mismatch: select an Account whose provider type matches the Deployment.
  • Provider Protocol is rejected: choose the exact protocol enum; NVIDIA embeddings use openai_embeddings.
  • 403 on Update: confirm access to lightapi.net/genai/updateLlmProviderDeployment/0.1.0 and the required write permission.

For route eligibility and gateway consumption, see the Deployments tab guide.

Create Provider Credential

Use /app/form/createProviderCredential to associate a Provider Endpoint or sidecar runtime with a versioned external secret reference. Open the form from Administration > GenAI Admin > LLM Models > Credentials by choosing Create provider credential.

Portal stores only the reference. Create the actual credential in the target environment’s supported secret manager before activating this record.

Before You Begin

You need:

  • a non-deleted Provider Endpoint and corresponding Deployment for an ENDPOINT credential, or a Deployment for SIDECAR_RUNTIME;
  • an external secret-manager entry containing the provider credential;
  • the URI syntax supported by the gateway’s configured secret resolver; and
  • an activation and optional expiration time for this version.

Do not paste the provider API key, token, password, JSON credential document, or authorization header into any field.

Form Fields

FieldRequiredExampleDescription
Host IdYes01964b05-552a-7c4b-9184-6857e7f3dc5fRead-only host that owns the Credential and Deployment.
Credential PurposeYesENDPOINTENDPOINT is resolved by the central gateway; SIDECAR_RUNTIME is resolved only inside the provider sidecar.
Provider EndpointFor ENDPOINTnvidia-free-embeddingsEndpoint whose bearer/API-key authentication uses this reference.
Provider DeploymentCurrent create compatibility pathnvidia-nemotron-3-embed-1b-locSelect the corresponding Deployment. It is mandatory for SIDECAR_RUNTIME and currently also required by the command create contract for Endpoint credentials.
Credential VersionYes2Positive version number unique for the selected Deployment. Increment it for each rotation.
Secret ReferenceYesenv:OPENAI_API_KEYEnvironment-variable reference resolved locally by the target gateway. This is a name, never the secret value.
Effective TimeYes2026-08-15T14:00:00ZISO-8601 timestamp when this version becomes eligible. Use an explicit timezone.
Expiration TimeNo2026-11-15T14:00:00ZOptional ISO-8601 cutoff. It must be later than Effective Time. Leave it empty for no scheduled expiration.

Portal generates providerCredentialId and initializes aggregateVersion. The form does not accept active; soft-delete state is backend-managed.

Purpose and owner

For ENDPOINT, select the Provider Endpoint and its corresponding Deployment. For SIDECAR_RUNTIME, select the Deployment and do not select an unrelated Endpoint. All references must be non-deleted and owned by the selected host.

Credential Version

Versions are unique per Deployment. A typical sequence is:

RotationCredential VersionEffective Time
Initial credential12026-05-01T00:00:00Z
First rotation22026-08-15T14:00:00Z
Second rotation32026-11-15T14:00:00Z

Create a new version for rotation. Do not reuse a version number or overwrite an older version to represent different secret material.

Secret Reference

For instance-property delivery, use the environment-variable name available to the gateway process:

env:OPENAI_API_KEY
env:AZURE_OPENAI_API_KEY
env:NVIDIA_API_KEY

Kubernetes, Docker, or HashiCorp Vault injection may populate that environment variable; Portal neither reads nor stores its value. Absolute external URIs such as vault://... remain valid control-plane references only when the target gateway is configured with a resolver that maps that exact reference. The default instance-property path resolves env:VARIABLE_NAME directly. Portal does not prove that the variable exists, so provision and test it on the target gateway before activation.

Values such as sk-live-..., Bearer ..., raw JSON, passwords, and copied API keys are forbidden. They can leak through events, logs, audit records, and UI history even if entered accidentally.

Effective And Expiration Times

Use ISO-8601 timestamps with a timezone, preferably UTC with Z:

Effective Time:  2026-08-15T14:00:00Z
Expiration Time: 2026-11-15T14:00:00Z

Publication eligibility uses the database clock. Before effectiveTs, the row is not eligible. At or after expiresTs, it is no longer eligible. An empty expiration means the time window does not expire automatically.

Activation window

Portal publishes the reference only while this time window is effective. Portal never resolves or tests the referenced secret.

After creation:

  1. Confirm the external secret exists in the target environment.
  2. Confirm the gateway’s workload identity can resolve it.
  3. Verify the activation window.
  4. Publish and test the configuration through the target gateway.

Submit The Credential

Choose Create Provider Credential. The form sends lightapi.net/genai/createLlmProviderCredential/0.1.0 and returns to the LLM Model Control Plane after success.

NVIDIA Endpoint credential

For the hosted Nemotron Endpoint, use:

FieldValue
Credential PurposeENDPOINT
Provider Endpointnvidia-free-embeddings
Provider DeploymentThe corresponding nvidia/nemotron-3-embed-1b Deployment
Credential Version1
Secret Referenceenv:NVIDIA_API_KEY
Effective TimeCurrent UTC time in ISO-8601 format
Expiration TimeEmpty for the local demo unless the key has a known expiry

Pass NVIDIA_API_KEY into the light-gateway process through runtime secret injection or Compose environment expansion. Never commit its value to Portal configuration. Publish only after the target gateway can resolve the variable.

Common Problems

  • Deployment list is empty: confirm the Deployment exists, is not deleted, and belongs to the selected host.
  • Secret Reference is rejected: enter env:VARIABLE_NAME (for example, env:OPENAI_API_KEY) or a URI supported by an explicitly configured resolver, not a raw credential.
  • Credential version already exists: increment the version for that Deployment.
  • Expiration is rejected: make it later than Effective Time and include a timezone.
  • Publication still fails: verify the Effective Time has arrived and the Expiration Time has not passed.
  • 403 on Create: confirm access to lightapi.net/genai/createLlmProviderCredential/0.1.0 and the required write permission.

For the full eligibility and rotation workflow, see the Credentials tab guide.

Update Provider Credential

Use /app/form/updateProviderCredential to change the activation window of an existing provider Credential. Open it from Administration > GenAI Admin > LLM Models > Credentials by choosing the row’s edit action.

The update form does not accept raw secret material. For an actual rotation, create a new credential record with an incremented version. Editing the binding, version, or reference is intended for correcting a record before activation.

Read-Only Fields

FieldExampleDescription
Host Id01964b05-552a-7c4b-9184-6857e7f3dc5fHost that owns the Credential.
Provider Credential Idf45ace27-9fa2-46c8-a267-f0824e0dde21Stable row identifier generated by Portal.
Aggregate Version4Optimistic-concurrency version. A stale value is rejected.

active, updateUser, and updateTs are backend-managed and are not submitted.

Editable Fields

FieldRequiredExampleDescription
Credential PurposeYesENDPOINTResolver boundary: central Endpoint or sidecar runtime.
Provider EndpointFor ENDPOINTnvidia-free-embeddingsEndpoint bound to the Credential.
Provider DeploymentFor SIDECAR_RUNTIME and current compatibility pathnvidia-nemotron-3-embed-1b-locCorresponding Deployment.
Credential VersionYes2Positive version unique within the selected Deployment. Correct it only before activation; create a new version for rotation.
Secret ReferenceYesenv:OPENAI_API_KEY_V2Environment-variable reference resolved locally by the gateway. Vault or another injector may populate the variable; never enter its value.
Effective TimeYes2026-08-15T14:00:00ZISO-8601 time when this version becomes eligible.
Expiration TimeNo2026-11-15T14:00:00ZOptional ISO-8601 cutoff later than Effective Time.

Use an explicit timezone in both timestamps. Leaving Expiration Time empty keeps the reference effective until it is replaced or deleted.

For NVIDIA, preserve purpose ENDPOINT, Endpoint nvidia-free-embeddings, and the corresponding Nemotron Deployment. Publish only after the gateway container can resolve env:NVIDIA_API_KEY. For key rotation, create version 2 with a new external reference instead of putting a new key value into this form.

Rotation Example

Suppose version 1 is currently used and version 2 should take over at 2026-08-15T14:00:00Z:

  1. Create version 2 with the new external reference and effective time.
  2. Verify the secret and resolver permissions.
  3. End version 1 by setting its Expiration Time to the cutover time.
  4. Publish and test the new snapshot through the target gateway.

Save The Update

Choose Update Provider Credential. The form sends lightapi.net/genai/updateLlmProviderCredential/0.1.0 with the stable identity, aggregateVersion, then returns to the LLM Model Control Plane.

Common Problems

  • Stale aggregate version: reopen the form from the Credentials tab and apply the change to the latest row.
  • Expiration is rejected: ensure it is later than Effective Time and both values contain a timezone.
  • Need a different Secret Reference after activation: create the next credential version instead of rewriting operational history.
  • Publication still reports no credentialed route: verify Effective Time has arrived, Expiration Time has not passed, and the row was not deleted.
  • 403 on Update: confirm access to lightapi.net/genai/updateLlmProviderCredential/0.1.0 and the required write permission.

For the full eligibility and rotation workflow, see the Credentials tab guide.

Create Public Alias

Use this form to create a stable, environment-specific model name and the policy contract that every route behind that name must satisfy. Applications use the Alias Name instead of a provider Deployment or physical model ID.

Despite the entity name, an Alias can be generally available (PUBLIC) or restricted to an agent or workload identity. Add its Routes, Credential, and Pricing records, then use Publish to review the complete configuration.

Important: operations, requiredCapabilities.embeddingSpace, requireExpectedEmbeddingSpace, and embeddingWorkloadLane form an immutable routing contract. To change any of them, create a new Alias revision instead of updating the existing Alias.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by Portal. All referenced records must belong to this host.10000000-0000-4000-8000-000000000001
EnvironmentEnvironment in which clients use the Alias. Its Routes must use compatible registrations and Deployments.loc
Alias NameStable model name presented to applications and agents. It must be unique within the host and environment.kb-index
OperationsJSON or YAML array containing generate, embed, or both. Choose Apply after editing structured data.["embed"]
Required CapabilitiesJSON or YAML object that every eligible Deployment must satisfy. Embedding Aliases require the complete embeddingSpace object described below.See Embedding space
Require Expected Embedding SpaceWhen enabled, embedding clients must send the expected space ID and revision. Enable this for Knowledge Base Aliases.true
Embedding Workload Lanestandard, kb_query, or kb_index. The two Knowledge Base lanes provide separate query and indexing admission paths.kb_index
Maximum Input TokensOptional Alias-level input-token limit. It must not exceed the selected model and qualified Deployment.4096
Maximum Output TokensOptional generation output limit. Leave it empty for an embedding-only Alias.8192
Maximum Request BytesOptional maximum serialized request size accepted through the Alias.1048576
Data ClassificationOptional classification used by data-handling and route policy. Use the vocabulary established for the host.public
Logging ModeNONE, METADATA, or REDACTED.METADATA
PII ModeDENY, REDACT, TOKENIZE, or ALLOW. Choose the most restrictive mode compatible with the use case.DENY
Replacement AliasOptional intended successor for migration. It is not an automatic redirect and cannot reference the Alias being created.governed-chat-v2
Alias VisibilityPUBLIC, INTERNAL_LEGACY, or INTERNAL_WORKLOAD. The visibility determines which identity binding fields are allowed.INTERNAL_WORKLOAD
Bound Agent DefinitionRequired only for INTERNAL_LEGACY; select the single agent allowed to resolve the Alias. Leave it empty for the other visibility modes.Legacy Support Agent
Bound Workload PrincipalRequired only for INTERNAL_WORKLOAD. It must exactly match the principal derived from the workload’s bearer token.knowledge-indexer

Operations and Required Capabilities are structured editors. After changing their JSON or YAML value, choose Apply before submitting the form.

Visibility and identity rules

Use exactly one of these shapes:

VisibilityBound Agent DefinitionBound Workload PrincipalIntended use
PUBLICEmptyEmptyNormal model discovery and routing
INTERNAL_LEGACYRequiredEmptyOne selected legacy agent
INTERNAL_WORKLOADEmptyRequiredA service or worker authenticated as the exact workload principal

A non-standard Knowledge Base lane (kb_query or kb_index) additionally requires all of the following:

  • operations is exactly ["embed"];
  • Require Expected Embedding Space is enabled; and
  • Alias Visibility is INTERNAL_WORKLOAD with a non-empty workload principal.

The Alias names kb-query and kb-index use hyphens. The corresponding lane identifiers kb_query and kb_index use underscores. They are distinct contracts and must not be substituted for each other.

Embedding space

An embedding Alias must declare exactly these six fields under requiredCapabilities.embeddingSpace:

FieldMeaning
spaceIdOperator-assigned identity for vectors that are safe to compare. Include the provider/model/output profile when that makes the identity unambiguous.
revisionPositive revision of that vector-space contract.
dimensionExact number of floating-point values returned for each vector.
normalizationnone or l2.
distanceMetriccosine, inner_product, or l2.
documentInputTransformVersionVersioned document preprocessing contract used before embedding, such as document-v1.

Matching dimensions alone do not make two embedding spaces compatible. Both Knowledge Base Aliases and every eligible primary or fallback Deployment must publish exactly the same six-field contract.

NVIDIA Nemotron Knowledge Base example

For nvidia/nemotron-3-embed-1b, NVIDIA currently documents a native 2048-dimensional float embedding, a 4096-token NIM limit, and no reduced dimension support. NVIDIA’s model card describes the output as L2-normalized, so the example uses L2 normalization with cosine distance. See the NVIDIA NIM support matrix and NVIDIA model card.

Create the indexing Alias first:

{
  "environment": "loc",
  "aliasName": "kb-index",
  "operations": ["embed"],
  "requiredCapabilities": {
    "embeddingSpace": {
      "spaceId": "nvidia-nemotron-3-embed-1b-float-v1",
      "revision": 1,
      "dimension": 2048,
      "normalization": "l2",
      "distanceMetric": "cosine",
      "documentInputTransformVersion": "document-v1"
    }
  },
  "requireExpectedEmbeddingSpace": true,
  "embeddingWorkloadLane": "kb_index",
  "maxInputTokens": 4096,
  "maxRequestBytes": 1048576,
  "dataClassification": "public",
  "loggingMode": "METADATA",
  "piiMode": "DENY",
  "aliasVisibility": "INTERNAL_WORKLOAD",
  "boundWorkloadPrincipal": "knowledge-indexer"
}

Then create the query Alias with the same embedding-space object and policy, changing only:

{
  "aliasName": "kb-query",
  "embeddingWorkloadLane": "kb_query",
  "boundWorkloadPrincipal": "knowledge-service"
}

The two workload-principal examples assume that the indexing and query bearer tokens resolve to knowledge-indexer and knowledge-service. If your tokens use different subjects, enter those exact resolved principal IDs instead.

NVIDIA retrieval models distinguish document (passage) input from query input. The Alias records the immutable vector-space and document-transform identity, but provider-specific request transformation remains a Deployment and gateway responsibility. Verify that the qualified provider path applies the corresponding passage and query behavior before activating the Routes.

General generation example

For a generally available generation Alias:

{
  "environment": "prod",
  "aliasName": "governed-chat",
  "operations": ["generate"],
  "requiredCapabilities": {
    "tools": true,
    "streaming": true
  },
  "maxInputTokens": 128000,
  "maxOutputTokens": 8192,
  "maxRequestBytes": 1048576,
  "dataClassification": "internal",
  "loggingMode": "METADATA",
  "piiMode": "REDACT",
  "aliasVisibility": "PUBLIC"
}

The backend creates the Public Alias Id and aggregate version. The active state is backend-managed through soft delete and is not part of this form.

Update Public Alias

Use this form to revise mutable policy, visibility, or migration metadata for an existing Alias. Routes remain separate and are updated from the Routes tab.

Host Id, Public Alias Id, and Aggregate Version are read-only. Reload the row after a concurrency conflict instead of changing Aggregate Version manually.

Immutable embedding contract

The following values are fixed at Alias creation:

  • operations;
  • requiredCapabilities.embeddingSpace;
  • requireExpectedEmbeddingSpace; and
  • embeddingWorkloadLane.

The update form displays the existing values for context but omits them from the submitted update. To change an operation, vector-space identity, dimension, normalization, distance metric, document transform, or workload lane, create a new Alias and migrate Routes/clients deliberately.

For NVIDIA kb-index and kb-query, preserve the exact space nvidia-nemotron-3-embed-1b-float-v1, revision 1, dimension 2048, L2 normalization, cosine distance, and document-v1 transform.

Mutable fields

FieldDescription
EnvironmentEnvironment served by the Alias; Routes must remain compatible.
Alias NameHost/environment-unique stable client name. Renaming requires coordinated client/config changes.
Maximum Input/Output TokensAlias request limits. Leave output empty for embedding-only Aliases.
Maximum Request BytesSerialized request-size limit.
Data ClassificationHost vocabulary used by governance and routing.
Logging ModeNONE, METADATA, or REDACTED.
PII ModeDENY, REDACT, TOKENIZE, or ALLOW.
Replacement AliasIntended successor; it is not an automatic redirect.
Alias VisibilityPUBLIC, INTERNAL_LEGACY, or INTERNAL_WORKLOAD.
Bound Agent DefinitionRequired only for INTERNAL_LEGACY.
Bound Workload PrincipalRequired only for INTERNAL_WORKLOAD; must match the authenticated principal exactly.

For the Knowledge Base Aliases, keep visibility INTERNAL_WORKLOAD and ensure the bound principal matches the query or indexing bearer-token subject. Do not switch a protected workload Alias to PUBLIC merely to bypass an identity failure; correct the token/principal binding.

Updating the Alias does not make it publishable by itself. It still requires compatible Routes, Deployments, Endpoint credentials, and Pricing. Soft-delete state remains backend-managed.

Create Alias Route

Use this form to connect a public Alias to a Provider Deployment. Together, the active Routes for an Alias define its ordered primary and fallback choices. Applications continue to send the Alias name; they never select the Deployment ID directly.

The selected Alias and Deployment must belong to the current host. The Deployment registration must use the Alias environment, and its model capabilities plus registration restrictions must satisfy the Alias’s required capabilities.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by the portal. Both referenced records must belong to this host.10000000-0000-4000-8000-000000000001
Public AliasNon-deleted Alias that clients use as their stable model name. Publication validates its routes and provider material.kb-index
Provider DeploymentNon-deleted Deployment that can serve the Alias. Its environment and embedding capabilities must match the Alias.nvidia-nemotron-3-embed-1b-loc
Route PriorityNon-negative ordering value. Lower values are evaluated first and must be unique within the Alias.0
Route WeightRead-only value fixed at 1 for the current MVP. Weighted selection is not supported yet.1
Fallback EnabledSelect when this Deployment should be used as a fallback rather than the preferred route.false
Canary PercentRead-only value fixed at 0 for the current MVP. Percentage-based canary routing is not supported yet.0
Residency ConditionsJSON or YAML governance object describing route residency constraints. Use Apply after editing. Use {} when no approved restriction applies. Current preview does not evaluate arbitrary conditions.{}

An Alias cannot contain the same Deployment twice. It also cannot contain two Routes with the same priority. A common convention is 0 for the preferred route and increasing values such as 10 and 20 for subsequent choices.

Primary Route Example

{
  "publicAliasId": "20000000-0000-4000-8000-000000000020",
  "providerDeploymentId": "30000000-0000-4000-8000-000000000030",
  "routePriority": 0,
  "routeWeight": 1,
  "fallbackEnabled": false,
  "canaryPercent": 0,
  "residencyConditions": {
    "regions": ["ca-central-1"]
  }
}

Fallback Route Example

For a second compatible Deployment, use another unique priority and enable fallback:

{
  "publicAliasId": "20000000-0000-4000-8000-000000000020",
  "providerDeploymentId": "30000000-0000-4000-8000-000000000031",
  "routePriority": 10,
  "routeWeight": 1,
  "fallbackEnabled": true,
  "canaryPercent": 0,
  "residencyConditions": {
    "regions": ["ca-central-1"]
  }
}

Creating a Route does not by itself make the Alias publishable. The Alias and Deployment must be present and internally consistent, and the Deployment needs an effective Credential and effective Pricing. The backend generates the Alias Route Id and aggregate version. The active state is backend-managed through soft delete and is not part of this form.

NVIDIA Knowledge Base routes

Submit this form twice: create one priority-zero Route for each Knowledge Base Alias. For the functional demo, both Routes may select the same hosted NVIDIA Deployment:

AliasDeploymentPriorityFallbackWeightCanary
kb-indexnvidia-nemotron-3-embed-1b-loc0false10
kb-querynvidia-nemotron-3-embed-1b-loc0false10

First submission:

{
  "publicAliasId": "select kb-index",
  "providerDeploymentId": "select nvidia-nemotron-3-embed-1b-loc",
  "routePriority": 0,
  "routeWeight": 1,
  "fallbackEnabled": false,
  "canaryPercent": 0,
  "residencyConditions": {}
}

Second submission uses the same values but selects kb-query as Public Alias. The values shown for the two selectors are dropdown labels; the form submits their UUIDs.

Use {} for Residency Conditions unless the Registration and Deployment carry an approved region restriction. Every routed Deployment must match the Alias’s complete embedding-space contract, not only dimension 2048.

Production-protected kb_index and kb_query lanes require genuinely separate runtime and capacity/quota domains. Routing both Aliases through one free shared NVIDIA Deployment is appropriate for this functional demo, but it is not production-isolation evidence.

Update Alias Route

Use this form to change the Deployment connected to an Alias, reorder a Route, or change its fallback and residency metadata. Updates affect control-plane configuration; a gateway sees the change only after a valid new publication is created and applied.

The Host Id, Alias Route Id, Route Weight, Canary Percent, and Aggregate Version are read-only. Weight and canary are fixed by the current MVP, while the aggregate version prevents an update from silently overwriting a newer change.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by the portal.10000000-0000-4000-8000-000000000001
Alias Route IdRead-only identifier generated when the Route was created.40000000-0000-4000-8000-000000000040
Public AliasAlias served by this Route. Changing it revalidates the selected Deployment against the new Alias.kb-index or kb-query
Provider DeploymentProvider endpoint used by the Route. It must match the Alias environment and required capabilities.nvidia-nemotron-3-embed-1b-loc
Route PriorityNon-negative ordering value; lower values come first. It must be unique among Routes for the Alias.0
Route WeightRead-only value fixed at 1. Weighted traffic splitting is not supported by the current MVP.1
Fallback EnabledWhether this Route is intended as a fallback-only choice.false
Canary PercentRead-only value fixed at 0. Percentage canary routing is not supported by the current MVP.0
Residency ConditionsJSON or YAML governance object for residency requirements. Choose Apply after editing. Use {} for this public NVIDIA demo.{}
Aggregate VersionRead-only record version sent with the update for optimistic concurrency. Reload the Route if another update has advanced it.6

Example

This update makes the Route a Canadian fallback with priority 10:

{
  "aliasRouteId": "40000000-0000-4000-8000-000000000040",
  "publicAliasId": "20000000-0000-4000-8000-000000000020",
  "providerDeploymentId": "30000000-0000-4000-8000-000000000031",
  "routePriority": 10,
  "routeWeight": 1,
  "fallbackEnabled": true,
  "canaryPercent": 0,
  "residencyConditions": {
    "regions": ["ca-central-1"]
  },
  "aggregateVersion": 6
}

The Route preview reports ordering and eligibility, but it does not execute a provider request or guarantee that fallback will succeed at runtime. Before publication, confirm that at least one Route for every Alias has a credentialed and priced Deployment. The active state is backend-managed through soft delete and is not part of this form.

For kb-index or kb-query, select only a Deployment configured for openai_embeddings and the Alias’s exact 2048-dimensional Nemotron embedding space. Changing a Route to another Deployment with the same vector dimension but a different space ID, revision, normalization, distance metric, or document transform is incompatible.

NVIDIA Knowledge Base update

For the functional demo, the kb-index and kb-query Route rows may both use nvidia-nemotron-3-embed-1b-loc. Preserve these primary-route values:

{
  "routePriority": 0,
  "routeWeight": 1,
  "fallbackEnabled": false,
  "canaryPercent": 0,
  "residencyConditions": {}
}

Normally there is nothing to update after creating those two rows. Use this form only to replace the Deployment, introduce a real fallback, or apply an approved residency condition. Select the correct Alias row before editing; changing the Public Alias moves the Route rather than copying it.

For production-protected kb_index and kb_query lanes, route each Alias to its separately qualified runtime and capacity/quota domain. Merely creating two Route rows that share one Deployment does not provide that isolation.

Create Pricing Version

Use this form to add an approved, effective-dated rate schedule for one Provider Deployment and operation. Pricing is separate from Deployment and Credential identity so rate changes remain versioned and auditable.

Rates use integer micros per one million tokens. One currency unit is 1,000,000 micros.

Fields

FieldRequiredDescription
Host IdYesRead-only tenant boundary.
Provider DeploymentYesDeployment whose operation is being priced.
OperationYesgenerate or embed; it must match the Deployment protocol.
Pricing VersionYesPositive business version unique for the Deployment.
Pricing BasisYesEXTERNAL_PROVIDER, ZERO_MARGINAL, or AMORTIZED_INTERNAL.
Input Micros Per Million TokensYesNon-negative input/embedding token rate.
Output Micros Per Million TokensFor generate onlyRequired for generation and prohibited for embed.
Cached Input Micros Per Million TokensNoOptional cached-input rate.
Effective TimeYesISO-8601 timestamp with timezone.
Expiration TimeNoOptional cutoff later than Effective Time.
Pricing SourceYesReference to the contract, provider page, or approved demo assumption.
Approved ByYesPerson, group, or automation identity approving the rate.

Pricing-basis rules are:

  • EXTERNAL_PROVIDER records a provider charge and may use zero only when the approved external rate is actually zero;
  • ZERO_MARGINAL requires all supplied rates to be zero; and
  • AMORTIZED_INTERNAL requires at least one non-zero rate.

NVIDIA free endpoint example

For a free-demo entitlement with no marginal token charge, select the NVIDIA Nemotron Deployment and use:

{
  "operation": "embed",
  "pricingVersion": 1,
  "pricingBasis": "ZERO_MARGINAL",
  "inputMicrosPerMillion": 0,
  "cachedInputMicrosPerMillion": 0,
  "effectiveTs": "2026-08-10T00:00:00Z",
  "expiresTs": null,
  "source": "nvidia-build-free-endpoint-demo",
  "approvedBy": "local-demo-operator"
}

Leave Output Micros Per Million Tokens empty. Do not enter 0 in that field: the embedding contract requires it to be omitted/null.

Verify the current NVIDIA account terms before using ZERO_MARGINAL. If the account is billed or quota usage must carry a monetary rate, choose EXTERNAL_PROVIDER and enter the approved input price instead. A free endpoint can still have capacity and rate limits even when its marginal price is zero.

The system does not automatically close an older Pricing Version or reject all overlapping windows. Use a new version for a new rate period and publish a new gateway candidate. Portal generates Pricing Version Id and Aggregate Version; active remains backend-managed.

Update Pricing Version

Use this form to correct an existing Pricing Version’s Deployment, rates, effective window, or approval metadata. A change reaches a gateway only after a valid new publication is created and applied.

For a genuinely new provider rate period, prefer creating a new Pricing Version instead of rewriting a historical rate that may already be referenced by published configuration or usage evidence.

The Host Id, Pricing Version Id, and Aggregate Version are read-only. Aggregate Version provides optimistic concurrency protection; it is different from the operator-assigned Pricing Version.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by the portal.10000000-0000-4000-8000-000000000001
Pricing Version IdRead-only identifier generated when this Pricing record was created.50000000-0000-4000-8000-000000000050
Provider DeploymentDeployment to which this rate schedule applies.openai-prod-ca (30000000-0000-4000-8000-000000000030)
OperationPriced operation; it must match the Deployment protocol.embed
Pricing VersionPositive business version unique for the selected Deployment. It is not the optimistic-concurrency version.3
Pricing BasisEXTERNAL_PROVIDER, ZERO_MARGINAL, or AMORTIZED_INTERNAL.ZERO_MARGINAL
Input Micros Per Million TokensNon-negative input rate in micros per one million tokens.2500000
Output Micros Per Million TokensRequired for generate; must be empty for embed.10000000
Cached Input Micros Per Million TokensOptional cached-input rate retained by the control plane. The current MVP gateway projection does not consume it separately.1250000
Effective TimeISO 8601 timestamp when the rate becomes effective.2026-08-01T12:00:00Z
Expiration TimeOptional ISO 8601 timestamp later than Effective Time. Leave empty for no scheduled expiration.2026-11-01T12:00:00Z
Pricing SourceReference to the contract, price sheet, or agreement supporting the rate.provider-contract-2026-08-rev1
Approved ByIdentity that approved the corrected record.[email protected]
Aggregate VersionRead-only record version included with the update command. Reload the record if another update has advanced it.4

Example

{
  "pricingVersionId": "50000000-0000-4000-8000-000000000050",
  "providerDeploymentId": "30000000-0000-4000-8000-000000000030",
  "operation": "generate",
  "pricingVersion": 3,
  "pricingBasis": "EXTERNAL_PROVIDER",
  "inputMicrosPerMillion": 2500000,
  "outputMicrosPerMillion": 10000000,
  "cachedInputMicrosPerMillion": 1250000,
  "effectiveTs": "2026-08-01T12:00:00Z",
  "expiresTs": "2026-11-01T12:00:00Z",
  "source": "provider-contract-2026-08-rev1",
  "approvedBy": "[email protected]",
  "aggregateVersion": 4
}

After an update, verify that the Deployment does not have ambiguous overlapping Pricing windows and publish a new immutable gateway candidate before expecting runtime cost calculations to change. The active state is backend-managed through soft delete and is not part of this form.

For the NVIDIA embedding demo, preserve operation embed and leave output pricing empty. If the approved free entitlement has no marginal token charge, use ZERO_MARGINAL and zero input/cached-input rates. Prefer creating a new Pricing Version when the NVIDIA commercial terms or entitlement changes.

Create Model Policy

Use this form to create reusable model-governance intent for one host. A Model Policy is not assigned merely by creating it: use the Bindings tab afterward to associate it with an Agent, Client, Principal, or Product Profile and, optionally, a Public Alias.

The six policy fields accept JSON or YAML objects. Choose an editor tab, enter the object, and select Apply before saving. The example keys below are illustrative governance vocabulary, not a promise that every key is enforced. The backend accepts extensible objects, while runtime enforcement requires an approved publication mapping and a gateway that supports the mapped fields.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by the portal. The generated Policy and all of its Bindings belong to this host.10000000-0000-4000-8000-000000000001
Policy NameRequired, recognizable name unique within the host. Use a stable governance name rather than a provider or Deployment identifier.governed-chat-standard
Access PolicyObject describing intended subject and operation access. The example vocabulary must be mapped by the publication implementation before it can affect runtime authorization.{"allowedSubjectTypes":["AGENT","CLIENT"],"allowedOperations":["generate"]}
Budget PolicyObject describing intended per-request or period spending controls. Monetary examples use integer micros, where 1000000 micros is one currency unit.{"maxCostMicrosPerRequest":500000,"monthlyCostMicros":50000000}
Content PolicyObject describing intended logging and prompt or response handling.{"loggingMode":"METADATA","allowPromptLogging":false}
Cache PolicyObject describing intended cache use and constraints.{"enabled":false}
PII PolicyObject describing intended PII handling and applicable kinds.{"mode":"REDACT","allowedKinds":["EMAIL"]}
Native Extension PolicyObject allowlisting provider-specific request fields outside the portable model contract. Keep it narrowly scoped by provider.{"openai":{"allowedRequestFields":["service_tier"]}}

Complete Example

{
  "policyName": "governed-chat-standard",
  "accessPolicy": {
    "allowedSubjectTypes": ["AGENT", "CLIENT"],
    "allowedOperations": ["generate"]
  },
  "budgetPolicy": {
    "maxCostMicrosPerRequest": 500000,
    "monthlyCostMicros": 50000000
  },
  "contentPolicy": {
    "loggingMode": "METADATA",
    "allowPromptLogging": false
  },
  "cachePolicy": {
    "enabled": false
  },
  "piiPolicy": {
    "mode": "REDACT",
    "allowedKinds": ["EMAIL"]
  },
  "nativeExtensionPolicy": {
    "openai": {
      "allowedRequestFields": ["service_tier"]
    }
  }
}

Empty objects are valid when a policy domain is not yet specified. Do not put API keys, passwords, bearer values, authorization headers, or other raw secrets in any policy object. Provider credentials belong in the Credentials tab.

After creation, review the Policy, create the required Bindings, and use the publication workflow to translate supported policy intent into a new immutable gateway candidate. Creating the row alone does not change a running gateway. The backend generates Model Policy Id and Aggregate Version. The active state is backend-managed through soft delete and is not part of this form.

NVIDIA Knowledge Base note

A Model Policy is optional for the initial NVIDIA transport smoke test. If the host requires one for kb-index or kb-query, use operation embed, bind the Policy to the intended workload/Alias, and use budgets appropriate to the approved free entitlement. Do not place NVIDIA_API_KEY or its value in any policy object. A native-extension allowlist also does not implement NVIDIA’s query/passage transformation by itself; the provider adapter must support that request contract.

Update Model Policy

Use this form to revise a Model Policy’s name or governance objects. The change remains control-plane data until a supported publication mapping is validated and a new immutable gateway candidate is applied.

The Host Id, Model Policy Id, and Aggregate Version are read-only. Aggregate Version provides optimistic concurrency protection: reload the Policies tab if another update has advanced it.

The six policy fields accept JSON or YAML objects. Choose an editor tab, make the change, and select Apply before saving. The example keys below are illustrative. An accepted object does not guarantee runtime enforcement; each key requires an approved compiler mapping and compatible gateway support.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by the portal.10000000-0000-4000-8000-000000000001
Model Policy IdRead-only stable identifier generated when the Policy was created and referenced by Bindings or Agent configuration.60000000-0000-4000-8000-000000000060
Policy NameRecognizable name unique within the host. Renaming does not change the stable Model Policy Id.governed-chat-standard-v2
Access PolicyObject describing intended subject and operation access. Operations use the control-plane vocabulary generate or embed.{"allowedSubjectTypes":["AGENT"],"allowedOperations":["generate"]}
Budget PolicyObject describing intended spending controls. Monetary examples use integer micros.{"maxCostMicrosPerRequest":400000,"monthlyCostMicros":40000000}
Content PolicyObject describing intended content logging and handling.{"loggingMode":"METADATA","allowPromptLogging":false}
Cache PolicyObject describing intended cache behavior.{"enabled":false}
PII PolicyObject describing intended handling for personally identifiable data.{"mode":"REDACT","allowedKinds":["EMAIL","PHONE"]}
Native Extension PolicyObject allowlisting provider-specific request extensions.{"openai":{"allowedRequestFields":["service_tier"]}}
Aggregate VersionRead-only record version submitted with the update. Reload after a conflict instead of changing it manually.4

Complete Example

{
  "modelPolicyId": "60000000-0000-4000-8000-000000000060",
  "policyName": "governed-chat-standard-v2",
  "accessPolicy": {
    "allowedSubjectTypes": ["AGENT"],
    "allowedOperations": ["generate"]
  },
  "budgetPolicy": {
    "maxCostMicrosPerRequest": 400000,
    "monthlyCostMicros": 40000000
  },
  "contentPolicy": {
    "loggingMode": "METADATA",
    "allowPromptLogging": false
  },
  "cachePolicy": {
    "enabled": false
  },
  "piiPolicy": {
    "mode": "REDACT",
    "allowedKinds": ["EMAIL", "PHONE"]
  },
  "nativeExtensionPolicy": {
    "openai": {
      "allowedRequestFields": ["service_tier"]
    }
  },
  "aggregateVersion": 4
}

Do not place API keys, passwords, bearer values, authorization headers, or other raw secrets in a policy object. Use the Credentials tab for external secret references.

Before changing a Policy, review its Bindings and any Agent that selects its Model Policy Id. After any enforceable change, create and apply a new valid publication; an existing gateway snapshot does not update in place. The active state is backend-managed through soft delete and is not part of this form.

For the NVIDIA Knowledge Base aliases, use operation embed if a Policy is needed at all. Keep apiKey and input_type out of the policy: the API key is represented by the Credential’s env:NVIDIA_API_KEY reference, while the query-versus-passage request transformation belongs in the approved embedding adapter.

Create Policy Binding

Use this form to assign a Model Policy to an Agent, Client, Principal, or Product Profile. Optionally scope the assignment to a Public Alias. For a policy-selected Agent, mark exactly one Alias Binding as the Agent Default.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by Portal. The selected Policy and Alias must belong to this host.10000000-0000-4000-8000-000000000001
Model PolicyPolicy being assigned. The dropdown lists non-deleted Policies for the selected host.governed-chat-standard (60000000-0000-4000-8000-000000000060)
Subject TypeNamespace that defines how Subject Id is interpreted: AGENT, CLIENT, PRINCIPAL, or PRODUCT_PROFILE.AGENT
Subject IdExact stable identifier from the selected subject namespace. For AGENT, use the Agent Definition Id. The Binding table has no foreign key to the four different subject systems, so confirm this value carefully.10000000-0000-4000-8000-000000000099
Public AliasOptional Alias that scopes this assignment. It is required when Agent Default is selected. The dropdown lists non-deleted Aliases for the host; verify that the Alias has an applicable published route where it will be used.governed-chat (20000000-0000-4000-8000-000000000020)
Agent DefaultSelect only for an AGENT Binding with a Public Alias. It makes this Alias the Policy’s selected default for that Agent. Only one active default is allowed for the same Policy and Agent.true

Agent Default Example

{
  "modelPolicyId": "60000000-0000-4000-8000-000000000060",
  "subjectType": "AGENT",
  "subjectId": "10000000-0000-4000-8000-000000000099",
  "publicAliasId": "20000000-0000-4000-8000-000000000020",
  "agentDefault": true
}

For a policy-selected Agent, the current resolver matches subjectId to the Agent Definition Id and requires exactly one active default Alias. A missing default produces NO_DEFAULT; multiple matching defaults are treated as ambiguous and model resolution fails.

Non-Agent Example

{
  "modelPolicyId": "60000000-0000-4000-8000-000000000060",
  "subjectType": "PRINCIPAL",
  "subjectId": "user-1234",
  "publicAliasId": "20000000-0000-4000-8000-000000000020",
  "agentDefault": false
}

Client, Principal, and Product Profile Bindings are control-plane assignments for an approved policy compiler or authorization integration. They are not automatically enforced by the current Agent resolver or by merely storing the row.

Before saving an Agent Default, confirm that the Agent selects this Model Policy and that the Alias has eligible, published Routes and provider material. The backend generates Model Policy Binding Id and Aggregate Version. The active state is backend-managed through soft delete and is not part of this form.

For kb-index or kb-query, a Binding is optional unless an Agent or other subject is expected to select the embedding Alias through a Model Policy. If you create one, bind the exact workload identity to the corresponding Alias; do not use a Binding as a substitute for the Alias workload-identity allowlist or Route eligibility.

Update Policy Binding

Use this form to change the Policy assignment, subject, optional Alias scope, or Agent Default selection on an existing Binding.

The Host Id, Model Policy Binding Id, and Aggregate Version are read-only. Aggregate Version provides optimistic concurrency protection; reload the Bindings tab if another change has advanced it.

Fields

FieldDescriptionExample
Host IdRead-only tenant boundary supplied by Portal.10000000-0000-4000-8000-000000000001
Model Policy Binding IdRead-only identifier generated when the Binding was created.70000000-0000-4000-8000-000000000070
Model PolicyPolicy assigned by this Binding. The dropdown lists non-deleted Policies for the host.governed-chat-standard (60000000-0000-4000-8000-000000000060)
Subject TypeNamespace for Subject Id: AGENT, CLIENT, PRINCIPAL, or PRODUCT_PROFILE. Changing it also changes the meaning of Subject Id.AGENT
Subject IdExact stable identifier in the selected namespace. For AGENT, this must be the Agent Definition Id used by model resolution.10000000-0000-4000-8000-000000000099
Public AliasOptional Alias scope. It is required when Agent Default is selected.governed-chat-v2 (20000000-0000-4000-8000-000000000021)
Agent DefaultFor an AGENT Binding, selects this Public Alias as the Policy default. Clear the previous default before or while assigning another one so only one active default remains.true
Aggregate VersionRead-only record version submitted with the update. Reload after an update conflict instead of editing it manually.3

Complete Example

{
  "modelPolicyBindingId": "70000000-0000-4000-8000-000000000070",
  "modelPolicyId": "60000000-0000-4000-8000-000000000060",
  "subjectType": "AGENT",
  "subjectId": "10000000-0000-4000-8000-000000000099",
  "publicAliasId": "20000000-0000-4000-8000-000000000021",
  "agentDefault": true,
  "aggregateVersion": 3
}

If Agent Default is selected, the form requires subjectType=AGENT and a Public Alias. The database also allows at most one active default for the same Policy and Agent. If another Binding currently owns that default, update it to agentDefault=false before saving this one.

Review the Agent Definition before changing modelPolicyId, subjectType, or subjectId; a mismatch can leave a policy-selected Agent without a resolvable default. Ensure a replacement Alias is eligible and published before directing an Agent to it. The gateway does not query this Binding row during an inference request, and the active state is backend-managed through soft delete rather than this form.

For the NVIDIA Knowledge Base demo, preserve the distinction between kb-index and kb-query when changing an Alias scope. Pointing both workload identities at one Binding does not create independent provider capacity or quota; that isolation is established by eligible Routes and their Deployments.

Create API

Use this form to register a new API record for the current host.

After submission, the API becomes available for version creation, marketplace publishing, MCP onboarding, instance links, and access-control tasks.

Important fields:

  • apiId: stable API identifier for the host
  • apiName: user-facing API name
  • apiStatus: lifecycle status
  • ownerPositionId: optional position owner for team access

Update API

Use this form to update API metadata.

Updating an API changes descriptive and ownership metadata for the API record. It does not replace the API version specification.

Important fields:

  • apiName: user-facing API name
  • apiStatus: lifecycle status
  • ownerPositionId: optional position owner for team access

Create API Version

Use this form to add a version to an existing API.

After submission, the API version can be linked to instances, gateway flows, MCP tools, marketplace publishing, and access-control rules.

Important fields:

  • apiId: parent API
  • apiVersion: version label
  • apiType: API style such as OpenAPI, GraphQL, Hybrid, or MCP
  • serviceId: backing service identifier
  • spec: API specification text, or MCP tools/list JSON output for MCP API versions
  • transportConfig: MCP transport and URL when apiType is MCP
  • ownerPositionId: optional position owner for team access

MCP Tool Discovery

For MCP API versions, there are two ways to populate tools:

  • If the portal service can reach the MCP server, select MCP as the API Type and fill transportConfig, for example {"transport":"streamable http","url":"http://localhost:5000/mcp"}.
  • If the portal service cannot reach the MCP server because of firewall or security boundaries, call the MCP server yourself and paste the response into spec.

Example manual discovery call:

curl --location --request POST 'http://localhost:5000/mcp' \
  --header 'Content-Type: application/json' \
  --data-raw '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Paste the response into Spec / MCP Tools JSON. The form accepts any of these payload shapes.

Full JSON-RPC response:

{
  "jsonrpc": "2.0",
  "result": {
    "tools": [
      {
        "name": "echo",
        "description": "Echoes back the input",
        "inputSchema": {
          "type": "object",
          "properties": {
            "message": {
              "type": "string"
            }
          },
          "required": [
            "message"
          ]
        }
      }
    ]
  },
  "id": 1
}

Object with a top-level tools array:

{
  "tools": [
    {
      "name": "echo",
      "description": "Echoes back the input",
      "inputSchema": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          }
        },
        "required": [
          "message"
        ]
      }
    }
  ]
}

Raw tools array:

[
  {
    "name": "echo",
    "description": "Echoes back the input",
    "inputSchema": {
      "type": "object",
      "properties": {
        "message": {
          "type": "string"
        }
      },
      "required": [
        "message"
      ]
    }
  }
]

Keep transportConfig populated with the real MCP transport and URL when the runtime still needs it for invocation.

Update API Version

Use this form to update API version metadata and integration details.

Updating a version can affect downstream instance links, gateway behavior, and task flows that reference the API version.

Important fields:

  • apiVersion: version label
  • apiType: API style
  • serviceId: backing service identifier
  • spec: API specification text, or MCP tools/list JSON output for MCP API versions
  • transportConfig: MCP transport and URL when apiType is MCP
  • protocol, envTag, and targetHost: runtime routing details
  • ownerPositionId: optional position owner for team access

MCP Tool Discovery

For MCP API versions, there are two ways to refresh tools:

  • If the portal service can reach the MCP server, select MCP as the API Type and fill transportConfig, for example {"transport":"streamable http","url":"http://localhost:5000/mcp"}.
  • If the portal service cannot reach the MCP server because of firewall or security boundaries, call the MCP server yourself and paste the response into spec.

Example manual discovery call:

curl --location --request POST 'http://localhost:5000/mcp' \
  --header 'Content-Type: application/json' \
  --data-raw '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Paste the response into Spec / MCP Tools JSON. The form accepts any of these payload shapes.

Full JSON-RPC response:

{
  "jsonrpc": "2.0",
  "result": {
    "tools": [
      {
        "name": "echo",
        "description": "Echoes back the input",
        "inputSchema": {
          "type": "object",
          "properties": {
            "message": {
              "type": "string"
            }
          },
          "required": [
            "message"
          ]
        }
      }
    ]
  },
  "id": 1
}

Object with a top-level tools array:

{
  "tools": [
    {
      "name": "echo",
      "description": "Echoes back the input",
      "inputSchema": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          }
        },
        "required": [
          "message"
        ]
      }
    }
  ]
}

Raw tools array:

[
  {
    "name": "echo",
    "description": "Echoes back the input",
    "inputSchema": {
      "type": "object",
      "properties": {
        "message": {
          "type": "string"
        }
      },
      "required": [
        "message"
      ]
    }
  }
]

Keep transportConfig populated with the real MCP transport and URL when the runtime still needs it for invocation.

Create App

Use this form to register a client application.

After submission, the app can own OAuth clients and can be linked to service instances.

Important fields:

  • appId: stable app identifier for the host
  • appName: user-facing app name
  • isKafkaApp: whether this app uses Kafka-specific behavior
  • ownerPositionId: optional position owner for team access

Update App

Use this form to update client application metadata.

Updating an app does not automatically change OAuth clients or instance app links that reference the app.

Important fields:

  • appName: user-facing app name
  • operationOwner and deliveryOwner: business ownership metadata
  • ownerPositionId: optional position owner for team access

Create Client

Use this form to create an OAuth client.

An OAuth client can be associated with an app, API version, or instance, depending on the selected ownership context.

Important fields:

  • clientName: user-facing client name
  • clientType: client type
  • clientProfile: OAuth profile
  • providerId: OAuth provider
  • ownerPositionId: optional position owner for team access

Save The Generated Secret

After the client is created, the portal returns the generated clientId and clientSecret. Copy both values and store them in your secret manager before leaving the result page.

The portal does not persist the clear clientSecret. It stores only a verifier for future authentication, so the original secret cannot be shown again. If the secret is lost, regenerate it from the OAuth Client page and update any systems that use the old secret.

Save The Generated Credentials

After the client is created, the response includes the generated clientId and clientSecret. Copy both values and store them in the target application’s secret manager or deployment configuration immediately.

The clear clientSecret is shown only once. The portal stores only a verifier for later authentication, so it cannot show the original secret again after you leave the result page.

If the secret is lost, use the OAuth Client page to regenerate it. Regeneration creates a new secret and invalidates the old secret for future token requests.

Update Client

Use this form to update OAuth client metadata.

Changing client settings can affect token issuance, access scope, and downstream integrations that use the client.

Important fields:

  • clientName: user-facing client name
  • clientScope: requested scopes
  • tokenExType: token exchange type
  • ownerPositionId: optional position owner for team access

Create Client Token

Use this form to create a long-lived token for an OAuth client.

Client tokens are sensitive. Create them only for clients you own or are authorized to manage.

Important fields:

  • clientId: OAuth client that will receive the token
  • clientSecret: client credential used for token creation
  • ownerPositionId: optional position owner for team access

Create Instance

Use this form to create a product/service instance.

After submission, the instance can be linked to API versions, client apps, runtime endpoints, and configuration records.

Important fields:

  • instanceName: user-facing instance name
  • productVersionId: product version for the instance
  • serviceId: service identifier
  • environment, region, and lob: deployment metadata
  • ownerPositionId: optional position owner for team access

Environment Configuration Templates

The environment field provides a dropdown of standard environments defined globally in light-portal (e.g., dev, sit, uat, stg, prd).

When setting up a host, you can customize configurations at this environment level. By doing so, the environment acts as a configuration template.

For example, if you customize the dev environment for your host, any new instances you create that select dev as their environment will automatically inherit those customized properties. This prevents you from needing to repeatedly define the same baseline configuration for every single instance.

Of course, this inheritance is flexible: if a specific instance requires unique settings, you can override those environment-level properties directly at the instance level.

Env Tag

The envTag (Environment Tag) acts as a label to logically separate an instance based on its configuration, deployment namespace, or simply to serve as an alias for the same Service ID.

Critically, the combination of Host ID, Service ID, and Env Tag is used to uniquely identify an instance. This unique triad is what the system uses to load the correct configuration from the config server and to register the instance to the controller.

By default, the options in the Env Tag dropdown mirror the standard global environment list. However, because it supports host-specific overrides, each host or tenant can add their own customized Env Tags via the Ref Table Admin page (by creating a table named environment under their Host ID).

Update Instance

Use this form to update service instance metadata.

Updating an instance can affect task context, instance links, and owner-scoped visibility.

Important fields:

  • instanceName: user-facing instance name
  • serviceId: service identifier
  • current: whether this instance is the current instance for the service
  • ownerPositionId: optional position owner for team access

Environment Configuration Templates

The environment field provides a dropdown of standard environments defined globally in light-portal (e.g., dev, sit, uat, stg, prd).

When setting up a host, you can customize configurations at this environment level. By doing so, the environment acts as a configuration template.

For example, if you customize the dev environment for your host, any new instances you create that select dev as their environment will automatically inherit those customized properties. This prevents you from needing to repeatedly define the same baseline configuration for every single instance.

Of course, this inheritance is flexible: if a specific instance requires unique settings, you can override those environment-level properties directly at the instance level.

Env Tag

The envTag (Environment Tag) acts as a label to logically separate an instance based on its configuration, deployment namespace, or simply to serve as an alias for the same Service ID.

Critically, the combination of Host ID, Service ID, and Env Tag is used to uniquely identify an instance. This unique triad is what the system uses to load the correct configuration from the config server and to register the instance to the controller.

By default, the options in the Env Tag dropdown mirror the standard global environment list. However, because it supports host-specific overrides, each host or tenant can add their own customized Env Tags via the Ref Table Admin page (by creating a table named environment under their Host ID).

Create Instance API

Use this form to link an API version to an instance.

After submission, the relationship can be used for route prefixes, MCP tools, configuration, and access-control workflows.

Important fields:

  • instanceId: target instance
  • apiVersionId: API version to link
  • ownerPositionId: optional position owner for team access

Create Instance API Path Prefix

Use this form to add a path prefix to an instance API link.

Path prefixes help map incoming gateway paths to the correct API surface.

Important fields:

  • instanceApiId: instance API relationship
  • pathPrefix: route prefix
  • ownerPositionId: optional position owner for team access

Update Instance API Path Prefix

Use this form to update ownership metadata for an instance API path prefix.

The path prefix itself is part of the relationship key and should be treated as stable for the existing record.

Important fields:

  • instanceApiId: instance API relationship
  • pathPrefix: route prefix
  • ownerPositionId: optional position owner for team access

Create Instance App

Use this form to link a client app to an instance.

After submission, the app can be connected to APIs exposed by the same instance.

Important fields:

  • instanceId: target instance
  • appId: client app
  • appVersion: app version
  • ownerPositionId: optional position owner for team access

Create Instance App API

Use this form to connect an instance app relationship to an instance API relationship.

This link tells the portal which app can use which API on a specific instance.

Important fields:

  • instanceAppId: instance app relationship
  • instanceApiId: instance API relationship
  • ownerPositionId: optional position owner for team access

Create Runtime Instance

Use this form to create a runtime endpoint for a service.

Runtime instances describe where a service is reachable and support operational workflows.

Important fields:

  • serviceId: service identifier
  • protocol: runtime protocol
  • ipAddress and portNumber: endpoint location
  • instanceStatus: runtime status
  • ownerPositionId: optional position owner for team access

Update Runtime Instance

Use this form to update a runtime endpoint.

Updating runtime details can affect operational workflows that depend on the service endpoint.

Important fields:

  • runtimeInstanceId: runtime endpoint record
  • serviceId: service identifier
  • ipAddress and portNumber: endpoint location
  • instanceStatus: runtime status
  • ownerPositionId: optional position owner for team access

Create Schedule

Use this form to create a scheduled portal event.

After submission, the scheduler can emit the configured event according to the selected frequency and start time.

Important fields:

  • scheduleName: user-facing schedule name
  • frequencyUnit and frequencyTime: schedule cadence
  • startTs: first scheduled time
  • eventTopic, eventType, and eventData: event payload
  • ownerPositionId: optional position owner for team access

Update Schedule

Use this form to update a scheduled portal event.

Changing schedule timing or event data affects future executions.

Important fields:

  • scheduleName: user-facing schedule name
  • frequencyUnit and frequencyTime: schedule cadence
  • eventTopic, eventType, and eventData: event payload
  • ownerPositionId: optional position owner for team access

Create Workflow Definition

Use this form to create a workflow definition.

After submission, the workflow definition can be started manually or referenced by task flows and automation.

Important fields:

  • namespace: workflow namespace
  • name: workflow name
  • version: workflow version
  • definition: workflow YAML
  • catalogVisible: publish the workflow in Marketplace Workflow Catalog
  • ownerPositionId: optional position owner for team access

Update Workflow Definition

Use this form to update workflow definition metadata and YAML.

Updating the definition affects future workflow starts. Existing process instances may continue according to their already captured definition state.

Important fields:

  • wfDefId: workflow definition record
  • namespace, name, and version: workflow identity
  • definition: workflow YAML
  • catalogVisible: publish or remove the workflow from Marketplace Workflow Catalog
  • ownerPositionId: optional position owner for team access

Portal View Task Help

This section contains task-level help for workflows that span multiple pages and forms.

Task help should explain the goal, prerequisites, required steps, optional steps, and common next actions.

Available task guides:

Onboard API to MCP Gateway

Use this task to expose an existing API through MCP Gateway.

Typical steps:

  • select or create an API
  • select or create an API version
  • choose a deployment mode
  • link the API version to a gateway or sidecar instance
  • select MCP tools
  • configure access control when required

Register Standalone MCP Server

Use this task to register an MCP server that is not derived from an existing API version.

Typical steps:

  • register the MCP server
  • add a server version
  • link the server to a gateway
  • review MCP tools

Publish API

Use this task to prepare an API for publication and review.

Typical steps:

  • create or select the API
  • create or select an API version
  • review the marketplace listing

Register AI Agent

Use this task to register an AI agent as an API marketplace asset.

Typical steps:

  • create or select the API
  • create the API version with API type agt
  • create the agent definition for the same API version id
  • assign skills when the agent needs reusable behavior
  • review tools exposed through the assigned skills
  • configure role permissions before exposing the agent
  • link the agent API version to a runtime instance when deployment metadata is available

If the agent does not need reusable skills yet, skip the skill assignment step. The tool review step is only useful after skills are assigned, so it can remain optional while you continue to access control or runtime linking.

After all required steps are complete and the remaining optional steps are complete or skipped, use Complete Task on the task detail page. Completing the task clears its stored task context so it no longer appears in Recent Tasks.

The agent definition id is the API version id. This keeps the API catalog and GenAI agent profile as one logical asset instead of two separate identities.

Validate LLM Embeddings Through The Live Gateway

Use this workflow after authoring the LLM records in Portal. Validation is an ordinary, authenticated request to the selected live light-gateway replica; it is not a provider test performed by Portal.

The NVIDIA demo validates the public Aliases kb-query and kb-index, backed by nvidia/nemotron-3-embed-1b, against the declared 2048-dimensional embedding space.

What This Validation Does

The checked helper sends curl requests to the gateway’s Alias discovery and embeddings APIs. The gateway selects the active Route, resolves its provider credential locally, calls the provider, and returns bounded response metadata.

The validation:

  • does not ask Portal for a provider API key or gateway bearer token;
  • does not send provider credentials through helper arguments;
  • does not call a provider endpoint directly;
  • does not store a vector, raw provider body, or gateway credential;
  • does not mutate Portal authoring records, publications, Aliases, or Deployments; and
  • does not claim that every Deployment behind an Alias is healthy.

An Alias may succeed by using a fallback Route. A successful Alias probe proves only that one normal routed request completed with the expected embedding contract.

Operator Workflow

1. Publish the configuration

In the LLM Model Control Plane Publication tab, select the environment and gateway instance. Choose Generate from active records, review the preview, and choose Publish to instance. Create and promote the corresponding config snapshot, then restart or explicitly reload the selected gateway as required by your deployment.

Portal publication history confirms the configuration application to the selected instance. It is not proof that a particular running replica loaded the revision.

2. Confirm the selected gateway loaded the snapshot

Before sending a billable model request, restart the selected gateway or explicitly reload the llm-router module. Require a successful standard startup/reload result and verify that it reports the intended immutable config-snapshot version.

Stop if startup or reload fails. Alias discovery alone is not proof of the intended revision because an older snapshot can expose the same Alias. A failed reload leaves the previous last-known-good LLM runtime active.

3. Provision the provider key only on the gateway

Provision the NVIDIA credential through the selected gateway deployment’s protected environment or secret-injection mechanism, then restart or reload that replica. The published credential record contains only the external secret reference.

Do not enter the provider value into Portal, a helper argument, a request file, a ticket, or a validation report. For the local demo, follow the deployment instructions in all-in-lt/llm-gateway-rust/NVIDIA-DEMO.md from portal-config-loc.

4. Prepare the gateway client credential

Run the helper from a network location authorized to call the gateway. Create a protected curl header file containing the caller’s normal gateway bearer token:

set -euo pipefail
umask 077

gateway_header_file=$(mktemp)
trap 'rm -f -- "$gateway_header_file"' EXIT
read -rsp 'Gateway client bearer token: ' gateway_client_token
printf '\n'
printf 'Authorization: Bearer %s\n' "$gateway_client_token" >"$gateway_header_file"
unset gateway_client_token
chmod 600 "$gateway_header_file"

The token is not echoed and does not appear in curl’s process arguments. This is a gateway caller credential, not the NVIDIA provider credential.

5. Run the checked curl helper

From portal-config-loc/all-in-lt/llm-gateway-rust, validate the query lane:

validation/validate-embedding.sh \
  --gateway-url https://localhost:8444 \
  --alias kb-query \
  --header-file "$gateway_header_file" \
  --ca-file config/ca.pem \
  --expected-space-id nvidia-nemotron-3-embed-1b \
  --expected-space-revision 1 \
  --expected-dimension 2048 \
  --timeout-seconds 30

Validate the indexing lane independently by changing only the Alias:

validation/validate-embedding.sh \
  --gateway-url https://localhost:8444 \
  --alias kb-index \
  --header-file "$gateway_header_file" \
  --ca-file config/ca.pem \
  --expected-space-id nvidia-nemotron-3-embed-1b \
  --expected-space-revision 1 \
  --expected-dimension 2048 \
  --timeout-seconds 30

For a publicly trusted production gateway, omit --ca-file and use the system trust store. Never disable certificate verification or follow redirects. The gateway URL must be the selected gateway origin, not a provider URL.

A passing helper emits one bounded report:

{
  "schemaVersion": "lightapi.llm.embedding-validation/v1",
  "status": "pass",
  "category": "validated",
  "alias": "kb-query",
  "requestId": "example-request-id",
  "httpStatus": 200,
  "contract": {
    "expected": {"spaceId": "nvidia-nemotron-3-embed-1b", "spaceRevision": 1, "dimension": 2048},
    "actual": {"spaceId": "nvidia-nemotron-3-embed-1b", "spaceRevision": 1, "dimension": 2048}
  },
  "vectorCount": 1,
  "configGeneration": 1,
  "billedCostMicros": 0
}

The real request ID and billed cost vary. The helper never prints the vector. The embeddings request traverses normal gateway authentication, authorization, audit, quota, routing, and billing. Treat it as a normally audited and potentially billable provider request.

Optional Rust validator

When the light-gateway executable is available, its checked Rust wrapper can validate embeddings or a fixed, one-token generation request. It is a client of the already-running gateway; it does not load another snapshot or start another gateway server.

The embedding command uses the same protected header file and contract as the shell helper:

light-gateway validate-llm-live \
  --gateway-url https://localhost:8444 \
  --operation embeddings \
  --alias kb-query \
  --header-file "$gateway_header_file" \
  --ca-file config/ca.pem \
  --timeout-seconds 30 \
  --expected-space-id nvidia-nemotron-3-embed-1b \
  --expected-space-revision 1 \
  --expected-dimension 2048

To validate generation, select one public Alias and one supported operation:

light-gateway validate-llm-live \
  --gateway-url https://localhost:8444 \
  --operation chat-completions \
  --alias public-chat-alias \
  --header-file "$gateway_header_file" \
  --ca-file config/ca.pem \
  --timeout-seconds 30

light-gateway validate-llm-live \
  --gateway-url https://localhost:8444 \
  --operation responses \
  --alias public-responses-alias \
  --header-file "$gateway_header_file" \
  --ca-file config/ca.pem \
  --timeout-seconds 30

Each generation validation sends exactly one ordinary, non-streaming Alias request with the fixed text Reply with OK. and a one-token output limit. The command does not accept arbitrary model input and does not enumerate Routes, probe fallback Deployments, disconnect streams, test capacity, or call a provider directly.

JSON is the default output. Add --output text for a bounded rendering derived from the same report structure. Neither renderer includes generated text, embedding vectors, raw error bodies, or credentials. The request remains normally authorized, audited, quota-accounted, and potentially billable.

The Rust command is additive. The curl workflow and shell helper remain the portable, required operator interfaces.

6. Correct, republish, and repeat

Use the report’s category, requestId, and configGeneration to identify the corrective action below. Change the responsible Portal record or gateway-local state, publish a new revision when configuration changed, confirm the selected replica acknowledgement, and repeat the same probe.

Retain only the bounded report when operational policy requires evidence. Do not retain the bearer header file, raw response, provider response, API key, request body, or embedding values.

Troubleshooting

Condition or report categoryWhat it distinguishesCorrective action
Publication acknowledgement absent, PENDING, FAILED, or DIVERGENTPublication not applied to the selected replica. This is different from an Alias lookup failure.Correct snapshot promotion, replica selection, digest divergence, or reload failure. Wait for ACKNOWLEDGED before probing.
gatewayAuthorizationCaller denied by gateway policy (401 or 403); this is not provider authentication.Obtain a valid gateway caller token and confirm the caller is authorized for Alias discovery and /v1/embeddings. Use the request ID in gateway audit records.
aliasNotVisibleAlias missing from the active snapshot, although the replica is reachable.Confirm the Alias and at least one compatible Route are active in the target environment, republish, promote the snapshot, reload, and confirm acknowledgement.
gatewayError with gateway service_unavailableGateway credential materialization failure, configuration failure, audit failure, or endpoint availability failure.Use the request ID and sanitized gateway diagnostics to identify the unavailable subsystem. Verify that external secret references match credentials injected into the gateway, then restart or reload when appropriate. Never copy the value into Portal.
providerError after credential materializationProvider rejection, including an invalid/revoked credential, physical model, base URL, or provider protocol.Use the request ID and sanitized gateway diagnostics to identify the rejected setting. Correct the gateway credential or Portal endpoint/deployment record, republish if configuration changed, and retry.
providerRateLimitedProvider rate limit.Wait for the provider retry window or correct account quota/capacity. Retry without changing Portal records unless the configured account or Route must change.
providerTimeout or transportProvider/gateway timeout or network/TLS failure.Check gateway reachability, trusted CA, DNS, egress policy, provider availability, and configured timeouts. Keep TLS verification enabled.
embeddingContract with different space ID, revision, or dimensionEmbedding-space mismatch. Mixing these vectors would corrupt retrieval behavior.Stop indexing/querying. Align the Alias, Registration, Deployment, and knowledge-base embedding-space contract, republish, and create a new index generation when required.
embeddingContract with missing/malformed data or success headersInvalid provider response or incompatible adapter mapping.Verify provider protocol and physical model, inspect sanitized gateway diagnostics by request ID, correct the Endpoint or Deployment, republish, and retry.
gatewayErrorAnother bounded gateway failure that is not safely classified by the helper.Look up the request ID in gateway logs/audit data, correct the reported local or configuration condition, and retry. Do not attach the raw response to a ticket.
protectedHeaderFile, caFile, or localDependencyLocal validation setup is incomplete or unsafe.Install the named dependency or recreate the protected file with the documented ownership and mode. Do not weaken the file or TLS checks.

If an Alias has multiple Routes, validate each physical Deployment separately only through an explicitly authorized operational procedure that can control routing. Repeating this ordinary Alias probe cannot establish that every fallback Deployment works.

Manage Instance

Use this task to create, review, and connect service instances.

Typical steps:

  • create or review the instance
  • create or review runtime endpoints
  • link APIs to the instance
  • link apps to the instance
  • manage app API links and path prefixes

Manage Client App

Use this task to manage a client app and its OAuth clients.

Typical steps:

  • create or review the client app
  • create or review OAuth clients
  • link the app to an instance
  • create or review client tokens

Manage Workflow

Use this task to create and operate workflow definitions.

Typical steps:

  • create or review workflow definitions
  • start a workflow
  • review process instances, tasks, assignments, worklists, and audit logs

Configs

Use config help pages to understand runtime configuration properties managed through portal-view and the config server.

Common config areas:

  • access control
  • WebSocket routing and connection controls
  • logging filter
  • handler chains and paths

See WebSocket Router Configuration for the properties loaded from light-gateway/config/websocket-router.yml.

See MCP Router Configuration for the MCP endpoint, protocol profile, schema budget, resource limit, cache, and tool properties loaded from light-gateway/config/mcp-router.yml.

Access Control

The Access Control configuration defines the global policies for request authorization and response filtering in the gateway. The configuration resides in access-control.yml and is managed through the portal-view interface and config server.

The gateway uses a shared access-control runtime (implemented in light-pingora) that applies to both HTTP API access control and MCP router access control.

Overview of Configuration Properties

PropertyTypeDefaultDescription
enabledBooleanfalseGlobal switch to enable or disable access-control checking and response filtering.
accessRuleLogicString"any"Rule execution logic (any or all) when multiple req-acc rules are matched.
defaultDenyBooleantrueFallback policy when no authorization rules are defined for a requested endpoint.
defaultIncludeBooleanfalseFallback policy for response row filtering when a user’s claims do not match any rules.
skipPathPrefixesArray of String[]List of path or tool name prefixes that bypass access control checking and filtering.
claimMappingsMap of String to Array of String{}Maps permission dimensions such as roles or tenant to JWT claim names.

HTTP API Access Control vs. MCP Router Access Control

The properties configured in access-control.yml affect HTTP API traffic and Model Context Protocol (MCP) tools in complementary ways.

1. HTTP API Access Control

For regular HTTP API traffic, the access control runtime operates in the gateway handler chain:

  • Request Authorization (req-acc): Evaluates Celsius (CEL) expressions and role-based policies before forwarding the request to downstream services.
  • Response Filtering (res-fil): Modifies the downstream HTTP response (filtering out unauthorized JSON fields/columns or rows) before returning the response to the client.

2. MCP Router Access Control

For MCP traffic, the router leverages the same runtime but adapts the phases specifically for JSON-RPC tool calls (tools/call):

  • Request Authorization (req-acc): Runs prior to invoking the downstream HTTP or local MCP tool. If authorized, the tool is called.
  • Response Filtering (res-fil): Evaluates row and column filters on the JSON payload contained within the MCP result (structuredContent and text content) before delivering it back to the AI agent.
  • System Operations: Standard MCP lifecycle requests (e.g., initialize, tools/list) bypass access control and are handled directly by the router.

Access Control: Enabled

The enabled property acts as the master switch for the access control runtime within the gateway.

Configuration Options

enabled: true
  • true: The access control system is active. Both request authorization and response filtering are enforced.
  • false: The access control system is bypassed. All incoming requests and downstream responses are permitted to pass through without evaluation.

Behavior Separation

HTTP API Access Control

When set to false, the HTTP handler chain bypasses request authorization (req-acc) and response filtering (res-fil) checks. Requests are forwarded directly to downstream microservices, and responses are returned unfiltered.

MCP Router Access Control

When set to false, the MCP router bypasses security checks for tool calls (tools/call):

  • AI agents can invoke any configured MCP tool without req-acc checks.
  • Response payloads from downstream tools are returned to the agent without res-fil filtering.

Note

Even if enabled is set to false, access rules defined in rule.yml remain loaded in memory, allowing them to take effect immediately when access control is re-enabled or reloaded.

Access Control: Access Rule Logic

The accessRuleLogic property determines the logic applied when evaluating multiple request authorization (req-acc) rules for a single endpoint.

Configuration Options

accessRuleLogic: any
  • any (Default): At least one matching request authorization rule must evaluate to true (logical OR). If any matching rule allows the request, access is granted.
  • all: Every matching request authorization rule must evaluate to true (logical AND). If any rule fails, access is denied.

Behavior Separation

HTTP API Access Control

Applies when an HTTP API endpoint maps to multiple req-acc rules in rule.yml.

  • Under any logic, the gateway permits the request if any matched CEL or role condition succeeds.
  • Under all logic, every matched rule is executed, and all must succeed for the gateway to forward the request to the backend.

MCP Router Access Control

Applies when an MCP tool endpoint (derived from the tool name and method, e.g. accounts@call) matches multiple req-acc rules.

  • Under any logic, a tool call is authorized as long as one rule allows it.
  • Under all logic, a tool call is authorized only if all matching rules allow it.

Access Control: Default Deny

The defaultDeny property defines the fallback authorization policy for endpoints that do not have any explicitly configured rules in rule.yml.

Configuration Options

defaultDeny: true
  • true (Default): The gateway fails closed. If a request is received for an endpoint with no mapping in rule.endpointRules, or no rules listed under req-acc, the request is denied.
  • false: The gateway fails open. If an endpoint does not have explicit authorization rules, access is allowed by default.

Behavior Separation

HTTP API Access Control

  • defaultDeny: true: Every HTTP endpoint exposed by the gateway must have a matching entry in rule.yml with at least one passing req-acc rule. Unconfigured endpoints will return a 403 Forbidden response.
  • defaultDeny: false: Only HTTP endpoints explicitly configured with req-acc rules in rule.yml are guarded. Unlisted API endpoints are publicly accessible without authentication/authorization checks.

MCP Router Access Control

  • defaultDeny: true: Every MCP tool must map to an endpoint key (e.g. accounts@call) in rule.endpointRules with a defined request access policy. If no policy is found, the tool call is blocked, returning a JSON-RPC authorization error to the calling AI agent.
  • defaultDeny: false: The MCP router permits tool calls for any tools that do not have explicit rules configured in rule.yml. This is useful in staging or development environments where you want to expose new MCP tools without writing policy rules for every endpoint.

Access Control: Default Include

The defaultInclude property determines the row-filtering fallback behavior when a user’s request context does not match any role-based row filter conditions in the response filtering phase.

Configuration Options

defaultInclude: false
  • true: The gateway retains all rows by default. If the user’s claims do not match any configured role conditions, the response data is returned unfiltered (retaining all rows).
  • false: The gateway filters out all rows by default. If the user’s claims do not match any configured role conditions, the response payload returns empty (no rows).

Behavior Separation

HTTP API Access Control

When the response filtering (res-fil) phase invokes a row-filter action:

  • If the user’s roles or attributes do not match any role definition in the filter, defaultInclude: false cleanses the JSON array completely, returning an empty list [] to the client.
  • If defaultInclude: true is configured, the gateway retains the original list of rows, logging a warning about the fallback bypass.

MCP Router Access Control

When response filtering (res-fil) is applied to an MCP tool result:

  • The JSON payload inside the tool response is filtered.
  • If the agent/caller claims do not match the row-filter role specifications, defaultInclude: false empties the rows in the structured content.
  • If defaultInclude: true, the MCP tool results remain intact and are delivered to the agent without row filtration.

Access Control: Skip Path Prefixes

The skipPathPrefixes property specifies a list of path or tool name prefixes that bypass access control checking and filtering entirely.

Configuration Options

skipPathPrefixes:
  - /api/v1/public
  - local_mcp

Behavior Separation

HTTP API Access Control

For HTTP API traffic, the gateway compares the request URI path against the configured prefixes:

  • If the request path starts with one of the prefixes in skipPathPrefixes (e.g., /api/v1/public), the request bypasses the request authorization (req-acc) phase and the response bypasses response filtering (res-fil).
  • This is commonly used for health check endpoints, public documentation, or unauthenticated assets.

MCP Router Access Control

For MCP traffic, the router applies prefix matching in two distinct ways:

  1. Tool Name Prefix: If the invoked tool name starts with a prefix listed in skipPathPrefixes (e.g. local_mcp matching a tool named local_mcp_echo), the tool call bypasses req-acc and the results bypass res-fil response filtering.
  2. Endpoint Key Prefix: If the target endpoint key derived for the tool call starts with a configured prefix (e.g. accounts matching accounts@call), the tool call and response also bypass all access control enforcement.

Access Control: Claim Mappings

The claimMappings property maps access-control permission dimensions to the JWT claim names used by your identity provider.

  • Type: Map of permission dimension to an array of claim names
  • Default: {}
  • Config-server property prefix: access-control.claimMappings
claimMappings:
  roles:
    - realm_roles
    - application_roles
  groups:
    - member_of
  tenant:
    - tenant_id

In config-server values.yml, set individual map entries with fully qualified property names:

access-control.claimMappings.roles: [realm_roles, application_roles]
access-control.claimMappings.groups: [member_of]
access-control.claimMappings.tenant: [tenant_id]

What It Is Designed For

The mapping allows policies to keep stable permission keys even when tokens use deployment-specific claim names. It is shared by:

  • role-based request authorization (req-acc);
  • row and column response filters (res-fil); and
  • MCP tools/list visibility evaluation.

The standard permission keys and their built-in claim aliases are:

Permission keyBuilt-in JWT claim aliases
rolesrole, roles
groupsscp, grp, group, groups
positionspos, position, positions
attributesatt, attribute, attributes
usersuid, user_id, sub

If a key has a non-empty configured mapping, its configured claim names replace the built-in aliases for that key. Unmapped standard keys continue to use their built-in aliases. When multiple claim names are listed, values from all present claims are combined for matching.

Custom row or column dimensions are also supported. For example, a tenant mapping makes the tenant_id claim available to row.tenant and col.tenant policy entries. For an unmapped custom dimension, the runtime looks for a claim with the same name as the dimension.

Setup

  1. Inspect the verified JWT claims produced by the deployment’s identity provider.
  2. Add a mapping only where the token’s claim name differs from the built-in alias or where the policy uses a custom dimension.
  3. Use plural keys (roles, groups, positions, attributes, and users) in claimMappings, even though row and column policy objects use singular dimension names such as row.role and col.group.
  4. Reload the access-control configuration and test both a matching and a non-matching caller for each affected authorization or filter rule.

The older toolsListAccessControl.claimMappings location remains a compatibility fallback. Top-level claimMappings takes precedence for the same key and is the recommended location because it applies consistently across authorization, response filtering, and MCP tool visibility.

Logging Filter

Use logging.filter to control Rust runtime logging for light-gateway and other light-fabric services from config server values.yml.

The value uses the Rust tracing filter syntax. Set a default level first, then add more specific module targets when you need detailed logs for one area.

Example:

logging.filter: info,light_pingora::security=debug

This keeps the service at info level overall and enables debug logs only for the light_pingora::security target. This is useful when debugging JWT verification failures without turning on debug logs for HTTP clients, TLS, and every gateway request.

Log Levels

Supported levels, from least to most verbose:

LevelUse
errorOnly failures that require attention.
warnWarnings and errors.
infoNormal operational events. This is the recommended default.
debugDiagnostic details for troubleshooting.
traceVery detailed execution flow. Use for short troubleshooting windows only.

off can be used for a specific noisy target when you want to suppress it.

Filter Syntax

Common patterns:

# Default info for all targets.
logging.filter: info

# Debug only JWT/security logic.
logging.filter: info,light_pingora::security=debug

# Trace unified-security routing while keeping the rest at info.
logging.filter: info,light_pingora::unified_security=trace

# Debug MCP request handling.
logging.filter: info,light_pingora::mcp=debug

# Debug config loading and runtime reloads.
logging.filter: info,light_runtime=debug

# Reduce noisy dependency logs while debugging gateway code.
logging.filter: info,light_pingora::security=debug,reqwest=warn,hyper_util=warn,rustls=warn

Rules:

  • Separate directives with commas.
  • The first bare level, such as info, is the default for all targets.
  • Use target=level for a specific crate or module.
  • More specific targets override broader targets.
  • Target names use Rust module paths, such as light_pingora::security.

Common Gateway Targets

These targets are useful for light-gateway troubleshooting:

TargetWhat it covers
light_gatewayGateway application code and proxy handling.
light_pingoraShared Pingora framework code.
light_pingora::securityJWT verification, JWK loading, issuer and audience checks.
light_pingora::unified_securityUnified security handler routing across JWT, SJWT, Basic Auth, and API key.
light_pingora::mcpMCP routing, backend MCP calls, and MCP response diagnostics.
light_pingora::handlerHandler duration reporting when handler timing is enabled.
light_pingora::pii_tokenizationPII tokenization and detokenization runtime warnings.
light_runtimeRuntime bootstrap, config loading, module registry, config reload, and controller registration.
light_clientHTTP client configuration and OAuth client support.
portal_registryControl-plane websocket registration and registry client behavior.
reqwestOutbound HTTP client internals.
hyper_utilLower-level HTTP client connection and pooling logs.
rustlsTLS handshake and certificate details.
pingora_corePingora server lifecycle, listeners, and protocol logs.
pingora_proxyPingora proxy request handling.
tungsteniteWebSocket handshake and frame-level support used by registry connections.

Use the narrowest target that contains the evidence you need. For example, prefer info,light_pingora::security=debug over plain debug when investigating JWT verification.

Reload Behavior

logging.filter is reloadable. If the control plane reloads all modules, the runtime logging module reloads the filter from the latest config server values. If logging.filter is not present in values.yml, an all-module reload can return the process to the default filter.

To keep a debug filter across reloads, store it in config server values.yml:

logging.filter: info,light_pingora::security=debug

Then reload the runtime configuration from the control plane. The new filter applies without restarting the gateway.

Recommendations

  • Keep the default at info in shared environments.
  • Add debug or trace only for the module under investigation.
  • Remove short-term debug or trace overrides after the issue is resolved.
  • Avoid logging full tokens, secrets, request bodies, or response bodies unless the target log point is known to mask sensitive data.

MSAL Auth: Cookie SameSite

The cookieSameSite property in the msal-auth (and msal-exchange / stateless-auth) configuration maps directly to the SameSite attribute in HTTP Set-Cookie headers. It controls whether the browser should send session cookies (such as accessToken and csrf) along with cross-site requests. This is a foundational browser security mechanism designed to protect against Cross-Site Request Forgery (CSRF) and govern cross-origin tracking.

Configuration Options

You can configure this property in your handler’s configuration file (e.g., msal-auth.yml):

cookieSameSite: None

The gateway maps this property directly to the standard HTTP options (case-sensitive as None, Lax, or Strict):

  • None: The browser sends the cookie with both cross-site and same-site requests.
    • Requirement: Modern browsers mandate that if SameSite=None, the cookie must also be marked as Secure (meaning cookieSecure: true). If you set None with Secure: false, browsers like Chrome and Edge will silently block the cookie.
  • Lax: The cookie is not sent on cross-site API requests (e.g., AJAX/Fetch), except for top-level navigations (like a user clicking a standard link to your site from another site). This is the default behavior of modern browsers if the SameSite attribute is missing entirely.
  • Strict: The cookie is sent only if the request originates from the exact same site that set the cookie. Cross-site requests will never include the cookie.

Why Default to None?

In modern microservice and Single Page Application (SPA) architectures, the frontend UI and the backend API Gateway are frequently hosted on different origins, especially during development.

For example:

  • Frontend SPA: http://localhost:3000 (Local React/Angular dev server)
  • Backend Gateway: https://api.dev.mycompany.com (or https://localhost:8443)

Because the ports and/or domains don’t match, the browser considers API requests between them as “cross-site”. If cookieSameSite defaulted to Lax or Strict, the browser would refuse to send the authentication cookies when the local UI calls the backend API, leading to immediate 401 Unauthorized errors out of the box.

Defaulting to None provides a seamless developer experience for decoupled SPAs. To safely allow None, light-fabric pairs this behavior with robust Double Submit Cookie CSRF protections (requiring the X-CSRF-TOKEN header). This ensures that even though the browser attaches the cookie cross-origin, an attacker cannot successfully forge a state-changing request because they cannot read or supply the necessary CSRF header token.

MSAL Auth: Enabled

The enabled property controls whether the msal-auth handler is active and processing requests in the gateway.

Configuration Options

enabled: true
  • true: The handler is fully active. It will intercept requests to the loginPath and logoutPath, validate sessions on protected routes, and enforce CSRF protections.
  • false: The handler is effectively disabled. Even if it is listed in the execution chain in handler.yml, it will immediately yield control to the next handler without performing any authentication checks or modifications.

Usage

This toggle is extremely useful for temporarily bypassing authentication in local development or test environments without having to re-write the entire handler.yml routing chain.

MSAL Auth: Login Path

The loginPath property specifies the endpoint where the Single Page Application (SPA) submits a Microsoft Entra ID token to establish a gateway session.

Configuration Options

loginPath: /auth/ms/login

Usage

When the msal-auth handler receives a POST matching this exact path:

  1. It expects a valid Microsoft Entra ID token in the Authorization: Bearer header.
  2. It validates the token using the security-msal.yml configuration.
  3. If valid, it generates a fresh CSRF token and responds with the accessToken and csrf cookies using Set-Cookie headers.

The request body is optional. A zero-length request is accepted even when a shared client sets Content-Type: application/json.

This path must also be mapped in handler.yml to trigger the msal-auth handler.

chains:
  bff:
    - cors
    - msal-auth

paths:
  - path: /auth/ms/login
    method: POST
    exec:
      - bff
  - path: /auth/ms/login
    method: OPTIONS
    exec:
      - bff

Keep OPTIONS routed through a chain with CORS before msal-auth.

MSAL Auth: Logout Path

The logoutPath property specifies the endpoint where the Single Page Application (SPA) can explicitly terminate an active session.

Configuration Options

logoutPath: /auth/ms/logout

Usage

When the msal-auth handler receives a credentialed POST matching this exact path, it handles session termination by clearing the session cookies. Send the readable csrf cookie value as X-CSRF-TOKEN when logout CSRF enforcement is enabled. No request body is required; a zero-length body is also accepted with Content-Type: application/json.

On success it returns 204 No Content with no body or response content type. It also returns deletion Set-Cookie headers for accessToken and csrf, the complete cookie set owned by this runtime.

This path must also be mapped in handler.yml to trigger the msal-auth handler.

chains:
  bff:
    - cors
    - msal-auth

paths:
  - path: /auth/ms/logout
    method: POST
    exec:
      - bff
  # Keep permanently so preflight reaches CORS before the auth handler.
  - path: /auth/ms/logout
    method: OPTIONS
    exec:
      - bff

Logout is POST-only. A legacy GET or another unsupported method returns 405, ERR10008, and Allow: POST before cookie deletion. The OPTIONS route remains.

MSAL Auth: Cookie Domain

The cookieDomain property controls the Domain attribute applied to all Set-Cookie headers generated by the handler.

Configuration Options

cookieDomain: localhost

or

cookieDomain: .mycompany.com

Usage

The Domain attribute tells the browser which hosts are allowed to receive the cookie.

  • If you specify a host without a leading dot (e.g., localhost or api.mycompany.com), the browser will only send the cookie to that exact domain.
  • If you specify a domain with a leading dot (e.g., .mycompany.com), the browser will send the cookie to that domain and all of its subdomains (e.g., app.mycompany.com, admin.mycompany.com).

Note: If the domain is misconfigured or doesn’t match the URL you are using to access the gateway, the browser will refuse to save the cookie entirely.

MSAL Auth: Cookie Path

The cookiePath property controls the Path attribute applied to all Set-Cookie headers generated by the handler.

Configuration Options

cookiePath: /

Usage

The Path attribute dictates the URL paths for which the cookie is valid. The browser will only send the cookie if the request URL matches or is a subdirectory of this path.

In most Single Page Application (SPA) configurations, this should be set to /. Setting it to / ensures that the accessToken and csrf cookies are sent on every API request directed at the gateway, regardless of the API’s specific path (e.g., /api/v1/users, /v2/data).

If you run multiple distinct applications behind the same domain and want to isolate their cookies by route, you can specify a narrower path (e.g., /my-app/).

MSAL Auth: Cookie Secure

The cookieSecure property maps to the Secure attribute on the Set-Cookie headers generated by the gateway.

Configuration Options

cookieSecure: false

or

cookieSecure: true

Usage

When cookieSecure is set to true, the browser will only transmit the cookie over a secure, encrypted connection (HTTPS). It will flatly refuse to send the cookie over plain HTTP.

  • Development: You typically set this to false when developing locally over http://localhost.
  • Production: You must set this to true in production to prevent cookies from being intercepted by network eavesdroppers.
  • SameSite Dependency: If you configure cookieSameSite: None (which allows cross-origin requests), modern browsers require cookieSecure to be true. If cookieSameSite: None is paired with cookieSecure: false, browsers like Chrome and Edge will reject the cookie outright.

MSAL Auth: Session Timeout

The sessionTimeout property specifies the default expiration time (in seconds) for the session cookies, if the provided Microsoft Entra ID token lacks an explicit exp claim.

Configuration Options

sessionTimeout: 3600

Usage

When a user logs in via the /auth/ms/login endpoint, the gateway parses the Microsoft Entra ID token and looks for the exp (expiration) claim.

  • If the token contains a valid exp claim, the cookies are set to expire exactly when the Entra ID token expires.
  • If the token lacks an exp claim, the sessionTimeout value is used as a fallback to calculate the expiration duration.

When the cookies expire, the browser will stop sending them. To maintain uninterrupted access, the SPA is responsible for silently refreshing the Entra ID token via MSAL.js and calling /auth/ms/login again before the cookies expire.

Handler Config

Use the handler config to define which handlers are available, how handler chains are composed, and which chain runs for each request path.

Common properties:

  • handlers: handler aliases enabled for this gateway
  • chains: named handler chains
  • paths: request path and method mappings
  • defaultHandlers: fallback chain when no path entry matches

Handler Path

Use handler paths to select the handler chain for an incoming gateway request.

Each path entry matches a request by HTTP method and path. The exec list names the chain or handlers to run.

Supported path patterns:

  • exact path, such as /customers
  • path template, such as /customers/{customerId}
  • trailing wildcard, such as /customers/* or /*

Examples:

paths:
  - path: /customers/{customerId}
    method: GET
    exec:
      - apiChain
  - path: /customers/*
    method: GET
    exec:
      - apiChain
  - path: /*
    method: POST
    exec:
      - apiChain

Important behavior:

  • /customers matches only /customers
  • /customers/{customerId} matches one segment after /customers
  • /customers/* matches /customers and any deeper path under /customers
  • /* matches any path for the configured method

For sidecar API proxy routes, point the matching API methods to the API proxy chain.

WebSocket Router Configuration

The WebSocket router selects an upstream service for an HTTP/1.1 WebSocket upgrade and lets light-gateway proxy the upgraded byte stream. Its settings reside in websocket-router.yml and can be supplied through portal-view and config server properties named websocket-router.<property>.

The router is active only when the selected handler.yml chain contains the websocket handler. There is intentionally no enabled property in the Rust configuration. websocket-router.yaml is accepted as a compatibility fallback, but websocket-router.yml is preferred.

Properties

PropertyTypeDefaultDescription
defaultProtocolStringhttpDefault discovery protocol for targets that do not specify one.
defaultEnvTagString or nullunsetDefault discovery environment tag.
pathPrefixServiceObject{}Maps request-path prefixes to service discovery targets.
originAllowlistObject{}Lists browser origins allowed to open protected WebSocket paths.
applicationProtocolsObject{}Lists application WebSocket subprotocols allowed for protected paths.
preserveRoutingHeadersBooleanfalsePreserves service-id routing headers when forwarding upstream.
idleTimeoutMsInteger3600000Closes tunnels that have no traffic in either direction for this duration.
maxConnectionDurationMsInteger or nulldisabledCaps the total lifetime of each WebSocket tunnel.
maxActiveConnectionsInteger or nulldisabledCaps active WebSocket connections in one gateway process.
maxUpgradeRequestsPerSecondInteger or nulldisabledCaps WebSocket upgrade attempts per second in one gateway process.

Complete example

defaultProtocol: https
defaultEnvTag: dev
pathPrefixService:
  /ctrl/mcp:
    serviceId: com.networknt.controller-1.0.0
    protocol: https
    envTag: dev
originAllowlist:
  /ctrl/mcp:
    - https://local.localhost
    - https://localhost:3000
applicationProtocols:
  /ctrl/mcp: []
preserveRoutingHeaders: false
idleTimeoutMs: 3600000
maxConnectionDurationMs: 900000
maxActiveConnections: 1024
maxUpgradeRequestsPerSecond: 128

Configuration is validated at startup and reload. An invalid protocol, origin, path target, or application subprotocol rejects the candidate configuration. On a failed reload, the last valid runtime remains active. A successful reload preserves the process-level connection and rate-limit counters, and does not terminate tunnels that are already upgraded.

WebSocket Router: defaultProtocol

defaultProtocol is the discovery protocol used when a selected pathPrefixService entry does not define its own protocol.

  • Type: String
  • Default: http
  • Allowed values: http, https

The value is trimmed and converted to lowercase during configuration loading. Any other value, including ws, wss, or ftp, rejects the configuration. Use http for a non-TLS upstream and https for a TLS upstream; the gateway still performs the WebSocket upgrade after connecting with that HTTP protocol.

defaultProtocol: https
pathPrefixService:
  /events: com.networknt.events-1.0.0

The protocol field on a path target overrides this default. A non-blank protocol query parameter on the request overrides both and is subject to the same http/https validation.

WebSocket Router: defaultEnvTag

defaultEnvTag supplies the service-discovery environment tag when a selected pathPrefixService target does not define envTag.

  • Type: String or null
  • Default: unset

Blank values are normalized to unset. A target-level envTag overrides the default, and a non-blank env_tag or envTag query parameter overrides both. The resolved value is passed to registry discovery together with the service id and protocol.

defaultEnvTag: dev
pathPrefixService:
  /chat: com.networknt.chat-1.0.0
  /events:
    serviceId: com.networknt.events-1.0.0
    envTag: sit

In this example, /chat discovers the dev instance while /events discovers the sit instance.

WebSocket Router: pathPrefixService

pathPrefixService maps request paths to service-discovery targets.

  • Type: Map of path prefix to service id or target object
  • Default: {}
pathPrefixService:
  /chat: com.networknt.chat-1.0.0
  /ctrl/mcp:
    serviceId: com.networknt.controller-1.0.0
    protocol: https
    envTag: dev

A target object supports:

FieldRequiredBehavior
serviceIdYesNon-blank service identifier used for discovery. service_id is also accepted.
protocolNohttp or https; inherits defaultProtocol when omitted.
envTagNoDiscovery environment; inherits defaultEnvTag when omitted. env_tag is also accepted.

Prefixes are trimmed, given a leading / when missing, and have trailing / characters removed. Matching respects path-segment boundaries, so /chat matches /chat and /chat/room, but not /chatty. When several prefixes match, the longest prefix wins. / acts as a catch-all.

Target selection precedence is:

  1. First non-blank Service-Id, service_id, or serviceId request header.
  2. First non-blank service_id or serviceId query parameter.
  3. Longest pathPrefixService match.

Routing query parameters (protocol, service-id variants, environment-tag variants, and csrf) are removed before the upstream request is sent. The configuration also accepts a JSON/YAML map string for config-server injection and the legacy prefix=serviceId&prefix=serviceId string, but the object form is recommended because it can express protocol and environment explicitly.

If no header, query value, or prefix provides a target, the gateway rejects the upgrade with HTTP 403. Discovery failure or no usable endpoint returns 502. For /ctrl/mcp, configure the separate /ctrl/mcp@connect access-control rule as well as this route.

WebSocket Router: originAllowlist

originAllowlist controls which browser origins may establish protected WebSocket connections. It is a WebSocket handshake control and is separate from cors.allowedOrigins.

  • Type: Map of path to an array of origins
  • Default: {} (deny for paths that require an origin allowlist)
originAllowlist:
  /ctrl/mcp:
    - https://local.localhost
    - http://localhost:3000
    - https://localhost:3000

The current browser control-plane handshake performs an exact lookup for /ctrl/mcp. The map must therefore contain that exact normalized path and a non-empty list. Missing Origin, a missing/empty list, or an unlisted origin returns HTTP 403 before CSRF, access-control, or upstream discovery checks.

Each entry must be an absolute http or https origin containing only scheme, host, and optional port. Paths, queries, fragments, credentials, null, and wildcards are rejected. Comparison uses the normalized origin and remains scheme- and port-sensitive; for example, http://localhost:3000 does not allow https://localhost:3000.

When the property is entered as one config-server value, use a JSON object:

{"/ctrl/mcp":["https://local.localhost","https://localhost:3000"]}

WebSocket Router: applicationProtocols

applicationProtocols lists optional WebSocket application subprotocols that clients may offer for a protected path.

  • Type: Map of path to an array of strings
  • Default: {}
applicationProtocols:
  /ctrl/mcp:
    - mcp.v1

For /ctrl/mcp, the browser must always offer the gateway-generated CSRF subprotocol (csrf.<token>). That CSRF value is validated separately and must not be configured here. An empty list means that only the required CSRF subprotocol is accepted.

Configured values must be valid HTTP token strings, must not start with csrf., and are de-duplicated at load time. If the client offers any non-CSRF protocol that is not in the path’s list, the gateway rejects the handshake with HTTP 400 instead of silently ignoring it.

Only allowed application protocols are forwarded upstream; the CSRF protocol is consumed by the gateway. If the upstream selects a protocol that was not offered and allowed, the gateway treats the upstream handshake as invalid and returns 502.

WebSocket Router: preserveRoutingHeaders

preserveRoutingHeaders controls whether service-id routing headers are sent to the upstream service after the gateway has selected a target.

  • Type: Boolean
  • Default: false

When false, the gateway removes these headers from the upstream request:

  • Service-Id
  • service_id
  • serviceId
preserveRoutingHeaders: false

Keep the default unless the upstream application explicitly consumes one of these headers. Setting the property to true exposes client-supplied routing metadata to that application. It does not preserve browser credentials for the control-plane route: /ctrl/mcp applies an additional credential-sanitization step and installs only the trusted authorization created by the gateway.

WebSocket Router: idleTimeoutMs

idleTimeoutMs is the maximum time a WebSocket tunnel may have no payload traffic in either direction.

  • Type: Positive integer, in milliseconds
  • Default: 3600000 (one hour)
  • Disabled by: blank, null, or 0
idleTimeoutMs: 300000

The activity timestamp starts when the upstream connection is established and is refreshed by non-empty traffic from either client or upstream. When the elapsed idle time exceeds the limit, the gateway terminates the tunnel with a timeout error.

This property also helps determine the underlying tunnel I/O timeout. When maxConnectionDurationMs is configured too, the gateway uses the smaller of the two values for I/O timeout scheduling, while enforcing both semantics independently.

WebSocket Router: maxConnectionDurationMs

maxConnectionDurationMs limits the total lifetime of a WebSocket tunnel, regardless of activity.

  • Type: Positive integer, in milliseconds
  • Default: disabled
  • Disabled by: blank, null, or 0
maxConnectionDurationMs: 900000

The timer starts when the upstream connection is established. Once the limit is exceeded, the gateway terminates the tunnel with a timeout error even if data is still flowing. This is useful for forcing periodic reauthentication and limiting the lifetime of credentials captured during the original upgrade.

For authenticated connections, choose a value no longer than the intended authorization lifetime. Existing tunnels retain the limits selected when they were upgraded; a configuration reload does not disconnect them immediately.

WebSocket Router: maxActiveConnections

maxActiveConnections caps the number of active WebSocket connections held by one light-gateway process.

  • Type: Positive integer
  • Default: disabled
  • Disabled by: blank, null, or 0
maxActiveConnections: 1024

The limit is process-wide across routes handled by this WebSocket router; it is not per path, user, client IP, or gateway cluster. Each gateway replica keeps its own counter. A permit is acquired after routing, authorization, rate-limit, and upstream target selection, and is released when that request context ends.

When the process is at the limit, a new upgrade is rejected with HTTP 503. Use maxUpgradeRequestsPerSecond as the complementary control for bursts of upgrade attempts that may fail before becoming active connections.

WebSocket Router: maxUpgradeRequestsPerSecond

maxUpgradeRequestsPerSecond limits accepted WebSocket upgrade attempts before the gateway selects and connects to an upstream target.

  • Type: Positive integer
  • Default: disabled
  • Disabled by: blank, null, or 0
maxUpgradeRequestsPerSecond: 128

The implementation uses a fixed one-second counter shared by the WebSocket router runtime in one gateway process. It is not per path, user, client IP, or cluster, and each gateway replica enforces its own limit. The counter state is preserved across successful configuration reloads.

When the limit has already been reached for the current second, the gateway rejects the request with HTTP 429. Requests rejected earlier by handshake or access-control validation do not consume this counter because those checks run first.

MCP Router Configuration

The MCP router exposes configured HTTP and MCP backend operations as an MCP tool facade. Its settings reside in mcp-router.yml and are supplied through portal-view/config server properties named mcp-router.<property>.

The selected handler.yml chain must contain the mcp handler. The top-level enabled property must also be true; either control can disable the router. mcp-router.yaml is accepted as a compatibility fallback, but mcp-router.yml is preferred.

Core properties

PropertyTypeDefaultDescription
enabledBooleantrueEnables the router; the same page explains both protocol-profile flags.
pathString/mcpExact HTTP path handled by the MCP router.
maxSessionsInteger10000Process-wide legacy frontend session limit.
maxSessionsPerClientInteger100Legacy session limit for one authenticated or anonymous binding.
maxRequestBodyBytesInteger1048576Maximum MCP request body size.
maxResponseBodyBytesInteger4194304Maximum buffered MCP/backend response size.
maxJsonDepthInteger128Maximum nesting depth of an MCP JSON-RPC request.
originAllowlistArray[]Exact browser origins allowed to call the MCP endpoint.
toolsArray[]Tool catalog, target, schema, credential, and runtime metadata.

Schema properties

PropertyTypeDefaultDescription
schema.defaultDialectStringDraft 2020-12 URIRequired JSON Schema dialect.
schema.allowExternalRefsBooleanfalseControls external $ref; currently must remain false.
schema.maxSchemaBytesInteger1048576Maximum serialized size of each input/output schema.
schema.maxDepthInteger64Maximum schema-document nesting depth.
schema.maxSubschemasInteger4096Maximum object/subschema count per schema.
schema.maxConcurrentValidationsInteger32Process-wide schema validation admission capacity.
schema.validationWatchdogMsInteger50Observational warning threshold for validation work.

Protocol properties

protocols.legacy and protocols.stateless share the enabled and versions pages because portal-view property links use the final property-name segment.

PropertyTypeDefaultDescription
protocols.legacy.enabledBooleantrueKeeps the session-oriented profile available.
protocols.legacy.versionsArrayFour supported versionsAccepted legacy protocol versions.
protocols.stateless.enabledBooleantrue in the shipped templateEnables the 2026-07-28 stateless profile.
protocols.stateless.versionsArray[2026-07-28]Accepted stateless protocol versions.
protocols.stateless.discoverTtlMsInteger30000server/discover cache lifetime.
protocols.stateless.discoverCacheScopeStringprivateIdentity-aware discovery cache scope.
protocols.stateless.toolsListTtlMsInteger30000tools/list cache lifetime.
protocols.stateless.toolsListCacheScopeStringprivateIdentity-aware tool-list cache scope.
protocols.stateless.maxDiscoverCacheEntriesInteger1024Process-local discovery cache capacity.
protocols.stateless.maxToolsListCacheEntriesInteger4096Process-local tool-list cache capacity.
protocols.stateless.maxToolsListItemsInteger1024Maximum visible tools in one result.
protocols.stateless.maxConcurrentRequestsInteger1024Process-wide stateless request capacity.
protocols.stateless.maxConcurrentRequestsPerPrincipalInteger32Concurrent stateless requests per principal.
protocols.stateless.maxConcurrentBackendCallsPerTargetInteger32Concurrent stateless backend calls per target.
protocols.stateless.maxSubscriptionsInteger10000Process-wide stateless subscription limit.
protocols.stateless.maxSubscriptionsPerPrincipalInteger4Subscription limit per principal.
protocols.stateless.maxSubscriptionDurationMsInteger900000Maximum subscription lifetime.
protocols.stateless.statelessToLegacyBridgeStringrejectRejects stateless calls to legacy MCP backends.

Every numeric limit must be greater than zero. Configuration is validated at startup and reload; invalid profiles, schemas, origins, limits, or tool targets reject the candidate runtime. A successful reload compatibility-filters legacy sessions, invalidates revision-bound caches, and closes superseded stateless subscriptions, publishing tools/list_changed when the tool catalog or policy changed.

MCP Router: enabled

Three enabled properties control different layers:

Full propertyShipped defaultBehavior
mcp-router.enabledtrueBuilds and registers the MCP runtime. When false, the mcp handler does not match requests.
mcp-router.protocols.legacy.enabledtrueEnables session-oriented MCP. It must remain true in the current dual-profile release.
mcp-router.protocols.stateless.enabledtrueEnables the 2026-07-28 sessionless profile.
enabled: true
protocols:
  legacy:
    enabled: true
  stateless:
    enabled: true

The mcp handler must also appear in the applicable handler.yml chain. A false top-level flag or an inactive handler disables routing. Setting legacy to false rejects configuration. If stateless is enabled, its versions list must be non-empty. Although the Rust struct’s bare fallback leaves stateless off, the shipped light-gateway template explicitly defaults it to true.

MCP Router: path

path is the exact HTTP path owned by the MCP router.

  • Type: String beginning with /
  • Default: /mcp
path: /mcp

Query strings are ignored during path matching, so /mcp?sessionId=... matches /mcp. Child paths such as /mcp/tools do not match. handler.yml must route the required methods on the same path to the mcp handler. A value without a leading slash rejects configuration.

MCP Router: maxSessions

maxSessions caps legacy frontend sessions stored by one gateway process.

  • Type: Positive integer
  • Default: 10000

Before rejecting a new initialize request, the router purges expired sessions. Legacy sessions expire after 30 minutes of inactivity and the normal purge interval is one minute. If the store remains full, initialization returns HTTP 503 with an MCP resource-limit error. Stateless requests do not consume this session store. Each gateway replica enforces its own limit.

MCP Router: maxSessionsPerClient

maxSessionsPerClient caps legacy sessions for one trusted client binding.

  • Type: Positive integer
  • Default: 100

Authenticated requests bind capacity to the authenticated principal. Anonymous requests require a trusted connection binding, which is hashed before use. Expired sessions are purged before the router rejects a new one. If the client is still at the limit, initialization returns HTTP 429. The counter is local to each gateway process and does not affect stateless requests.

MCP Router: maxRequestBodyBytes

maxRequestBodyBytes limits the complete MCP HTTP request body.

  • Type: Positive integer, in bytes
  • Default: 1048576 (1 MiB)

The gateway reads the request through a bounded buffer before JSON parsing. A larger body is rejected with HTTP 413 and JSON-RPC code -32600. Compressed request bodies are rejected by the gateway’s MCP path, so this budget applies to the bytes actually received as the JSON request body.

MCP Router: maxResponseBodyBytes

maxResponseBodyBytes bounds MCP responses and buffered downstream responses.

  • Type: Integer of at least 2048, in bytes
  • Default: 4194304 (4 MiB)

The router checks known Content-Length, enforces the limit while buffering, and bounds serialized JSON-RPC results such as tools/list. Oversized backend or catalog results fail safely instead of allocating without limit. Values below 2048 reject configuration because the gateway reserves enough space for bounded protocol error responses.

MCP Router: maxJsonDepth

maxJsonDepth limits nesting in the parsed JSON-RPC request value.

  • Type: Positive integer
  • Default: 128

Arrays, objects, and scalar leaves each contribute to the recursive depth. A request exceeding the limit returns HTTP 400 and JSON-RPC code -32600. This protects runtime request parsing; schema-document complexity is governed separately by schema.maxDepth and schema.maxSubschemas.

MCP Router: originAllowlist

originAllowlist lists exact browser origins allowed to call the MCP endpoint. It is independent of cors.allowedOrigins.

  • Type: Array of strings
  • Default: []
originAllowlist:
  - https://local.localhost
  - https://localhost:3000

Each entry must be an absolute http or https origin containing only scheme, host, and optional port. Credentials, path, query, fragment, suffix rules, and wildcards are rejected. Host names are normalized to lowercase; matching remains scheme- and port-sensitive.

Requests with exactly one Origin header must match the list. Missing/empty allowlists, malformed origins, multiple Origin headers, and unlisted origins return HTTP 403 before payload parsing. Non-browser clients that omit Origin are allowed to continue through normal authentication and authorization.

MCP Router: schema.defaultDialect

schema.defaultDialect selects the JSON Schema dialect used to compile tool input and output schemas.

  • Type: String
  • Default and only supported value: https://json-schema.org/draft/2020-12/schema

A tool schema may omit $schema and inherit this dialect. If $schema is present, it must equal the configured dialect exactly. Any other configured or per-tool dialect rejects the router configuration during startup or reload.

MCP Router: schema.allowExternalRefs

schema.allowExternalRefs controls whether tool schemas may resolve $ref or $dynamicRef outside the schema document.

  • Type: Boolean
  • Default and required value: false

The current runtime has no approved external resolver policy, so setting this property to true rejects configuration. Local references beginning with # remain available. Any external reference found during schema preflight is rejected even when it is nested inside another schema keyword.

MCP Router: schema.maxSchemaBytes

schema.maxSchemaBytes limits the serialized size of each configured input or output schema.

  • Type: Positive integer, in bytes
  • Default: 1048576 (1 MiB)

The gateway serializes and preflights each schema independently while building the tool catalog. Exceeding the limit rejects configuration before traffic is accepted. This is a schema-compilation budget, not the tool-call request or response budget.

MCP Router: schema.maxDepth

schema.maxDepth limits nesting within each tool schema document.

  • Type: Positive integer
  • Default: 64

The preflight traversal counts nested objects and arrays throughout the schema, including definitions and keyword values. A schema whose traversal depth exceeds the limit rejects configuration. Use maxJsonDepth for JSON-RPC request values; the two limits protect different data structures.

MCP Router: schema.maxSubschemas

schema.maxSubschemas limits the number of object nodes examined in one tool schema.

  • Type: Positive integer
  • Default: 4096

Every object encountered by schema preflight contributes to the count, including nested property schemas and definitions. Exceeding the budget rejects configuration before validator compilation. This prevents broad schemas from consuming unbounded startup CPU and memory even when their nesting depth is acceptable.

MCP Router: schema.maxConcurrentValidations

schema.maxConcurrentValidations controls process-wide admission to input and output schema validation work.

  • Type: Positive integer
  • Default: 32

The runtime creates at most this many validation workers, capped further by available CPU parallelism, and uses the same value as the bounded work queue and admission semaphore. When capacity is exhausted, the tool call fails safely with an MCP error result instead of waiting in an unbounded queue. Each gateway replica maintains its own pool.

MCP Router: schema.validationWatchdogMs

schema.validationWatchdogMs is an observational threshold for a schema validation job.

  • Type: Positive integer, in milliseconds
  • Default: 50
  • Alias accepted by the Rust loader: validationTimeoutMs

When validation takes longer, the worker logs a warning with elapsed and configured milliseconds. It does not interrupt or cancel the validator; the name “watchdog” is intentional. Use maxConcurrentValidations to bound admission and concurrency.

MCP Router: protocol versions

The final property name versions is used by both protocol profiles.

protocols.legacy.versions

  • Default: [2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05]
  • The list must be non-empty and contain no duplicates.
  • Only those four versions are supported by the legacy adapter.

protocols.stateless.versions

  • Shipped default: [2026-07-28]
  • When stateless is enabled, the list must be non-empty.
  • The current stateless adapter accepts only 2026-07-28, without duplicates.
protocols:
  legacy:
    versions: [2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05]
  stateless:
    versions: [2026-07-28]

The stateless classifier requires the request header version and the params._meta["io.modelcontextprotocol/protocolVersion"] value to agree. Unsupported, duplicated, or cross-profile versions reject configuration or the request, depending on where the mismatch is found.

MCP Router: protocols.stateless.discoverTtlMs

discoverTtlMs is the lifetime of a cached stateless server/discover result.

  • Type: Positive integer, in milliseconds
  • Default: 30000

The same value is returned to clients as the result’s ttlMs. Cache keys include protocol version, principal fingerprint, forwarded cache-vary headers, configuration revision, and policy revision. Entries are process-local and are recomputed after expiration or revision change.

MCP Router: protocols.stateless.discoverCacheScope

discoverCacheScope declares the privacy scope of stateless server/discover results.

  • Type: String
  • Default and only supported value: private

The result is cached with identity-, header-, configuration-, and policy-sensitive keys and reports cacheScope: private to the client. Public or shared caching is rejected because authentication, delegation, and policy can affect what a caller is permitted to discover.

MCP Router: protocols.stateless.toolsListTtlMs

toolsListTtlMs is the lifetime of a cached authorization-filtered stateless tools/list result.

  • Type: Positive integer, in milliseconds
  • Default: 30000

The result reports the same value as ttlMs. Cache keys bind the entry to the principal, policy/config revisions, protocol version, and relevant forwarded headers. A tool catalog or policy reload changes the revision and causes a new result to be produced.

MCP Router: protocols.stateless.toolsListCacheScope

toolsListCacheScope declares the privacy scope of cached stateless tools/list responses.

  • Type: String
  • Default and only supported value: private

Tool visibility is authorization-sensitive, so cache entries are keyed by principal fingerprint, policy/config revisions, protocol version, and relevant headers. The gateway reports cacheScope: private; configuring another value rejects the stateless profile.

MCP Router: protocols.stateless.maxDiscoverCacheEntries

maxDiscoverCacheEntries caps cached stateless server/discover results in one gateway process.

  • Type: Positive integer
  • Default: 1024

The cache removes expired records on lookup and evicts the least recently touched key when insertion exceeds capacity. Entries are not shared across gateway replicas. Because keys are principal- and revision-sensitive, size this limit for the expected active identity and policy cardinality.

MCP Router: protocols.stateless.maxToolsListCacheEntries

maxToolsListCacheEntries caps cached stateless tools/list results in one gateway process.

  • Type: Positive integer
  • Default: 4096

The cache is process-local, TTL-bound, and evicts the least recently touched key when capacity is exceeded. Entries vary by principal, protocol, relevant headers, configuration revision, and policy revision, so this limit may need to be larger than the discovery cache in multi-tenant deployments.

MCP Router: protocols.stateless.maxToolsListItems

maxToolsListItems caps the number of authorization-visible tools returned by one stateless tools/list operation.

  • Type: Positive integer
  • Default: 1024

The router evaluates visibility first and then applies this limit. If the visible catalog is larger, it returns a bounded catalog-limit error instead of pagination; non-empty pagination cursors are not currently supported. The serialized result must also fit maxResponseBodyBytes.

MCP Router: protocols.stateless.maxConcurrentRequests

maxConcurrentRequests caps in-flight stateless MCP requests in one gateway process.

  • Type: Positive integer
  • Default: 1024

Admission is fail-fast and uses an owned semaphore, so completion, cancellation, or panic releases capacity automatically. When no global permit is available, the request returns HTTP 429 with an MCP resource-limit error. Legacy session requests use separate controls.

MCP Router: protocols.stateless.maxConcurrentRequestsPerPrincipal

maxConcurrentRequestsPerPrincipal caps in-flight stateless requests for one authenticated or trusted anonymous binding.

  • Type: Positive integer
  • Default: 32

The per-principal permit is acquired together with the global request permit. If either limit is exhausted, the gateway returns HTTP 429. Counters are process-local and disappear when no active permit retains that principal’s semaphore.

MCP Router: protocols.stateless.maxConcurrentBackendCallsPerTarget

maxConcurrentBackendCallsPerTarget caps concurrent calls to one normalized backend target.

  • Type: Positive integer
  • Default: 32

The limit applies to stateless frontend calls and calls to stateless MCP backends. Different targets have independent semaphores. Capacity is fail-fast and process-local; an exhausted target produces a tool execution error stating that the backend exceeds a gateway resource limit.

MCP Router: protocols.stateless.maxSubscriptions

maxSubscriptions caps active stateless subscriptions/listen streams in one gateway process.

  • Type: Positive integer
  • Default: 10000

The subscription hub holds bounded channels and removes a subscription when the stream ends or its lease is dropped. If the global or per-principal limit is reached, registration returns HTTP 429. Gateway replicas do not share subscription counters.

MCP Router: protocols.stateless.maxSubscriptionsPerPrincipal

maxSubscriptionsPerPrincipal caps active stateless subscriptions for one authenticated or trusted anonymous binding.

  • Type: Positive integer
  • Default: 4

The limit is checked with the process-wide maxSubscriptions limit when a subscriptions/listen request registers. Exceeding either returns HTTP 429. Counts are released when the stream closes, expires, is cancelled, or is superseded by a runtime reload.

MCP Router: protocols.stateless.maxSubscriptionDurationMs

maxSubscriptionDurationMs caps a stateless subscription stream’s lifetime.

  • Type: Positive integer, in milliseconds
  • Default: 900000 (15 minutes)

The actual deadline is the earlier of this configured duration and the authenticated credential’s exp time. Expired credentials reject registration with HTTP 401. The stream emits keep-alive comments every 15 seconds and a terminal completion frame at its deadline.

MCP Router: protocols.stateless.statelessToLegacyBridge

statelessToLegacyBridge controls whether a stateless frontend request may be adapted to a session-oriented legacy MCP backend.

  • Type: String
  • Default and only supported value: reject

The current release deliberately fails closed: a stateless call to a tool whose backend profile is legacy returns an MCP tool error instead of creating or reusing hidden backend session state. Other values reject configuration. Use a tool with backendMcpProtocol: stateless and explicit credential settings for a stateless-to-stateless path.

MCP Router: tools

tools is the catalog exposed by tools/list and executed by tools/call.

  • Type: Array of tool objects
  • Default: []
tools:
  - name: weather.get
    endpointName: get_weather
    description: Get weather information
    apiType: http
    serviceId: com.networknt.weather-1.0.0
    protocol: https
    envTag: dev
    path: /weather/{city}
    method: GET
    endpoint: /weather/{city}@get
    inputSchema:
      type: object
      required: [city]
      properties:
        city:
          type: string
    outputSchema:
      type: object
    toolMetadata:
      routing:
        parameters:
          city: path
      safety:
        idempotent: true

Tool fields

FieldDefaultBehavior
namerequiredUnique gateway-facing tool name. When stateless is enabled, it must be 1-128 ASCII letters, digits, ., -, or _.
endpointNamenameReal backend MCP operation name when it differs from the gateway-facing name.
descriptionemptyDescription returned by tools/list.
apiTypehttphttp (also accepts rest/openapi) or mcp.
protocoltarget-dependentDiscovery protocol used with serviceId.
serviceIdunsetRegistry target. Either non-blank serviceId or targetHost is required.
envTagunsetOptional registry environment tag.
targetHostunsetAbsolute direct target base URL. Private/loopback/link-local/metadata targets are blocked unless explicitly approved in runtime metadata.
pathrequiredBackend path beginning with /; OpenAPI placeholders must have matching routing metadata.
methodGETGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, or CALL. CALL resolves to POST when an input schema was explicitly configured, otherwise GET.
endpoint<path>@<method>Access-control endpoint identifier.
backendMcpProtocollegacy for MCP toolslegacy or stateless; stateless must be selected explicitly.
sessionIndependentfalseMCP-backend declaration; invalid on an HTTP tool. It does not by itself convert a legacy backend into stateless.
backendCredentialModecompatibility-dependentcaller, exchange, service, anonymous, or legacy-only caller-compat. Stateless backends require an explicit mode.
backendResourceunsetOAuth resource/audience. Required for caller and exchange.
inputSchema{type: object}Draft 2020-12 object-root schema used to validate arguments.
outputSchemaunsetOptional schema used to validate structured tool output.
toolMetadata{}Routing, safety, lifecycle, and runtime controls.

Tool names and backend contracts must be unique and internally consistent. Tools resolving to the same backend identity must agree on backend profile, credential mode, and resource. HTTP tools cannot configure MCP backend-profile fields.

Important toolMetadata controls

  • routing.parameters.<name> maps an input property to path, query, header, cookie, or body. Every {placeholder} in path requires the same parameter to be mapped to path.
  • routing.endpointId or top-level endpointId supplies runtime endpoint identity metadata.
  • runtime.allowPrivateTargetHost: true opts an approved internal direct target out of public-address SSRF blocking. Do not enable it for untrusted values.
  • runtime.retry.enabled, maxAttempts, retry status/events, and backoff configure bounded retries. Retries require safety.idempotent: true; a destructive tool also needs an idempotency-key argument.
  • safety.idempotent, safety.destructive, and schema x-mask annotations affect retry and argument masking behavior.
  • lifecycle carries published lifecycle metadata consumed by the runtime catalog.

An input-schema property may use x-mcp-header to require its canonical value in a stateless transport header. Header names must be safe HTTP tokens, cannot be gateway-owned or sensitive, and may annotate only bounded string, boolean, or safe-range integer properties.

Portal View Concepts

This section contains reusable explanations for portal concepts referenced by many pages, forms, and tasks.

Concept help should be linked from page or field-level help when a short label or tooltip is not enough.

Ownership And Positions

Portal records can have an individual owner and a position owner.

owner_user_id is derived from the authenticated user when a record is created. It should not be submitted from normal browser forms.

owner_position_id is optional and can be selected on owner-aware forms. It allows users with the matching effective position to see or manage the record when service-side authorization grants that scope.

Rows with no owner user and no owner position are legacy or unassigned records. They should normally be visible only to all-scope administrators until ownership is assigned.

Hosts And User Hosts

A host is the tenant boundary for most portal records.

User-host membership determines which host a user can work in. Most admin pages and generated forms operate against the currently selected host.

When a user cannot see expected records, first confirm that the correct host is selected and that the user has membership for that host.

API Versioning

An API is the stable business record. An API version is the concrete version that can be linked to instances, MCP tools, marketplace listings, and access control rules.

Create the API first, then create one or more API versions under it. Operational relationships should usually reference the API version instead of only the API.

OAuth Client Ownership

OAuth clients can be owned by apps, API versions, or instances depending on the selected creation context.

Ownership affects which users can see or modify client records. Regular users should manage only clients they own or can access through their position. Administrators can manage all clients for the host when their role allows it.

Implementation

Local Portal Setup

This guide starts the local Light Portal runtime from one repository:

~/lightapi/portal-config-loc

portal-config-loc contains the local Compose stacks and startup script. The script downloads released service jars, UI assets, image tags, and the baseline event snapshot from https://cdn.networknt.com.

Quick Start

Clone or update the repository under ~/lightapi:

cd ~
mkdir -p lightapi
cd lightapi
git clone [email protected]:lightapi/portal-config-loc.git

If they are already cloned:

cd ~/lightapi/portal-config-loc
git pull --rebase

Optional: Use Your Own Events

The importer reads the cached CDN event file:

~/lightapi/.release-state/assets/events.json

To initialize a new local database with your own snapshot, set RELEASE_ASSET_CACHE_DIR and replace its events.json before running deploy-local.sh for the first time:

mkdir -p ~/lightapi/.release-state/assets
cp /path/to/your/events.json ~/lightapi/.release-state/assets/events.json

Do not use a different filename. After the script has imported events into Postgres, replacing the file will not change the existing database. To reinitialize from a different file, remove the Postgres named volume, replace the cached events.json, and start the script again with IMPORT_EVENTS=auto.

For Podman:

cd ~/lightapi/portal-config-loc/all-in-lt
podman compose -f docker-compose.yml -f docker-compose-rust.yml down -v

For Docker:

cd ~/lightapi/portal-config-loc/all-in-lt
docker compose -f docker-compose.yml -f docker-compose-rust.yml down -v

Start the Rust stack with Docker Compose:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="docker compose" \
CONTAINER_CMD=docker \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

Start the Rust stack with Podman Compose:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="podman compose" \
CONTAINER_CMD=podman \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

Open the portal at:

https://localhost

If the selected configuration uses hostnames such as dev.lightapi.net, add them to the local hosts file and point them to 127.0.0.1.

What The Script Does

deploy-local.sh downloads asset archives from the CDN into .release-state/assets and extracts them into the selected stack only when the target directories are missing or empty. It does not overwrite populated asset directories during a normal run.

For the lt rust stack, the script starts all-in-lt with the Rust compose override. It also downloads docker-images.env and passes it to Compose so the stack can use the published release image tags.

When IMPORT_EVENTS=auto is set, the script waits for Postgres, checks event_store_t, and imports the cached CDN events.json only if the event store is empty. This is intended for a brand new environment or after removing the Postgres named volume. Leave IMPORT_EVENTS unset for normal restarts.

Automatic event import uses the event-importer container image by default:

CONTAINER_CMD=podman
EVENT_IMPORTER_IMAGE=networknt/event-importer:latest

Use EVENT_IMPORT_RUNNER=local only when you intentionally provide a local event-importer build.

Postgres uses a Compose named volume called postgres-data instead of the host bind directory postgres-db/data. This avoids rootless Podman permission and SELinux label issues on Fedora Silverblue. To reset Postgres for a selected stack, run Compose directly from that stack directory with down -v.

Ubuntu

Docker Compose is the simplest Ubuntu path. Install Docker Engine and the Compose plugin by following the official Docker Ubuntu guide, then run:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="docker compose" \
CONTAINER_CMD=docker \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

Podman also works on Ubuntu after installing Podman and a Compose provider:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="podman compose" \
CONTAINER_CMD=podman \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

References:

Fedora Silverblue

Fedora Silverblue already fits a Podman-first local workflow. Install the Compose provider once, then reboot into the new deployment:

sudo rpm-ostree install podman-compose
systemctl reboot

Rootless Podman normally cannot bind host port 443. The local configuration expects https://localhost, so allow unprivileged processes to bind from 443 upward before starting the stack:

printf 'net.ipv4.ip_unprivileged_port_start=443\n' | \
  sudo tee /etc/sysctl.d/99-rootless-low-ports.conf
sudo sysctl --system

Then start the stack:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="podman compose" \
CONTAINER_CMD=podman \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

References:

Postgres Permission Recovery

Postgres data is stored in a Compose named volume, not in postgres-db/data. If you previously started the stack before this change and hit a permission error on the old bind-mounted data directory, pull the latest config and recreate the stack:

cd ~/lightapi/portal-config-loc
git pull --rebase
COMPOSE_CMD="podman compose" CONTAINER_CMD=podman ./scripts/deploy-local.sh lt rust stop
COMPOSE_CMD="podman compose" CONTAINER_CMD=podman IMPORT_EVENTS=auto ./scripts/deploy-local.sh lt rust

If you need a completely fresh database after a failed first run, remove the Compose volume before starting again:

cd ~/lightapi/portal-config-loc/all-in-lt
podman compose -f docker-compose.yml -f docker-compose-rust.yml down -v

Controller Certificate Recovery

If controller-rs fails with a message that CONTROLLER_TLS_CERT_PATH points to missing /keystore/server.pem, use the latest Compose files and recreate the Rust stack. The cert files are tracked in all-in-lt/light-controller-rust, but rootless Podman on Silverblue needs the keystore bind mount to be SELinux relabeled.

cd ~/lightapi/portal-config-loc
git pull --rebase
COMPOSE_CMD="podman compose" CONTAINER_CMD=podman ./scripts/deploy-local.sh lt rust restart

macOS

Docker Desktop is the simplest macOS path. Install Docker Desktop, start it, then run the same script from Terminal:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="docker compose" \
CONTAINER_CMD=docker \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

Podman Desktop can also be used. Start the Podman machine first, then use the Podman command form:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="podman compose" \
CONTAINER_CMD=podman \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

References:

Windows

Use WSL2 Ubuntu for the local development shell. Clone the repositories under the WSL home directory, not under a Windows drive mount:

~/lightapi/portal-config-loc

With Docker Desktop, enable WSL integration for the Ubuntu distribution and run the script inside the WSL shell:

cd ~/lightapi/portal-config-loc
COMPOSE_CMD="docker compose" \
CONTAINER_CMD=docker \
IMPORT_EVENTS=auto \
RUST_LOG=info \
./scripts/deploy-local.sh lt rust

Podman on Windows also works through a Podman machine, but WSL2 plus Docker Desktop is usually the shortest setup path for this local stack.

References:

Common Commands

Show status:

COMPOSE_CMD="podman compose" ./scripts/deploy-local.sh lt rust status

Show logs:

COMPOSE_CMD="podman compose" ./scripts/deploy-local.sh lt rust logs

Restart:

COMPOSE_CMD="podman compose" ./scripts/deploy-local.sh lt rust restart

Stop:

COMPOSE_CMD="podman compose" ./scripts/deploy-local.sh lt rust stop

Force event import:

COMPOSE_CMD="podman compose" \
CONTAINER_CMD=podman \
IMPORT_EVENTS=true \
./scripts/deploy-local.sh lt rust start

Sign In

Portal Dashboard

The Portal Dashboard is served by the portal-view single-page application.

  • Guest User Access:
    Upon landing on the dashboard, a guest user can:

    • View certain menus.
    • Perform limited actions within the application.
  • Accessing Privileged Features:
    To access additional features:

    1. Click the User button.
    2. Select the Sign In menu item.

Login View

  • Redirection to Login View:
    When the Sign In menu item is clicked, the browser is redirected to the Login View single-page application. This application is served by the same instance of light-gateway and handles user authentication against the OAuth 2.0 server (OAuth Kafka) to initiate the Authorization Code grant flow.

  • OAuth 2.0 Client ID:
    The client_id is included in the redirect URL as a query parameter. This ensures that the client_id is sent to the OAuth 2.0 server to obtain the authorization code. In this context, the client_id is associated with the portal-view application.

  • Login View Responsibilities:
    The Login View is a shared single-page application used by all other SPAs across various hosts. It is responsible for:

    • Authenticating users.
    • Ensuring that user credentials are not passed to any other single-page applications or business APIs.
  • SaaS Deployment in the Cloud:
    In a SaaS environment, all users are authenticated by the OAuth 2.0 server using the light-portal user database. As a result, the user type does not need to be passed from the Login View.

  • On-Premise Deployment:
    For on-premise deployments, a customized Login View should include a radio button for selecting the user type. Typical options for most organizations are:

    • Employee (E)
    • Customer (C)
  • Customized Authentication:
    Based on the selected user type:

    • Employees are authenticated via Active Directory.
    • Customers are authenticated using the customer database.

    A customized authenticator implementation should handle this logic, ensuring the correct authentication method is invoked for each user type.

Login Form Submission

  • Form Submission Endpoint:
    /oauth2/N2CMw0HGQXeLvC1wBfln2A/code

  • Request Details:

    • Headers:
      • Content-Type: application/x-www-form-urlencoded
    • Method:
      • POST
    • Body Parameters:
      • j_username: The user’s username.
      • j_password: The user’s password.
      • remember: Indicates whether the session should persist.
      • client_id: The OAuth 2.0 client identifier.
      • state: A hardcoded value (requires additional work for dynamic handling).
      • user_type: (Optional) Specifies the type of user (e.g., employee or customer).
      • redirect_uri: (Optional) The URI to redirect after authentication.

Light Gateway

The light-gateway instance acts as a BFF and it has a routing rule to route any request with prefix /oauth2 to kafka-oauth server.

OAuth Kafka

  • LightPortalAuthenticator

    A request to hybrid-query:

    {"host":"lightapi.net","service":"user","action":"loginUser","version":"0.1.0","data":{"email":"%s","password":"%s"}}
    

User Query

  • LoginUser

This handler calls loginUserByEmail method from PortalDbProviderImpl.

PortalDbProviderImpl

The input for this method is the user’s email. Upon successful execution, the method returns a JSON string containing all user properties retrieved from the login query.

LightPortalAuthenticator

The authenticator will utilize the user data returned from the above query to validate the password. Upon successful password verification, it will return an Account object with the following attributes:

  • Principal: The user’s identifier, which is the email.
  • Roles: A collection containing a single element—the user’s JSON

After the Account object is created and returned, control is passed to the HostIdCodePostHandler.

HostIdCodePostHandler

It get the client_id from the submitted form and call dbProvider.queryClientByClientId to get client information. Upon successful, it get the Account object created by the authenticator above from the security context.

Create a UUID authorization code and a map associates with the code. The map contains properties that need to create authorization code token. Some properties from the client and the entire user json.

Call the ClientUtil.createAuthCode with the codeMap to create the authorization code and then redirect the code to back to the redirect uri.

ClientUtil.createAuthCode

The ClientUtil gets a client credentials token and call the CreateAuthCode handler in the hybrid-command to publish the code to the Kafka cluster in order to notify other party about this code. The codeMap is passed to the handler as data.

CreateAuthCode Handler

The handler create a MarketCodeCreatedEvent and pass the entire input map to the event as value field.

MarketQueryStreams

It processes the MarketCodeCreatedEvent and calls dbProvider.createMarketCode with the event.

createMarketCode

This method in dbProvider will put the event value into cacheManager cache named “auth_code”. Now, the code is ready to be query from the market-query.

Portal View

The HostIdCodePostHandler redirects the browser to Portal View with GET /authorization?code=...&state=.... This is an OAuth callback and remains GET-only; it is not part of the session-mutation POST migration. Enabled /google, /facebook, and /github authorization-code callbacks follow the same GET-only rule.

StatelessAuthHandler

If the request path matches to the configured authPath, it will retrieve the code from the query parameter. Then create a csrf UUID token and an AuthorizationCodeRequest to get a token via OauthHelper. This request will have the auth code, the csrf token and other properties from the configuration. The request is sent to the HostIdTokenPostHandler to create the authorization code token.

Consent cancellation and normal stateless sign-out use credentialed, bodyless POST /logout. When logout CSRF enforcement is qualified, send the readable csrf cookie value as X-CSRF-TOKEN. Success is 204 No Content with deletion cookies. Route OPTIONS /logout permanently through CORS before StatelessAuthHandler. Logout is POST-only; a legacy GET or another unsupported method returns 405, ERR10008, and Allow: POST before cookie deletion.

HostIdTokenPostHandler

It calls dbProvider.queryClientByClientId and then verify the clientId and clientSecret matches.

It invokes ClientUtil.getAuthCodeDetail from the market-query service and calls the ClientUtil.deleteAuthCode to remove the auth code as it is one-time code.

Login View

The login-view is a Single Page Application (SPA) built with React and Vite. It serves as the user interface for the OAuth 2.0 Authorization Code flow within the LightAPI ecosystem.

Overview

This application acts as the front-end for the Authorization Server. When a user attempts to access a protected resource on a client application (the “Portal”), they are redirected to this application to authenticate and grant consent.

It handles:

  • User Authentication (Username/Password).
  • Social Login (Google, Facebook, GitHub).
  • OAuth 2.0 Consent Granting.
  • Password Management (Forgot Password, Reset Password).

Technology Stack

  • Framework: React 18
  • Build Tool: Vite
  • UI Library: Material UI (MUI) v6
  • Routing: React Router DOM v6
  • Social Login:
    • Google: @react-oauth/google
    • Facebook: @greatsumini/react-facebook-login
    • GitHub: Manual OAuth 2.0 flow with react-social-login-buttons

Key Flows

1. OAuth 2.0 Authorization

The application expects to be opened with standard OAuth 2.0 query parameters:

  • client_id: The ID of the client application requesting access.
  • response_type: Typically code.
  • redirect_uri: Where to redirect after success.
  • state: A random string generated by the client to prevent CSRF.
  • scope: Requested permissions.

Process:

  1. The Login component extracts these parameters from the URL.
  2. User submits credentials or uses social login.
  3. On success, the application receives an authorization code from the backend.
  4. To grant consent (if configured), the user is shown the Consent screen.
  5. Finally, the browser is redirected to the redirect_uri with the code and state.

2. Social Login Configuration

The application supports multiple identity providers.

  • Google: Uses the modern Google Identity Services. Configured in src/main.jsx via GoogleOAuthProvider.
  • Facebook: Uses the Facebook SDK wrapper. Configured in src/components/FbLogin.jsx.
  • GitHub: Uses a manual popup flow. The client ID is configured in src/components/GithubLogin.jsx. The redirect URI /github/callback handles the code extraction.

3. Backend Integration

The application proxies API requests to the backend (Light Gateway/OAuth Provider) using vite.config.js proxy settings during development.

  • /oauth2/*: For token and code endpoints.
  • /portal/*: For user management commands (login query).
  • /google, /facebook, /github: Endpoints to exchange social tokens/codes for LightAPI authorization codes.

Development

Setup

yarn install

Run Locally

yarn dev

Runs on https://localhost:5173 by default.

Build

yarn build

Generates production assets in the dist folder.

Project Structure

  • src/components/: Reusable UI components (Login forms, Social buttons).
  • src/theme.js: MUI theme configuration.
  • src/main.jsx: Application entry point and providers.
  • vite.config.js: Vite configuration including proxy rules.

Portal Services

This section provides an overview of the services utilized by Light Portal. Each service is implemented as a separate repository and is initialized during the hybrid-query or hybrid-command startup process. These services are designed to handle specific functionalities within the portal and may interact with one another to execute complex operations.

Light Portal adopts the Command Query Responsibility Segregation (CQRS) pattern, categorizing services into two types: Query and Command. Query services manage read operations, while Command services handle write operations, ensuring a clear separation of responsibilities.

Attribute Service

Attribute Query Service

Handles queries related to attributes.

Services Used

Attribute Command Service

Handles commands related to attributes.

Services Used

  • user-query

Client Service

Client Query Service

Handles queries related to clients.

Services Used

Client Command Service

Handles commands related to clients.

Services Used

  • user-query

Config Service

Config Query Service

Handles queries related to configurations.

Services Used

Config Command Service

Handles commands related to configurations.

Services Used

  • user-query
  • config-query

Deployment Service

Deployment Query Service

Handles queries related to deployments.

Services Used

Deployment Command Service

Handles commands related to deployments.

Services Used

  • user-query

Group Service

Group Query Service

Handles queries related to groups.

Services Used

Group Command Service

Handles commands related to groups.

Services Used

  • user-query

Host Service

Host Query Service

Handles queries related to hosts.

Services Used

Host Command Service

Handles commands related to hosts.

Services Used

  • user-query

Instance Service

Instance Query Service

Handles queries related to instances.

Services Used

Instance Command Service

Handles commands related to instances.

Services Used

  • user-query

OAuth Service

OAuth Query Service

Handles queries related to OAuth.

Services Used

OAuth Command Service

Handles commands related to OAuth.

Services Used

  • user-query
  • oauth-query

Position Service

Position Query Service

Handles queries related to positions.

Services Used

Position Command Service

Handles commands related to positions.

Services Used

  • user-query

Product Service

Product Query Service

Handles queries related to products.

Services Used

Product Command Service

Handles commands related to products.

Services Used

  • user-query

Role Service

Role Query Service

Handles queries related to roles.

Services Used

Role Command Service

Handles commands related to roles.

Services Used

  • user-query

Rule Service

Rule Query Service

Handles queries related to rules.

Services Used

  • service-query

Rule Command Service

Handles commands related to rules.

Services Used

  • user-query
  • host-query

Service Service

Service Query Service

Handles queries related to services.

Services Used

Service Command Service

Handles commands related to services.

Services Used

  • user-query

User Service

User Query Service

Handles queries related to users.

Services Used

User Command Service

Handles commands related to users.

Services Used

  • user-query
  • service-query

Portal View

OAuth 2.0 State Verification

This document describes the implementation of CSRF protection for the OAuth 2.0 authorization code flow in the portal-view application.

Overview

To prevent Cross-Site Request Forgery (CSRF) attacks during the OAuth 2.0 authentication process, we implement a state parameter check. A random state string is generated before the authentication request and verified upon the callback.

Implementation Details

State Generation

Location: src/components/Header/ProfileMenu.tsx

When the user initiates the sign-in process:

  1. A random alphanumeric string is generated.
  2. This string is stored in the browser’s localStorage under the key portal_auth_state.
  3. The string is appended as the state query parameter to the OAuth 2.0 authorization URL.
// Generate a random state for CSRF protection
const state = Math.random().toString(36).substring(7);
localStorage.setItem('portal_auth_state', state);

const defaultUrl =
  `https://locsignin.lightapi.net?client_id=...&state=${state}`;

Redirect Handling

Location: src/App.tsx

To ensure the state query parameter is preserved during the redirect from the root path (/) to the dashboard, a custom RedirectWithQuery component is used. This component handles both standard query parameters and hash-based redirects (common with certain OAuth providers or router configurations).

  1. Checks window.location.hash for paths (e.g., /#/app/dashboard?state=...).
  2. Prioritizes the hash path if present to ensure react-router receives the correct target.
  3. Appends existing query parameters from useLocation().search.
  4. Uses useNavigate for the redirection.
const RedirectWithQuery = ({ to }: { to: string }) => {
  // ... logic to preserve search params and handle hash paths
  if (window.location.pathname === to) return; // Prevent loop
  // ...
  navigate(target, { replace: true });
};

State Verification

Location: src/pages/dashboard/Dashboard.tsx

Upon successful authentication, the provider redirects the user back to the application (defaulting to the Dashboard).

  1. The application retrieves the state parameter from the URL query string.
  2. It retrieves the stored state from localStorage (portal_auth_state).
  3. The two values are compared:
    • Match: The verification succeeds, and the portal_auth_state is removed from localStorage.
    • Mismatch: The verification fails. The user is alerted and immediately logged out via signOut to protect the session.
useEffect(() => {
  const searchParams = new URLSearchParams(location.search);
  const state = searchParams.get('state');

  // Check if we have a state and haven't attempted verification yet in this mount
  if (state && !verificationAttempted.current) {
    verificationAttempted.current = true;
    const storedState = localStorage.getItem('portal_auth_state');
    if (storedState === state) {
      console.log('OAuth state verified successfully.');
      localStorage.removeItem('portal_auth_state');
      // Remove state from URL to prevent re-verification
      const newSearchParams = new URLSearchParams(location.search);
      newSearchParams.delete('state');
      navigate({ search: newSearchParams.toString() }, { replace: true });
    } else {
      console.error('OAuth state mismatch. Potential CSRF attack.');
      alert('OAuth state mismatch. Potential CSRF attack. Logging out...');
      signOut(userDispatch, navigate);
    }
  }
}, [location, navigate, userDispatch]);

Testing State Mismatch (Manual Steps)

To manually verify the security logout mechanism:

  1. Ensure you are logged in to the application.
  2. Open your browser’s Developer Tools (F12) and go to the Console tab.
  3. Set a dummy “valid” state in your local storage:
    localStorage.setItem('portal_auth_state', 'my_secret_state');
    
  4. Manually modify the URL to include a different state parameter.
    • Example: https://localhost:3000/app/dashboard?state=attackers_fake_state
    • Note: If using hash routing, ensure it is inside the hash: https://localhost:3000/#/app/dashboard?state=attackers_fake_state
  5. Press Enter to navigate.

Expected Result:

  1. An alert appears: “OAuth state mismatch. Potential CSRF attack. Logging out…”
  2. The user is immediately signed out of the application.

Configuration

light-gateway

Client Credentials Token

All the accesses from the light-gateway to the downstream APIs should have at least one token in the Authorization header. If there is an authorization code token in the Authorization header, then a client credentials token will be added to the X-Scope-Token header by the TokenHandler.

Since all light portal services have the same scopes (portal.r and portal.w), one token should be enough for accessing all APIs.

Add the client credentials token config in client.yml section.

# Client Credential
client.tokenCcUri: /oauth2/N2CMw0HGQXeLvC1wBfln2A/token
client.tokenCcClientId: f7d42348-c647-4efb-a52d-4c5787421e72
client.tokenCcClientSecret: f6h1FTI8Q3-7UScPZDzfXA
client.tokenCcScope:
  - portal.r
  - portal.w

Add TokenHandler to the handler.yml section.

# handler.yml
handler.handlers:
  .
  .
  .
  - com.networknt.router.middleware.TokenHandler@token
  .
  .
  .
handler.chains.default:
  .
  .
  .
  - prefix
  - token
  - router

Add the TokenHandler configuration token.yml section.

# token.yml
token.enabled: true
token.appliedPathPrefixes:
  - /r
  

light-reference

Cors Configuration

As the light-gateway is handling the SPA interaction and cors, we don’t need to enable the cors on the reference API. However, the cors handler is still registered in the default handler.yml in case the reference API is used as a standalone service.

In the light-portal configuration, we need to disable the cors.

# cors.yml
cors.enabled: false

Client Configuration

We need to load the jwk from the oauth-kafka service to validate the incoming jwk tokens. To set up the jwk, add the following lines to the values.yml file.

# client.yml
client.tokenKeyServerUrl: https://localhost:6881
client.tokenKeyUri: /oauth2/N2CMw0HGQXeLvC1wBfln2A/keys

Test

Automated Integration Testing & AI Agent Strategy for Light-Portal

Document Type: Engineering Strategy / Architecture
System: Light-Portal (Multi-Service Architecture)


1. Executive Summary

As Light-Portal scales into a complex multi-service ecosystem, traditional end-to-end (E2E) tests become too slow, brittle, and difficult to maintain. To enable rapid updates without fear of regression, we must adopt a Shift-Left Layered Integration Approach.

Furthermore, to minimize the manual overhead of test creation and maintenance, this strategy incorporates AI QA Agents capable of autonomously generating, executing, and self-healing test suites based on structured declarative specifications.


2. Core Automated Integration Strategy

To test inter-service communication reliably and rapidly, we will implement the following methodologies:

A. Consumer-Driven Contract (CDC) Testing

Instead of spinning up the entire portal ecosystem to test a single integration, we will use Pact.

  • How it works: The “Consumer” service defines the expected API structure (the contract). The “Provider” service checks its responses against this contract during its CI pipeline.
  • Benefit: Catches breaking API changes instantaneously without requiring a full staging environment.

B. Ephemeral Environments

Tests should never rely on shared, persistent environments which are prone to state pollution.

  • Tooling: Testcontainers or dynamic Docker Compose files.
  • Execution: During the CI/CD pipeline, isolated instances of necessary services (e.g., databases, message brokers like Kafka, OAuth providers) are spun up, tested against, and destroyed.

C. API-First Testing

Because Light-Portal relies on strict API boundaries, UI-based testing should be minimized for integration validation.

  • Tooling: Karate DSL or REST Assured.
  • Benefit: Tests the actual data contracts and service boundaries directly, resulting in faster and more resilient tests.

D. Mocking External Dependencies

  • Tooling: WireMock or Mountebank.
  • Usage: Stub out third-party APIs or external legacy systems to ensure our integration tests are entirely deterministic and not subject to external network failures.

3. AI Agent Automation Capabilities

Autonomous AI agents can significantly reduce the testing bottleneck. Within this architecture, AI agents will be utilized for the following tasks:

  1. Test Generation: Automatically parse OpenAPI specifications to generate exhaustive test suites covering positive paths, edge cases, and error handling (400, 401, 429, 500).
  2. Self-Healing Test Pipelines: When an engineer modifies an API schema intentionally, the AI agent will detect the resulting broken test, read the commit diff, and automatically generate a Pull Request to align the test with the new API schema.
  3. Synthetic Data Generation: Generate realistic, schema-compliant JSON payloads for testing, avoiding hard-coded or outdated mock data.
  4. State Machine Exploration: Execute multi-step user journeys by exploring the API state (e.g., Authenticate -> Register Service -> Query Gateway -> Validate Routing).

4. AI-Optimized Test Specifications & Plans

AI agents require structured, semantic, and declarative inputs to function reliably. To direct the AI agent, we will provide test plans in the following formats:

A. OpenAPI / AsyncAPI Specifications (The Golden Source)

The most effective way to instruct an AI is to provide the API design spec.

  • AI Action: The agent reads openapi.yaml, identifies required headers (e.g., JWT authorizations) and payload schemas, and writes the baseline integration code automatically.

B. Behavior-Driven Development (BDD) / Gherkin Syntax

For complex business logic, engineers and product managers will write Gherkin specs. The AI agent translates this plain English into executable API scripts.

Example Spec:

Feature: Light-Portal Service Registration

  Scenario: Registering a new microservice routing path
    Given the light-oauth2 service provides a valid admin JWT
    When I send a POST request to "/portal/services" with the following payload:
      """
      {
        "serviceId": "demo-service",
        "route": "/api/v1/demo"
      }
      """
    Then the response status should be 201
    And the service should be discoverable via the light-router instance

C. Declarative YAML Test Workflows

Instead of writing imperative code (Java/Node.js), test workflows should be written in YAML. YAML is highly deterministic and minimizes AI syntax hallucinations.

Example Spec:

# AI Agent Workflow Instructions
name: Developer Onboarding Flow
steps:
  - name: Get Token
    api: POST /oauth/token
    extract: 
      token: response.body.access_token
  - name: Register Service
    api: POST /portal/services
    headers:
      Authorization: Bearer ${token}
    assert:
      status: 200

D. Flow-Based “User Stories” (Agentic Prompting)

For autonomous exploration, the AI can be given high-level flow objectives. The agent is responsible for breaking the flow into actual API requests.

Example Prompt to Agent:

“Simulate a developer onboarding flow for Light-Portal. 1. Request an OAuth token. 2. Register a new mock-service to the portal. 3. Update the rate-limiting configuration for that service to 5 requests per minute. 4. Send 10 concurrent requests to verify the rate limit correctly throws a 429 error.”


5. Conclusion & Next Steps

By combining Contract Testing (Pact), Ephemeral Environments (Testcontainers), and Declarative AI-driven Automation, Light-Portal can scale its microservices with confidence.

Immediate Action Items:

  1. Standardize and centralize all openapi.yaml files for Light-Portal services.
  2. Integrate Testcontainers into the primary CI/CD pipeline.
  3. Select an AI testing tool/framework (e.g., CodiumAI, Postman Postbot, or a custom LLM script) and seed it with our initial Gherkin business flows.

Tutorial

light-gateway

Onboard an LLM Through llm-gateway

This tutorial onboards a hosted model into the Light Portal LLM control plane, publishes it to a dedicated llm-gateway, and validates it through a stable Public Alias. The worked example uses Groq’s qwen/qwen3.6-27b, but the same workflow applies to other hosted or OpenAI-compatible providers, including the compatible APIs offered by Amazon Bedrock.

Applications must use the Public Alias created in this tutorial. They must not depend on the provider name, Deployment ID, or physical model ID. That separation makes later provider and model migrations a routing change instead of an application release.

Important

Provider availability, limits, capabilities, and prices change. The Qwen values below were verified against Groq’s documentation on August 14, 2026. Recheck the provider’s current model catalog and contract before creating or updating records.

Recommendation for free-tier demos

Groq will stop serving llama-3.3-70b-versatile to free and developer-tier customers on August 16, 2026. Groq recommends either openai/gpt-oss-120b or qwen/qwen3.6-27b.

The two replacements have different operational profiles. The published prices are included because the LLM control plane requires effective Pricing metadata; they are not the deciding factor for a free-plan development demo.

Groq modelLifecycleInput/output price per 1M tokensContext / maximum completionDistinguishing features
openai/gpt-oss-120bProduction$0.15 / $0.60131,072 / 65,536Text generation, reasoning, tool use, JSON modes, and Groq built-in browser/code tools
qwen/qwen3.6-27bPreview$0.60 / $3.00131,072 / 16,384Text and image input, reasoning/non-reasoning modes, tool use, JSON Object Mode, vision, multilingual use, and strong coding benchmarks

For a development host whose main purpose is demonstrating local tool calling, onboard both models under separate Aliases and select the Alias per agent. Do not assume that benchmark or marketing claims make either model universally better at tool calling. In the August 14, 2026 dev-host qualification, both models produced a valid get_weather tool call with tool_choice set to auto and required. GPT-OSS also accepted an OpenAI named-function tool_choice object and followed a short exact-answer instruction more predictably. Qwen rejected that named-function form and a request that required two parallel tool calls, and its normal text response included a visible <think> block. The checked Qwen capability therefore keeps parallelTools: false.

This is one bounded compatibility test, not a general quality ranking. Qwen’s vision and multilingual capabilities may still make it the better Alias for some agents, while GPT-OSS is a useful default for agents that need predictable OpenAI-style text and tool behavior. Score both against the product’s real tool schemas, arguments, results, and multi-step conversations.

Qwen’s preview lifecycle is acceptable for a non-production demonstration as long as the demo does not promise an availability SLA. Groq currently lists Qwen 3.6 27B on its Free Plan with the same published request and token limits as GPT-OSS 120B: 30 requests per minute, 1,000 requests per day, 8,000 tokens per minute, and 200,000 tokens per day. Users should verify the exact limits in their own Groq organization before testing.

Reconsider lifecycle guarantees, paid rates, sustained quotas, privacy, residency, support, and fallback design when this moves from a demo to a commercial cloud service. That future production decision does not need to constrain the free-tier tutorial.

A suitable development migration is therefore:

  1. Onboard Qwen as assistant-qwen and GPT-OSS as assistant-gpt-oss.
  2. Run the same tool-calling acceptance matrix against both Aliases.
  3. Assign each agent the Alias that passes its workload-specific cases.
  4. Keep the existing application Alias stable if users already depend on it.
  5. Route that stable development Alias to the selected model only after its demo cases pass.

The current Alias Route form fixes weight at 1 and canary percentage at 0. Use a separate demo Alias or an external test split; do not claim that the current control plane performs percentage-based canary routing.

Sources:

What the workflow creates

flowchart LR
    S[Provider contract] --> M[Global Model]
    M --> R[Host and environment Registration]
    A[Provider Account] --> E[Provider Endpoint]
    R --> D[Deployment]
    E --> D
    D --> C[External Credential reference]
    D --> P[Effective Pricing]
    L[Public Alias] --> T[Alias Route]
    D --> T
    C --> U[Publication]
    P --> U
    T --> U
    U --> G[Config snapshot and module reload]
    G --> V[Authenticated live validation]

These records have deliberately different responsibilities:

RecordResponsibility
ModelGlobal provider/model identity, limits, modalities, operations, and static capabilities
RegistrationApproval to use that Model on one host and in one logical environment
Provider AccountNon-secret billing, quota, and capacity ownership
Provider EndpointWire protocol, base URL, authentication mode, and network transport
DeploymentExact callable model runtime plus capacity and readiness declarations
CredentialVersioned reference to a secret resolved by the gateway; never the secret value
Public AliasStable client-facing model name and required policy/capability contract
Alias RouteOrdered connection from an Alias to a compatible Deployment
PricingEffective-dated rates for the Deployment operation
PublicationTyped llm-router.* properties applied to one selected gateway instance

Policies and Bindings are optional governance records. They are not required to establish the provider connection or publish a normal public generation Alias. Add them only when a subject needs policy-based model selection, budgets, or other supported governance behavior.

Before you begin

You need:

  • permission to manage the global LLM catalog and the selected host’s LLM records;
  • an active gtw instance for the target instance environment tag;
  • a running dedicated llm-gateway that loads com.networknt.llm.gateway-1.0.0 configuration;
  • a provider account with permission to use the physical model;
  • a protected secret-injection path for the target gateway process; and
  • a gateway caller token for the final /v1/models and /v1/chat/completions validation.

Do not put a provider key in Portal, a Model, Endpoint headers, a Deployment, an Alias, a command line, source control, or this documentation. Portal stores only a reference such as env:GROQ_API_KEY. The provider key is resolved inside the target gateway process. The bearer token used by an application to call llm-gateway is a different credential.

1. Qualify the provider contract

Record the following information from primary provider documentation before opening Portal:

QuestionWhy it matters
What is the exact physical model ID?The gateway sends this value upstream without translating the provider’s catalog.
Is the model production, preview, or deprecated?Determines rollout and fallback requirements.
Which API operation and wire format serve it?Selects the gateway Provider Protocol.
What base URL ends immediately before the operation path?The gateway appends /chat/completions, /responses, /messages, or /embeddings.
What authentication scheme is required?Selects NONE, BEARER, or API_KEY; the current generic client does not sign AWS SigV4 requests.
Which regions and data classifications are approved?Constrains the Registration, Deployment, and Route.
What are the context and output limits?Bounds the Model and Alias.
Which modalities and features were actually tested?Controls declared and required capabilities.
What are the current input, output, and cached-input rates?Creates an auditable Pricing record.

The current gateway accepts these provider wire contracts:

Provider ProtocolGateway appendsOperation
openai_chat/chat/completionsgenerate
openai_responses/responsesgenerate
anthropic_messages/messagesgenerate
openai_embeddings/embeddingsembed

Provider identity and provider protocol are independent. Groq uses Provider Type groq and Provider Protocol openai_chat. An OpenAI-compatible Amazon Bedrock endpoint uses Provider Type bedrock and, for Chat Completions, Provider Protocol openai_chat.

Do not select openai_chat merely because a provider has an HTTP API. Verify that it implements the compatible request, response, error, and streaming contract. Native Bedrock Converse and InvokeModel, for example, are not openai_chat.

2. Prepare the reference catalog

The Model and Deployment forms use dependent reference-data dropdowns. Before creating the Model, confirm that these values are selectable:

Reference tableQwen valueGPT-OSS value
model_providergroqgroq
model_nameqwen/qwen3.6-27bopenai/gpt-oss-120b
model_familyqwengpt

The following active reference relations are also required:

  • provider_name: from groq to each exact model name;
  • model_name_family: from each model name to its qwen or gpt family.

The current LLM forms query this catalog without a host parameter, so these are platform-global catalog values. A host-only reference value will not make the Model dropdown work. If a value is missing, ask a reference-data/platform administrator to add the table only if the table itself is absent, then add the value, its locale label, and both relations. Event-based bootstrap jobs must query first and generate only the missing aggregates. Do not substitute a similar model ID; provider IDs are exact and case-sensitive unless the provider explicitly documents otherwise.

See Reference Table Admin for the global reference-data model.

3. Verify the physical model directly

Use the provider’s console or an approved direct probe from the same egress zone as the gateway. For Groq, first verify that the project can see the exact model:

set -euo pipefail
umask 077

provider_header_file=$(mktemp)
trap 'rm -f -- "$provider_header_file"' EXIT
read -rsp 'Groq API key: ' provider_api_key
printf '\n'
printf 'Authorization: Bearer %s\n' "$provider_api_key" >"$provider_header_file"
unset provider_api_key

curl --fail-with-body --silent --show-error \
  --header @"$provider_header_file" \
  --header 'Content-Type: application/json' \
  https://api.groq.com/openai/v1/models

Then send one bounded Chat Completions request. It consumes provider quota and may be billable if the account is not on the Free Plan:

curl --fail-with-body --silent --show-error \
  --header @"$provider_header_file" \
  --header 'Content-Type: application/json' \
  --data '{
    "model": "qwen/qwen3.6-27b",
    "messages": [{"role": "user", "content": "Reply with exactly: provider-ok"}],
    "reasoning_effort": "none",
    "temperature": 0,
    "max_completion_tokens": 16,
    "stream": false
  }' \
  https://api.groq.com/openai/v1/chat/completions

Stop if the model is absent, blocked by organization/project permissions, or does not accept the required operation. Fix provider access before creating Portal records. A successful provider probe does not validate gateway authentication, policy, publication, or routing; it validates only the upstream premise.

Groq API keys are scoped to the Groq project, not minted per model. The same GROQ_API_KEY can serve Qwen, GPT-OSS, and other models visible to that project and allowed by its permissions. Create additional keys only for deliberate rotation, isolation, or separate projects—not because this tutorial creates a second Deployment.

4. Create the global Model

Open Marketplace > LLM Model Catalog, choose Create, and enter:

FieldQwen example
Provider Typegroq
Physical Model Idqwen/qwen3.6-27b
Model Familyqwen
Model VersionLeave empty unless the provider publishes a separate stable version identifier
Context Token Limit131072
Output Token Limit16384
Modalities["text", "image"]
Operations["generate"]

Use this conservative static capability contract:

{
  "operations": ["generate"],
  "generation": {
    "content": {
      "text": true,
      "images": true,
      "tools": true,
      "parallelTools": false,
      "structuredJson": true,
      "reasoningUsage": false
    },
    "streaming": true
  }
}

The Qwen model page documents vision, tool use, JSON Object Mode, reasoning, and text output. This contract deliberately leaves parallelTools and reasoningUsage false because the checked Groq path rejected the parallel two-tool case and exposed reasoning inside normal message content. Declared capability means the provider path is allowed to satisfy that requirement; it is not descriptive marketing metadata. Set a capability to true only after the full gateway path proves the exact behavior required by the application.

Choose Apply after editing each structured JSON or YAML value, then create the Model. See Create LLM Model for editor behavior and field-level troubleshooting.

5. Create the host Registration

Open Administration > GenAI Admin > LLM Models, select the target host, and open Registrations. Choose Create registration.

Use:

  • LLM Model: the new groq / qwen/qwen3.6-27b catalog Model;
  • Environment: the logical LLM environment served by the target gateway, such as dev or prod;
  • Regions: empty for Groq’s public global endpoint unless an approved provider region is part of the contract;
  • Data Classifications: only classifications approved for this provider path;
  • Capability Restrictions: {} unless the host must narrow the global Model contract.

The Registration environment must match the Public Alias environment and the logical environment of the selected gateway instance at publication time.

6. Create or reuse the Provider Account

Open Accounts. Reuse an existing Groq Account only when it represents the same billing principal and quota pool. Otherwise create one with values such as:

{
  "accountName": "groq-developer",
  "providerType": "groq",
  "billingPrincipal": "groq-project-llm-platform",
  "quotaGroupId": "groq-developer-capacity",
  "capacityMetadata": {
    "modelLifecycle": "preview"
  }
}

billingPrincipal and quotaGroupId are operator-assigned governance names, not secrets. Record actual approved rate-limit metadata when useful; do not copy catalog defaults that differ from the account’s real limits.

See Create Provider Account.

7. Create or reuse the Provider Endpoint

One Groq OpenAI-compatible Endpoint can be reused by compatible Groq chat Deployments under the same Provider Account. In Provider Endpoints, use:

{
  "endpointName": "groq-openai-chat-public",
  "providerProtocol": "openai_chat",
  "baseUrl": "https://api.groq.com/openai/v1",
  "headers": {},
  "endpointAuthMode": "BEARER",
  "apiKeyHeader": null,
  "networkProfileMode": "PUBLIC_TLS",
  "networkTermination": "NATIVE",
  "networkZoneId": null,
  "trustBundleReference": null,
  "poolIdleTimeoutMs": 30000,
  "clientRefreshIntervalMs": 300000,
  "plaintextRiskAcknowledged": false
}

Select the Provider Account created or reused in the previous step. Do not add /chat/completions to baseUrl; the gateway appends it. Do not add an Authorization header or API key. The Credential supplies the bearer token at runtime.

See Create Provider Endpoint.

8. Create the Deployment

In Deployments, select the new Registration, Groq Account, and Groq Endpoint. Use a distinct Deployment rather than rewriting the existing Llama Deployment:

{
  "deploymentName": "groq-qwen3-6-27b-dev",
  "providerType": "groq",
  "providerProtocol": "openai_chat",
  "physicalModelId": "qwen/qwen3.6-27b",
  "baseUrl": "https://api.groq.com/openai/v1",
  "deploymentRevisionId": "groq-qwen3-6-27b-dev/r1",
  "physicalRuntimeId": "groq/qwen-qwen3-6-27b",
  "capacityDomainId": "groq-developer-capacity",
  "runtimeCapacity": {
    "maxParallelRequests": 2,
    "maxQueuedRequests": 32,
    "coldStartTimeoutMs": 30000,
    "streamSetupTimeoutMs": 10000,
    "requestTimeoutMs": 120000
  },
  "readinessPolicy": "IMMEDIATE",
  "expectedSidecar": null,
  "region": null,
  "transportBounds": {}
}

Capacity values are bounded starting assumptions, not a claim about Groq entitlement. Set them from measured latency, the selected account’s rate limits, and the application’s timeout budget. Increase deploymentRevisionId when the callable runtime contract changes.

See Create Provider Deployment.

9. Provision and reference the Credential

Provision GROQ_API_KEY in the secret manager or protected environment of the target llm-gateway workload. In the local portal-config-loc and portal-config-dev Compose paths, the key can be supplied through the private ~/.config/lightapi/light-portal.env file and is passed to the dedicated llm-gateway container. Never commit that file or print its value.

An ENDPOINT credential belongs to the shared endpoint contract. Before creating anything, look for an active credential with the same host, Provider Endpoint, and Credential Version. Reuse it for every compatible Groq Deployment. Do not create one credential per model or Deployment; the control plane enforces endpoint/version uniqueness.

If no matching endpoint credential exists, create one in Credentials:

FieldValue
Credential PurposeENDPOINT
Provider Endpointgroq-openai-chat-public
Provider DeploymentSelect one Deployment attached to the Endpoint if the form requires it; resolution remains endpoint-level
Credential Version1
Secret Referenceenv:GROQ_API_KEY
Effective TimeA current or earlier ISO-8601 UTC timestamp
Expiration TimeEmpty unless the provider key has a known expiry

Restart or explicitly reload the gateway after changing its injected secret. Portal cannot determine whether the referenced environment variable exists.

See Create Provider Credential.

10. Create a demo Alias and Route

In Aliases, create a new Alias for the tool-calling demo:

{
  "environment": "dev",
  "aliasName": "assistant-qwen",
  "operations": ["generate"],
  "requiredCapabilities": {
    "tools": true,
    "streaming": true
  },
  "requireExpectedEmbeddingSpace": false,
  "embeddingWorkloadLane": "standard",
  "maxInputTokens": 65536,
  "maxOutputTokens": 16384,
  "maxRequestBytes": 1048576,
  "dataClassification": "internal",
  "loggingMode": "METADATA",
  "piiMode": "REDACT",
  "aliasVisibility": "PUBLIC"
}

Adjust classification, logging, PII handling, and visibility to the workload’s approved policy. The conservative input bound leaves substantial room for the completion inside the provider context window; applications may choose lower limits.

In Routes, connect assistant-qwen to groq-qwen3-6-27b-dev:

{
  "routePriority": 0,
  "routeWeight": 1,
  "fallbackEnabled": false,
  "canaryPercent": 0,
  "residencyConditions": {}
}

The form supplies the selected Alias and Deployment UUIDs. A Route is not eligible until the Deployment also has an effective Credential and Pricing.

See Create Public Alias and Create Alias Route.

11. Create effective Pricing

In Pricing, select the Qwen Deployment and enter the rates verified on the Groq model page:

{
  "operation": "generate",
  "pricingVersion": 1,
  "pricingBasis": "EXTERNAL_PROVIDER",
  "inputMicrosPerMillion": 600000,
  "outputMicrosPerMillion": 3000000,
  "effectiveTs": "2026-08-14T00:00:00Z",
  "expiresTs": null,
  "source": "https://console.groq.com/docs/model/qwen/qwen3.6-27b",
  "approvedBy": "llm-platform-owner"
}

Rates are integer micros per one million tokens: one currency unit is 1,000,000 micros. Leave Cached Input Micros empty because the cited Qwen page does not publish a cached-input rate. Replace the values when the provider contract changes, create a new Pricing Version, and publish again.

See Create Pricing Version.

Repeat the records for GPT-OSS

Reuse the same Groq Provider Account, Endpoint, and active endpoint Credential. Create separate Model, Registration, Deployment, Pricing, Alias, and Route records with these differences:

FieldGPT-OSS value
Physical Model Idopenai/gpt-oss-120b
Model Familygpt
Context / Output Token Limit131072 / 65536
Modalities / Operations["text"] / ["generate"]
Deployment Namegroq-gpt-oss-120b-dev
Runtime CapacityStart with maxParallelRequests: 2
Aliasassistant-gpt-oss
Alias Input / Output Limit65536 / 32768
Input / Output Micros per Million150000 / 600000

Use the same conservative generation capability object as Qwen except set images: false. Keep parallelTools: false until a workload-specific parallel test passes through the full gateway path.

12. Generate and publish the gateway configuration

Open Publication in the LLM Model Control Plane:

  1. Select the target Instance Env Tag.
  2. Select the active gtw LLM Gateway Instance.
  3. Confirm that the displayed LLM environment matches the Registration and Alias environment.
  4. Choose Generate from active records.
  5. Review the read-only typed properties. They must contain the exact model ID, openai_chat, the Groq base URL, declared capabilities, Alias Route, effective Pricing, and only the credential reference.
  6. Confirm that no raw provider key appears anywhere in the preview.
  7. Choose Publish to instance.
  8. Create and promote the corresponding configuration snapshot, then reload or restart the selected gateway as required by the deployment.
  9. If you used reload, confirm that the llm-router module reload succeeded. If you restarted, confirm the gateway loaded the promoted snapshot and reports the LLM module as available before sending an upstream validation request.

Publication applies instance properties. Snapshot promotion plus a successful startup or explicit llm-router reload proves that the configuration was loaded. It does not prove that Groq accepted a request or that the model meets the workload’s quality requirements.

13. Validate through the live gateway

Prepare a protected header file containing the gateway caller token. This is not GROQ_API_KEY:

set -euo pipefail
umask 077

gateway_header_file=$(mktemp)
trap 'rm -f -- "$gateway_header_file"' EXIT
read -rsp 'Gateway client bearer token: ' gateway_client_token
printf '\n'
printf 'Authorization: Bearer %s\n' "$gateway_client_token" >"$gateway_header_file"
unset gateway_client_token

List the Aliases visible to this caller:

curl --fail-with-body --silent --show-error \
  --header @"$gateway_header_file" \
  --cacert /path/to/gateway-ca.pem \
  https://localhost:8444/v1/models

Then send a small request through the Alias:

curl --fail-with-body --silent --show-error \
  --header @"$gateway_header_file" \
  --header 'Content-Type: application/json' \
  --cacert /path/to/gateway-ca.pem \
  --data '{
    "model": "assistant-qwen",
    "messages": [{"role": "user", "content": "Reply with exactly: gateway-ok"}],
    "temperature": 0,
    "max_tokens": 512,
    "stream": false
  }' \
  https://localhost:8444/v1/chat/completions

Because this demo depends on tool calling, also send a request containing a realistic function schema:

curl --fail-with-body --silent --show-error \
  --header @"$gateway_header_file" \
  --header 'Content-Type: application/json' \
  --cacert /path/to/gateway-ca.pem \
  --data '{
    "model": "assistant-qwen",
    "messages": [{
      "role": "user",
      "content": "Use the supplied tool to get the status of the MCP demo."
    }],
    "tools": [{
      "type": "function",
      "function": {
        "name": "get_demo_status",
        "description": "Get the readiness status of a product demo feature.",
        "parameters": {
          "type": "object",
          "properties": {
            "feature": {
              "type": "string",
              "enum": ["mcp", "workflow"]
            }
          },
          "required": ["feature"],
          "additionalProperties": false
        }
      }
    }],
    "tool_choice": "required",
    "temperature": 0,
    "max_tokens": 512,
    "stream": false
  }' \
  https://localhost:8444/v1/chat/completions

The response must contain choices[0].message.tool_calls, select get_demo_status, and encode {"feature":"mcp"} as valid JSON arguments. For full acceptance, let the application execute the tool, append the returned assistant message and a tool message with the matching tool_call_id, then send the conversation back through the same Alias. The model must consume the tool result and either produce the final answer or request the next valid tool.

Use the real production origin and system trust store in production. Never use -k, disable certificate verification, or point the client at the provider URL. A successful response proves one normal routed request through gateway authentication, authorization, Alias selection, credential resolution, provider dispatch, response decoding, usage, pricing, and audit.

For the checked local smoke suite, use:

cd /home/steve/workspace/light-portal-test
make llm LLM_PUBLIC_ALIAS=assistant-qwen

The suite consumes provider quota and may be billable depending on the account plan. Keep load and token counts bounded.

14. Run a workload acceptance matrix

Test the behavior the application actually uses, not only a greeting:

AreaMinimum evidence
Basic chatRepresentative prompts return usable answers without unsupported fields.
StreamingValid SSE frames terminate cleanly and preserve usage/audit behavior.
Tool callingTool names and JSON arguments are valid; required and parallel-tool cases behave as declared.
Structured outputRequired JSON mode/schema behavior is valid for the selected protocol.
Reasoning controlsIf provider-specific reasoning controls are exposed and policy-allowlisted, supported values behave correctly and hidden reasoning is not exposed unexpectedly.
VisionRequired image formats, sizes, counts, and response behavior pass if the workload uses images.
Context/output limitsRequests near the approved application bounds fail or complete predictably.
Safety and PIIPrompt-injection, sensitive-data, logging, and redaction expectations pass.
ReliabilityRate limits, timeouts, cancellation, retryability, and fallback behavior are understood.
QualityA fixed representative tool set is scored against both Qwen and GPT-OSS before assigning an agent Alias.
Quota/latencyObserved token use, time to first token, and completion latency fit the Free Plan demo limits.

If a test fails, narrow the declared capabilities or fix the provider path. Do not leave an unproven capability set to true merely to make an Alias eligible.

15. Promote on the development host

After the replacement passes:

  1. Ensure the stable development Alias has a compatible Qwen Deployment, effective Credential, and Pricing.
  2. Move the old primary Route to a higher unique priority and mark it as a fallback, or remove it if it can no longer serve traffic.
  3. Add Qwen at priority 0 with fallback disabled.
  4. A fallback is optional for a non-production demo. If continuity matters, use another currently served and tested model rather than the soon-to-be-decommissioned Llama model.
  5. Generate fresh typed properties, inspect the route order, publish, promote the snapshot, and confirm startup or the explicit llm-router reload.
  6. Run the same smoke request through the unchanged stable Alias.
  7. Retain the old records for audit until the rollback window closes; do not rewrite the Qwen Deployment to represent another physical model.

After August 16, 2026, llama-3.3-70b-versatile is not a functional rollback for affected Groq tiers. Complete the replacement and fallback publication before the shutdown date.

Adapt the workflow to Amazon Bedrock

Amazon Bedrock now offers OpenAI-compatible Chat Completions and Responses APIs and an Anthropic-compatible Messages API. Prefer the bedrock-mantle endpoint when the selected model supports it. The Portal mapping for compatible Chat Completions is:

Portal fieldBedrock-compatible value
Provider Typebedrock
Provider Protocolopenai_chat
Base URLhttps://bedrock-mantle.{region}.api.aws/v1
Endpoint AuthenticationBEARER
Secret Referenceenv:AWS_BEARER_TOKEN_BEDROCK
RegionThe approved AWS Region
Physical Model IdExact model or inference-profile ID returned for that endpoint/account

Use an Amazon Bedrock API key for this generic bearer path. The current llm-gateway provider client can send no authentication, bearer authentication, or an API key in authorization/x-api-key; it does not generate AWS SigV4 signatures. If the selected Bedrock model is available only through native Converse/InvokeModel, or organizational policy requires SigV4, configuration alone is insufficient. First deploy an approved TLS adapter/sidecar that exposes one of the supported gateway protocols, or add and qualify a native provider adapter. Never mislabel the native Bedrock API as openai_chat.

Bedrock model IDs, inference profiles, regional availability, capabilities, and pricing are account- and region-specific. Discover them from the chosen endpoint and AWS account, then create the reference values, global Model, Registration, Account, Endpoint, Deployment, Credential, Alias, Route, and Pricing exactly as above.

See:

Troubleshooting

SymptomLikely cause and correction
Provider, model, or family dropdown is emptyAdd the missing global reference value, locale, provider_name relation, and model_name_family relation.
Provider returns 404Check the exact base URL and physical model ID. The base URL must not already contain the operation path.
Provider returns 401 or 403Confirm model permission and that the provider key is injected into the selected llm-gateway process under the variable named by secretReference.
Publish reports no eligible CredentialCheck purpose, Endpoint/Deployment references, version, database-clock effective time, and expiration.
Publish reports no effective PricingCreate a generate price for a chat/responses/messages Deployment and make its effective window current.
Route is incompatibleAlign host, logical environment, provider type, physical model, protocol operation, Registration restrictions, and required capabilities.
Gateway returns Alias not foundConfirm the caller can see the Alias and the intended config snapshot was loaded by startup or a successful llm-router reload.
Gateway returns 503 after publicationInspect the complete llm-router properties and gateway logs. One malformed Deployment capability/protocol/pricing contract can invalidate configuration beyond the new Alias.
Gateway reports LLM_CONFIG_INVALIDInspect every provider in the published snapshot. A missing environment variable such as an unused provider’s secret reference can invalidate the complete router; omit that provider from the snapshot or inject its secret.
Qwen returns a provider 400 for tool choiceUse the portable string form "tool_choice":"auto" or "required"; the checked Groq Qwen path rejected a named-function object.
Qwen exhausts a small output budget or returns <think> textReasoning can consume completion tokens and may appear in message content. Allow a bounded larger completion budget and make the application handle or reject visible reasoning explicitly.
A parallel tool request failsKeep parallelTools: false, avoid requiring parallel calls on the Alias, and sequence tool calls until the exact provider/model path is qualified.
A provider-specific request field is rejectedThe gateway rejects unknown extensions by default. Use only a reviewed field explicitly permitted by the applicable native-extension Policy, or omit it.
Structured JSON editor looks correct but Create is disabledChoose Apply to commit the JSON/YAML draft to the form model.
Secret appears in a preview, event, log, or ticketStop, revoke and rotate it, remove the exposed material through the approved incident process, and retain only an external reference.

For the complete control-plane record order and publication behavior, see LLM Model Control Plane.

Retire an LLM from llm-gateway

This tutorial removes a provider model from active routing without changing the model name used by applications. The worked example retires Groq’s llama-3.3-70b-versatile before its August 16, 2026 decommission date and moves the existing assistant-dev Alias to qwen/qwen3.6-27b.

Use the same workflow for a Bedrock model, a provider migration, or any other model lifecycle change. The important sequence is always qualify, cut over, publish, validate, delete, and publish again. Do not begin with Delete.

Applications and agents should call a stable Public Alias such as assistant-dev. If they call a physical model ID directly, change them to an Alias before retiring the model.

What retirement means

Retirement is a soft deletion from the active LLM control-plane configuration. It preserves event history for audit and replay, but excludes the retired records from new publications.

Deleting a global Model also soft-deletes its active dependent records:

  • Model Registrations;
  • Provider Deployments;
  • Alias Routes that still target those Deployments;
  • Provider Credentials that still belong to those Deployments; and
  • Pricing Versions for those Deployments.

The cascade is useful for model-exclusive data, but it makes deletion the last step. Move every shared or reusable dependency first.

Warning

A Credential is resolved through a Provider Endpoint, but the control-plane record also belongs to a Provider Deployment. If the active Credential still names the retiring Deployment, deleting the Model deactivates that Credential and can break other models using the same Endpoint. Reassign or replace the Credential before deleting the Model.

Do not delete a shared Provider Account or Provider Endpoint when only one model is retiring. Do not delete the model’s global reference-table values or locale labels merely to hide an inactive model; they can be needed for event history, audit, and replay.

1. Inventory the dependency graph

Open Administration > GenAI Admin > LLM Models, select the target host and environment, and identify:

  1. the global Model and its current Aggregate Version;
  2. every Registration for that Model;
  3. every Deployment under those Registrations;
  4. every Alias Route targeting those Deployments;
  5. every Credential and Pricing Version attached to those Deployments; and
  6. every Agent, policy binding, workflow, or test that uses the affected Public Alias.

Record the current publication version and export or retain a tested event baseline before changing anything. Aggregate versions shown in the control plane are required for update and delete commands; do not guess them from an older event file.

For the Groq example, the initial relationships are:

flowchart LR
    A[assistant-dev] --> L[Llama Deployment]
    A --> G[Gemini fallback]
    C[Groq endpoint Credential] --> L
    Q[assistant-qwen] --> D[Qwen Deployment]
    D --> E[Shared Groq Endpoint]
    L --> E

The Alias Route and Credential must move from the Llama Deployment to the Qwen Deployment before Llama is deleted.

2. Qualify the replacement

Complete Onboard an LLM Through llm-gateway for the replacement model before changing an existing Alias. Confirm that its Model, Registration, Deployment, Credential, Pricing, Alias, and Route are active.

Publish the replacement under a temporary or model-specific Alias, then run the acceptance cases used by the real agents:

  • a normal non-streaming response;
  • a streaming response;
  • every required tool schema and representative arguments;
  • the tool-result continuation turn;
  • structured JSON, if used;
  • input and output limits; and
  • expected provider errors, timeouts, and rate limits.

Do not infer compatibility from a provider benchmark. Retirement is safe only after the replacement passes the application’s actual request shapes.

3. Cut over the stable Alias

In Alias Routes, update the route that currently targets the retiring Deployment:

FieldGroq example
Public Aliasexisting assistant-dev Alias
Provider Deploymentgroq-qwen3-6-27b-dev
Route Priority0
Route Weight1
Fallback Enabledfalse
Canary Percent0

Keep the existing Alias ID. Consumers should not need configuration or code changes. If a second compatible route remains, verify its priority explicitly; do not rely on row insertion order.

If the replacement has lower context or output limits, update the Public Alias limits before the cutover or ensure callers already stay within the replacement contract.

4. Preserve shared credentials

If the retiring Deployment owns a Credential needed by a retained model, use one of these approaches:

  • update the existing Credential so Provider Deployment names a retained Deployment on the same Endpoint; or
  • create a new active Credential version for the retained Deployment and verify it is the effective Endpoint credential.

Keep Provider Endpoint, Credential Purpose, and the external secret reference consistent unless this change also rotates credentials. For the Groq example, the reference remains env:GROQ_API_KEY; no API key value is stored in Portal or in an event file.

Before proceeding, confirm the retained Qwen and GPT-OSS Deployments resolve an active, effective, unexpired Credential through the shared Groq Endpoint.

5. Publish and validate the cutover

Open Publication, regenerate the candidate, validate it, and publish it to the selected llm-gateway instance. Check that:

  • assistant-dev resolves to the replacement Deployment first;
  • the retiring Deployment is no longer referenced by an active route;
  • provider, credential, pricing, and capability validation succeeds; and
  • the target gateway starts from the promoted snapshot or successfully reloads llm-router.

Call /v1/models and /v1/chat/completions through llm-gateway with the stable Alias. Repeat the agent tool-calling tests. Keep this routed state for a short observation window when the environment is shared with other users.

If validation fails, move the Alias Route back to the previous Deployment and publish again. This is the simplest rollback and is available only while the provider still serves the old model.

6. Delete the global Model

After the stable Alias is proven on the replacement, open LLM Models, find the exact provider and physical model ID, and choose Delete. Confirm the current Aggregate Version.

For this example, delete only:

providerType: groq
physicalModelId: llama-3.3-70b-versatile

Do not delete qwen/qwen3.6-27b, openai/gpt-oss-120b, the Groq Provider Account, or the shared Groq Endpoint.

The delete event deactivates the Model and any dependents that still point to its Deployment. Verify that the previously moved Alias Route and Credential remain active. If they were unexpectedly deactivated, stop and repair the control-plane rows before publishing.

7. Publish the retired state

Generate and publish the next immutable gateway revision. The candidate must:

  • omit the retired Model and Deployment;
  • omit its inactive Registration and Pricing;
  • retain the replacement routes and effective credentials;
  • contain no Alias that references the retired Deployment; and
  • pass the expected provider, deployment, and Alias counts.

Validate /v1/models, normal generation, streaming, and tool calling once more. A database row marked inactive is not sufficient: retirement is complete only when the gateway is running a publication that no longer contains the model.

8. Preserve the local baseline

Portal control-plane changes are authored and validated only in the local portal-config-loc/all-in-lt database. Import the reviewed retirement event file there, verify the read models and live gateway behavior, and then export a new global snapshot to recreate the canonical events.json.

Use that exported events.json to recreate the databases for portal-config-dev and light-portal-install. Do not maintain a separate development-only copy of the LLM gateway control-plane records; those environments are downstream consumers of the local global snapshot.

After recreating a database, verify that event replay produces the same active Models, Deployments, Aliases, Routes, Credentials, Pricing Versions, and latest publication as the local baseline.

Event-file checklist

An import-ready retirement event file normally contains, in this order:

  1. updates or creates that move routes and credentials to retained Deployments;
  2. LlmModelDeletedEvent with the Model’s current Aggregate Version; and
  3. LlmGatewayInstancePublicationCreatedEvent containing the post-retirement properties and the next application version.

Before import, validate that:

  • every randomly generated event or entity ID is UUID v7, while deterministic publication IDs match the control-plane derivation contract;
  • subject, payload identity, Aggregate Type, and versions agree;
  • update/delete versions match the current local event streams;
  • the importer will allocate each nonce: "0" sentinel atomically;
  • event IDs and subject/version pairs do not already exist; and
  • the publication contains neither the retired Deployment nor a stale Alias route to it.

Generate the file from current local read models immediately before the change. A previously generated retirement file becomes stale as soon as one of its aggregates or the publication version changes.

Rollback after deletion

Deleting the Model cascades across several independently versioned aggregates. Updating only the Model does not reactivate all of them. A rollback after deletion therefore requires either:

  • explicit, version-correct reactivation or recreation of the Model and every required dependent aggregate, followed by a new publication; or
  • restoration and replay of a previously tested canonical event baseline.

Prefer routing rollback before deletion. Once the provider’s decommission date passes, the old physical model is not a usable fallback even if its Portal records are restored.

Create the workflow MCP smoke tool from the portal

This tutorial creates the workflow-mcp-smoke workflow and exposes it as the workflow_mcp_smoke MCP tool. Create both resources through the portal UI. Do not insert rows into wf_definition_t, tool_t, or workflow_tool_binding_t.

Light Portal uses event sourcing. The command services append domain events, and the query-side projector derives the database read models from those events:

Create Workflow Definition
  -> WorkflowDefinitionCreatedEvent
  -> wf_definition_t

Create Tool with Execution Placement = workflow
  -> ToolCreatedEvent
  -> tool_t
  -> workflow_tool_binding_t

The workflow binding is part of the tool creation contract. There is no separate Create Workflow Tool Binding page.

Prerequisites

  • Sign in to the portal and select the host where the smoke tool will run.
  • Use an account that can administer workflow definitions and GenAI tools.
  • Make sure light-workflow, the portal command service, the portal query service, PostgreSQL, and the event projector are running.
  • Download the versioned workflow-mcp-smoke definition.

The host must not already contain an active workflow with this identity:

namespace: light-demo
name: workflow-mcp-smoke
version: 1.0.0

It must also not contain an active tool named workflow_mcp_smoke that was inserted by an older SQL fixture. A projection-only record has no aggregate history and cannot safely be replaced with the normal Create command. Start with a clean database or have an administrator quarantine/remove the legacy fixture before following this tutorial. Do not use the normal Update or Delete buttons to repair projection-only data.

Workflow definition

The importable YAML is maintained with this tutorial and is included below so the rendered mdBook and downloadable asset cannot drift apart:

document:
  dsl: "1.0.3"
  namespace: light-demo
  name: workflow-mcp-smoke
  version: "1.0.0"
  title: Workflow MCP Smoke Test
  summary: Deterministic read-only workflow used to qualify the gateway-to-workflow MCP path.
  tags:
    testType: local
    dependencies: none
evaluate:
  language: cel
input:
  schema:
    format: json
    document:
      type: object
      additionalProperties: false
      required:
        - message
      properties:
        message:
          type: string
          minLength: 1
          maxLength: 256
output:
  schema:
    format: json
    document:
      type: object
      additionalProperties: false
      required:
        - message
        - executedBy
      properties:
        message:
          type: string
        executedBy:
          type: string
do:
  - finish:
      set:
        message: "${{ message }}"
        executedBy: light-workflow

1. Create the workflow definition

  1. In the portal sidebar, expand Workflow Admin and select Wf Definition.

  2. Select Create New WfDefinition.

  3. In Workflow Editor, select Import and open workflow-mcp-smoke.yaml.

  4. Confirm the editor populated these values:

    FieldValue
    Namespacelight-demo
    Nameworkflow-mcp-smoke
    Version1.0.0
    Publish in Workflow CatalogEnabled

    The Host ID comes from the signed-in user’s selected host. Leave the Workflow Definition ID empty; the command service generates it.

  5. Select Validate and resolve any reported errors.

  6. Select Save once. A successful save displays Workflow definition saved and assigns a Workflow Definition ID.

  7. Record the generated Workflow Definition ID as <wfDefId>. You will use it when creating the tool.

  8. Return to Wf Definition, refresh the table, and use Update to confirm that the saved definition can be loaded. This also confirms that the event and projection have reached the same aggregate version.

Do not immediately submit the same Create form again while waiting for the projection. Refresh the list instead.

2. Create the workflow-backed tool and binding

  1. In the sidebar, expand GenAI Admin and select Tool.

  2. Select Create Workflow-backed Tool.

  3. Enter the following tool values. Fields not listed here can remain empty.

    FieldValue
    Nameworkflow_mcp_smoke
    DescriptionRun a deterministic read-only workflow and return the supplied message.
    Routing DomainWorkflow
    Semantic Namespaceworkflow-smoke
    Semantic DescriptionQualify workflow-backed MCP execution.
    Lifecycle Statusactive
    Read OnlyEnabled
    IdempotentEnabled
    DestructiveDisabled
    Human Approval RequiredDisabled
    Version1.0.0
    Execution Placementworkflow
    Workflow Definitionlight-demo/workflow-mcp-smoke @ 1.0.0

    Implementation Type is not required for a workflow-placed tool. The workflow binding, rather than a Java class, REST endpoint, script, or MCP server, identifies its execution target.

  4. Portal loads the immutable workflow definition and derives the internal definition, schema, and policy digests. These implementation fields are not entered by the user. The reviewed workflow-tool runtime profile supplies the binding defaults.

  5. Submit Create Tool Form once.

  6. Record the generated Tool ID as <toolId>. The command service uses this as the stable tool reference unless one was explicitly supplied. It also generates the binding ID because bindingId was omitted from the JSON.

  7. Return to Tool, refresh the table, and open Update Tool for workflow_mcp_smoke. Confirm that:

    • Execution Placement is workflow.
    • Stable Tool Reference equals <toolId>.
    • Workflow Binding contains the selected Workflow Definition ID and a generated bindingId.
    • The tool is active.

For a synchronous binding, the portal requires a read-only, non-destructive, headless tool: Read Only must be enabled while Destructive and Human Approval Required remain disabled. The server derives the binding integrity fields from the selected published workflow version and tool schemas.

3. Point light-gateway at the generated identities

The database identities are generated by the event-sourced Create commands, so do not keep the fixed 22000000-... IDs from the old SQL fixture.

Open portal-config-loc/all-in-lt/light-gateway-rust/config/mcp-router.yml and find the workflow_mcp_smoke entry. Update only these identity fields:

workflowBinding:
  stableToolRef: <toolId>
  workflowDefinitionId: <wfDefId>

Keep the workflow version, schemas, digests, timeouts, result mode, and budget aligned with the values entered in the portal. Reload or restart light-gateway after changing its configuration.

The light-gateway file is runtime configuration, not a light-portal projection. Updating it does not replace the requirement to create the portal entities through commands/events.

4. Verify the smoke path

  1. In Workflow Admin > Wf Definition, confirm that workflow-mcp-smoke has exactly one active row and that Update opens it without an aggregate-version error.

  2. In GenAI Admin > Tool, confirm that workflow_mcp_smoke has exactly one active row and that Update Tool shows the workflow binding.

  3. From a terminal on the Docker host, set a valid portal authorization value. Keep the Bearer prefix:

    export LIGHT_PORTAL_AUTHORIZATION='Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkFacDAyVzZqZGNxWHpxTkk2MVlaelEifQ.eyJpc3MiOiJ1cm46Y29tOm5ldHdvcmtudDpvYXV0aDI6djEiLCJhdWQiOiJ1cm46Y29tLm5ldHdvcmtudCIsInN1YiI6IjAxOTY0YjA1LTU1MzItN2M3OS04Y2RlLTE5MWRjYmQ0MjFiOCIsImV4cCI6MTc4NjYyMjU0MywianRpIjoiRkMxM0ZmYzNUMm0zLXlMT1RHc3NHQSIsImlhdCI6MTc4NjYyMTk0MywibmJmIjoxNzg2NjIxODIzLCJ2ZXIiOiIxLjAiLCJjaWQiOiJmN2Q0MjM0OC1jNjQ3LTRlZmItYTUyZC00YzU3ODc0MjFlNzIiLCJzY3AiOlsicG9ydGFsLnIiLCJwb3J0YWwudyJdLCJjbGllbnRfaWQiOiJmN2Q0MjM0OC1jNjQ3LTRlZmItYTUyZC00YzU3ODc0MjFlNzIiLCJzY29wZSI6InBvcnRhbC5yIHBvcnRhbC53IiwiY3NyZiI6IjNFS3lmUkxpVFBtTFM2QXVIMEZNcGciLCJlaWQiOiJzaDM1IiwiZW1sIjoic3RldmUuaHVAbGlnaHRhcGkubmV0IiwiaG9zdCI6IjAxOTY0YjA1LTU1MmEtN2M0Yi05MTg0LTY4NTdlN2YzZGM1ZiIsInJvbGUiOiJhY2NvdW50LW1hbmFnZXIgYWRtaW4gZ2l0aHViLXJlYWRlciBob3N0LWFkbWluIG1jcC1yZWFkZXIgdXNlciIsInVpZCI6IjAxOTY0YjA1LTU1MzItN2M3OS04Y2RlLTE5MWRjYmQ0MjFiOCIsInV0eSI6IkUifQ.D47-XR66YEdm_KLr8sgNyKxmuXeLtqjMv0h4AIhf8w5ph0T-l4Cgyc2GIP76finZjy04OguEeAfN_qqAqQu2sLb-OOTo3-WekSmmKQAX5yJLKZJup8DbShNydhTES4GklgLVltF86Cj5npJBtj8VF3Kptd67gdrZ8TF-7o9DvLjD8Umv2kz1vjijCX0J5xyjjX8HFC7cOGsHd8gKieGTZfVykTcJlZMjbzU3zlYIIjYlkmpLrQDai56eJF7wKr-CoXV3Gg3fXjUK7wsLfo1nbBLr0I20qMdXAJLYnfoXszaN3ZYGKl27W-Hh6X5iITFrTD92XavAVU24QEltjJzk7A'
    

    Then query light-gateway directly through its published HTTPS port with MCP tools/list:

    curl -skS --max-time 40 \
      -H "Authorization: $LIGHT_PORTAL_AUTHORIZATION" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "MCP-Protocol-Version: 2026-07-28" \
      -H "MCP-Method: tools/list" \
      --data-binary '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"workflow-smoke","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}}' \
      https://localhost/mcp
    

    Confirm that the response advertises a tool whose name is workflow_mcp_smoke.

  4. Invoke the advertised tool with a tools/call request:

    curl -skS --max-time 40 \
      -H "Authorization: $LIGHT_PORTAL_AUTHORIZATION" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "MCP-Protocol-Version: 2026-07-28" \
      -H "MCP-Method: tools/call" \
      -H "MCP-Name: workflow_mcp_smoke" \
      --data-binary '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"workflow_mcp_smoke","arguments":{"message":"hello from workflow MCP"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"workflow-smoke","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}}' \
      https://localhost/mcp
    
  5. Confirm that the response has isError: false, contains the supplied message, and includes:

    {
      "executedBy": "light-workflow"
    }
    

For repeatable regression coverage against portal-config-loc or a development light-portal-install deployment, run the corresponding light-portal-test lane:

cd /home/steve/workspace/light-portal-test
make workflow-mcp

Troubleshooting

The workflow Create event goes to the DLQ

Check for an older projection-only row with the same host, namespace, name, and version. Do not edit the new event payload. Remove or quarantine the invalid legacy fixture, then use the supported event replay flow so the valid WorkflowDefinitionCreatedEvent builds the projection.

The tool Create event goes to the DLQ

Check for an older projection-only workflow_mcp_smoke tool or binding before retrying. Also confirm that <wfDefId> identifies an active workflow at version 1.0.0; the projector rejects a workflow binding whose referenced definition/version is unavailable.

The Create Tool form rejects the binding

Verify all of the following:

  • wfDefId is a UUID.
  • All four digests start with sha256: and contain 64 lowercase hexadecimal characters.
  • The binding and top-level Schema Digest values are identical.
  • invocationMode is sync and executionClass is interactive.
  • The tool is read-only, non-destructive, and does not require human approval.
  • Structured-data edits were applied before the form was submitted.

tools/list still shows the old IDs

Update the light-gateway mcp-router.yml entry with the IDs generated by the portal and restart or reload light-gateway. Do not change projection-table IDs to match the old configuration.

Build a Customer 360 workflow-backed MCP tool

This tutorial exposes three existing REST reads as one customer_360 MCP tool. A Serverless Workflow definition runs the calls in parallel and appends their responses into one ordered result array:

customer_360({customerId, channel})
  -> light-gateway /mcp
  -> light-workflow
       |-- GET /customers/{customerId}
       |-- GET /customers/{customerId}/preferences?channel={channel}
       `-- GET /customers/{customerId}/policies
  -> [{profile}, {preferences}, {policies}]

The demo reuses demo-customer-profile-api; it does not deploy an MCP wrapper for the REST API.

Create the workflow definition and tool through the Portal UI. Do not insert rows into wf_definition_t, tool_t, workflow_tool_binding_t, or workflow_endpoint_target_t. Light Portal is event sourced:

Create Workflow Definition
  -> WorkflowDefinitionCreatedEvent
  -> wf_definition_t

Create Tool with a workflow binding and endpoints
  -> ToolCreatedEvent
  -> tool_t
  -> workflow_tool_binding_t
  -> workflow_endpoint_target_t

The workflow binding and its registered endpoint targets are part of the Create Tool command. There is no separate binding or endpoint-target Create page.

What this simplified demo covers

  • REST API aggregation without an MCP wrapper service.
  • Parallel orchestration with a workflow fork.
  • A simple append transformation into one MCP response.
  • Portal command/event creation of the workflow, tool, binding, and endpoint registrations.
  • Static mcp-router.yml publication for a local Light-Fabric instance.

This is an incremental demo, not the final transformer-tool implementation. The current workflow HTTP executor calls the internal demo endpoints directly and does not forward the MCP caller’s JWT. In addition, the current non-competing fork fails the invocation when any branch fails; it does not yet return the required partial response. Original-JWT propagation, gateway-routed API destinations, UI-side transformation validation, config-server publication, and partial-error append behavior remain follow-up work.

Prerequisites

  • Start the portal-config-loc/all-in-lt Rust stack.
  • Confirm light-gateway, light-workflow, hybrid-command, hybrid-query, PostgreSQL, and demo-customer-profile-api are healthy.
  • Sign in to Light Portal and select the target host.
  • Use an account that can administer workflow definitions and GenAI tools.
  • Download the versioned customer-360-mcp definition.
  • Confirm that the host does not already have an active workflow named customer-360-mcp at 1.0.0 or an active tool named customer_360.

The included demo data uses customer CUST-1001 and channel portal.

Workflow definition

The importable YAML is maintained with this tutorial and is included below so the rendered mdBook and downloadable asset cannot drift apart:

document:
  dsl: "1.0.3"
  namespace: light-demo
  name: customer-360-mcp
  version: "1.0.0"
  title: Customer 360 Workflow MCP Demo
  summary: Read a customer profile, preferences, and policies in parallel and append the API responses.
  tags:
    demo: workflow-mcp
    capability: aggregation
evaluate:
  language: cel
input:
  schema:
    format: json
    document:
      type: object
      additionalProperties: false
      required:
        - customerId
        - channel
      properties:
        customerId:
          type: string
          minLength: 1
          maxLength: 64
        channel:
          type: string
          minLength: 1
          maxLength: 32
output:
  schema:
    format: json
    document:
      type: object
      additionalProperties: false
      required:
        - customerId
        - results
      properties:
        customerId:
          type: string
        results:
          type: array
          minItems: 3
          maxItems: 3
          items:
            type: object
            additionalProperties: false
            required:
              - source
              - status
              - data
            properties:
              source:
                type: string
              status:
                type: string
                const: success
              data:
                type: object
do:
  - loadCustomerContext:
      fork:
        branches:
          - profile:
              call: http
              with:
                method: GET
                endpoint:
                  uri: http://demo-customer-profile-api:8085/customers/${{customerId}}
                output: content
              metadata:
                endpointRef: customer-360.profile
          - preferences:
              call: http
              with:
                method: GET
                endpoint:
                  uri: http://demo-customer-profile-api:8085/customers/${{customerId}}/preferences?channel=${{channel}}
                output: content
              metadata:
                endpointRef: customer-360.preferences
          - policies:
              call: http
              with:
                method: GET
                endpoint:
                  uri: http://demo-customer-profile-api:8085/customers/${{customerId}}/policies
                output: content
              metadata:
                endpointRef: customer-360.policies
        compete: false
      export:
        as:
          responses: .output
  - appendResponses:
      set:
        customerId: "${{ customerId }}"
        results:
          - source: profile
            status: success
            data: "${{ responses.profile }}"
          - source: preferences
            status: success
            data: "${{ responses.preferences }}"
          - source: policies
            status: success
            data: "${{ responses.policies }}"

1. Review the workflow

The workflow has one non-competing fork with three GET branches. Each HTTP task declares metadata.endpointRef; a workflow-backed invocation is allowed to call only an active endpoint registered by the tool binding with the same reference and method.

After the fork joins, appendResponses produces this response shape:

{
  "customerId": "CUST-1001",
  "results": [
    {"source": "profile", "status": "success", "data": {}},
    {"source": "preferences", "status": "success", "data": {}},
    {"source": "policies", "status": "success", "data": {}}
  ]
}

The fixed order makes the append transformation predictable even though the three calls execute concurrently.

2. Create the workflow definition

  1. In the Portal sidebar, expand Workflow Admin and select Wf Definition.

  2. Select Create New WfDefinition.

  3. In Workflow Editor, select Import and open customer-360-mcp.yaml.

  4. Confirm these values:

    FieldValue
    Namespacelight-demo
    Namecustomer-360-mcp
    Version1.0.0
    Publish in Workflow CatalogEnabled

    The selected host supplies Host ID. Leave Workflow Definition ID empty so the command handler generates it.

  5. Select Validate and resolve any error.

  6. Select Save once. Wait for Workflow definition saved.

  7. Return to Wf Definition, refresh the list, and open the new row with Update. Record its generated Workflow Definition ID as <wfDefId>.

Opening the Update page confirms that the event and projection are both available. Do not submit Create repeatedly while waiting for projection.

The normalized definition digest for the supplied file is:

sha256:a7c1c07164110840222fd2528122d75ced9f69d4e7b7b2944ea15dddd14e10ae

Changing the workflow changes this digest. If you edit the definition, compute and use the new value consistently in the tool binding and router config.

3. Create the tool, binding, and endpoint registrations

  1. In the sidebar, expand GenAI Admin and select Tool.

  2. Select Create Workflow-backed Tool.

  3. Enter these values. Unlisted optional fields can remain empty.

    FieldValue
    Namecustomer_360
    DescriptionRead a customer profile, preferences, and policies in parallel and append the API responses.
    Routing DomainCustomer
    Semantic Namespacecustomer-360
    Semantic DescriptionAggregate customer context for an agent in one read-only call.
    Lifecycle Statusactive
    Read OnlyEnabled
    IdempotentEnabled
    DestructiveDisabled
    Human Approval RequiredDisabled
    Version1.0.0
    Execution Placementworkflow
    Workflow Definitionlight-demo/customer-360-mcp @ 1.0.0

    Implementation Type is not required for a workflow-placed tool.

  4. Portal loads the immutable workflow definition and derives the internal definition, schema, and policy digests. These implementation fields are not entered by the user. The reviewed workflow-tool runtime profile supplies execution limits and resolves cataloged endpoint references.

  5. Submit Create Tool Form once.

  6. Return to Tool, refresh the list, and open customer_360 with Update Tool. Record the generated Tool ID as <toolId> and confirm:

    • Execution Placement is workflow.
    • Stable Tool Reference equals <toolId>.
    • The binding contains the selected Workflow Definition ID and a generated bindingId.
    • All three endpoint entries are present.

The command handler generates the Tool ID and binding ID. The event projector creates all four read-model types from the one ToolCreatedEvent; do not fill in any missing projection row manually.

The reviewed runtime profile must permit at least three parallel branches for this workflow. Confirm that limit when reviewing the generated binding; Portal does not currently validate workflow structure against runtime-profile limits.

4. Enable the static light-gateway entry

Open portal-config-loc/all-in-lt/light-gateway-rust/config/mcp-router.yml. The local demo configuration includes a complete customer_360 entry after the smoke tool.

  1. Set workflowBinding.stableToolRef and workflowBinding.workflowDefinitionId to the IDs generated by your Portal commands. The IDs shipped in a developer checkout are valid only for the database in which those Create events were produced.

  2. Keep the - name: customer_360 entry aligned under tools:.

  3. Do not change the schemas, digests, timeouts, or budget unless you also update the Portal binding to match.

  4. Confirm light-gateway-rust/config/access-control.yml contains customer_360 under skipPathPrefixes for this local demo.

  5. Restart light-gateway from portal-config-loc/all-in-lt:

    docker compose -f docker-compose.yml -f docker-compose-rust.yml restart light-gateway
    

Editing mcp-router.yml publishes runtime configuration; it does not create a Portal entity and is not a substitute for the event-sourced steps above.

5. Verify from the desktop

Set a current access token, including the Bearer prefix. Do not copy a token from this tutorial:

export LIGHT_PORTAL_AUTHORIZATION='Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkFacDAyVzZqZGNxWHpxTkk2MVlaelEifQ.eyJpc3MiOiJ1cm46Y29tOm5ldHdvcmtudDpvYXV0aDI6djEiLCJhdWQiOiJ1cm46Y29tLm5ldHdvcmtudCIsInN1YiI6IjAxOTY0YjA1LTU1MzItN2M3OS04Y2RlLTE5MWRjYmQ0MjFiOCIsImV4cCI6MTc4NjYyMjU0MywianRpIjoiRkMxM0ZmYzNUMm0zLXlMT1RHc3NHQSIsImlhdCI6MTc4NjYyMTk0MywibmJmIjoxNzg2NjIxODIzLCJ2ZXIiOiIxLjAiLCJjaWQiOiJmN2Q0MjM0OC1jNjQ3LTRlZmItYTUyZC00YzU3ODc0MjFlNzIiLCJzY3AiOlsicG9ydGFsLnIiLCJwb3J0YWwudyJdLCJjbGllbnRfaWQiOiJmN2Q0MjM0OC1jNjQ3LTRlZmItYTUyZC00YzU3ODc0MjFlNzIiLCJzY29wZSI6InBvcnRhbC5yIHBvcnRhbC53IiwiY3NyZiI6IjNFS3lmUkxpVFBtTFM2QXVIMEZNcGciLCJlaWQiOiJzaDM1IiwiZW1sIjoic3RldmUuaHVAbGlnaHRhcGkubmV0IiwiaG9zdCI6IjAxOTY0YjA1LTU1MmEtN2M0Yi05MTg0LTY4NTdlN2YzZGM1ZiIsInJvbGUiOiJhY2NvdW50LW1hbmFnZXIgYWRtaW4gZ2l0aHViLXJlYWRlciBob3N0LWFkbWluIG1jcC1yZWFkZXIgdXNlciIsInVpZCI6IjAxOTY0YjA1LTU1MzItN2M3OS04Y2RlLTE5MWRjYmQ0MjFiOCIsInV0eSI6IkUifQ.D47-XR66YEdm_KLr8sgNyKxmuXeLtqjMv0h4AIhf8w5ph0T-l4Cgyc2GIP76finZjy04OguEeAfN_qqAqQu2sLb-OOTo3-WekSmmKQAX5yJLKZJup8DbShNydhTES4GklgLVltF86Cj5npJBtj8VF3Kptd67gdrZ8TF-7o9DvLjD8Umv2kz1vjijCX0J5xyjjX8HFC7cOGsHd8gKieGTZfVykTcJlZMjbzU3zlYIIjYlkmpLrQDai56eJF7wKr-CoXV3Gg3fXjUK7wsLfo1nbBLr0I20qMdXAJLYnfoXszaN3ZYGKl27W-Hh6X5iITFrTD92XavAVU24QEltjJzk7A'

List tools through light-gateway’s published HTTPS port:

curl -skS --max-time 45 \
  -H "Authorization: $LIGHT_PORTAL_AUTHORIZATION" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'MCP-Method: tools/list' \
  --data-binary '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"customer-360-demo","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}}' \
  https://localhost/mcp | jq

Confirm that result.tools contains customer_360, then invoke it:

curl -skS --max-time 45 \
  -H "Authorization: $LIGHT_PORTAL_AUTHORIZATION" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'MCP-Method: tools/call' \
  -H 'MCP-Name: customer_360' \
  --data-binary '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"customer_360","arguments":{"customerId":"CUST-1001","channel":"portal"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"customer-360-demo","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}}' \
  https://localhost/mcp | jq

Confirm that isError is false and that the appended result contains:

  • Profile: Avery Chen.
  • Preferences: category travel and channel portal.
  • Policy: POL-AUTO-1001.

The request goes directly from the desktop to https://localhost/mcp; no docker exec is required.

To run the repeatable Hurl assertions from light-portal-test against portal-config-loc or a development light-portal-install deployment:

cd /home/steve/workspace/light-portal-test
make workflow-mcp

Troubleshooting

The Create event goes to the DLQ

Check for an older active resource with the same workflow identity or tool name. Also confirm the tool event references the active workflow ID and exact version. Repair the bad event or legacy fixture through the supported event replay/cleanup flow; do not insert or update projection rows.

The tool form rejects the binding

Confirm that:

  • <wfDefId> is a UUID belonging to the active customer-360-mcp definition.
  • Every digest starts with sha256: followed by 64 lowercase hexadecimal characters.
  • The top-level and binding Schema Digest values match.
  • The tool is read-only, non-destructive, and headless for synchronous use.
  • Every endpoint has endpointRef, endpointUri, and a non-empty allowedMethods array.
  • The structured editor changes were applied before submission.

The invocation says the endpoint is not registered

Compare each workflow metadata.endpointRef with its binding endpoint entry. The values are case-sensitive. Also verify that GET is in allowedMethods and that the endpoint is active on the same host and binding.

The invocation says the definition or policy does not match

Keep the workflow version and all four digests identical between the Portal binding and mcp-router.yml. Use the supplied definition without edits for the documented digest.

The fork exceeds the runtime budget

Set maximumParallelism to at least 3 in both the Portal binding and static router entry.

One REST call fails

The current non-competing fork fails the entire invocation. That is a known limit of this simplified demo. Do not present it as the customer’s required partial-response behavior; that needs the planned try/error-capture extension before failed branches can be appended beside successful responses.

light-knowledge

light-fabric-document-ingest

light-fabric-rag-search

License

Bronze

Silver

Gold