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¶
- Completed Guide 05 Production
- Basic understanding of callback events from Guide 03 Streaming & Callbacks
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¶
| 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. |
reviserequires metacog.fabrication_check_mode="revise"needsmetacognition_enabled=True. Without it, the engine emits aUserWarningat construction and downgrades effective mode torefusesemantics.
Confidence threshold (v7.0.3)¶
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.
Recommended deployment stance¶
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.
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_modedefaults. Always"off". Opt-in only.
See also¶
- Feature Catalog § Fabrication Detection
- Guide 05 Production — production hardening stack.
- Guide 07 Intent & Tool Routing — the intent verdict ties into tool routing; both are v7.0 additions.
- Release notes v7.0.0 — detector shipped.
- Release notes v7.0.3 — short-hex detector, per-verb grounding, Unicode tokenizer, confidence threshold.
- Release notes v7.0.6 — language-agnostic
intent_action_no_toolfinding viaIntentVerdictwiring. - Release notes v7.0.7 — typed
FabricationDetectedEvent+ observability closure.