Concepts: Agents & Actions
An Agent (app.agent) in Invariant is a state-derived semantic reasoning and action authority protocol over registered workflows.
"
app.agent()is not a bag of tools. It is a state-derived semantic interface to durable execution."
The Three Foundational Doctrines
text
Projection ──► What the agent may KNOW (pure domain facts, secret-free)
Agent Protocol ──► How knowledge and authority are PRESENTED to reasoning
Runtime ──► What may actually HAPPEN (fresh-state validated execution)1. "Projection controls what the agent may know."
Application business context is projection-derived. Ifprojectionis omitted, the SDK supplies an empty application context; raw Session context is never used as the fallback.
2. "The Agent Protocol controls how that knowledge and current authority are presented to reasoning."
One Agent. One semantic protocol. Many channel representations (Chat, Gemini Live, MCP).
3. "The Runtime controls what may actually happen."
The model proposes (AgentActionInvocation); the runtime validates fresh state and authorizes execution (agent.handleAction). External side-effects always cross durable runtime boundaries.
"Invariant's runtime-derived action surface remains $O(1)$ with respect to workflow count."
Whether 1 or 10,000 workflows are registered, the exposed tool surface remains minimal and state-derived.
1. The Agent Turn Context Model
Invariant formalizes the reasoning context delivered to the model as a composition of strictly defined elements:
$$\text{Agent Turn Context} = \text{Runtime Directive} + \text{Application Projection} + \text{Semantic State} + \text{Conversation Context} + \text{Developer Instructions}$$
text
┌───────────────────────────────────────────────────────────────────────────────┐
│ 1. INVARIANT RUNTIME DIRECTIVE (Framework Protocol - Domain Agnostic) │
│ • Role: Semantic Interpreter for "<agent-name>". │
│ • Responsibility: Interpret user intent & propose only valid actions. │
│ • Authority Constraint: Cannot mutate state or execute capabilities. │
│ • Directives: Propose from validActions; otherwise respond conversationally│
├───────────────────────────────────────────────────────────────────────────────┤
│ 2. RUNTIME PROJECTION (Execution State & Authority - Cannot Be Overridden) │
│ • Active Workflow: { id, runId, status, revision } │
│ • Awaited Boundary: { nodeId, label, prompt, schema } │
│ • Valid Actions: [ "submit_input", "cancel_workflow" ] │
│ • Available Workflows: [ { id, description }, ... ] │
├───────────────────────────────────────────────────────────────────────────────┤
│ 3. APPLICATION PROJECTION (Safe Exposure Boundary - Pure Domain Facts) │
│ • Pure, filtered view of business data (e.g. clientInfo, defaultLocation) │
│ • Unprojected fields (e.g. internalRiskScore, secret keys) never reach LLM │
├───────────────────────────────────────────────────────────────────────────────┤
│ 4. CONVERSATION CONTEXT (Bounded Ephemeral Reasoning Fuel) │
│ • Recent dialogue window (default: 8 turns) for pronouns & clarifications │
├───────────────────────────────────────────────────────────────────────────────┤
│ 5. CURRENT USER MESSAGE & DOMAIN INSTRUCTIONS │
│ • Domain persona, tone of voice, catalog details, latest user turn │
└───────────────────────────────────────────────────────────────────────────────┘IMPORTANT
Separation of Authority & Visibility:
- Application Projection (
app.projection): Controls domain visibility (what business data the model is allowed to see). - Runtime Projection: Controls execution state & authority (what workflows and actions are valid). Application projections can never override runtime execution authority.
ts
import { app } from "./runtime";
import { refundWorkflow, cancelSubscriptionWorkflow } from "./workflows";
// Explicit Exposure Boundary: Define what the agent is allowed to see.
const supportProjection = app.projection("support-agent-context", ({ session }) => ({
customer: {
id: session.context.customerId,
tier: session.context.accountTier,
},
}));
export const supportAgent = app.agent("customer-support", {
description: "AI support concierge assisting customers with orders and refunds",
// Pure domain instructions — ZERO framework boilerplate:
instructions: `
Be warm, concise, and professional.
Help customers with inquiries and explain refund policies.
`,
workflows: [refundWorkflow, cancelSubscriptionWorkflow],
projection: supportProjection,
conversation: {
recentTurns: 8,
},
});IMPORTANT
"Conversation history is an input to reasoning, not the source of application truth."
An agent uses recent dialogue as ephemeral reasoning fuel to understand what the user means ("Yes, please cancel that one"). Once the agent submits a valid Runtime Action (submit_input or start_workflow), the validated transition commits directly into durable state. The rest of your software reads durable state—never raw chat transcripts.
2. Multi-Turn Dialogue & Conversation History (history)
Real-world customer interactions are rarely single-turn commands. They are multi-turn dialogues where subsequent user messages depend entirely on what was said earlier:
text
Turn 1:
User: "I want to book an appointment"
Agent: ➔ Action: start_workflow("boulevard-booking")
➔ Output: "I'd love to help! Which category of service are you looking for?"
Turn 2:
User: "Grooming"
Agent: ➔ Action: submit_input({ categoryName: "Grooming" })
➔ Output: "Great! What hair length do you have (short or long)?"
Turn 3:
User: "short"
Agent: ➔ Action: submit_input({ haircutLength: "short" })
➔ Output: "Which stylist would you prefer for your short haircut?"Without conversation history, when the user responds with "short" in Turn 3, the LLM has no context to understand what "short" refers to.
How to Pass history in agent.run()
When executing a turn, pass the accumulated conversation messages via the history parameter:
ts
const turn = await supportAgent.run({
session,
message: "short",
history: [
{ role: "user", content: "I want to book an appointment" },
{ role: "assistant", content: "Which category of service are you looking for?" },
{ role: "user", content: "Grooming" },
{ role: "assistant", content: "What hair length do you have (short or long)?" },
],
});If the selected model adapter is missing, throws, or returns no usable proposal, the Promise rejects with AgentModelError (AGENT_MODEL_FAILED). Treat it as an application error boundary: return an honest unavailable response from your HTTP/channel layer if appropriate, but do not record a fabricated assistant turn. Invariant emits llm.call.failed and never emits llm.call.completed for that failed turn.
Minimal Fastify Route Pattern
This sketch shows the request flow. It assumes an application authentication hook that returns the authoritative user ID. Replace the in-memory history map with durable application storage before production use:
ts
import { FastifyInstance } from 'fastify';
import { app } from './runtime';
import { supportAgent } from './agent';
// Demo-only history store. Use durable application storage in production.
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
createdAt: string;
}
const chatHistoryStore = new Map<string, ChatMessage[]>();
export async function chatRoutes(server: FastifyInstance) {
server.post<{
Params: { sessionId: string };
Body: { message: string };
}>('/api/sessions/:sessionId/messages', async (request, reply) => {
const { sessionId } = request.params;
const { message } = request.body;
const userId = await requireAuthenticatedUser(request);
// Restores durable Session context and, when the registered graph matches,
// its committed active .wait() boundary. It does not redispatch work.
const session = await app.sessions.loadOrCreateSession(sessionId, userId);
// 1. Retrieve the existing dialogue history for this session
const history = chatHistoryStore.get(sessionId) || [
{
role: 'assistant',
content: 'Welcome to Customer Concierge. How may we assist you today?',
createdAt: new Date().toISOString(),
},
];
// 2. Append the incoming user message
history.push({
role: 'user',
content: message,
createdAt: new Date().toISOString(),
});
// 3. Execute the agent turn with conversational continuity:
const turn = await supportAgent.run({
session,
message,
history: history.map(h => ({ role: h.role, content: h.content })),
});
// 4. Record only output explicitly marked safe to present
if (turn.outputDisposition === 'present' && turn.output !== undefined) {
history.push({
role: 'assistant',
content: turn.output,
createdAt: new Date().toISOString(),
});
chatHistoryStore.set(sessionId, history);
}
// 5. Return the causal boundary view; do not re-read Session after the action
return reply.status(200).send({
sessionId,
output: turn.output,
outputSource: turn.outputSource,
outputDisposition: turn.outputDisposition,
action: turn.action,
actionResult: turn.actionResult,
presentation: turn.presentation,
execution: turn.execution,
projection: turn.projection,
actionSurface: turn.actionSurface,
boundaryView: turn.boundaryView,
});
});
}3. How Invariant Bounds Conversation History
A common anti-pattern in traditional agent frameworks is stuffing 100+ turns of raw chat transcript into every LLM prompt:
text
Turn 1 ──┐
Turn 2 │
... ├──► Unbounded prompt bloating (massive token waste & latency)
Turn 98 │
Turn 99 ─┘Invariant solves this through two architectural boundaries:
text
1. Ephemeral Context (Sliding Window)
Invariant automatically bounds `history` (eight turns by default) so the LLM has immediate
dialogue continuity for pronouns and corrections without exploding token usage.
2. Durable Boundary Crossing
Once the user says "short", the Agent dispatches:
➔ submit_input({ haircutLength: "short" })
The Runtime validates and commits `{ haircutLength: "short" }` directly into PostgreSQL/SQLite.
Subsequent workflow steps (.capability, .reason, .step) read `state.haircutLength` directly
from durable storage without ever parsing or reading the chat transcript again.4. What Does the Model Actually Receive?
When an Agent turn executes (await supportAgent.run({ session, message, history })), Invariant assembles the model-facing context from three distinct sources:
text
You Define Invariant Derives
────────── ─────────────────
Agent instructions Current execution position
Developer context (Projection) Available workflows & schemas
Registered workflows Expected input schema (.wait)
Conversation history (Window) Currently valid Runtime Actions
│ │
└────────────┬────────────┘
▼
Model Context Payload
│
▼
LLMWho Defines What?
| Model Input Element | Who Defines It? | Where Does It Come From? |
|---|---|---|
| Agent instructions | Developer | app.agent({ instructions: "..." }) |
| Business context | Developer | projection: agentProjection |
| Conversation history | Application Server | agent.run({ history: [...] }) (bounded by SDK) |
| Available workflows | Developer + Runtime | Workflows registered on Agent, projected by Runtime |
| Workflow descriptions | Developer | Workflow metadata (description) |
| Workflow input schemas | Developer | Zod inputSchema defined on app.workflow() |
| Active execution | Runtime | Durable state of current runId |
| Expected input | Runtime | Current .wait() boundary schema |
| Valid actions | Runtime | Derived dynamically from current execution position |
| User message | Application / User | Current turn input (message) |
Current message modality
agent.run() accepts a string message and string-content history. The first-party model adapters add JSON semantic context as text; they do not convert images, audio, video, or files into provider-native content parts. See the Model Adapter input matrix.
5. Constant $O(1)$ Tool Surface & Action Protocol {#agent-protocol}
A major flaw in traditional tool-calling agent frameworks is tool explosion: registering 30 workflows produces 30+ distinct functions (start_booking, start_refund, start_reschedule, etc.), rapidly exhausting model attention, exploding context windows, and confusing tool routing.
Invariant solves this with a constant $O(1)$ generic tool protocol that leverages a fundamental asymmetry between starting a workflow and waiting for step input:
"
start_workflowneeds to select across candidate contracts.submit_inputdoes not: the runtime already knows exactly which contract is active."
1. In Idle State: Generic Selector
When idle, the model must select which workflow to trigger. It receives a single generic tool and the available candidate schemas contextually:
text
Tool: start_workflow({ workflowId: string, input: object })
Context: availableWorkflows = [ { id, description, inputSchema } ]2. In Active / Waiting State (.wait()): Exact Direct Schema
When waiting at a step boundary, there is one single authoritative contract. Rather than wrapping the schema in a generic wrapper, submit_input parameters are set directly to the exact wait schema:
text
Wait Node: .wait("haircut-length-ui", { schema: z.object({ haircutLength: z.enum(["short", "medium", "long"]) }) })
Tool: submit_input({ haircutLength: "short" | "medium" | "long" })
Context: prompt: "How long is your hair?", message: "short"This enables model providers (Gemini, OpenAI, Claude) to apply native constrained generation directly on the target schema, with zero extra token bloat:
text
IDLE STATE:
Tool: start_workflow({ workflowId, input })
Context: availableWorkflows = [ { id, description, inputSchema } ]
ACTIVE / WAITING STATE (.wait()):
Tool: submit_input( <EXACT CURRENT WAIT SCHEMA> )
Tool: cancel_workflow({ reason?: string })
Context: activeWorkflow = { id }, awaitedBoundary = { id, prompt, inputSchema }
CONVERSATIONAL TURN:
Response: plain text output (no action taken)6. The 3 Isolation Boundaries & Context Projections {#projections}
Invariant places model reasoning between three explicit boundaries:
text
What can the model KNOW? ──► Knowledge Boundary (Agent Context Projection)
Where can execution GO? ──► Execution Boundary (Registered Workflow Graphs)
What can the model actually DO? ──► Authority Boundary (Runtime Action Validation)| Boundary | Controlled By | Constraint | Framework Benefit |
|---|---|---|---|
| Knowledge | Application Developer | What the model can KNOW | Bounded token costs, zero irrelevant history noise. |
| Execution | Workflow Definition | Where execution can GO | Constrained hallucination blast radius, predictable flows. |
| Authority | Runtime Kernel | What the model can DO | Safe side effects; no unvalidated execution against stale state. |
Core Architectural Doctrines
1. "Workflow schemas define the runtime contract through which probabilistic intent may enter durable execution."
Workflows are not passive scripts; theirinputSchemaforms the authoritative entry gate that shapes model proposals.
2. "Projection controls visibility. Runtime state defines the proposal space. Validation controls authority."
Application data visibility is bounded byapp.projection(). Execution candidate actions are derived strictly from durable state. Authorization is enforced by the kernel.
3. "Schema-valid does not mean execution-authorized."
An LLM output may conform perfectly to a JSON schema, yet be rejected if execution advanced or revision changed during generation.
4. "Actions are generic. Schemas are contextual. Authority is state-derived."
Workflows and.wait()boundaries supply contracts dynamically; the tool surface remains minimal, constant, and clean.
5. "At a wait boundary, the model receives one question, one schema, and the minimum context required to answer it."
State accumulation belongs to durable infrastructure; reasoning fuel at a step is minimal and focused.
7. Action Authority & Fresh-State Validation {#action-authority}
Every executable model proposal inherits the exact authority frame under which the model reasoned. The runtime then performs a separate fresh-state authorization check before committing to the durable event log:
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. Runtime Captures the Reasoning Authority Frame │
│ ➔ Session revision + execution run/revision + active wait boundary │
│ ➔ Projected context and exact action surface come from that same frame │
└──────────────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2. Provider Reports Every Runtime Action Proposal │
│ ➔ start_workflow({ workflowId, input }) │
│ ➔ submit_input({ ...exact wait boundary schema... }) │
│ ➔ cancel_workflow({ reason? }) │
│ ➔ 0 proposals: text path · 1: admission · >1: reject all as ambiguous │
└──────────────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. Admission + Fresh Authority Validation │
│ ➔ Is the captured reasoning frame still current? │
│ ➔ Is this action STILL valid at the current live execution revision? │
│ ➔ Does the active workflow still pause at the target .wait() node? │
│ ➔ Does the input conform to the authoritative workflow / step schema? │
└──────────────────────────────────────┬──────────────────────────────────────┘
│ (Authorized by Runtime Kernel)
▼
[ Commit to Durable Event Log ]- Reasoning Authority Check: Compares the current Session/run/revision/boundary with the frame captured before inference. A response never acquires fresher authority merely because it arrives later. Drift rejects with
STALE_REASONING_FRAMEand executes nothing. - Exactly-One Proposal Rule: Official adapters preserve every provider tool call in provider order. Zero tool calls follow the text path, one becomes the candidate action, and more than one rejects as
AMBIGUOUS_MODEL_PROPOSALwith zero execution. - Fresh Shape, Schema & Authorization Check: Re-evaluates the single candidate against the live action surface and the authoritative workflow or
.wait()schema. This check is distinct from reasoning-frame validity.
8. Action vs. Presentation Separation
Invariant maintains a clean architectural separation between execution intent and conversational presentation:
action: What the model proposed (nameand normalizedpayload). A proposal is not evidence that execution occurred.actionResult: What the runtime authoritatively accepted or rejected after schema and fresh-state validation.output/modelOutput: Exact authored text with explicit provenance and disposition. The SDK never generates acknowledgement, success, failure, language, or brand-tone copy.presentation: Exact application-authored.wait()metadata at the current committed boundary. Channel projectors decide how to render or speak it.
When model text and an action arrive together, that text was authored before the action crossed authority and before its durable consequence was known. Invariant therefore preserves it in modelOutput, omits it from output, and marks outputDisposition: "withhold" whether the proposal is accepted or rejected. This prevents pre-settlement narration from being mistaken for evidence of waiting_input, success, or failure.
Action turns deliberately return no model-authored output. The application can render presentation, apply a channel-specific projector to the exact boundaryView, or run a separately designed post-settlement reasoning turn. Invariant does not perform an implicit ReAct/re-entry call.
Boundary-to-Boundary Action Semantics
Agent actions are boundary-to-boundary operations.
An accepted action is executed synchronously until the Beta Host reaches the next committed boundary requiring external input or a terminal outcome. The returned execution describes that settled consequence—not the intermediate admission state and not a later observation of the Session.
text
action accepted
↓
kernel/host drain synchronous consequences
↓
exact committed boundary frame
├── waiting_input
├── completed
├── failed
├── cancelled
└── cancellation_failedAcceptance and outcome are distinct. An authorized action can settle in a durable failed state:
ts
{
status: "accepted",
execution: {
runId: "run_123",
workflowId: "refund",
status: "failed",
revision: 7,
boundary: { kind: "terminal" },
error: { code: "CAPABILITY_FAILED", message: "Provider rejected request" },
},
}A Store outage or unexpected Host exception is different: the Promise rejects operationally and Invariant does not manufacture execution.status = "failed". SettledExecution contains committed execution truth only.
execution, presentation, application projection, and actionSurface are all derived from the exact immutable frame produced by the settling commit. boundaryView.revision therefore matches execution.revision for accepted actions, and AgentTurnResult.boundaryView reuses that same in-process object. Rejected actions omit execution, because they did not cross authority, but return the unchanged current boundaryView so a model or channel can correct its proposal.
9. Delivery Modes & Transport Decoupling
Invariant model adapters (@invariant-tech/live-gemini, @invariant-tech/google, @invariant-tech/openai) decide how candidate actions are presented to the model without altering core runtime semantics:
text
app.agent()
│
┌──────────────┴──────────────┐
│ │
▼ ▼
actionSurface(session) actionCatalog()
CURRENT AUTHORITY LIFETIME DECLARATIONS
(Derived from State) (Transport-Safe Superset)
│ │
▼ ▼
agent.prepare() Live WebSocket Setup
│ (Declares 3 funcs upfront)
▼ │
Chat/MCP Turn │
│ │
└──────────────┬──────────────┘
▼
AgentActionInvocation
│
▼
agent.handleAction()
│
Fresh-State Authority
│
┌─────────┴─────────┐
▼ ▼
Accepted Rejected
│ │
└─────────┬─────────┘
▼
exact committed boundary frame
│
┌─────────┴─────────┐
▼ ▼
next authority voice projection
(actionSurface) (prompt, options with input)
│
▼
persistent modelAction Surface vs. Persistent Action Catalog
agent.actionSurface(session): Current Authority. Dynamically derived from the live execution state ($O(1)$). Changes as execution advances and carries exact current wait schemas.agent.actionCatalog(): Transport Declaration. Static superset of actions the connection may need during its lifetime (start_workflow,submit_input,cancel_workflow). Used during initial connection handshakes for persistent transports (e.g. Gemini Live WebSocket). Does NOT grant runtime authority.
"The provider may know an action exists without currently having authority to use it. Authority is always derived from fresh runtime state."
Semantic Feedback & Bounded Rejection Recovery
When a model invokes an action that violates runtime schema or state constraints (e.g. calling submit_input missing required fields, or calling a stale action after a concurrent transition), the runtime validation layer rejects the proposal. Invariant enforces:
"Invalid model output becomes structured feedback, never application state."
Six Core Doctrines of Metacognitive Proposal Repair
Invariant formalizes the boundary between model reasoning and runtime authority under six core doctrines:
- "Normalize representation. Repair proposals. Clarify uncertain facts. Never invent corrections."
- "The model may correct its reasoning. Repairs must be grounded in authorized evidence."
- "Validation proves admissibility, not provenance."
- "Metacognition may reconsider reasoning, but it cannot manufacture new authority."
- "Metacognition inherits authority; it never refreshes authority merely by reasoning again."
- "Metacognition may explain only the world it actually observed."
Recovery Modes: off vs explain vs repair
Invariant models recovery as a single configuration on app.agent():
ts
rejectionRecovery: {
mode: 'off' | 'explain' | 'repair', // default: 'off'
}mode: 'off'(Default): Turn ends immediately upon rejection.outputDisposition: 'withhold',output: undefined, exactly 1 model call.mode: 'explain': Turn triggers exactly one recovery call withtools: [](zero execution authority). Model explains the missing fields or constraints to the user; any tool call returned is dropped.mode: 'repair'(Repair-or-Clarify): Turn triggers exactly one recovery call with authority strictly narrowed to the same semantic action target.- If the missing facts exist in the authorized context, the model repairs the proposal.
- If facts are missing or uncertain, the model clarifies with text.
- Exactly one recovery call; zero third attempts.
text
MODEL CALL #1 (phase = 'initial')
tools enabled (full actionSurface)
↓
action proposal (e.g. submit_input)
↓
RUNTIME VALIDATION (session.startWorkflow / session.submitInput)
/ \
accepted rejected (zero state mutation, revision unchanged)
↓
rejectionRecovery.mode !== 'off' && isRecoverableCode?
/ \
no yes
│ ↓
│ capture admission frame & authority
│ ↓
│ MODEL CALL #2 (phase = 'rejection_recovery')
│ • mode = 'explain' ──► tools = [] (text-only extraction)
│ • mode = 'repair' ──► tools = [pinnedTargetAction]
│ • sanitized feedback (no payload echo)
│ ↓
│ [RECOVERY OUTCOME]
│ ├── Pure Text ────► Clarify (present text if frame current)
│ ├── >1 Tools ────► Drop all (zero repair execution)
│ └── 1 Tool ────► Runner verification & fresh runtime auth
│ ├── Stale generation / Mismatch ──► Drop
│ └── Valid & Current ──────────────► Execute Repair
│ ↓
└─────────────────────────────────────────────────┤
↓
TURN ENDSKey Guarantees & Constraints
- Authority Inheritance & Generation Pinning: Initial and recovery inference both inherit a reasoning authority frame. Repair authority is captured directly from the pre-rejection admission snapshot (
runId + boundaryId + expectedRevisionforsubmit_input;workflowId + expectedSessionRevisionforstart_workflow). It is never reconstructed post-rejection. - ABA & OCC Protection: Before any initial or repaired proposal is executed, the runner verifies the complete pinned frame inside the Session serialization boundary. If concurrent activity advanced and later returned to a superficially similar state, the stale proposal is still dropped without execution.
- Surface Narrowing & Verification: The recovery call is advertised at most one pinned tool declaration (
start_workflownarrowsworkflowIdto aconstliteral). If a model attempts a different action or targets another workflow, the proposal is dropped. - Single Tool Call Limit: A recovery response may contain at most one tool call. If multiple tool calls are returned, the runner executes zero repairs.
- Output Purity & Companion Text Withholding: If a recovery response contains an executable proposal, its companion narration is strictly withheld (
output: undefined,outputDisposition: 'withhold'or'none'). Companion text is never presented alongside action proposals. - Stale Clarification Suppression: If concurrent action advances the session revision while a text clarification call is in flight, the clarification text is withheld (
outputDisposition: 'withhold') and the turn surfaces the current live boundary. - Strict Payload Privacy (No Echo): Semantic recovery feedback may contain only SDK-derived structural constraints (
path,code, and a bounded message synthesized from them). Default action traces carry argument keys and validationpath/code, never rejected values or custom validator messages. - At Most One Recovery Call: Rejection recovery is hardcoded to at most 1 recovery call. There is zero third attempt.
Recoverable Error Codes
Automatic rejection recovery is triggered only for validation errors where model correction or clarification helps the user:
WAIT_INPUT_INVALID: Payload violated the active.wait()boundary schema (e.g. missing required field).ACTION_INPUT_INVALID: Action invocation envelope violated its schema.WORKFLOW_INPUT_INVALID: Initial workflow input violatedinputSchema.
Rejection recovery is NOT triggered for:
ACTION_NOT_AUTHORIZED: Security / permission restriction.WAIT_BOUNDARY_CONFLICT: Concurrency/OCC conflict (the world moved; requires fresh prepare, not stale narration).- Provider & Transport Outages: Network failures or server errors.
HTTP 4xx is reserved for failures at the application's transport boundary (malformed JSON, unknown session, invalid authentication). Semantic execution outcomes are valid protocol feedback that guide the model to self-correct. Invariant does not provide the HTTP server or enforce this mapping; see Agent Protocol Rejections over HTTP.
10. Multi-Boundary Validation & Error Taxonomy
Invariant establishes three decoupled validation boundaries to guarantee that neither malformed model reasoning nor transport deserialization errors can ever corrupt durable application state:
text
Model Output / Provider Call
│
▼ [Transport Decode Boundary]
Unmarshal Provider Carrier (e.g. inputJson)
├── ✗ Malformed JSON ──► Reject: PROVIDER_ACTION_INVALID
│
▼ [Boundary 1: Action Contract & Authority]
validateJsonSchemaObject(action.schema, args)
├── ✗ Contract Violation ──► Reject: ACTION_INPUT_INVALID
├── ✗ Not in Action Surface ──► Reject: ACTION_NOT_AUTHORIZED
│
▼ [Boundary 2: Runtime Execution Boundary]
session.startWorkflow() / session.submitInput()
├── ✗ Invalid Workflow Input ──► Reject: WORKFLOW_INPUT_INVALID
├── ✗ Invalid Step Payload ──► Reject: WAIT_INPUT_INVALID
├── ✗ Stale Revision (OCC) ──► Reject: WAIT_BOUNDARY_CONFLICT
│
▼
✓ Durable State Mutation & Transition CommittedComplete Error Taxonomy Reference
| Code | Boundary | Trigger | Error Class / Source |
|---|---|---|---|
PROVIDER_ACTION_INVALID | Transport | Carrier unboxing failure (e.g., malformed JSON inside inputJson). | Adapter / Transport Decode |
ACTION_INPUT_INVALID | Boundary 1 | Action invocation envelope violates its JSON Schema contract (e.g., unrecognized keys, missing workflowId). | validateJsonSchemaObject |
ACTION_NOT_AUTHORIZED | Boundary 1 | Action is structurally valid but not authorized in the session's fresh actionSurface(session). | Agent Protocol |
WORKFLOW_INPUT_INVALID | Boundary 2 | Initial payload passed to startWorkflow() fails workflow.inputSchema validation. | WorkflowInputValidationError |
WAIT_INPUT_INVALID | Boundary 2 | Payload passed to submitInput() violates the active .wait() boundary RuntimeSchema. | WaitInputValidationError |
WAIT_BOUNDARY_CONFLICT | Boundary 2 | Concurrency/OCC conflict: revision mismatch or session is not in waiting_input state. | WaitBoundaryConflictError |
STALE_REASONING_FRAME | Agent admission | Session, run, execution revision, or wait boundary changed while the model was reasoning. Zero action execution. | Agent Protocol |
AMBIGUOUS_MODEL_PROPOSAL | Agent admission | A provider returned more than one executable proposal. All proposals are rejected. | Agent Protocol |
11. Debug Mode & Observability (INVARIANT_DEBUG=1)
To inspect reasoning metadata, action validation, and workflow boundaries in real time, activate Invariant's built-in Console Trace Sink:
bash
# In .env or terminal:
INVARIANT_DEBUG=1Or configure directly in application setup:
ts
export const app = invariant({
debug: true, // or debug: { level: 'debug' }
// Add observability.capture.reasoningBoundary only for approved sensitive-data sinks.
// ...
});When active, Invariant prints structured, color-coded visual cards for every execution event:
- Live Model Sessions: Declared tools vs. current state-derived authority.
- Model Calls: Agent and
.reason()start/completion/failure metadata; full prompts and raw outputs require explicit reasoning-boundary capture. - Validation Rejections: Exact issue paths (
locationId: Required field is missing) and invariant confirmation ("zero state mutation, execution history unchanged"). - Workflow Lifecycle: Instant notifications on workflow start, node transitions, and completions.