pleach
Operate

Fabrication detection

Multi-signal wipe pipeline — what `applyFabricationGuard` removes from a synthesized turn and the detectors it composes.

Fabrication detection is one concept in the safety & determinism thematic island — siblings of safety, scrubbers, determinism, and fingerprint.

applyFabricationGuard rewrites a synthesized turn before it reaches the user. The variable surface — what it removes and reports — is named tool calls the model invented, paragraphs that reference tools that failed or never ran, quantitative content fabricated when most tools failed, and confessions where the model retracts prior work mid-message.

It returns FabricationGuardResult: a triggered boolean, preservedContent, paragraph counts, the unavailableToolNames it acted on, and optional phantomTools, phantomReplacements, ungroundedEntities, dataInflation, and phantomWipeEscalation slots populated only when those signals fire.

Subpath@pleach/core/strategies/fabricationDetectorsSourcesrc/strategies/

Public exports

ExportKindPurpose
applyFabricationGuardfunctionThe multi-signal pipeline. Wipes paragraphs, returns the typed result.
contentReferencesMissingToolspredicateBytewise scan: does text mention any tool in unavailableToolNames (bare, readable, or alias)?
detectBulkFailureFabricationdetectorFires when failure ratio ≥ 0.8 and the response carries ≥ 3 distinct quantitative signals.
detectSelfFabricationConfessiondetectorTwo-gate fire: confession phrase + an 8-hex prefix matching a prior PriorToolUseEntry.jobId.
detectMethodResultFabricationConfessiondetectorPhrase-only confessions retracting fabricated method-result tables from this turn.
detectDegenerateProseQualitydetectorGarbled-text signal — fragment density + short-word ratio.
detectDataInflationdetectorLLM presented more rows than the tool returned.
isGuardBlockedFailurepredicateClassify a failed-tool error string as guard-blocked (BLOCKED:, DUPLICATE:, …) vs real API failure.
partitionFailedToolsfunctionSplit a failed-tool list into { apiFailedToolNames, guardBlockedToolNames }.

Public types

TypePurpose
FabricationGuardResultPipeline result envelope (see fields below).
FabricationGuardResultWithPluginFindingsExtends FabricationGuardResult with pluginFindings: readonly FabricationFinding[] — what applyFabricationGuard returns.
FabricationGuardStrategyBundleCanonical strategy shape carried on SessionRuntimeConfig.fabricationGuardStrategy. Re-exported via @pleach/core/types/strategies.
FabricationDetectorPlugin-contributed detector: { id, version?, detect(ctx) }.
FabricationDetectorContextWhat a detector receives: completedTools, assistantContent, userText, knownToolNames, guestDeniedTools?, callClass.
FabricationFindingWhat a detector returns: { detectorId, severity, reason, preservedContent?, evidence? }.
CompletedToolRecordTool envelope a detector indexes against: { id, name, status, error?, arguments?, result? }.
PriorToolUseEntryPer-call envelope the self-confession detector cross-references.

applyFabricationGuard has signature (params: ApplyFabricationGuardParams, config: ApplyFabricationGuardConfig) => FabricationGuardResultWithPluginFindings. partitionFailedTools(failedToolNames, errors) returns { apiFailedToolNames, guardBlockedToolNames }; isGuardBlockedFailure(errorMessage) is the underlying predicate.

Result shape

FabricationGuardResult carries the variable surface back to the caller.

FieldTypeSet when
triggeredbooleanAny signal fired.
preservedContentstringAlways — the cleaned turn body.
preservedParagraphsnumberParagraphs kept after wipe.
wipedParagraphsnumberParagraphs removed.
unavailableToolNamesstring[]Failed ∪ empty-result ∪ detected phantoms.
apiFailedToolNamesstring[]Real upstream failures (the partition split).
guardBlockedToolNamesstring[]Tools the guard suppressed (BLOCKED:, DUPLICATE:, …).
phantomToolsstring[]Tool names cited in prose that were never executed.
phantomToolsNotInRegistrystring[]Subset of phantomTools not in allKnownToolNames.
phantomReplacements{ phantom; replacement }[]Phantom → canonical-tool mappings from phantomWipeStrategy.
phantomWipeEscalation{ overlappingTools; sessionWipeCount }Same phantom set wiped twice or more in the session.
ungroundedEntitiesstring[]Named entities in prose absent from tool results. The detector slot defines what counts.
dataInflation{ toolName; claimedRows; actualRows }[]Model presented more rows than the tool returned.

FabricationGuardStrategyBundle

The canonical strategy shape is FabricationGuardStrategyBundle from @pleach/core/types/strategies. Hosts populate it once at runtime construction (SessionRuntimeConfig.fabricationGuardStrategy); every consumer site reads from the bundle instead of threading strategies inline. DEFAULT_FABRICATION_GUARD_STRATEGY_BUNDLE exports an empty bundle (quantitativePatterns: []) — tests and hosts without domain patterns use it as-is; the algorithm gracefully skips every optional slot.

SlotRequiredWhat it does
quantitativePatternsyesRegExp set the bulk-failure detector counts hits against. Domain-specific.
phantomDetectnoReturns phantom tool names cited in content that aren't in executedToolNames.
phantomWipeStrategynoMap a phantom name to a canonical real-tool replacement.
phantomWipeTrackernoPer-session tracker — populates phantomWipeEscalation when a phantom set is wiped repeatedly.
ungroundedEntityDetectnoReturns entity names cited in prose but absent from toolResultsRaw. The host defines what an entity is.
postExecFabricationDetectnoReturns true when prose fabricates results for failed-or-empty tools.
degenerateProseConfignoAugments the garbled-prose detector (additionalShortWords, thresholds).
detectUnqueriedCitationsnoStructured-shape detector consumed by graph nodes G2/G6/G7 — flags citations to sources never queried.
detectIdentifierMismatchnoStructured-shape detector — flags identifier mismatches between prose and tool results.
detectMarkdownSmilesMismatchnoStructured-shape detector — flags SMILES strings that don't match the source table.
detectCellLineAttributionFabricationnoStructured-shape detector — flags cell-line claims with no grounded source.
detectProcessClaimWithoutComputenoStructured-shape detector — flags process claims with no successful compute.

The first seven slots feed the lifted applyFabricationGuard algorithm (parameter shape mirrors ApplyFabricationGuardConfig); the five structured-shape detectors at the tail are read directly by graph node consumers and preserve their bag-side return shapes so consumer rewires stay structural-not-semantic.

contributeFabricationGuard plugin hook

A plugin returns a FabricationGuardImpl from contributeFabricationGuard(). The impl is a single object carrying applyFabricationGuard, isGuardBlockedFailure, the per-signal detectors (detectBulkFailureFabrication, detectUnqueriedCitations, detectSelfFabricationConfession, detectMethodResultFabricationConfession, detectIdentifierMismatch, detectCellLineAttributionFabrication, detectMarkdownSmilesMismatch, detectProcessClaimWithoutCompute), and the contentReferencesMissingTools predicate.

Plugin findings emitted by FabricationDetector[] (registered via the sibling contributeFabricationDetectors hook) merge into the returned FabricationGuardResultWithPluginFindings — the substrate's extension of FabricationGuardResult adding a pluginFindings: readonly FabricationFinding[] field. Legacy consumers ignore the new field; H-3.2 consumer rewires read it.

See contributeFabricationGuard for the full method list and registration shape.

Bag-entry retirement (NEAR-READY)

OrchestratorHotpathModules.fabricationGuard is the legacy untyped-bag entry for the same 11-function surface (the surface widened from 1 → 9 functions; today the bag carries 11). The typed FabricationGuardStrategyBundle slot + the contributeFabricationGuard hook supersede it; the entry is in NEAR-READY retirement status. The H-3.3 runtime probe recordFabricationGuardResolverPath emits per-invocation via:"bundle"|"bag-fallback"|"unwired" telemetry so the soak window can close before the bag-fallback path retires.

Two audit gates lock the retirement state:

GateWhat it catches
audit:fabrication-guard-bagSource-text regression. Fails on novel dynamicImportApp("orchestrator.fabricationGuard") call sites beyond the single baselined documentation-comment hit at appRegistries.ts.
audit:fabrication-guard-resolver-clean3-batch runtime soak ledger. Operator stages canvas dumps via --add-batch; :strict fails until the last 3 batches show auth via:"bundle" > 0 AND auth via:"bag-fallback" == 0. Replaces the calendar bake.

See /docs/host-adapter for the broader bag-retirement story across the four OrchestratorHotpathModules entries.

Calling the pipeline

import {
  applyFabricationGuard,
} from "@pleach/core/strategies/fabricationDetector";

const result = applyFabricationGuard(
  {
    fullContent: synthesizedTurn,
    failedToolNames: ["search_corpus"],
    emptyResultToolNames: [],
    isResynthesis: false,
    executedToolNames: ["search_corpus", "fetch_url"],
    allKnownToolNames: registry.list(),
    totalToolCount: 2,
    toolResultsRaw: rawToolResultsBlob,
  },
  {
    quantitativePatterns: DOMAIN_QUANTITATIVE_PATTERNS,
  },
);

if (result.triggered) {
  await respondWith(result.preservedContent);
} else {
  await respondWith(synthesizedTurn);
}

The two required arguments are the params envelope and the config. Everything else degrades gracefully.

Running a single detector

The detectors are exported individually so plugin authors and tests can compose them outside the pipeline.

import {
  detectBulkFailureFabrication,
  DEFAULT_BULK_FAILURE_PATTERN_THRESHOLD,
} from "@pleach/core/strategies/fabricationDetectors";

const bulk = detectBulkFailureFabrication({
  content: turnBody,
  failedToolCount: 4,
  totalToolCount: 5,
  quantitativePatterns: DOMAIN_QUANTITATIVE_PATTERNS,
});

if (bulk.detected) {
  console.warn(
    `bulk-failure fabrication: ${bulk.matchedPatterns} pattern hits`,
    bulk.matchedExamples,
  );
}

DEFAULT_BULK_FAILURE_PATTERN_THRESHOLD (3), DEFAULT_BULK_FAILURE_MIN_LENGTH (800), and DEFAULT_BULK_FAILURE_RATIO_THRESHOLD (0.8) ship as exported constants so consumers can tune the gates without re-deriving them.

Contributing a detector through a plugin

The graph's FabricationNode iterates the union of every plugin's contributeFabricationDetectors() return. A detector reads completedTools from the context and returns a FabricationFinding or null.

// lib/plugins/corpusGuard.ts
import type { HarnessPlugin } from "@pleach/core";
import type { FabricationDetector } from "@pleach/core/plugins";

const emptyResultDetector: FabricationDetector = {
  id: "empty-result-claim",
  detect(ctx) {
    const empty = ctx.completedTools.filter(
      (t) => t.name === "search_corpus" && Array.isArray(t.result) && t.result.length === 0,
    );
    if (empty.length === 0) return null;
    if (!/\bfound\b|\bresults? show\b/i.test(ctx.assistantContent)) return null;
    return {
      detectorId: "empty-result-claim",
      severity: "high",
      reason: "Prose claims results when search_corpus returned an empty array",
      evidence: { emptyToolCalls: empty.map((t) => t.id) },
    };
  },
};

export const corpusGuardPlugin: HarnessPlugin = {
  name: "corpus-guard",
  version: "0.1.0",
  contributeFabricationDetectors: () => [emptyResultDetector],
};

Register the plugin once at runtime construction and every turn runs the detector against its completedTools slice.

Host-supplied tool-argument fabrication detectors

The detectors above run against synthesized prose. A second class of detector runs against tool-call arguments before dispatch — when the model invents an argument value that has no provenance in the turn's prior tool results. The pattern composes a host-supplied verdict, a safetyTier field on the tool definition, and one of two routing destinations the runtime offers out of the box.

Worked example: a host wires a detector that flags HTTP URLs the model invented (no prior tool result returned them) versus URLs that appeared in a resolver tool's output earlier in the same turn. The detector returns a verdict; the runtime decides what to do with it based on the tool's safetyTier.

The pattern in four parts

  1. Tool authors tag the tool with safetyTier. Three values: "critical", "standard", "advisory". The field is part of the Tools contract — see that page for the values and the contribution path.

  2. The host contributes a detector that returns a verdict. The verdict carries a reason discriminator and the suspect argument. The detector reads the tool name, the args, and the prior tool results from the dispatch context.

  3. The verdict promotes through ApprovalDecisionKind. The runtime maps the verdict to a discriminator on the approval-decision enum. Hosts can contribute new discriminators alongside the detector (see Interrupts).

  4. Routing splits on safetyTier. Two destinations:

    safetyTierDestinationUser can override?
    "critical"A hard-halt pipeline stage short-circuits dispatch with _recoverable: false. The operator's pre-committed safety policy.No
    "standard"The InterruptApprovalCard surfaces with a ground-truth panel showing the suspect arg, the mismatch reason, and the recovery tools.Yes — accept, edit, or reject
    "advisory"Probe-only. The detector still fires for observability; no halt and no card.N/A — nothing to override

The hard-halt stage

The hard-halt routing registers as a pipeline stage at an explicit slot (today, slot 6d2 between the seed validator and the approval stage). Stages at that position run after the model-recoverable predicates but before the user-facing approval card — so a critical tier never reaches the card and the user is never offered a button that would override the operator's policy.

The stage returns an unrecoverable envelope (_recoverable: false, plus a structured error code) and emits a routing-discriminator probe so dashboards can count hard-halts separately from the broader suspect-fire metric.

Why this composes cleanly

The four parts are independent: a tool author sets safetyTier once on the definition. The host wires a detector once at plugin construction. The runtime owns the routing — there's no per-tool glue code. New tools inherit the routing for free as long as the detector recognizes their arg shape.

The audit invariant the substrate enforces: every tool tagged safetyTier: "critical" must be reachable by the host's detector. A "critical" tool with no detector coverage would silently bypass the hard-halt — the registration is the load-bearing artifact and CI fails when coverage is missing.

Adding a custom detector

The bundled detectors handle the common shapes (hallucinated UUIDs, malformed identifiers, file paths that don't exist). Domain-specific detectors register through the contributeFabricationDetectors plugin hook. The hook returns readonly FabricationDetector[]; each detector is an object with an id, an optional version, and a synchronous detect(ctx) method that returns a FabricationFinding or null.

import type { HarnessPlugin } from "@pleach/core"
import type {
  FabricationDetector,
  FabricationDetectorContext,
  FabricationFinding,
} from "@pleach/core/plugins"

const orderIdDetector: FabricationDetector = {
  id: "order-id-format",
  version: "0.1.0",
  detect(ctx: FabricationDetectorContext): FabricationFinding | null {
    // Order IDs are 8 digits prefixed ORD-. Flag any order-shaped token
    // in the synthesized prose that doesn't match the canonical format.
    const malformed = ctx.assistantContent.match(/\bORD-\d{1,7}\b/g)
    if (!malformed) return null
    return {
      detectorId: "order-id-format",
      severity: "medium",
      reason: "Prose cites an order ID that isn't 8 digits prefixed ORD-",
      evidence: { suspects: malformed },
    }
  },
}

export const myPlugin: HarnessPlugin = {
  name: "domain-detectors",
  version: "0.1.0",
  contributeFabricationDetectors: () => [orderIdDetector],
}

Three rules hold for any custom detector:

  • detect must be synchronous. Detectors fire on the hot path — per chunk in the streaming case — so a Promise return would block the stream observer contract.
  • The detector is side-effect-free. No telemetry writes, no storage reads, no network calls. Detectors classify; they don't enrich.
  • The severity field is required. One of "low", "medium", "high". It reports how serious the finding is and is hoisted into the guard's [FabricationGuard] plugin-detector-fired breadcrumb. It does not by itself trigger the hard-halt stage or the approval card — that routing belongs to the tool-argument verdict mechanism above, which keys on the tool's safetyTier, not on a finding's severity.

A related hook — contributeFabricationDetectorRules — lets a plugin contribute per-detector configuration (regexes, allowlists, thresholds) without re-implementing the detector itself. See Plugin contract for the full surface.

Where to go next

On this page