@gnldev/durable
v0.5.0
Published
Call-scoped exactly-once effect + deterministic-replay durability layer for Vercel AI SDK agents. Drop-in: generateText -> runDurable.
Maintainers
Readme
@gnldev/durable
Call-scoped exactly-once effect + deterministic-replay durability for Vercel AI SDK agents.
Wraps the AI SDK's agent loop (generateText/streamText) without changing it. After a crash/restart a
completed tool call is never executed a second time, and the agent makes the same decisions
(deterministic replay). The one thing neither other agent frameworks nor plain AI SDK give you structurally:
correctness.
The precise guarantee is at-most-once for a side effect, because the crash window between executing a tool and journaling its result cannot be closed by any client-side library — see the callout below. gnl's answer to that window is to refuse to guess: it blocks and asks, rather than silently re-running or silently continuing.
Install:
pnpm add @gnldev/durable— the storage adapters ship inside the same package. (Or clone the repo andpnpm install && pnpm -r build.)
npm i @gnldev/durable ai @ai-sdk/anthropicDurability in 2 lines
import { runDurable } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';
import { anthropic } from '@ai-sdk/anthropic';
import { tool, stepCountIs } from 'ai';
import { z } from 'zod';
const chargeCard = tool({
description: "Charge the customer's card",
inputSchema: z.object({ amount: z.number() }),
execute: async ({ amount }) => paymentApi.charge(amount),
});
const res = await runDurable({
runId: 'order-123', // ← the id of THIS WORK — never a sessionId (see below)
journal: new SqliteStorage('runs.db').runs, // ← state lives here
model: anthropic('claude-opus-4-8'),
tools: { chargeCard },
stopWhen: stepCountIs(10),
prompt: 'Process the order',
});If the process crashes mid-turn, call again with the same runId → it resumes where it left off, the
card is never charged a second time:
const res = await runDurable({ runId: 'order-123', journal, model, tools, prompt: 'Process the order' });One id is one JOB, never one session.
runDurableis the raw surface: the string you pass is the identity, so asessionIdor a conversation id there makes every later turn replay the first one forever. ThroughcreateGnlyou name the work instead (workKey) and the engine derives the id — see Work identity.
What "never charged twice" actually means: the guarantee is at-most-once. If a step already completed, it's replayed and skipped. But if the crash lands in the narrow window after the charge executed and before its success was journaled, resume can't know whether the charge went through — so for a side-effect tool with no
recover()and noidempotent/approval, it does not silently re-run and does not silently continue: it blocks and asks a human. Safe direction (never a double charge), but resume is not always seamless.Concretely,
runDurablethrowsSideEffectRetryBlockedError— it does not come back inresult.interrupts, which carries approval suspensions only. Catch it:import { SideEffectRetryBlockedError } from '@gnldev/durable'; try { await runDurable({ runId: 'order-123', journal, model, tools, prompt }); } catch (e) { if (e instanceof SideEffectRetryBlockedError) { // e.detail.key names the tool call that is in doubt. Ask an operator, then re-run with // approvals: { [toolCallId]: true } — or give the tool a recover() so this resolves itself. } else throw e; }
recover()is the way to avoid the human: it re-checks the provider and must return{ done: true, output }or{ done: false }. Any other shape is treated as "could not determine" and lands back on this same gate rather than re-running the effect.
If you know generateText, you already know this — same arguments, same return type. The only addition:
runId + journal.
How it works
durableTool— wraps a tool'sexecute. The key is thetoolCallIdthe AI SDK assigns; on replay the model response is reproduced verbatim, so the same id is generated → a tool that already succeeded never runs again.withDurableModel— via awrapLanguageModelmiddleware, records every model response to the journal and replays it on resume → the agent reproduces the same tool chain.Journal— an append-only log.InMemoryJournal(dev) ·SqliteStorage().runs(prod,node:sqlite, zero native dependencies).
Guarantee: a side effect is never repeated for the same
toolCallId(call-scoped, replay/resume-safe). Not "the LLM always produces the exact same text" — if there's a recorded entry, it's replayed; everything past the crash point runs live. A call whose outcome is unknown (after a crash/timeout) is first checked against the provider viatool.recover?(); if it's still unknown, the system blocks and asks for approval instead of silently retrying (see "Human-in-the-loop").Scope caveat (important): the default dedup is keyed by
toolCallId. If the model re-plans the same business action under a newtoolCallId(with identical or slightly different args — a documented AI SDK pattern), the call-scoped guard does not collapse it: the defaultlimits.sideEffectDuplicatesis'warn', which logs the duplicate but still executes it. For a true "this action happens once" guarantee across re-plans, opt intoidempotency: 'args'/idempotencyKey(next section) or setlimits.sideEffectDuplicatesto'block'/'suspend'. The 2-line quick-start example is call-scoped only.
Work identity (workKey)
A workKey is your name for a unit of work — not for a conversation. While the run it opened
still exists, another call arriving with the same workKey in the same workScope is routed to that
run instead of starting a second one.
Coming from thread_id? There the same key means continue this conversation. Here the same key
means this is the same job: reuse a workKey to retry work, never to add a turn. A conversation
is threadId — a different field, and you can use both at once.
A workKey is a business name (the invoice being issued, the document being published, the
firmware rollout for device 7742, tonight's reconciliation batch), not a random retry token. Keep
sensitive data out of it — a workKey is echoed in error bodies and shown on Studio screens.
const gnl = createGnl({
journal,
agents: {
// Default. The job belongs to a PERSON: Ayşe's `invoice-4471` and Mehmet's `invoice-4471` are
// two jobs, because the subject is part of the identity.
billing: { model, workScope: 'resource' },
// The job belongs to the INSTALLATION: tonight's reconciliation runs once, whichever of six
// workers woke up first.
reconcile: { model, workScope: 'org' },
},
});
await gnl.run('billing', {
workKey: 'invoice-2026-04-7742', // your name for the job
resourceId: 'u-142', // the address it is unique WITHIN ('resource' scope)
prompt: 'Issue the April invoice',
});Pass either a workKey or a runId, never both — two identities for one call is a question
with no honest answer. A raw runId is still first-class and always will be: runDurable,
resumeRun, forkRun and streamDurable take an id, because resume and fork cannot reverse a hash.
The id you get back
runId = derive(entityName, workScope, subject, workKey) — a 128-bit digest behind a run1_ prefix.
@gnldev/server and @gnldev/chat-adapter return it in the X-Gnl-Run-Id response header;
@gnldev/agui carries it in the AG-UI event envelope instead, because that route's answer is a
stream of events and the id belongs on the run's own RUN_STARTED, not beside it. In process,
compute it from the same four inputs:
import { derivedRunId } from '@gnldev/durable';
// NOTE THE `agent:` PREFIX — it is part of the entity name, not decoration.
const id = derivedRunId('agent:billing', 'resource', 'u-142', 'invoice-2026-04-7742');
// 'run1_<32 hex>' — and the same four inputs always produce the same idThe entity name carries a TYPE prefix: agent:<name> for the agent doors, wf:<name> for
runWorkflow. A workflow and an agent may share a registry name, and without the prefix pay the
pipeline and pay the agent would derive ONE id from one workKey — two unrelated jobs meeting in
a single run. If you compute an id yourself to look one up, use the prefix the engine used.
Three consequences worth knowing before you rely on it:
runIdis a stable pseudonym of yourworkKey, not an anonymisation of it. A low-entropy key (invoice-1,order-42) is recoverable by dictionary, and under GDPR the id keeps whatever personal-data status the key had. Treat the digest as a pseudonym, not as a scrub.- The agent's name is part of the identity. Two agents cannot collide on one
workKey— and renaming an agent breaks the resume of its half-finished runs, because the id no longer derives. - Collisions die structurally; secrecy does not. Anyone can compute the digest offline. What stops a guessed id from being read is the ownership gate, not the hash.
- A shape-valid impersonation is indistinguishable. A caller who knows all four inputs produces the same id a legitimate caller would, and the engine cannot tell the two apart — the protection is the ownership gates, not the derivation; and a slot that already exists is skipped by rollover, never re-born into.
What happens on the second call
Two axes, whose names are fixed even though v1 gives each exactly one behaviour — there is no field to set yet, and inventing a single-valued enum would be furniture. The names live here so that tomorrow's second value is an addition rather than a break.
| Axis | v1 value | The second call gets |
| --- | --- | --- |
| onConflict | 'reject' | The work is running right now → 409 run_busy + Retry-After. |
| onReuse | 'replay' | The work already finished → the recorded answer, nothing re-runs. |
What makes run_busy possible is a lock, and a lock has a TTL. "Running right now" is not
inferred from the journal's contents — it is a per-run lock (lock: { ttlMs }, default 300 000 ms)
taken before the run starts and renewed by a heartbeat while it lasts. Two consequences follow, and
they are the shape of the guarantee rather than caveats to it: a process that dies without releasing
holds the run only until the TTL expires, after which a retry is admitted and replays from the
journal; and a run that legitimately outlives its TTL keeps the lock alive by heartbeat, so the TTL
bounds crash recovery, not run length.
- A run that ended in FAILURE has no answer to replay, so the same
workKeyis free to run again. This is a rule, not a footnote: retrying failed work is the normal case, not an escape hatch. - The third collision class keeps its own name:
strictInput— same key, different content →409 run_input_mismatch. Inside therun1_space it is unconditional, with no opt-out: the caller did not choose that id, so "use a fresh runId" was never advice they could act on.
How long a workKey stays unique
As long as the run record lives — not a minute longer. Recognition is a property of the stored run, not of the text. The moment a sweep deletes that run, the key is a stranger again.
So the number to set is not "how long do I want history"; it is a comparison: your retention window
should not be shorter than the longest retry your clients can produce. If you cannot promise that,
open the tombstones (tombstones: true + tombstonePolicy: 'reject') — a late retry is then refused
with 409 run_swept instead of quietly starting the job over. A tombstone is a refusal, not an
answer, and it stores only a hash of the key.
LLM-aware idempotency (idempotency: 'args')
Call-keyed exactly-once (the default above, keyed by toolCallId) is blind to one real-world case: the
model re-planning the same work under a brand-new toolCallId (a documented pattern — the same tool
called 5× in one turn). Opt in per tool and GNL keys the journal by the arguments instead:
import { gnlTool } from '@gnldev/durable';
import { tool } from 'ai';
const tools = {
charge: gnlTool(
tool({ description: 'Charge an order', inputSchema: z.object({ orderId: z.string() }), execute: chargeCard }),
{
idempotency: 'args', // default: 'call' (toolCallId-keyed, unchanged)
// or dedup by a logical key: idempotencyKey: (input) => input.orderId,
},
),
};Same-key only (important):
idempotency: 'args'collapses calls that hash to the same key. It cannot dedup a genuinely different action key:refund({orderId:'123'})andrefund({orderId:'123', note:'retry'})hash differently → two refunds. "Exactly-once" here means "once per identical key", not "once per business intent". When the model may vary an irrelevant field, supply anidempotencyKeythat projects out only the fields that define the action (e.g.idempotencyKey: (args) => args.orderId) so the varyingnoteno longer splits the key.
Every duplicate — including concurrent duplicates inside the same step, which wait for the winner's
result instead of erroring — collapses into a single execution; the rest receive the journaled output.
The dedup window is run-scoped by default. Opt into idempotencyWindow: 'cross-run' to make the same
arguments (or logical key) execute once across all runs — retried jobs and re-triggered agents
included. Cross-run records live under the xrun: key prefix, outside any run's timeline: run
retention/sweep never touches them (purge explicitly with journal.deletePrefix('xrun:')), and
withOrg isolation still applies — including the options.idempotencyKey handed to your tool and forwarded to the provider, which carries the org so two organizations using the same orderId do NOT collapse into one charge at Stripe. Proof: test/cross-run-org-key.test.ts. Proof tests: test/args-idempotency.test.ts (reproduces the
duplicate-toolCallId pattern end-to-end) and test/cross-run-idempotency.test.ts.
Conversation-scoped dedup (idempotencyWindow: 'thread' + thread-scoped duplicate marker)
"This thread already created that product YESTERDAY, in another run" is invisible to run-scoped
dedup. Two conversation-scoped tools close it, both living under xthr:<threadId>: so ONE
purgeThread sweep reclaims the thread's whole dedup state (no immortal cross-run keys):
// Deterministic business key, silent dedup ACROSS RUNS of one conversation (Stripe semantics):
gnlTool(tool({ inputSchema: z.object({ orderId: z.string() }), execute: async () => ({ ok: true }) }),
{ sideEffect: true, idempotencyWindow: 'thread', idempotencyKey: (a: any) => a.orderId });
// LLM-shaped args where silence would be wrong — the ambiguous repeat becomes a HUMAN question
// carrying the first result's address (firstToolCallId). This one is a RUN option, not a tool one:
const limits = { sideEffectDuplicates: { action: 'suspend', scope: 'thread' } };Use the right layer: silent windows are for DETERMINISTIC business keys, where dedup is
unambiguously correct. For LLM-derived raw args an identical-looking second request may be a genuine
second intent — that case belongs to the suspend ladder (or the semantic gate below), where a human
answers. No default TTL, deliberately: a false positive costs one extra question, a false negative
fires the effect twice (ttlMs opts in). Records need a threadId at call time — absent one, the
window falls back to run scope LOUDLY. toolPolicy: 'strict-critical' raises the bar one rung:
every sideEffect: true tool must answer the crash window with recover() or a deterministic
idempotencyKey, or the run refuses to start.
Governance (policy)
The LLM sees all tools and reasons freely; policy only gates execution:
await runDurable({
runId, journal, model, tools, prompt,
guard: ({ toolName, args }) => {
// `args` is `unknown`: the guard sees every tool's arguments, so narrow before reading one.
const { amount } = args as { amount?: number };
if (toolName === 'chargeCard' && (amount ?? 0) > 1000) return { action: 'require-approval' };
return { action: 'allow' };
},
});deny→ the tool doesn't run, the model gets "not permitted" back (the LLM corrects itself).require-approval→ the run is suspended;result.interruptscomes back populated.
Human-in-the-loop (suspend / resume)
const r = await runDurable({ runId, journal, model, tools, prompt, guard });
if (r.interrupts.length) {
// ask the user… then approve and resume:
await runDurable({ runId, journal, model, tools, prompt, guard, approvals: { [r.interrupts[0].toolCallId]: true } });
}The approval decision is written to the journal as first-class (via claim, first decision wins):
even in the "approved but the process crashed before the tool ran" scenario, the decision is durable — the
next runDurable call applies the decision recorded in the journal even if the approvals parameter isn't
passed again.
confirm — the tool's own first-call human gate. For a tool consequential enough to ask a human
EVERY fresh call, declare it on the tool itself — no guard factory to remember (a two-step ceremony
where forgetting the factory leaves the flag silently unenforced is exactly the failure class this
replaces):
gnlTool(tool({ inputSchema: z.object({ amount: z.number() }), execute: async () => ({ ok: true }) }), {
sideEffect: true,
confirm: { reason: (args: any) => `charge ${args.amount} to card — confirm?` }, // or just `true`
});The fresh call suspends with the standard sentinel into the SAME approvals flow (Studio inbox, the
chat approve() helper); deny writes a terminal denied record; a pre-supplied approval skips the
gate and still meets the guard. Scope: per toolCallId — a model issuing a new call asks again. A
crashed (failed/running) attempt is NOT re-asked "confirm before it runs" — that state belongs to
the recover/reclaim ladder, which knows the effect may already have fired.
Streaming: catching blocked/limit protections
runDurable throws when a protection fires (loop / maxToolCalls / duplicate-side-effect /
tainted-side-effect block). streamDurable can't throw mid-stream — the block shows up as an internal
sentinel in fullStream (kept there deliberately: @gnldev/server / @gnldev/agui post-scan steps and
emit one terminal error event). As a direct consumer you still can't miss it:
const result = await streamDurable({ runId, journal, model, tools, prompt,
limits: { maxToolCalls: 5 },
onBlocked: (breach) => log.warn('protection fired', breach), // { kind, message, detail } — raw, structured
});
try {
const text = await result.text; // REJECTS with the typed error (RunLimitExceededError, …) if a block fired
} catch (err) { /* mirror of runDurable's throw */ }onBlocked(also ongnl.stream(name, { onBlocked })) fires once at stream finish with the raw structured breach — you decide what to show your users. It's advisory: a throw from it is swallowed.await result.text(and the other terminal promises —content,response,toolCalls, …) reject with the same typed errorstreamFinishError(steps)would return.steps/finishReason/usageandfullStreamitself deliberately never reject — the post-scan contract (streamFinishError(steps)) keeps working, which is what@gnldev/server/@gnldev/aguirely on.
Composable (any agent framework / your own loop)
import { withDurableModel, durableTools } from '@gnldev/durable';
const model = withDurableModel(anthropic('claude-opus-4-8'), { journal, runId });
const tools = durableTools(myTools, { journal, runId });
// use these with your own generateText OR with another framework's agentTools
@gnldev/studio(separate package) — web UI: runs/timeline + time-travel + approval queue.npx @gnldev/studio --db runs.db— point it at the journal you passed torunDurable. With a config file instead (--config gnl.config.ts) it also serves the Playground. Barenpx @gnldev/studiohas nothing to read and prints its usage.
Examples
npx tsx examples/no-double-charge.ts # no API key needed (mock model) — at-most-once proofAPI
| | |
|---|---|
| runDurable(args) → DurableResult | Drop-in generateText + journal/runId/guard/approvals. Adds .interrupts. Optional timeouts: { modelStepMs, toolMs, claimTtlMs } — on timeout StepTimeoutError flows through the existing failed/retry/recover paths (opt-in, behavior unchanged if not provided). |
| resumeRun(args) | Resume a suspended run: reads the prompt and the frozen limits back from the journal, so the resumed turn runs under the same bounds as the original. NOT an alias for runDurable — that name never existed. |
| withDurableModel(model, ctx) / durableTools(tools, ctx) | Composable wrappers. |
| InMemoryJournal / SqliteStorage (/sqlite, journal at .runs) | Journal adapters. |
| Guard, Interrupt, Journal, DurableResult | Types. |
| resolveModel(spec) / registerModelProvider(prefix, factory) | Turns 'provider/model' into a model, lazily importing the provider package. Four prefixes ship built in (openai, anthropic, google, mistral); a host teaches it more — see below. |
Your own provider (OpenAI-compatible endpoints)
An endpoint that speaks the OpenAI API — NVIDIA NIM, Together, vLLM, Ollama, a gateway, a local
server — is not one of the four built-in prefixes, so resolveModel('nvidia/…') does not know it
until the host says so:
import { createOpenAI } from '@ai-sdk/openai';
import { registerModelProvider } from '@gnldev/durable';
// `.chat(id)`, NOT `createOpenAI({...})(id)`. The bare call returns the RESPONSES model, and these
// servers speak /chat/completions — the wrong one fails as a 404 from your own endpoint.
const unregister = registerModelProvider('nvidia', (modelId) =>
createOpenAI({ baseURL: 'https://integrate.api.nvidia.com/v1', apiKey: process.env.NVIDIA_API_KEY })
.chat(modelId));After that, 'nvidia/meta/llama-3.1-70b-instruct' resolves everywhere a model string is accepted —
agent config, the Studio playground, a fallback chain. The returned function unregisters it, so a
test can add one without leaking it into the next.
Two things the router enforces, so a host does not have to:
- Prefixes are lowercase words.
' openai','OPENAI'and'openai 'are refused rather than normalised: two spellings of one name resolving through different factories in the same process is a difference nothing on screen would show. - Model ids are checked before they reach your factory. That string arrives from an HTTP body in
Studio, and a factory typically puts it in a request URL under your credentials — so
'../../etc', a whole URL, or anything with a newline is refused at the boundary rather than passed on.
License: Apache-2.0 — see LICENSE.
Critical profile (preset: 'critical')
The banking/defense/medical bundle as ONE opt-in switch — explicit opts still win field by field:
const gnl = createGnl({ journal, agents, preset: 'critical' });
// = toolPolicy 'strict-critical' + sideEffectDuplicates {action:'suspend', scope:'thread'} + exclusiveModelStep
// + an automatic per-run lock (ttl 300s) + strictInput + actor binding + conflictLedger
// + tombstonePolicy 'reject'What each protection answers, in one line each:
strictInput— one runId carries ONE request: the raw caller input is fingerprinted at freeze time; the same runId arriving with different content gets409 run_input_mismatch(Stripe's "same key, different payload" semantics — the rule is Stripe's, the status is ours: Stripe answers 400, we answer 409, because a conflict is what this is). Legitimate flows stay open: driving the run with its own frozen content is a replay, and an approval addressing any journaled toolCallId admits the re-POST — even retried after the record turned terminal.actor— the runId binds to its first caller (first-wins); a different actor re-driving it gets409 run_actor_mismatch. No actor on either side = no check: an auth-less deployment has no protection here, stated rather than silent.conflictLedger— every refusal (busy / thread / input / actor / swept) appends anidem:conflict:*record: codes, key hashes and the actor id, never content. Say it precisely, because the difference has legal weight: a hashedworkKeyis a stable pseudonym, not an anonymisation — a low-entropy key is recoverable by dictionary, and the record keeps whatever personal-data status the key itself had. The family lives OUTSIDE the run's sweep prefix — the audit's subject cannot erase its own refusal history. Read it withreadIdemLedger(journal), which THROWS withoutlistKeysrather than lying with an empty answer.auditOnReject: 'require'— the conflict-ledger append becomes a PRECONDITION of the refusal: if the audit store cannot record the "no", the caller gets the audit error, never an unrecorded 409. Default'best-effort'(the refusal always lands; a failed append warns).tombstonePolicy: 'reject'— a retention-swept runId's late retry is refused (409 run_swept) instead of silently re-running side effects whose dedup window died with the journal. Pair withsweepRuns({ tombstones: true }); the REAL contract stays: retention window ≥ client retry horizon.sweepRuns({ suspendedTtlMs })gives abandoned suspended runs an expiry so they stop accumulating forever.
Journal guidance: prefer Postgres. On Redis, configure
waitReplicas: { replicas: 1, timeoutMs: 1000, onTimeout: 'throw' } and know the honest bound — the
throw fires AFTER the write, so an unacknowledged claim becomes visible, not undone; treat thrown
claims as a reconciliation suspect list. And the single-home bound, restated where it matters: every
guarantee above spans any number of workers on ONE journal store; two regions with independent
journals are two independent dedup windows — route a runId to its home journal. Scope stated
honestly: the preset wraps run()/stream(); runWorkflow()/runNetwork() entry paths are not
covered yet — apply protections there explicitly.
The decision hierarchy, which every layer here serves: deterministic (work identity, unique
constraints, fingerprints) > human gate (confirm, suspend ladders) > probabilistic (semantic
candidates, working memory). workKey sits at the top of the deterministic layer — it is the
run-level answer to "is this the same job?", settled by exact equality before any tool runs, and the
semantic gate below never overrides it. And the recipe that no framework can automate away: give critical tools
a read-before-write sibling (createProduct ↔ findProduct) so the agent checks the system of
record before acting — and for the same-key half, the framework leg exists: a tool-level
lookup(input, { idempotencyKey }) hook is consulted before the FIRST attempt of a side-effect tool
({exists: true, output} journals the found result without firing; a throw is fail-open with a loud
warn). lookup answers BEFORE the first attempt; recover answers the crash window AFTER a failure
— distinct on purpose — the source of truth is never the conversation, and the LAST line of defense
is always a unique constraint or upsert in the external system itself.
Semantic duplicate-candidate gate (sideEffectDuplicates.semantic)
The honest claim, verbatim: this layer finds past side-effect work that LOOKS similar in meaning
("create product ABC" said two different ways — where hash-based dedup is blind) and, ONLY when the
deterministic field comparison also matches (tool name, identity fields, amount fields), puts the
first result next to an approval question. It never silently skips, blocks or tells the model
"already done" on your behalf; the final word is always deterministic field equality + a human. It is
best-effort and fail-open: if your embedder is unreachable or no candidate clears the bar, behavior
is today's behavior — no regression, and no guarantee either. Decision hierarchy: deterministic >
human gate > probabilistic — this layer is the third class serving the first two as a candidate
finder. Which also fixes where it sits relative to run identity: a run's workKey is settled by
exact equality before any tool executes, and nothing on this layer can turn two work names into one
job or one work name into two.
It does not replace layers 1-4 (hash/claim/confirm/critical); it runs beneath them, and it
refuses to start where no approvals channel exists. The quality of your semanticIdentity.keys
declaration IS the quality of the protection.
A declaration that cannot identify the call stands its layers down — for that call, and it says so. Checked per call, because the keys are static but the VALUES arrive with the arguments:
| keys points at | Verdict | Why |
|---|---|---|
| a string, number, boolean | usable | identifies |
| a Date | usable | String(d) varies per call |
| a plain object | unusable | every one of them is '[object Object]' |
| an array | unusable | element types flatten: [1,2] and ['1','2'] both become '1,2', so different work compares equal |
| a field absent from the args (a typo) | unusable | every call compares equal to every other |
| a prototype name ('constructor', 'toString') | unusable | not an argument you passed; resolves to one constant |
"Unusable" is measured on EVERY key: a declaration like ['sku','warehouse'] where only the
optional warehouse is missing stays usable, and the question still gets asked.
When it fires, every identity-based layer is off for that call — cross-channel XID, the confirm
decoration, the semantic gate, and createBatch's preflight — and the call proceeds exactly as it
would without them. One incident is written (readIncidents, source semantic-guard, action
warn) naming the FIELD names, never their values; BatchPlan.xidIdentityUnusable names the
skipped items on the batch path. Nothing is blocked that would otherwise run: the failure direction
is a protection withdrawn and announced, never a job refused on a bad comparison.
// Double opt-in: the run-level block AND the tool-level declaration — either absent, layer inert.
const limits = {
sideEffectDuplicates: {
action: 'suspend', scope: 'thread', // required — config-time throw otherwise
semantic: {
embed: myEmbed, // (texts: string[]) => Promise<number[][]>
embedModelId: 'text-embedding-3-small', // required stamp — mixed-model cosine is meaningless
minSimilarity: 0.6, // candidate threshold (recall side; misses are safe)
},
},
};
const createProduct = gnlTool(tool({
inputSchema: z.object({ sku: z.string(), price: z.number(), cancel: z.boolean().optional() }),
execute: async () => ({ ok: true }),
}), {
sideEffect: true,
semanticIdentity: {
keys: ['sku'], // the business identity — REQUIRED, non-empty
amountFields: ['price'], // identity-equal + amount-differ → its own question
discriminatorFields: ['cancel'], // negation gate: differ → deterministically not a duplicate
describe: (args: any) => `create product ${args.sku}`, // the PII boundary: ONLY this reaches the embedder
},
});Local, in-process embeddings (recommended for the critical profile — nothing leaves the machine).
The embed contract is provider-agnostic; a ~240MB multilingual model behind it removes the API
bill, the rate limit AND the PII question in one move:
// npm i @huggingface/transformers (~150-400MB RAM at runtime, ~5-20ms per short sentence on CPU)
import { pipeline } from '@huggingface/transformers';
const extractor = await pipeline('feature-extraction', 'Xenova/multilingual-e5-small', { dtype: 'q8' });
const embed = async (texts: string[]) => {
// e5 family quirk: inputs want a "query: " prefix — bake it into the adapter, never into callers.
const out = await extractor(texts.map((t) => `query: ${t}`), { pooling: 'mean', normalize: true });
return out.tolist();
};
// embedModelId: 'local:multilingual-e5-small@q8' ← stamp the QUANTIZATION too — a re-quantized
// model produces different vectors, and the stamp is what keeps old records out of the comparison.
// The same closure serves @gnldev/memory's semantic recall — one model, both jobs.Cost model (documented so the bill is never a surprise): ~1 embed call per guarded side-effect call
on the happy path (the recall-side vector is cached by args hash and reused for the write). Records
live in YOUR journal under xthr:<threadId>: (~3KB each, no vector DB, no index) and die with the
thread in the same purgeThread sweep as everything else. Not written on failed/suspended work;
ttlMs on the parent block ages records out of consideration. Named TOCTOU bound: two CONCURRENT
paraphrase twins (different hashes, same identity, in flight together) cannot see each other's
records — both run; this layer's promise is duplicates SEPARATED IN TIME, concurrency belongs to the
exact-hash/lock layers below. What this feature is NOT (binding for
docs and marketing alike): not a "meaning engine", not AI-powered duplicate prevention, not intent
detection, not "semantically exactly-once" — negation and magnitude are solved by the STRUCTURED
fields, never by the vector.
The rule ladder and the judge (semantic.rules, semantic.judge)
The gate above asks nothing when the identity fields differ — "TV-42" and "tv-42" normalize into one
job, but "Samsung 42 inch TV" and "SMSNG TV42" do not, and v1 only counts those (droppedIdentity).
Two optional rungs work that residue, both opt-in, neither able to decide anything by itself.
Why not just lower the threshold: we measured it. On 300 labelled pairs run through a real
embedder, "same job, written differently" averaged 0.906 and "different job that looks alike"
averaged 0.852 — the bands overlap, so every threshold trades recall against false alarms one for
one. Concretely: Philips Airfryer XL vs XXL (two different products) scored 0.959, HIGHER than a
genuine paraphrase pair at 0.740. A score-only arm would ask about neighbouring SKUs and sibling
companies while missing the abbreviations it exists for, and an operator trained to approve
question-storms approves the real duplicate too. That arm is not deferred; it is refused.
The ladder (rules) is deterministic, free, and journalled. Its constitution is encoded in the
types, because the direction a rule may conclude is the whole safety argument:
| class | may conclude | examples |
|---|---|---|
| normalizer | match → a question | digit VALUE (0142 = 142, 42 ≠ 43), digit concatenation, character folding (Danışmanlık A.Ş. = danismanlik as, Air Fryer = Airfryer) |
| separator | separate → candidate drops | size ladder (XL ≠ XXL), one-character short-code difference (abc ≠ abd) |
Every rule here is an algorithm, not a list — rules: true is the whole configuration. That is a
deliberate correction, not an oversight. An earlier design had a third class fed by caller-supplied
dictionaries (synonyms, locale tokens, initials); it was removed once measured, for two reasons that
are worth stating because they generalise. First, the safety constitution capped that class at
"defer to the next rung" — which is also what happens when no rule matches, so it could not change a
single outcome while still asking for upkeep. Second, on both calibration sets every ladder
decision came from the list-free rules; the dictionary surface contributed nothing. A knob that
changes no behaviour and decays silently is worse than no knob.
Dropping is the safe direction throughout: a separate means no question, which is today's
behaviour. And what needs world knowledge — is Ahmet the same person as Mehmet, is a depot a
warehouse — goes to the judge, which brings that knowledge with it and needs no maintenance from you.
Open-ended prefix matching and phonetic skeletons are not in the framework at all — not disabled,
absent. Measured directly: a prefix rule that looked sound on one dataset matched Berg to Bergman
and Pro to ProHeat on a fresh one, two different people and two different products.
(gazetteers survives as an escape hatch for a genuinely small, stable closed set — twelve
warehouses, not "Turkish surnames" — and only pays off when a judge is configured, since it saves
judge calls rather than changing any answer. Default empty; leave it that way unless you have
measured a reason.)
Where these rungs actually earn their keep — measured, and not where we first assumed. We ran 144
turns of natural conversation through the real engine with a real tool model writing every argument.
The ladder matched nothing and the judge, called twelve times, asked nothing. The reason is worth
knowing before you enable either: a competent tool model CANONICALISES for you. "one more of that
television" comes back as sku: "TV-42" — byte-identical to the earlier call — so the exact-hash
layer catches it and the semantic rungs never get their turn.
So: on identity fields the model can canonicalise (SKUs, invoice references, order ids) you may not
need these rungs at all. They earn their keep where the model passes text through as written —
support-ticket subjects, customer and company names, free-text descriptions — which is exactly where
"same job, different wording" survives all the way to the tool call. Read your own scan.grayCalls
before deciding; if it stays near zero, that is your answer.
And before you write the declaration, check the SCHEMA. Every false alarm in that run had one
cause: two orders to different warehouses looked identical because createOrder had no warehouse
field. The distinguishing fact never reached the tool call, and no declaration can separate what the
engine cannot see. semanticIdentity.keys is the second question; the first is whether the tool's
input schema carries what makes two jobs different.
The judge (judge) is the last rung, and it does not decide either. It answers one question —
"do these two records name the same real-world thing?" — and a same answer buys exactly one thing:
a human is asked. different, unsure, a timeout, an exhausted budget and an unparseable reply all
mean today's behavior, and every one of them is journalled. The judge is never told the work ran
before (that is the permanent model-notification ban, projected onto this surface), and it receives
only the two canonical sentences plus the tool name — never the raw args, amounts or discriminators.
Read that literally, because the default matters: the canonical sentence is BY DEFAULT built from
your declared identity values ("<toolName>: <identity values>"), so with no describe() override
your SKUs, invoice references and customer names DO reach the judge provider — a second provider
beside the embedder. describe() is the redaction point for both. If your compliance position is
"nothing identifying leaves the process", the recipe is the same one as for embeddings: a local model
behind both closures, or a describe() that emits hashed or bucketed identifiers.
describe() is also where RECALL quality is won or lost for free-text identity fields. Measured
systematic blind spot: mixed-language term pairs ("Karanlık mod" vs "Dark mode", "dışa aktarma" vs
"export") score below the candidate threshold, and a record the recall gate never surfaces is never
seen by the rules or the judge — the miss is silent and final. If an identity field can carry the
same term in more than one language, normalize it to ONE canonical language inside describe()
(and inside keys normalization if the field is also an identity key). This is a per-tool, per-field
decision — the framework deliberately ships no translation dictionary.
// The `semantic` block from above, with the two quality layers filled in.
const semantic = {
embed: myEmbed, embedModelId: 'local:multilingual-e5-small@q8',
rules: true, // defaults; or an object to supply your own lists
judge: {
// TRANSPORT ONLY: the framework renders the prompt, you own the model and the bill.
complete: async ({ system, user }) => (await myModel(system, user)).text,
judgeModelId: 'your-judge-model',
qualification: cert, // from @gnldev/semantic-qualify — REQUIRED
maxCallsPerRun: 10, // journal-backed slots; survive resume
timeoutMs: 8000,
},
};The certificate is not ceremony. On identical fixtures with the identical prompt, one model
answered 43% of the paraphrase pairs correctly and another 100%. A third, from a family unrelated to
the one that wrote the fixtures, scored 93% — so the spread is about the model, not about whose
phrasing it recognises. A judge you have not measured is a
layer that looks installed and is not — so a missing, weak (recall < 0.70 or false alarms > 0.05),
model-mismatched or prompt-version-mismatched certificate is a config-time throw, the sibling of
v1's empty-keys throw. Swapping the model or upgrading past a prompt-version bump invalidates it
and the exam must be re-sat:
npx gnl-semantic-qualify --judge ./my-judge.mjs --model your-judge-model # → gnl-judge-cert.jsonThe bench evaluates blind (opaque ids, shuffled order, labels never sent) and scores against the same
exported constants the runtime enforces. Point it at --fixtures ./yours.json to measure something
your model cannot have seen; the published set stops being held out the moment it is published, and
the certificate stamps EXAM performance, not field accuracy.
Cost, stated before you enable it. The judge runs only on the gray residue, at most once per
call, bounded by maxCallsPerRun slots that are claimed in the journal (so a crash loop cannot re-buy
the budget, and a timeout burns a slot on purpose). Verdicts are cached symmetrically and stamped
with model, prompt and ruleset versions, so a replay never pays twice and a swapped model is never
served the previous one's answers.
Count first, judge second. Studio's /semantic-guard reports scan.grayCalls: the number of
guarded CALLS that produced a gray residue. The unit is deliberate — one call can surface several
candidates, but the judge speaks at most once per call, so calls (not candidates) are what a judge
would cost. Run with rules on and judge off for a while, read it, then decide. Two traps worth naming: droppedIdentity is not a second quote
(it counts the same candidates from the other side, so adding them double-counts), and its MEANING
shifts when you enable rules — separator drops move into droppedByRule, so the same traffic
reports a smaller droppedIdentity after the upgrade and pre/post baselines are not comparable. In
our measurements the ladder settled about a third of the paraphrases for free and dropped a third to
a half of the look-alikes before any judge call.
Measured on conversation traffic: 0.10 judge calls per guarded call (12 calls across 126
tool-calling turns), and none of them produced a question — the gray band was genuinely made of
different work. The maxCallsPerRun default of 10 is therefore roomy rather than tight; it is a
backstop against a pathological thread, not a budget you should expect to spend.
The chain, end to end:
exact-hash → cosine candidate (≥0.60) → identity fields
equal → question (v1, unchanged)
ladder match → question (deterministic, origin: 'rule')
ladder separate → today's behavior + counted (droppedByRule)
gray + judge → 'same' → question · anything else → today's behavior + incident
gray, no judge → today's behavior + counted (grayUnjudged)Studio's semantic card breaks the questions down by which rung asked (identity / rules / judge)
beside precision@suspend. A judge share climbing over time is the first sign your identity
declarations or dictionaries stopped matching the traffic.
When a question turns out to be unnecessary — a human read it and ran the work anyway — the card
also names the declaration it rested on (byDeclaration):
createOrder · sku — 4 of 4 questions ran anywayprecision@suspend tells you how many questions were wrong; this tells you which declaration
produced them, which is the part you can change. Questions all resting on the same one or two fields
usually mean the tool call never carried what separates the jobs — two orders for different
warehouses are identical to the gate if createOrder has no warehouse field. That is a schema fix
before it is a semanticIdentity fix: the gate can only see what the tool call carries. Read the
count as a ranking of suspects, not a verdict — a repeat someone deliberately approved lands in the
same column.
What this is NOT (binding for docs and marketing alike, extending the v1 list): the judge does
not decide or approve; the ladder does not "catch duplicates" (no guarantee language); this is not
AI-verified dedup; no synthetic measurement here is a field-accuracy promise; and "a qualified judge"
never equals "reliable protection" — the live precision@suspend number is the only evidence that
the questions were worth asking.
Replay disclosure (honest narration)
When a tool result is answered from the journal instead of executing (the repeat of already-done work), the model receives a plain successful result — and would naturally announce a fresh success ("your order has been created!") for work that did NOT run. Measured live; a user cannot tell the replay from the real thing. Two mechanisms close that, without touching the permanent panel rule (the model must NEVER know work was done BEFORE deciding to call — that would enable silent, unauditable dedup):
- Envelope (always on): every consumed pre-existing record lands on the result as
result.replayedToolCalls: [{ toolCallId, toolName, status, origin }]— out-of-band, never shown to the model.originseparates'window'(genuinely earlier work) from'self'(this very request resuming — an approval continuation must not be narrated as "an earlier request"; the identity test isresolvedToolCallIds, not key prefix).@gnldev/serverforwards the field on run responses; UIs can badge it. replayDisclosure: 'explain'(opt-in, per run orcreateGnlconfig): a TRANSIENT system note is injected into the step FOLLOWING the consume — the model then narrates honestly ("the operation was not performed again; this is the record of the earlier one"). The note is prepareStep-only: it never reaches the journal, thread memory, or any later turn (pinned by a memory-attached test), and onlyorigin:'window'+status:'succeeded'entries earn it. The model stays blind at decision time, informed only while narrating. Honest bounds: a replay consumed on the FINAL step gets no note (no following live step — the envelope covers it), and the stream surface fires the note but does not yet stamp the envelope on the stream result.
Repeat questions carry their context
Under the critical preset the duplicate policy is thread-scoped suspend: a deliberate identical
repeat becomes a human question EVERY time — approval creates the second job for real, denial
doesn't (product rule: never silently swallow an intentional repeat, never silently run one). And
because the confirm gate fires before the duplicate ladder, the confirm question itself is
decorated rather than left generic:
- exact repeat →
⚠ Identical work was ALREADY COMPLETED earlier in this conversation (first result: <toolCallId>) — approve only if you intend a deliberate repeat. - same normalized identity, different spelling (
'LAMBA-1'vs'lamba-1') → the confirm arm runs the same semantic candidate finder locally:⚠ Work with the SAME business identity appears ALREADY COMPLETED …(the score only finds; identity fields decide; the outcome is text on a HUMAN question — never a silent decision; fail-open if the embedder is down) - same identity, different amounts →
⚠ … the amounts DIFFER — check carefully before approving.
The full behavior matrix (different words / key order / spelling / amounts / genuinely different
work / approve / deny / score-alone-never-decides / embedder-down) is pinned in
test/repeat-matrix.test.ts.
Scale characteristics (measured)
Measured on real Postgres, real key schemas, 2048-dim vectors (scripts/bench-scale.ts in the
companion prod-test app). What grows with what:
| Layer | Cost shape | Measured | |---|---|---| | Exact dedup (hash/marker/lock) | O(1) point reads per tool call | 0.25 ms/read at 2 000 markers | | Semantic recall | linear in THIS THREAD's side-effect records (listKeys + N gets + N cosines, in-process) | 100 recs → 36 ms · 500 → 160 ms · 2 000 → ~650 ms | | Record writes (semantic/marker) | O(1) per successful side effect | ~3–4 ms/record | | HERMES suggestion scans | linear in the TENANT's suggestion/lesson count (approval-time, off the hot path) | 2 000 records → ~0.5 s full scan |
The load-bearing property: semantic cost is thread-local — it grows with the number of side-effect jobs in ONE conversation, not with users, tenants, or total volume. A deployment with a million users whose conversations each carry tens of side-effect jobs pays tens of milliseconds per gated call; horizontal scale is the ordinary kind (more workers on one journal — the single-home routing contract above). Practical bounds, stated honestly:
- A single conversation with thousands of side-effect jobs pushes semantic recall toward a second per gated call. That is the designed v1 boundary (in-process brute force, thread scope); cross-thread recall and an external vector index (Qdrant-class) are the deliberate v2, gated on telemetry data — not a rewrite, the recall interface already isolates the scan.
- HERMES merge/promotion scans are full-prefix scans per tenant; comfortable to ~10⁴ suggestions/lessons per tenant, indexable in v2 the same way.
- Embedding latency is the caller's own closure (one call per gated side effect, content-keyed cache in front): a remote API adds its round trip; the documented local-model recipe keeps it on-machine.
Retention and erasure
The journal is append-only by design, and these are the deliberate exception: permanent deletion,
for a legal erasure request or an age-based retention policy. They all need the deletePrefix port
(the InMemory/SQLite/Postgres/Redis adapters provide it) and throw a clear error where it is absent —
a deletion that silently did nothing would be the worst possible failure here.
| Call | Deletes | Reach for it when |
|---|---|---|
| purgeRun(journal, runId) | one run and its nested sub-agent/workflow children | a single run must go |
| purgeThread(journal, threadId) | mem: + the whole xthr: family (dedup window, semantic records, tombstones, judge verdicts) | a conversation must go |
| purgeResource(journal, resourceId) | a PERSON: their runs, their threads, cross-channel ids, lesson counters | an erasure request arrives |
| purgeBatch(journal, batchId) | one batch's bookkeeping | a batch must be re-run from scratch |
| purgeOrganization(journal, orgId) | everything under org:<id>: | a tenant leaves |
| sweepRuns(journal, { olderThanMs }) | runs whose LAST activity is older than the threshold | scheduled retention |
| sweepThreads(journal, { olderThanMs }) | threads the memory port knows, by age | scheduled retention |
| sweepLog(journal, ns, opts) | durable-log entries in one namespace | a log namespace grows without bound |
Two things the sweeps deliberately do NOT do:
- Suspended runs are kept (
keepSuspended, default true). Work waiting on a human is not silently deleted by a clock. PasssuspendedTtlMsto put a ceiling on that. - A run whose age cannot be measured is kept (
keptNoTsin the result). "I could not tell how old this is" and "this is new" are different answers, and only one of them justifies keeping it — but deleting on an unmeasurable age is the one that cannot be undone.
import { createRetentionSweeper } from '@gnldev/durable';
// Scheduled sweeps on an interval. `sweepThreads` is NOT part of this loop — thread state is
// conversation data, and deleting it on a timer is a policy decision, not a default.
const sweeper = createRetentionSweeper(journal, {
intervalMs: 60 * 60_000, // default: hourly
sweep: { olderThanMs: 90 * 24 * 60 * 60_000 }, // runs older than 90 days
onError: (err) => console.error('retention sweep failed', err),
});
sweeper.start();
// …
await sweeper.stop();Erasure and retention run in any order
purgeResource(rid) finds a person's threads through their RUNS, and retention deletes runs by age.
Run the sweep first and the link is gone — the erasure request would walk an empty list while
thread-scoped state built from that person's own arguments stayed on disk. Measured before this was
closed: after sweepRuns + purgeResource, a record carrying 'createRecord: iban-tr55' survived.
So sweepRuns now leaves resthr:<resourceId>:<threadId> behind when it deletes a run that named
both, and purgeResource follows those traces, purges the threads, and then deletes the traces
themselves — a trace names a person and must not outlive them. Cost: one write per DELETED run, and
nothing at all on the normal path. You do not have to order the two operations.
One case stays open by construction: a run with no resourceId cannot leave a trace, so its
thread state has no owner and no erasure request can reach it. listOrphanThreadState(journal)
counts these without deleting anything (gnl doctor prints the number); sweepThreads() reports
the same list in orphanThreadState and purges by age as it goes.
This library will not delete that data for you, and that is a decision rather than a gap. A
retention period is a property of YOUR obligations — a bank's and a game studio's are not the same
number, and a framework that picked one would be picking it for both. What a framework can do is
make the pile impossible to miss, which is what the count is for: gnl doctor prints it, the Studio
retention panel shows it, and neither requires you to delete anything to find out. Deleting on a
timer is one line away (sweepThreads({ olderThanMs })) the day you decide what the timer should be.
Production deployment notes (exactly-once preconditions)
The exactly-once guarantee rests on one precondition: storage never loses an acknowledged write (detailed analysis: the core-hardening review). For multi-worker production:
- Postgres (recommended
runsbackend): run it withsynchronous_commit = on+synchronous_standby_names(quorum). Under asynchronous replication, a primary failover can lose acknowledged CAS writes → the same tool can run twice. - Redis: since replication is always asynchronous, it is NOT RECOMMENDED for
runs(the replay journal) in failover setups — usecomposite()to keeprunson Postgres and use Redis forwork/cache(this is also how the adapter's capability design is meant to be used). - SQLite: meant for a single node; WAL mode gives sufficient filesystem durability.
- Outbound side effects: the strongest end-to-end defense is using a provider-side idempotency
key on tool calls (see the pattern in
test/idempotency-key.test.ts) — regardless of the storage layer, the external system rejects the duplicate.
License
Apache-2.0 — see LICENSE.
