Skip to content
8 min read · 1,575 words

IMPORTANT

enforce and mutate describe what happens to a BLOCKING violation. Since rules default to severity: 'advisory', most findings are reported through OrderingGuardResult.advisories and never reach either mode. Set severity: 'blocking' on a rule to bring it under enforce/mutate — and see the caveat on Which API Surface a Rule Applies To before you do.

Operating Modes & Repair Strategies ​

In production agent loops, keeping a conversation moving is often as critical as validating protocol correctness. Most validation libraries treat rejection as the only civilized response to a rule violation, even when what failed was a trivially fixable ordering quirk. Refusing to fix something you can deterministically repair isn't rigor; it's laziness that costs users a live turn.

The ordering guard battery supports two operating modes configured by the action option in OrderingGuardOptions: one that trusts nothing and refuses to touch your state, and one that actually tries to keep your execution loop alive.

TL;DR — which mode do I actually want?

enforce in development and CI. mutate in production.

  • enforce is your smoke detector. It will not put out the fire for you, and it will not pretend the fire isn't there. Use it while you're writing adapters, debugging a pipeline stage, or running conformance tests against synthetic traces — anywhere you want a violation to be loud, because a loud failure right now is what stops a silent one from reaching a real user later.
  • mutate is what you actually ship. A real user mid-conversation does not care that a vendor's chat template stamped a Thought a millisecond after its ToolCall — they care that their agent kept working. mutate repairs what's safely repairable and only escalates the violations that genuinely can't be fixed without inventing facts, so production traffic gets continuity instead of a 400 for a bug that was never theirs to cause.

Shipping enforce to production is how a mechanically-fixable ordering quirk becomes a support ticket. Shipping mutate to your test suite is how a real regression slips through disguised as a "successful" repair. The mode is an environment decision, not a personal preference — pick wrong in either direction and you've traded one failure mode for a worse one.

Quick Start: Wiring the Middleware ​

The validation battery exports two middleware factories from @nhtio/adk/batteries/validation:

  1. orderingGuardDispatchMiddleware — Evaluates turn state on every dispatch iteration within the execution loop. Rejects violations via ctx.nack(error).
  2. orderingGuardTurnMiddleware — Evaluates turn state once before the execution loop begins. Halts violations via ctx.abort(error).
ts
import { TurnRunner } from '@nhtio/adk'
import { orderingGuardDispatchMiddleware } from '@nhtio/adk/batteries/validation'

const runner = new TurnRunner({
  executor: myExecutor,
  dispatchInputPipeline: [
    orderingGuardDispatchMiddleware({
      // String names resolve automatically from the built-in family recipe and atomic behavior catalogs
      profiles: ['anthropic-manual-thinking'],
      // 'mutate' automatically fixes repairable violations; 'enforce' strictly rejects
      action: 'mutate',
      onViolation: 'nack', // 'nack' rejects dispatch iteration; 'throw' raises an error
      onRepair: 'log', // 'log' logs warnings via console.warn; 'silent' suppresses logs
    }),
  ],
})

Profile Resolution

Passing string profile names (such as 'anthropic-manual-thinking' or 'strict_alternation') into profiles: [...] resolves them automatically from the built-in family recipe and atomic behavior registries.

The Two Modes: enforce vs mutate ​

ts
export interface OrderingGuardOptions {
  profiles: (string | OrderingProfile)[]
  mode?: 'union-of-rules' | 'each' | 'first-match'
  action?: 'enforce' | 'mutate'
  onViolation?: 'nack' | 'throw'
  onRepair?: 'log' | 'silent'
  allowMetadataFallbackRepair?: boolean
  snapshotStashKey?: string
  disableAdvisoryRuleIds?: string[]
}

1. action: 'enforce' (Strict Paranoid Validation) ​

enforce is the default mode. It performs pure, read-only validation against your configured profiles, trusting nothing, repairing nothing, and treating every blocking rule violation as an immediate dispatch failure.

If any blocking violation is detected:

  • In orderingGuardDispatchMiddleware, the iteration is rejected via ctx.nack(error) (or throws E_ORDERING_VIOLATION if onViolation: 'throw').
  • In orderingGuardTurnMiddleware (which has no nack capability), execution is halted via ctx.abort(error).

No mutation of context primitives or timeline state occurs in enforce mode.

Use enforce mode when:

  • Debugging pipeline transformations and verifying that upstream stages emit conformant history.
  • Running CI conformance tests against custom executors or synthetic conversation traces.

2. action: 'mutate' (Best-Effort Auto-Repair) ​

mutate is the pragmatic mode: instead of dropping a live conversation on the floor over a sequence glitch, it actively repairs violations that have unambiguous, deterministic, and content-preserving fixes.

When violations are detected in mutate mode:

  1. The guard classifies violations into repairable and unrepairable sets.
  2. Safe repair strategies are applied to the timeline and context state.
  3. The guard re-evaluates the repaired effective timeline.
  4. If all blocking violations are resolved, execution continues (next()).
  5. If unrepairable violations remain (e.g. lost historical context), the guard invokes onViolation (nack, abort, or throw) for the unrepaired subset.

Repairs applied during mutate mode are stored on ctx.stash under __orderingGuardLastResult (an OrderingGuardResult object) for downstream observability.

Repair Strategies by Rule Type ​

This table is an honesty document. It spells out exactly which protocol violations this battery can safely paper over and which ones it flatly refuses to touch. That refusal is deliberate: silently fabricating a plausible-looking fix for lost conversation history or wedged primitives would be far worse than rejecting the dispatch outright.

Rule TypeStrategy in mutate ModeRepairable?Concrete Execution Behavior
OrderRulereorderYesShifts the offending primitive's createdAt (via the matching ctx.mutate*) so it sorts immediately before the primitive it must precede, then re-evaluates the repaired timeline to confirm the fix holds. This reaches the real turn state — an adapter's next history assembly sees the corrected order directly, nothing further to consume.
AlternationRuleinsert-alternation-fillerYesInserts a synthetic Message filler in the opposite role between consecutive same-role messages via ctx.storeMessage, with a neutral acknowledgement as its content. Fillers are scaffolding for ONE dispatch: each pass deletes the previous pass's fillers (via ctx.deleteMessage) and excludes any that remain from the timeline it evaluates, so they can never become repair inputs to themselves.
RequiredMetadataRulefill-required-metadataOpt-inFills missing vendor metadata with a documented fallback sentinel. Authorized either by the rule itself (fallbackRepairAuthorized: true — set where the fallback is a value the VENDOR publishes, as on Gemini's thought_signature_required) or globally by allowMetadataFallbackRepair: true for a rule that does not. Both still require action: 'mutate'.
PreservationRuleNoneNoNever repairable. When historical primitives or payload fields are dropped or altered upstream, the guard cannot invent lost context. Rejects dispatch when severity: 'blocking'; the shipped profiles are advisory.
AdjacencyRulereorder-adjacentYesShifts the disallowed successor's createdAt so it sorts immediately BEFORE the primitive it may not follow, then re-evaluates. Every primitive survives — only relative position changes, where dropping the offending message would lose content the caller meant to send.
RoleRemapRuleN/AN/AWire role remapping hints are adapter-level concerns, not pipeline ordering mutations.
StaleContentAdvisoryRuleN/AN/ANever blocks. Advisory rules produce informational notices only; no repair needed.
IdentifierUniquenessRulerenumber-colliding-idsYesRenames every member of a collision group through the context's atomic group operation, then re-evaluates. Persistence commits before local state changes, so a throwing store leaves the context exactly as it was rather than half-renamed. Requires the context's group-replacement capability; absent it, the repair is not attempted and the finding stays unrepaired (a blocking collision then nacks). Dispatch surface only — the shipped profile sets surface: 'dispatch', so the surface filter removes it on the turn path entirely: it neither reports nor repairs there. A consumer who sets surface: 'both' gets turn-level detection, where the turn guard reports and, in mutate mode, materialises this strategy.

Caveats for Mutate Mode ​

When configuring action: 'mutate', keep the following behavioral boundaries in mind:

  1. Scope of OrderRule Repairs: When OrderRule repairs an ordering mismatch (such as placing thoughts before tool calls), the guard shifts the offending primitive's createdAt by calling the matching ctx.mutateToolCall/mutateThought/mutateMessage directly, then re-evaluates the repaired timeline to confirm the fix actually resolves the violation. Because this reaches the real ctx.turnMessages/turnThoughts/turnToolCalls state — not just an in-memory copy — any LLM adapter's own history assembly sees the corrected order on its next pass without reading anything from ctx.stash. ctx.stash.get('__orderingGuardEffectiveTimeline') remains available purely for observability, if you want to inspect exactly what the guard did on a given iteration.
  2. Metadata Fallback Provenance: action: 'mutate' alone will not INVENT vendor metadata. Fabricating a vendor signature is a direct provenance claim about where reasoning originated — it is the one place this battery could convincingly lie on your behalf, and it requires the explicit allowMetadataFallbackRepair: true opt-in. A rule may waive that for its own fallback with fallbackRepairAuthorized: true, which is reserved for values the vendor itself publishes for replayed history: a sentinel is not a forged signature, it is a documented way of stating that no signature exists. See Gemini Thought Sentinels for details.
  3. renumber-colliding-ids rewrites durable ids. When mutate repairs a tool_call_id_uniqueness collision, it renames every member of the group and persists the renamed calls. Anything you have recorded against a call id — logs, traces, join tables, your own spool keys — can point at an id that no longer exists. This is the same class of act as the reorder repair, which rewrites createdAt on persisted primitives, and action: 'mutate' is the consent: a consumer who sets it is asking the guard to change turn state. If you need to correlate external records against tool-call ids, prefer enforce (which rejects a collision rather than renaming it) or enable the toolCallIdFilter ingress hook so the id is de-collided before it ever reaches durable storage.

See Also ​