Skip to content

Guide 08 — Fabrication Detection

v7.0 added fabrication_check_mode — a cross-reference detector that catches drafts referencing identifiers or claim-verb phrases the LLM never actually backed with a tool call. v7.0.3 hardened the detector after a fabric-deployment QA incident uncovered three gaps. This guide walks through the full detector matrix and the deployment stance the team settled on.

Prerequisites

What the detector catches

The detector runs after LLM response generation, cross-referencing draft text against:

  • The turn's fired tool results (tool name + result payload).
  • The turn's hydrated context (memory injection).
  • The user message (for quoted identifiers).

When a draft references an identifier or claim verb that has no grounding in any of those three sources, a FabricationFinding is produced.

Finding patterns

Source: src/symfonic/agent/middleware/fabrication.py.

Pattern Example Confidence
uuid 9f2b1e47-00cb-4b42-8e12-cc07e0f0c1d0 (canonical 36-char or 32-char compact) 0.95
kv task_id=abc123 / pid=9f2b1e47 / run_id=... 0.8
verb_claim "I spawned subagent X" — per-verb grounded via _VERB_TO_TOOL_HINTS 0.8
short_hex_near_verb 6-16 hex chars within ~60 chars of a claim verb (v7.0.3) 0.6
short_hex_near_key 6-16 hex chars immediately after task_id= / pid= / etc. (v7.0.3) 0.6
intent_action_no_tool IntentVerdict(label="action") with zero tool calls — language-agnostic (v7.0.6) 0.7

Unanchored short-hex ("see commit abc1234") is deliberately left alone — every git SHA, ETag, commit hash, and CSS color code would otherwise false-positive.

Language-agnostic intent_action_no_tool (v7.0.6)

_CLAIM_VERB_PATTERN is intentionally English-only ("I ran", "I sent", ...). Spanish passive ("Gráfica enviada"), French elision ("J'ai envoyé"), German separable verbs, and Portuguese third-person all bypass it. Adding per-locale verb lexicons was rejected as technical debt — combinatorial growth, grammar coupling, drift from the actual concern.

v7.0.6 reuses the already-computed IntentVerdict instead. When intent_filter_mode or tool_routing_mode is observe/enforce, the classifier runs once per turn and the engine threads the verdict into apply_fabrication_check. When the verdict says label="action" (confidence ≥ 0.55) AND tool_calls_this_turn is empty, the detector emits intent_action_no_tool at confidence 0.7 regardless of response language.

Domains opt in per locale by populating DomainTemplate.tool_trigger_keywords — the same keyword map v6.1.8 already uses for tool-manifest gating. No detector code is locale-specific:

from symfonic.agent.domain import DomainTemplate

ES_DOMAIN = DomainTemplate(
    name="es-charts",
    tool_trigger_keywords={
        "send_chart": ["gráfica", "chart", "enviar", "envío"],
        "send_sms":   ["sms", "mensaje", "mensajería"],
    },
)

A user query containing those keywords classifies as action (given the classifier's 2-of-3 signal rule). If the LLM then fabricates a Spanish confirmation without firing send_chart/send_sms, the new finding fires.

Confidence 0.7 sits below the default fabrication_refuse_min_confidence=0.8 so the finding is telemetry- only under the default threshold. Dial to 0.65 to opt into refuse-mode draft replacement.

Zero-cost: when both intent_filter_mode and tool_routing_mode are "off", the engine never invokes the classifier and the new finding never fires — byte-identical to v7.0.5 behaviour.

Per-verb grounding (v7.0.3)

Before v7.0.3, the detector short-circuited verb claims the moment any tool fired this turn. On a turn that ran hydrate_context, the prose "I spawned subagent 9f2b1e47" was silenced even though spawn_subagent never executed.

v7.0.3 replaced the global short-circuit with a per-verb check:

_VERB_TO_TOOL_HINTS = {
    "spawned":   ("spawn", "subagent", "run_agent", "exec", "start_"),
    "sent":      ("send", "messag", "sms", "email", "twilio", "post"),
    "ran":       ("run", "exec", "shell", "bash"),
    "queried":   ("query", "search", "find", "select", "sql"),
    # ... additional verbs
}

A verb claim is dropped only when a fired tool's name contains one of the verb's hint fragments. Verbs not listed in the map preserve the pre-patch blanket behaviour (any tool grounds).

The four modes

fabrication_check_mode: Literal["off", "observe", "revise", "refuse"] = "off"
Mode Behaviour
"off" (default) Detector never runs. TOOL DISCIPLINE prompt directive (v6.1.10) is the only defence.
"observe" Detector runs. Every finding emits ExtensionEvent(type="fabrication_detected"). Draft unchanged.
"revise" Findings thread into MetacognitiveMiddleware.evaluate() which forces a revise verdict. On success the revised draft replaces the response.
"refuse" Draft replaced with a safe envelope. ExtensionEvent(type="fabrication_blocked") fires.

revise requires metacog. fabrication_check_mode="revise" needs metacognition_enabled=True. Without it, the engine emits a UserWarning at construction and downgrades effective mode to refuse semantics.

Confidence threshold (v7.0.3)

fabrication_refuse_min_confidence: float = 0.8

Controls which findings trip draft replacement in refuse mode. observe mode ignores the threshold — it always emits telemetry for every finding.

Threshold Catches
1.0 Nothing (observe-only refusal)
0.8 (default) uuid (0.95) + kv (0.8) + verb_claim (0.8)
0.5 Everything, including short_hex_near_verb and short_hex_near_key (0.6)
0.0 Same as 0.5; any finding trips refusal

The default 0.8 was chosen because the short-hex detector is context- anchored but not certain — a 6-16 hex token near a claim verb is suggestive but can false-positive on legitimate short hashes (git SHAs, ETags). Keep short-hex advisory while UUID / kv findings refuse.

Domain-specific identifier keys

DomainTemplate.identifier_keys extends the built-in lexicon with per-domain keys the detector should flag alongside task_id, pid, run_id, job_id:

from symfonic.agent.domain import DomainTemplate

SRE_DOMAIN = DomainTemplate(
    name="sre",
    identifier_keys=["ticket_id", "incident_id", "pagerduty_id"],
    tool_manifest=[...],
)

Default [] preserves v6.x behaviour. The detector still checks the built-in lexicon — identifier_keys is purely additive.

The team lands on observe first, then refuse once telemetry validates the thresholds.

Step 1 — Observe only

from symfonic.agent.config import FrameworkConfig

cfg = FrameworkConfig(
    fabrication_check_mode="observe",
)

Subscribe a handler to capture findings and build a ground-truth dataset of true positives and false positives:

class FabricationAuditor:
    def __init__(self) -> None:
        self.findings: list[dict] = []

    async def on_extension_event(self, event) -> None:
        if event.type == "fabrication_detected":
            self.findings.append(event.payload)
            # event.payload: {"kind": str, "match": str, "confidence": float,
            #                 "anchor_verb": str | None, ...}

Run this for a week of real traffic. Tag each finding as TP or FP. Compute precision at different confidence thresholds against your dataset — that number drives the threshold you pick in step 3.

Step 2 — Validate the i18n path

If your deployment sees non-ASCII traffic, verify the v7.0.3 Unicode tokenizer change has not surfaced new false positives. The tokenizer change (TOKEN_RE upgraded to \w[\w\-]* with re.UNICODE + NFC normalisation) affects the intent classifier, not the fabrication detector directly. But any downstream feature that combines detector findings with intent labels should re-baseline.

Step 3 — Escalate to refuse

Once you have a precision estimate:

cfg = FrameworkConfig(
    fabrication_check_mode="refuse",
    fabrication_refuse_min_confidence=0.8,  # tune based on step-1 data
)

Common confidence choices:

  • 0.8 (default): catch UUID + kv + verb_claim. Short-hex stays advisory. This is what fabric deployments ran post-v7.0.3.
  • 0.5: aggressive — catch short-hex too. Use this if your precision on short-hex was good AND your domain sees few legitimate short hex references.
  • 1.0: observe-only; never refuses. Use this if you want the telemetry without the behavioural change.

Step 4 — Consider revise instead

If metacognition_enabled=True is already set, revise is strictly better than refuse: instead of replacing the draft with a safe envelope, it uses the metacog pipeline to produce an actual corrected response.

cfg = FrameworkConfig(
    fabrication_check_mode="revise",
    metacognition_enabled=True,
)

Metacog pairs nicely with the fabrication detector because the findings give metacog a concrete revision prompt: "the draft claims X but no tool produced X — revise".

Observing findings via callbacks

Typed event (v7.0.7+)

v7.0.7 promotes on_fabrication_detected from an untyped ExtensionEvent callback to a frozen FabricationDetectedEvent dataclass exported from symfonic.core.contracts. Annotate the event parameter with the typed class and the wiring will dispatch only the typed event to that handler:

from symfonic.core.contracts import FabricationDetectedEvent

class FabricationAuditor:
    async def on_fabrication_detected(
        self, event: FabricationDetectedEvent,
    ) -> None:
        print(
            f"[fab] run_id={event.run_id} mode={event.mode} "
            f"action_taken={event.action_taken} "
            f"max_conf={event.max_finding_confidence:.2f} "
            f"threshold={event.refuse_threshold:.2f} "
            f"findings={len(event.findings)}"
        )

The full event schema:

Field Type Notes
run_id str Same run_id as AgentStartEvent / AgentEndEvent so dashboards can correlate findings with run telemetry
mode Literal["off", "observe", "revise", "refuse"] The effective fabrication-check mode for the turn
action_taken Literal["none", "observed", "revise_forced", "refuse_replaced"] What the wiring did with the findings
findings list[dict] FabricationFinding.to_event_payload() shape; same as v7.0.6
refuse_threshold float The fabrication_refuse_min_confidence value in effect this turn
max_finding_confidence float max(f.confidence for f in findings)
original_draft_preview str First 240 characters of the draft that produced the findings
replacement_preview str \| None First 240 characters of the safe envelope when action_taken == "refuse_replaced", else None
preview_truncated bool True when the original draft was longer than 240 characters

Privacy stance

original_draft_preview and replacement_preview carry raw LLM output. PII redaction is the handler's responsibility -- same stance documented for LLMPreCallEvent (v6.1.7). Persist these fields only after applying your deployment's redaction policy.

SRE dashboard recipe

Bucket counts by (mode, action_taken):

from collections import Counter
from symfonic.core.contracts import FabricationDetectedEvent

class DashboardSink:
    def __init__(self) -> None:
        self.counts: Counter[tuple[str, str]] = Counter()

    async def on_fabrication_detected(
        self, event: FabricationDetectedEvent,
    ) -> None:
        self.counts[(event.mode, event.action_taken)] += 1

Pair with event.max_finding_confidence to tune the threshold: plot a histogram of max_finding_confidence faceted by action_taken. The crossover point between "observed" and "refuse_replaced" is the current threshold; shift it left to catch more, right to catch fewer. The wiring already reports refuse_threshold per turn so a dashboard tile can graph the live value.

Zero-cost guarantee

The wiring constructs FabricationDetectedEvent only when at least one registered handler exposes on_fabrication_detected. With no handlers (or a CallbackManager whose handlers do not implement the hook), no event objects are built. A regression-pinned test (test_wiring_no_handler_does_not_construct_typed_event) spies on FabricationDetectedEvent.__init__ and asserts zero constructions on a finding-bearing turn with callbacks=None.

Legacy v7.0.6 handlers

Handlers whose on_fabrication_detected is annotated with Any (or no annotation, or ExtensionEvent) keep receiving the v7.0.6 untyped payload through v7.0.7 with a one-shot DeprecationWarning. The legacy path is removed in v7.1; migrate to the typed annotation to silence the warning:

# v7.0.6 (still works in v7.0.7 with DeprecationWarning):
class LegacyAuditor:
    async def on_fabrication_detected(self, event) -> None:
        kind = event.payload["findings"][0]["kind"]

# v7.0.7+ (typed, no warning):
from symfonic.core.contracts import FabricationDetectedEvent

class TypedAuditor:
    async def on_fabrication_detected(
        self, event: FabricationDetectedEvent,
    ) -> None:
        kind = event.findings[0]["kind"]

What the detector does NOT do

  • No LLM calls. The detector is a deterministic cross-reference pass. Zero cost on turns with no findings.
  • No accent folding (v7.0.3 scope). "gráfica" does not match "grafica". Tracked for v7.1 behind an opt-in knob.
  • No pre-LLM router. A fabric-side classifier that decides intent before the first LLM call is fabric's work, not core.
  • No change to fabrication_check_mode defaults. Always "off". Opt-in only.

See also