Skip to content

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. If projection is 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


                          LLM

Who Defines What?

Model Input ElementWho Defines It?Where Does It Come From?
Agent instructionsDeveloperapp.agent({ instructions: "..." })
Business contextDeveloperprojection: agentProjection
Conversation historyApplication Serveragent.run({ history: [...] }) (bounded by SDK)
Available workflowsDeveloper + RuntimeWorkflows registered on Agent, projected by Runtime
Workflow descriptionsDeveloperWorkflow metadata (description)
Workflow input schemasDeveloperZod inputSchema defined on app.workflow()
Active executionRuntimeDurable state of current runId
Expected inputRuntimeCurrent .wait() boundary schema
Valid actionsRuntimeDerived dynamically from current execution position
User messageApplication / UserCurrent 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_workflow needs to select across candidate contracts. submit_input does 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)
BoundaryControlled ByConstraintFramework Benefit
KnowledgeApplication DeveloperWhat the model can KNOWBounded token costs, zero irrelevant history noise.
ExecutionWorkflow DefinitionWhere execution can GOConstrained hallucination blast radius, predictable flows.
AuthorityRuntime KernelWhat the model can DOSafe 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; their inputSchema forms 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 by app.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 ]
  1. 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_FRAME and executes nothing.
  2. 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_PROPOSAL with zero execution.
  3. 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 (name and normalized payload). 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_failed

Acceptance 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 model

Action 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:

  1. "Normalize representation. Repair proposals. Clarify uncertain facts. Never invent corrections."
  2. "The model may correct its reasoning. Repairs must be grounded in authorized evidence."
  3. "Validation proves admissibility, not provenance."
  4. "Metacognition may reconsider reasoning, but it cannot manufacture new authority."
  5. "Metacognition inherits authority; it never refreshes authority merely by reasoning again."
  6. "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 with tools: [] (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 ENDS

Key Guarantees & Constraints

  1. 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 + expectedRevision for submit_input; workflowId + expectedSessionRevision for start_workflow). It is never reconstructed post-rejection.
  2. 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.
  3. Surface Narrowing & Verification: The recovery call is advertised at most one pinned tool declaration (start_workflow narrows workflowId to a const literal). If a model attempts a different action or targets another workflow, the proposal is dropped.
  4. 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.
  5. 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.
  6. 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.
  7. 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 validation path/code, never rejected values or custom validator messages.
  8. 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 violated inputSchema.

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 Committed

Complete Error Taxonomy Reference

CodeBoundaryTriggerError Class / Source
PROVIDER_ACTION_INVALIDTransportCarrier unboxing failure (e.g., malformed JSON inside inputJson).Adapter / Transport Decode
ACTION_INPUT_INVALIDBoundary 1Action invocation envelope violates its JSON Schema contract (e.g., unrecognized keys, missing workflowId).validateJsonSchemaObject
ACTION_NOT_AUTHORIZEDBoundary 1Action is structurally valid but not authorized in the session's fresh actionSurface(session).Agent Protocol
WORKFLOW_INPUT_INVALIDBoundary 2Initial payload passed to startWorkflow() fails workflow.inputSchema validation.WorkflowInputValidationError
WAIT_INPUT_INVALIDBoundary 2Payload passed to submitInput() violates the active .wait() boundary RuntimeSchema.WaitInputValidationError
WAIT_BOUNDARY_CONFLICTBoundary 2Concurrency/OCC conflict: revision mismatch or session is not in waiting_input state.WaitBoundaryConflictError
STALE_REASONING_FRAMEAgent admissionSession, run, execution revision, or wait boundary changed while the model was reasoning. Zero action execution.Agent Protocol
AMBIGUOUS_MODEL_PROPOSALAgent admissionA 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=1

Or 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.

Invariant Durable Execution Engine.