Skip to main content

Session Custody

Blueprint · ← Wire app · Session custody · Memory →

Plane ① decides and may freeze the route (stickiness + version pointers). Plane ② starts when the agentic app copies those pointers, writes the run pin, and takes custody of token, loop budget, and checkpoints.

THE CLAIM

The agentic app is the only component that holds both the conversation and the credentials. It never sends the latter to the LLM. The router may pin the route. It does not pin the loop. Nobody re-resolves active after decide.

Who pins what

Two records. The router should pin first so stickiness and the async start share the same freeze. The app must persist a copy so a worker restart does not depend on router TTL, and so it never looks up active again.

RecordWho writes itLives whereJob
Route pinIntent router (or front door cache)Hot KV with TTLsession_id → frozen route_id + route_table_version + activation_target + correlation_id
DecisionIntent routerAudit / decision storerouter_layer, outcome, eligible_routes
Run pinAgentic appDurable run storeCopy of those versions plus loop_step, checkpoint, token, run_id

correlation_id on the async start is run_id (or maps 1:1). The UI never pins. The Kafka producer never pins. The model never pins.

Do not "pin the rest later from the route" by loading whatever is active for route_id. The async body carries frozen pointers. The app stores that copy, then pins loop and token.

How-to for async start: Wire agentic app. Stickiness as a classify layer: Layered classifier.

What you pin on the run

Write at route decision (before the first LLM call). Mid-session promote of any artifact must not change an in-flight run without explicit policy.

PinSourceWhy
route_table_versionPlane ① decisionReplay which contract classified the turn
route_idRoute decision recordScoped manifest and policy
activation_targetRoute rowWhich agentic app Plane ① starts. Omit = shared runtime. Pin with correlation_id so resume hits the same app
manifest_versionWhen tool_manifest is not noneTool schemas stay stable for the run
policy_profileRoute rowPEP/PDP version for side effects
model_profileRoute rowCoarse tier passed to Plane ③; not a vendor model id
workflow_idPatterns 2-3Fixed outer stages
max_loop_stepsPattern 1Cap for Observe → Decide → Tool
memory_profileRoute rowIsolation, TTL, whether long-term recall is allowed

The catalog is not the pin. Reload fraud-investigate-v2 at the pinned manifest_version, not whatever is active now. Field dictionary: Route contract reference. Token shape: Token and session boundary. Store, recall, erase: Memory. Manifest pointer: Manifest lifecycle.

Durable run record

Do not keep the pin only in process memory. A pod restart must load the same contract by run_id.

Run record (JSON)
{
"run_id": "run-9f3c",
"correlation_id": "run-9f3c",
"session_id": "sess-88",
"status": "running",
"pinned_at": "2026-08-14T02:11:05Z",
"expires_at": "2026-08-15T02:11:05Z",
"pin": {
"route_id": "fraud_investigate",
"route_table_version": "2026.08.1",
"activation_target": "https://assistant-app.internal/v1/runs",
"manifest_id": "fraud-investigate-v2",
"manifest_version": "2026.08.1",
"policy_profile": "fraud_ops_read_plus_notes",
"model_profile": "reasoning-standard",
"max_loop_steps": 12,
"loop_step": 3
},
"checkpoint": {
"last_tool": "get_alert",
"working": { "alert_id": "alert-88421", "account_id": "acct-4412" }
}
}

Update loop_step and checkpoint after each tool, especially before a write. memory_profile.loop: checkpoint is the policy that this record exists. This page is where it lives.

Store options

Datav1When to escalate
Run pin + working slots + statusPostgres runs keyed by run_idAlways for production Plane ②
Loop checkpoint (Pattern 1 / 2 side effects)Same row, or Temporal / step table (run_id = workflow_id)Fraud, payments, any resume-at-step-K
ConversationSession store with ttl_hoursChat. Kafka jobs often omit this
Stickiness / route pinRedis TTL: session_idroute_id + correlation_id + versionsPlane ①. Not enough by itself for loop resume
TokenToken store or re-mint from IdP on resumeNever conversation Redis, never the LLM trace

Do not replay only the chat log into the model and call that resume. That is not exact step recovery.

Durability is a job, not always Temporal

You always need a durable store if the run must survive a crash. You do not always need Temporal, Camunda, or Step Functions. A workflow engine is one implementation of durability. LangChain retries of an LLM call are not durability for a wire.

Temporal (when you use it) lives in the agentic app, not in the intent router. Workers stay almost stateless. The Temporal cluster has its own database (Postgres, MySQL, or Cassandra). Typical enterprise: one cluster, many task queues. Do not give each agent its own Temporal database.

workflow_id on the route row is the versioned stage graph. Temporal workflow_id is this execution. Map run_id / correlation_id to that instance. Sticky "yes" signals the same workflow; it does not Start a new one.

NeedEnoughOverkill
Pattern 0, one inferenceNothing extraTemporal
Short Pattern 1, one app, resume this graphLangGraph (or similar) checkpointer, or Postgres runsCamunda
Pattern 2, a few stages, same serviceStep table (run_id, current_stage, idempotency keys)Full BPM suite
Multi-day human wait, many workers, money, sagas across systemsTemporal / Camunda / Step FunctionsHomemade sleep + cron

Idempotency keys plus a row already prevent double-pay. A workflow engine adds activity retry policy, timers, and history so you do not invent those.

Where the LLM framework fits: Pattern 2/3 outer order stays in the step table or Temporal. LangChain / LangGraph / Strands run inside a stage (llm_role, or Pattern 1's loop). Do not let the planner own KYC stage order. Do not start every copilot with Camunda. Do not run KYC only in RAM.

GAIN v1: Postgres runs in the agentic app. Promote to Temporal when that table becomes a homemade workflow engine.

Restart path

pod dies
→ new instance gets resume for run_id
→ load pin from runs table
→ load manifest at pinned version
→ load checkpoint (or continue Temporal workflow)
→ next tool still hits PEP with the same subject

The front door needs session_idcorrelation_id so the next "yes" or Kafka retry hits this row. If the router pin has expired, resume from the app's run record, not from active.

Loop bounds

The app owns the budget. The LLM only proposes.

BoundAppliesOn hit
max_loop_stepsPattern 1 whole loopStop; return partial or escalate
Stage list + timeoutsPattern 2Advance or fail the stage; the model cannot invent a stage
max_tool_calls per stagePattern 3Stop that stage; do not leak the next stage allowlist

Credential stripping

Before any inference call:

  1. Strip auth headers, env, and downstream secrets from the payload
  2. Send messages + scoped tool schemas only (or none on Pattern 0)
  3. Keep the token in app custody for PEP, never in the model context
  4. On resume, re-bind or refresh the token. Do not paste a stale bearer from the conversation store

See PGAR LLM proposal · Token and session boundary.

Failure classes

FailureSymptom
Token forwarded to LLMPGAR test failure; secrets in trace
No route_table_version on the runCannot replay which contract ran
Pin only in one pod's RAMRestart starts a new run or drifts to active manifest
Router Redis as the only pinApp infers the contract; mid-run table publish leaks new tools
App re-resolves active after start"Pin later from the route"; versions drift
Mid-session manifest swapTool set drifts; eval cannot attribute
Unbounded loopCost blow-up; no max_loop_steps
LangChain retry as the only write recoveryDuplicate side effect, or silent drop after crash

Trace fields

session_id, run_id, correlation_id, route_id, route_table_version, manifest_version, policy_profile, model_profile, orchestration_step, loop_step, max_loop_steps

Memory →