GNL
Docs menu
Core · Free@gnldev/durable

Safe-by-default duplicate guard

When a side-effecting tool is about to run AGAIN with arguments identical to an earlier successful call in the same run, the ladder (off → warn → reflect → block → suspend) intervenes — nudging the model to reconsider or stopping the duplicate outright.

What it's for / when to use it#

A confused model can call the same side-effecting tool twice with the SAME arguments — a double charge, a double email, a duplicate shipment. The duplicate guard is on by default: it detects when a side-effect tool (charge, sendEmail, …) is about to execute again with args identical to an earlier successful call in the same runId, and applies the configured response. It is a per-run, in-run protection (it does NOT dedup across separate runs — for that use cross-run idempotency).

The ladder#

Set the response with limits.sideEffectDuplicates (default warn). The levels escalate in strictness:

const'off'

No detection — the duplicate executes.

const'warn'

Executes as before, but writes a named journaled incident on each occurrence (an adoption ramp — never a silent duplicate).

const'reflect'

Does NOT stop the run: delivers ONE journaled 'reconsider' nudge to the model (record status 'reflected'); an identical repeat AFTER the nudge escalates to a block.

const'block'

Stops the duplicate via DuplicateSideEffectError — nothing is written for the blocked call.

const'suspend'

Writes a suspended record → the duplicate lands in the Approvals flow (a human decides).

Which repeat hits which layer#

A repeat is not one thing, and no single layer sees all of them. Two axes decide: WHO produced the repeat (a confused model, or a user asking for the same job again) and whether it arrived in the SAME conversation. GNL closes three of the four cells today; the fourth is a deliberately open gap, and it is written down as one:

constexact-hash / marker

A model repeat with IDENTICAL arguments, in the same run or the same conversation → the ladder on this page. Fully deterministic: no score, no embedder, the argument hash is enough.

constXID

A user repeat whose identity fields are EQUAL after normalization — across conversations, and across channels too. XID is deterministic and resource-scoped (the person owns the identity): the same business identity is visible in one O(1) read whether it arrived from chat, a batch or a cron, and the question carries that — this job was completed 1 minute ago, via chat. It comes for free when the tool declares semanticIdentity and the call carries a resourceId; no embedder involved.

constsemanticIdentity

A different spelling or a paraphrase, same conversation → the hash is blind here. The only layer that can step in is semantic identity, and it only finds a CANDIDATE: the normalized identity fields and a human decide.

constopen gap (v2)

Paraphrase + a different conversation → an OPEN GAP. Semantic recall is thread-scoped, and XID cannot see it either unless the identity is equal after normalization. This is a deliberate boundary: cross-thread recall and an external vector index are the data-gated v2.

The third row in detail — the double opt-in, the decision hierarchy, the rule ladder and the judge's certificate — has its own page: semantic-duplicate-gate.

Setup / import#

The guard lives in @gnldev/durable and is configured per run via RunLimits:

import { createGnl, DuplicateSideEffectError, type RunLimits } from '@gnldev/durable';

Step-by-step usage#

Pass limits to gnl.run. An unmarked tool is treated as a side effect; a second identical call in the same runId throws DuplicateSideEffectError under block:

// 'charge' is unmarked treated as a side effect (H7: sideEffect ?? idempotent !== true).
// A second identical charge in the SAME runId is caught by the ladder.
const limits: RunLimits = { sideEffectDuplicates: 'block' };

try {
  await gnl.run('pay', { runId: 'order-42', prompt: 'charge the customer', limits });
} catch (e) {
  if (e instanceof DuplicateSideEffectError) {
    // e.detail.firstToolCallId the original successful call
    console.log('duplicate blocked:', e.detail.toolName, e.detail.firstToolCallId);
  }
}

Prefer reflect when you'd rather let the model recover: it receives one nudge and can proceed correctly; only a stubborn identical repeat is then blocked:

// 'reflect' does NOT stop the run: the model gets one journaled "reconsider" nudge
// (record status 'reflected'). An identical repeat AFTER the nudge escalates to a block.
const limits: RunLimits = { sideEffectDuplicates: 'reflect' };

Mark a genuinely idempotent tool with idempotent: true so the guard never fires for it (read-only lookups, status polls):

// Exempt a genuinely idempotent tool so the guard never fires for it:
const getStatus = { execute: async (a) => fetchStatus(a), idempotent: true };

API reference#

typeRunLimits.sideEffectDuplicates

RunLimits field: 'off' | 'warn' | 'reflect' | 'block' | 'suspend'. Default 'warn'. Applies to a side-effect tool about to repeat with identical args in the same run.

classDuplicateSideEffectError

Thrown under 'block' (and after an ignored 'reflect' nudge). detail: { toolName, argsHash, firstToolCallId, toolCallId }.

typeAnyTool.sideEffect / idempotent

A tool is side-effectful by default (H7: sideEffect ?? idempotent !== true). Mark idempotent: true (or sideEffect: false) to exempt it from the guard.

Reflect vs block
reflect is the pragmatic default choice for real agents: the nudge lets a confused model fix itself without killing the run, and the escalation-to-block after an identical repeat keeps the safety guarantee. The nudge text is model-facing and brand-neutral.
Note
The default is warn, not off: behavior does not change, but every duplicate is named as a journaled incident — so an operator can query it instead of it evaporating into a console line.
Related
See exactly-once-tools for the per-run exactly-once guarantee this builds on, taint-guard for the sibling ladder that fires on untrusted content, human-in-loop-approvals for the Approvals flow that suspend routes into, and semantic-duplicate-gate for the differently-worded case where the hash stays blind.