Skip to content
8 min read · 1,670 words

Declarative Rule Types Reference ​

Every atomic profile in the validation battery is composed of one or more declarative rule objects. The battery supports twelve typed rule contracts exported from @nhtio/adk/batteries/validation.

ts
export type OrderingRule =
  | OrderRule
  | RequiredMetadataRule
  | AlternationRule
  | AdjacencyRule
  | PreservationRule
  | RoleRemapRule
  | StaleContentAdvisoryRule
  | IdentifierFormatRule
  | NonEmptyTurnRule
  | ToolIdentityRule
  | SchemaIntegrityRule
  | IdentifierUniquenessRule

Severity Defaults to Advisory

A field severity?: 'blocking' | 'advisory' exists on every rule type (except StaleContentAdvisoryRule, which is inherently advisory). When omitted, severity defaults to 'advisory'.

This default exists because catalog rules were originally derived from vendor documentation, but a live audit measured against native vendor APIs found that 16 of 17 rules blocked turn state the vendor actually accepts (only thought-signature-required was confirmed enforced). Defaulting to advisory ensures the battery reports protocol deviations for observability without rejecting dispatches the model serves. You opt into gating per rule with severity: 'blocking'.


1. OrderRule (type: 'order') ​

Enforces relative ordering between two primitive categories (before and after).

ts
export interface OrderRule {
  type: 'order'
  id: string
  before: 'message' | 'thought' | 'toolCall'
  after: 'message' | 'thought' | 'toolCall'
  scope: 'adjacent-same-role-group' | 'entire-turn'
  onlyLatestGroup?: boolean
  severity?: 'blocking' | 'advisory'
}
  • before / after: Defines the required relative order.
  • scope: When 'adjacent-same-role-group', ordering is checked within contiguous role segments. When 'entire-turn', ordering spans the full dispatch timeline.
  • onlyLatestGroup: When true, ignores older historical groups and checks only the active turn (e.g. Anthropic's manual thinking rule).
  • severity: Omitted means 'advisory'. Opt into 'blocking' to reject or repair.
  • Repair in mutate mode: Repaired via reorder by adjusting createdAt on the offending primitive so it sorts immediately before the primitive it must precede.

2. RequiredMetadataRule (type: 'requiredMetadata') ​

Enforces that a primitive carries specific provider metadata in its payload object.

ts
export interface RequiredMetadataRule {
  type: 'requiredMetadata'
  id: string
  kind: 'message' | 'thought' | 'toolCall'
  applyTo: 'first-in-group' | 'every'
  requiredPayloadKey: string
  severity?: 'blocking' | 'advisory'
  gatedByReplayCompatibility?: string[]
  fallbackPayloadValue?: unknown
  fallbackReplayCompatibility?: string
  fallbackRepairAuthorized?: boolean
}
  • applyTo: 'first-in-group' targets only the leading primitive in an assistant group; 'every' checks all matching primitives.
  • requiredPayloadKey: Dot-path in value.payload (e.g. 'thoughtSignature').
  • severity: Omitted means 'advisory'. (Note: thought_signature_required sets severity: 'blocking' explicitly because it is confirmed enforced by Gemini 3).
  • gatedByReplayCompatibility: Optional wire format tags that restrict when this rule fires.
  • fallbackPayloadValue / fallbackReplayCompatibility: Documented sentinel values used by mutate mode when metadata fallback repair is enabled.
  • fallbackRepairAuthorized: When true, authorizes mutate mode to apply fallbackPayloadValue without requiring the global allowMetadataFallbackRepair switch. Reserved for vendor-published bypass sentinels (such as Gemini's skip_thought_signature_validator).
  • Repair in mutate mode: Repaired via fill-required-metadata when authorized.

3. AlternationRule (type: 'alternation') ​

Enforces strict role cycling across conversation turns.

ts
export interface AlternationRule {
  type: 'alternation'
  id: string
  roles: ReadonlyArray<'user' | 'assistant'>
  mode: 'strict'
  maxPerGroup?: number
  severity?: 'blocking' | 'advisory'
}
  • roles: Permitted role alternation sequence (normally ['user', 'assistant']).
  • mode: 'strict' requires every consecutive turn to alternate roles.
  • maxPerGroup: Optional upper bound on ToolCall primitives within a single assistant role group (e.g. 1 for Llama 3).
  • severity: Omitted means 'advisory'. Opt into 'blocking' to gate dispatch.
  • Repair in mutate mode: Repaired via insert-alternation-filler by materializing a temporary synthetic Message in the opposite role.

4. AdjacencyRule (type: 'adjacency') ​

Constrains the immediate successor of a primitive. In this ADK, tool execution results are stored directly on ToolCall rather than on separate message payloads, so adjacency rules directly forbid disallowed primitive kinds from appearing immediately after a specified primitive.

ts
export interface AdjacencyRule {
  type: 'adjacency'
  id: string
  first: 'message' | 'thought' | 'toolCall'
  disallowBetween: Array<'message' | 'thought' | 'toolCall'>
  severity?: 'blocking' | 'advisory'
}
  • first: The primitive kind whose immediate successor is restricted.
  • disallowBetween: Array of primitive kinds that are forbidden from appearing immediately after first.
  • severity: Omitted means 'advisory'. Opt into 'blocking' to enforce.
  • Repair in mutate mode: Repaired via reorder-adjacent. Shifts the disallowed successor's createdAt so it sorts immediately before first. Every primitive survives without dropping content.

5. PreservationRule (type: 'preservation') ​

A stateful check that diffs the current dispatch timeline against the previous iteration's snapshot on ctx.stash.

ts
export interface PreservationRule {
  type: 'preservation'
  id: string
  kind: 'message' | 'thought' | 'toolCall'
  invariant:
    | 'count-non-decreasing'
    | 'payload-field-stable'
    | 'pruned-after-latest-turn'
  payloadField?: string
  resetOnModelSwitch?: boolean
  severity?: 'blocking' | 'advisory'
}
  • count-non-decreasing: Historical primitive count of kind must never decrease.
  • payload-field-stable: Value at payloadField must remain unchanged across iterations.
  • pruned-after-latest-turn: Pruning is permitted prior to the latest non-tool-call user message; all primitives at or after that boundary must remain present and unchanged.
  • resetOnModelSwitch: Resets the snapshot baseline when the target model changes.
  • severity: Omitted means 'advisory'.
  • Repair in mutate mode: Never repairable. When history is dropped or altered upstream, the guard refuses to invent lost context. Rejects dispatch if configured as blocking.

6. RoleRemapRule (type: 'roleRemap') ​

Describes required provider-specific wire role tags for model families with custom role schemas (such as IBM Granite).

ts
export interface RoleRemapRule {
  type: 'roleRemap'
  id: string
  kind: 'message' | 'thought' | 'toolCall'
  variant: string
  expectedRoleTag: string
  severity?: 'blocking' | 'advisory'
}
  • variant: Profile-defined mapping identifier expected on the payload (e.g. 'granite-3.x').
  • expectedRoleTag: Dot-path resolved inside value.payload (e.g. 'roleTag' checks payload.roleTag). Must not prefix with payload..
  • severity: Omitted means 'advisory'. Because payload.roleTag is consumer-supplied, defaulting to advisory prevents rejecting tool calls for callers who do not hand-annotate payloads.

7. StaleContentAdvisoryRule (type: 'staleContentAdvisory') ​

Non-blocking hygiene recommendation rule that checks for obsolete content without blocking dispatch.

ts
export interface StaleContentAdvisoryRule {
  type: 'staleContentAdvisory'
  id: string
  kind: 'message' | 'thought' | 'toolCall'
  scope: 'before-latest-user-turn'
  optOutOptionKey: string
}
  • scope: Identifies content predating the latest user turn ('before-latest-user-turn').
  • optOutOptionKey: Identifies the corresponding configuration option (e.g. 'preserveThinking') mapping to OrderingGuardOptions.disableAdvisoryRuleIds.
  • Inherently advisory: Never blocks dispatch, regardless of operating mode.

8. IdentifierFormatRule (type: 'identifierFormat') ​

Requires a primitive's identifier to satisfy provider length and character set constraints.

ts
export interface IdentifierFormatRule {
  type: 'identifierFormat'
  id: string
  kind: 'message' | 'thought' | 'toolCall'
  maxLength?: number
  allowedPattern?: string
  severity?: 'blocking' | 'advisory'
}
  • kind: Primitive category constrained (typically 'toolCall').
  • maxLength: Maximum allowed identifier length. For example, OpenAI Codex returns HTTP 400 for IDs longer than 64 characters.
  • allowedPattern: Regular expression character class permitted (e.g. '[A-Za-z0-9_-]' for AWS Bedrock Converse). Anchored automatically by the evaluator.
  • severity: Omitted means 'advisory'. Both Codex and Converse fail on every credential when an ID violates these constraints, so a single bad ID can exhaust an entire provider pool.
  • Repair in mutate mode: Not auto-repairable (re-identifying active tool calls could break correlation).

9. NonEmptyTurnRule (type: 'nonEmptyTurn') ​

Requires a conversation turn to carry content the provider can act upon — prose or an adjacent tool call.

ts
export interface NonEmptyTurnRule {
  type: 'nonEmptyTurn'
  id: string
  role: 'assistant' | 'user'
  onlyTerminal?: boolean
  severity?: 'blocking' | 'advisory'
}
  • role: Role whose turns are constrained ('assistant' or 'user').
  • onlyTerminal: When true, evaluates only the final turn in the timeline. Gemini rejects a request whose terminal turn contains only thought: true with finishReason: MALFORMED_RESPONSE. When false, evaluates every turn matching role (Mistral returns HTTP 400 for any empty assistant turn).
  • severity: Omitted means 'advisory'.
  • Repair in mutate mode: Not auto-repairable.

10. ToolIdentityRule (type: 'toolIdentity') ​

Requires every replayed ToolCall to name a tool that the current request actually declares in ctx.tools.

ts
export interface ToolIdentityRule {
  type: 'toolIdentity'
  id: string
  severity?: 'blocking' | 'advisory'
}
  • Evaluator context: Inspects replayed tool calls against ctx.tools. If no tool registry is provided, the rule skips silently.
  • Failure mode caught: Gemini matches functionResponse.name against functionDeclarations. A tool result naming an undeclared tool causes Gemini to return HTTP 200 with an empty candidate (parts: [{text: ''}]) and no error. A gateway forwards this as finish_reason: "stop" with content: null, leaving the caller in a silent loop.
  • severity: Omitted means 'advisory'. Highly recommended as 'blocking' on Gemini pipelines.
  • Repair in mutate mode: Not auto-repairable.

11. SchemaIntegrityRule (type: 'schemaIntegrity') ​

Requires every tool input schema declared in ctx.tools to be internally satisfiable: any property in required must also exist in properties.

ts
export interface SchemaIntegrityRule {
  type: 'schemaIntegrity'
  id: string
  severity?: 'blocking' | 'advisory'
}
  • Evaluator context: Evaluated against declared tools in ctx.tools. Skips silently if no tools are passed.
  • Failure mode caught: An unsatisfiable tool schema causes Amazon Nova to return HTTP 200 with the required field silently omitted from tool call arguments, producing zero errors at any layer.
  • severity: Omitted means 'advisory'.
  • Repair in mutate mode: Not auto-repairable (requires tool definition schema correction).

12. IdentifierUniquenessRule (type: 'identifierUniqueness') ​

Requires identifiers of a primitive kind to be unique across the dispatch timeline. Unlike IdentifierFormatRule — a per-entry predicate over length and character set — this is a cross-entry set property whose finding names a collision group: three calls sharing one id is one defect, not three pairs. The evaluator scans the whole timeline because the collision is definitionally cross-turn; any role- or group-scoped check would partition the colliding pair apart and see nothing wrong in either half.

ts
export interface IdentifierUniquenessRule {
  type: 'identifierUniqueness'
  id: string
  kind: 'message' | 'thought' | 'toolCall'
  renameStrategy?: (previousId: string, memberIndex: number) => string
  surface?: 'dispatch' | 'turn' | 'both'
  severity?: 'blocking' | 'advisory'
}
  • kind: Primitive whose identifiers must be unique (typically 'toolCall').
  • renameStrategy: Produces a replacement identifier when mutate mode repairs a collision. Defaults to () => uuidv6(). Every member of a collision group shares ONE id — that is what makes it a collision — so previousId alone cannot distinguish them: memberIndex (0-based, ordered by timeline seq) is what a deterministic strategy must vary on. Without it a pure strategy returns the same replacement for every member and the materialiser rejects its own repair. The strategy must be pure and total in BOTH arguments, and may not return an id already held anywhere in the timeline — not merely elsewhere in the group; the materialiser verifies both and fails the repair rather than trusting it. A Responses-facing profile supplies a composite-preserving strategy that rewrites only the callId half of a `${callId}|${itemId}` id and carries a valid fc_… item id through, because a bare-uuid replacement would drop the reasoning-item replay link.
  • surface: Which guard surface the rule applies to. Omitted means the rule applies on both surfaces. 'dispatch' keeps the rule inert on the turn middleware (where a blocking collision would abort the turn before the dispatch that could repair it ever ran); 'both' enables turn-level detection, where the rule reports and, in mutate mode, repairs the collision; under enforce it reports and aborts — ordinary blocking-rule behaviour under enforce, not special to this rule or surface. Defaults to 'both' on every other rule type, so existing rules are unchanged.
  • severity: Omitted means 'advisory'. The shipped tool_call_id_uniqueness profile sets 'blocking' deliberately — an advisory finding can never be repaired, and reusing an id corrupts result correlation.
  • Repair in mutate mode: Repaired via renumber-colliding-ids on the dispatch surface, when the context exposes the group-replacement capability. The repair renames every member of the group (renaming all but one would leave the survivor holding the colliding id, so the rule would fire again next iteration and never converge) and rewrites the ids in durable storage. Where the capability is absent the repair is not attempted and the finding stays unrepaired — which, for a blocking rule, means the dispatch nacks.

See Also ​