2026-08-25 · Commissioned by Fede off the Camellia missing-application-link incident (PR #6278 thread) · Every file:line verified against origin/main at head 6167989bc8 (2026-08-25); the incident trace read directly from propflow-prod (read-only)
POLICY_GATE_MODE=off. A second disarmed guard (the leasing fee-fabrication guard) sits next to it. §2–§3.Fede's ask, verbatim: "I want to know exactly what change introduced the guard that re-wrote the message, why it decided to re-write the 'right' response and what other gaps it has."
The precise answer: no guard re-wrote anything. The mechanism that discarded the correct reply is not a guard at all — it is the agent loop's final-text-extraction seam, and it has a three-commit history:
| Date | Change | What it did |
|---|---|---|
| 2026-03-23 | c199434602 | Introduced lastIntermediateText: when Clara writes prose in the same response as a tool call and the post-tool response comes back empty, deliver the pre-tool prose instead of nothing. A fallback, nothing more. |
| 2026-05-27 | aea19ca983PR #1540 | The change Fede is asking about. Stopped persisting pre-tool prose as its own message. From the commit body: "the agent loop was persisting that text as its own assistant message AND then persisting the final post-tool reply — rendering two near-identical customer-facing bubbles… The intermediate text was only ever meant as a fallback… Outbound delivery sends only the final responseText, so persisting the draft was pure duplication. This surfaced after RULE 7.5 (PR #1243) made Clara reliably emit reply-text + an escalation tool_use in one turn, so every leasing escalation produced a double bubble." After this, only the final iteration's text ships; pre-tool text survives solely as the empty-final fallback. |
| 2026-08-19 | Application-link incident → FINAL_REPLY_STANDS_ALONE_RULE | First production proof the seam drops content: "your application link has been sent" shipped over a draft carrying the full fee schedule. Fixed at the prompt layer (clara-delivery.ts:147): the final message must stand alone and contain everything. |
| 2026-08-25 | Daniel's email → PR #6273 (prompt) + PR #6278 (ce121fd357, deterministic salvage) | Second production proof — with the 08-19 prompt rule injected on that very turn. The prompt layer alone demonstrably does not hold; #6278 added the code-layer backstop at the seam itself. |
PR #1540 fixed a real defect: on the escalation shape it targeted, the pre-tool text and the final text were near-identical duplicates, so "deliver only the final" was safe — the content survived in the final bubble. The fix's implicit assumption is final ⊇ draft: whatever mattered in the draft is restated in the final reply. That assumption held for the duplicate-bubble shape and is false in general. On Daniel's turn the draft held the substance — the application link (https://www.camelliadenver.com/#floor-plans, pulled verbatim from PROPERTY INFO), the 12-month special, studio ~$1,000 / 1BR ~$1,200 ranges — and the final was a content-free wrap-up ("All set — just let me know your preferred day and time…"). Same seam, opposite content distribution — and the loop deleted the substance. The model's own stored [[WHY]] on the final turn claims it "Provided application link, surfaced the matching 12-month special, presented unit ranges": it genuinely believed the draft had shipped.
Read directly from propflow-prod (TRACE#trace_de3ff842-a479-4425-9795-6040ff7b10b7, conv a05c3c99-…, 2026-08-25T19:18Z). All ten steps:
agent:identity_resolution — verified_prospectagent:tenant_workorder_context — successagent:capability_composition — LEASING, ESCALATE_PM · 19 toolsclaude-email-initial — iteration 0: the complete, correct reply (application link camelliadenver.com/#floor-plans, the 12-month special, studio ~$1,000 / 1BR ~$1,200) + the update_prospect tool call, same responsetool:update_prospect — successclaude-email-loop-1 — iteration 1: the "All set —" wrap-up. Only this text shipped.safety:internal-prompt-strip — no leak detected, blocked=falsesafety:fair-housing — Compliant blocked=falsesafety:promise-backing — no unbacked handoff claim, blocked=falsesafety:pii-strip — no PII, blocked=falseSo on this turn: every guard that ran passed the text through unmodified. The typed-tool-call strip recorded no step (no markup). The hallucination guard never ran — it only arms behind a renewal-offer or active-tour snapshot, and a first-contact Zillow lead has neither. The policy gate (the honesty layer) and the leasing fee-fabrication guard — the two mechanisms whose job description covers this claim family — are both disarmed by default and recorded nothing. The discard happened before any guard saw anything: iteration 0's text was never a candidate for delivery in the first place.
One more honest nuance: even armed, the policy gate as specced would likely not have caught this turn. Its judge verifies claims in the draft against evidence; Daniel's delivered reply made no false claim — it omitted the answer. Omission-of-the-ask is a completeness failure, a different check (it needs the inbound message as an input, which the gate today does not receive — §3.3). The thing that catches Daniel's shape deterministically is exactly what #6278 shipped.
There are two stages. Pre-generation guards decide whether Clara replies at all; the post-generation pipeline mutates or replaces the drafted reply before send. All line numbers are agents/clara/lib/agent/conversation-manager.ts on origin/main (2026-08-25, post-#6278) unless noted.
| Guard | Where | Layer | On trip | Status |
|---|---|---|---|---|
| Runaway auto-reply ceiling | CM L3808 · guards/runaway-ceiling.ts | code, deterministic | Suppresses further auto-replies (two-AI / echo loops); parks for human review | ARMED |
| Farewell loop | CM L3904 · guards/farewell-loop.ts | code, deterministic | Suppresses reply to trailing closings ("take care!") | ARMED |
| Escalated-thread gate | CM L1089 / L4023 · escalated-gate.ts | code, deterministic | Suppresses auto-replies on threads a human owns | ARMED |
| Emergency guard | CM L7794 | code | In-loop life-safety routing | ARMED |
| Duplicate-WO guard | CM L7613 | code | Blocks duplicate work-order creation in-loop | ARMED |
| FirstTurnViolation retry | CM L7348 | code → LLM | The only regenerate loop in the codebase: leasing turn-1 with no tool call → re-calls Claude with forced tool_choice, once | ARMED |
| Prompt-layer rules | clara-leasing.ts:93–95,145,1033 · clara-delivery.ts:147 | prompt | NEVER INVENT A FIGURE / NEVER INVENT A WHEN, fair-housing + HUD service-animal rules, RULE 7.0 route-to-team, FINAL_REPLY_STANDS_ALONE | ALWAYS INJECTED |
This is the "centralized layer that runs before Clara responds." It already exists: every text/email reply flows through these in exactly this order.
| # | Mechanism | Where | Layer | On trip | Scope | Status |
|---|---|---|---|---|---|---|
| 1 | Intermediate-draft-drop salvage (PR #6278) | CM L8493 · intermediate-draft-drop-guard.ts | code, pure/deterministic | Auto-heal: merges dropped URL/$ facts from the pre-tool draft back in, additively; fails closed on any tool failure | not voice | ARMED |
| 2 | Empty-final fallback | CM L8519 | code | Delivers last pre-tool text when the final is empty (the 2026-03-23 behavior) | all | ARMED |
| 3 | Decision-why extraction | CM L8547 · safety/decision-why.ts | code | Silently strips Clara's internal [[WHY]] marker; Sentry-critical on malformed residue | text/email | ARMED |
| 4 | Typed-tool-call strip | CM L8573 · safety/tool-call-markup.ts | code | Strip; if nothing survives, substitutes an honest "couldn't get that done" + office phone; Sentry-critical | all | ARMED |
| 5 | Internal-prompt strip | CM L8618 · safety/internal-prompt-strip.ts | code | Strip paragraphs quoting system context / naming internal tools; Sentry-critical | all | ARMED |
| 6a | Fair housing | CM L8663 → compliance/outbound-safety.ts registry → policies/fair-housing-policy.ts | prompt + LLM classifier | Rewrite (classifier-authored suggestedRewrite) or block → canned safe reply. Fails CLOSED on classifier error | leasing, prospects/unknown only (not tenants, not PMs, not voice) | ARMED |
| 6b | Promise-backing | same registry · policies/promise-backing-policy.ts | code | Rewrite unbacked "I've passed this to the team" into an honest first-person commitment + deferred real escalation open (fires only after the text is final) | same as 6a | ARMED |
| 7 | PII strip | CM L8687 | code | Strip, silent | all outbound | ARMED |
| 8 | Hallucination / made-up-price guard | CM L8736 · guards/hallucination-guard.ts | prompt + code, deterministic | Block → safe escalation ack, original draft kept as audit row, and auto-fires the real escalation (renewal_escalate / notify_leasing_team) so the promised follow-up is true | only behind an open renewal offer or active tour snapshot | ARMED (narrow) |
| 9 | Output gate (ADR-0101 S2) | CM L8997 · turn-integrity/output-gate.ts | code | Log only — date/weekday consistency + access-code leak vs committed tour facts | per-property flag | OBSERVE-ONLY |
| 10 | Anomaly Review Gate | CM L9017 · turn-integrity/anomaly-review-gate.ts | code | Hold: fatal tool failure this turn → park the draft for PM review, send a fact-free holding message, page #alerts (deduped, circuit-breaker) | per-property flag, not PM turns | ARMED |
| 11 | Turnover fabrication guard | CM L9150 | code, deterministic | Block → one of three honest replies; draft parked for audit | turnover approval turns | ARMED |
| 12 | Turnover async-honesty guard | CM L9264 | code | Rewrite completion framing ("created") → in-progress framing while WOs are still minting | turnover | ARMED |
| 13 | Turnover edit-honesty guard | CM L9328 · turnover-edit-honesty-guard.ts | code | Block/rewrite on claimed edits that didn't happen | turnover | ARMED |
| 14 | Same-turn escalation constraint + team-inbox silence | CM L9447 / L9410 | code, deterministic | Template-substitute the ack+handoff copy on escalating turns; on team-monitored email inboxes, suppress entirely (Decision 9) | escalating turns | ARMED |
| 15 | Policy Gate — the honesty layer | CM L9610 · compliance/policy-gate.ts + guards/trajectory-judge.ts + guards/claim-prefilter.ts + policies/escalation-claims-policy.ts | code registry + Haiku judge behind a bilingual regex prefilter | pass / repair(fire the tool that makes it true) / hold(park + holding copy) / block(reserved) — full enforce wiring exists incl. audit rows, bilingual correction copy, repair-success = "a human was actually reached" | not voice, not PM | DISARMED (POLICY_GATE_MODE=off) |
| 16 | Leasing fee-fabrication guard | CM L9996 · leasing/lease-terms/fee-fabrication-guard.ts | code, deterministic | Block any $ figure not traceable to this turn's rendered grounding (prompt + tool results + pricingDetails) → parked draft + handoff ack + auto-escalation | leasing, not voice, not PM | DISARMED (LEASING_FEE_FABRICATION_GUARD_MODE=off) |
Reading the table against Fede's vision: the "centralized layer that runs before Clara responds" is not missing — it is rows 1–16, one call site, fixed order. What's fragmented is the decision logic: rows 8, 11–14 are hand-written per-domain checks bolted on over four months of incidents, while row 15 is the general framework they were supposed to converge into — built, spec'd (docs/planning/policy-gate-spec.md, PR #5815), measured offline, and never armed. Row 16 is a second finished-but-dark guard. The architecture source of truth (§4.5, "The honesty layer — history, failure, redesign") already tells this exact story and prescribes the convergence; this doc's job is to confirm the code matches it (it does) and put the arming decision on the table.
| Desired action | Exists? | Evidence |
|---|---|---|
| Auto-heal (preferred) | EXISTS, armed in 3 forms | (a) #6278 salvage merges dropped facts back — deterministic auto-heal; (b) the hallucination guard auto-fires the real escalation its substitute text promises; (c) the policy gate's repair verdict fires the tool that makes an unsupported claim true — implemented, disarmed. The "make it true" design beat blocking decisively on real data: 0/166 false positives vs 0/28 true catches for the blocking twin (source-of-truth §4.5, "the natural experiment"). |
| Flag for engineering / human review | EXISTS, armed in 2 forms | (a) Anomaly Review Gate holds the draft, sends holding copy, pages #alerts; (b) every blocking guard parks the original draft as a msg_blocked audit row visible on the conversation + Decision Line ("a safety check caught it"); (c) the policy gate's hold verdict generalizes this — disarmed. |
| Push back to Clara to rewrite | DOES NOT EXIST | No guard verdict re-invokes the model. Nearest neighbors: FirstTurnViolation re-calls Claude with forced tool_choice (pre-guard, turn-1-leasing only, missing-tool-call only); fair housing ships an LLM-authored suggestedRewrite (authored by the classifier in the same call — not a regenerate-with-feedback loop through the agent). |
The absence is a decision the codebase already made once, implicitly: post-generation guards are deterministic precisely because they backstop a non-deterministic generator. A regenerate loop reintroduces that non-determinism at the safety boundary, plus:
Position taken here: don't build it as part of arming; add a revise verdict later, scoped to completeness findings only, one retry, deterministic re-check, never on voice — and only once the offline replay shows the residual class actually occurring. That decision is raised for Fede in §8.
Mapping Fede's four inputs against what the built (disarmed) gate actually receives (EvaluatePolicyGateInput, policy-gate-types.ts:143–149):
| Fede's bar | Gate today | Gap |
|---|---|---|
| "what tools it called" | YES — this turn's toolSteps with real result payloads | — |
| "what context it had" | PARTIAL — last 20 earlier-turn rows + distilled grounding facts (tour snapshot, prepared offer, escalation status) | Not the rendered system prompt / property knowledge Clara actually saw; grounding is a curated subset |
| "what came in" | NO — contextMessages is sliced to before this turn (slice(0, turnStartIndex), CM L9628); the current inbound message is not an input | Blocks any completeness check ("did the reply answer the ask?") — the Daniel class is invisible to the judge by construction |
| "what it reasoned" | NO — thinking blocks are not captured into the trace or the judge input | The [[WHY]] marker (Clara's own one-line rationale) IS captured per turn and would be a cheap proxy input; full reasoning capture is a separate lift |
| "independently as a judge" | YES — separate model (Haiku, temp 0, forced structured output, 2.5s budget, fail-open-and-counted on timeout), behind a bilingual claim prefilter so it never runs on "sounds good!" turns | — |
| "block problematic messages" | BUILT — hold/repair wired end-to-end; block reserved for high-confidence deterministic hits | DISARMED |
So the built gate is a trace-level judge in shape — it grades the draft against tool evidence and history, not just against token patterns — but it is a claim verifier, not yet a completeness checker, and two of Fede's four inputs (the inbound, the reasoning) aren't plumbed. Both are additive input changes to PolicyGateEvalInput, not redesigns; the replay harness already reconstructs turns from AgentTrace rows that carry the inbound, so the offline measurement can validate the enriched judge before it ever runs live.
Fede: "Us as the humans we'll be annotating conversations to build a golden data set of what good and bad is which then should serve as our regression suite to avoid bad changes going prod."
| Piece | Today | Verdict |
|---|---|---|
| Per-domain regression evals | 57 promptfoo domain configs / 59 datasets (evals/); every classification bug must become a case; CI-gated on Clara-path PRs | EXISTS — but single-turn/prompt-level, not whole-conversation good/bad |
| Guard replay over real traffic | scripts/eval/replay-guards.ts — 12 registered guard predicates, deterministic replay over ≥30 days of prod, hand-labeled per row (the RCA merge gate). #6278's 7-row labeled table is the live example | EXISTS — this IS human annotation, but per-guard, per-PR; labels live in PR bodies, raw reports gitignored — no durable corpus accumulates |
| Auto-pull + judge disagreement pipeline | evals/pull-golden-data.ts (pull transcripts + SMS, LLM-judge, flag disagreements → evals/candidates/) | DORMANT — the com.propflow.eval-pipeline launchd job is not loaded on the mini and evals/candidates/ does not exist in the repo |
| Post-hoc conversation judging | conversation-review-daily | PAUSED since ~Aug 16 (source of truth, open decision Q1) — nothing currently reviews a finished conversation after the fact |
| Whole-conversation annotation surface | — | MISSING — no way for Fede/Gera/PMs to label a real conversation good/bad from the UI, and no store those labels land in |
Sketch of the missing half (deliberately small): (1) a label action on the conversation page — good / bad + one-line why + which turn — writing a PII-safe pointer row (conversationId + turn index + label, never copied text); (2) the labeled set becomes a replay manifest: the existing replay harness re-runs the guard chain + (offline) the judge over exactly those conversations on every candidate build, asserting bad ones trip and good ones don't; (3) the same rows calibrate the trajectory judge (its spec already calls for a ≥200-case gold set). This turns the annotation habit Fede described into the regression gate he described, using two harnesses that already exist. It also answers Q1: resuming post-hoc judging feeds the candidate queue.
Fede's third ask: what other gaps does the mechanism have, and does it get refactored, eliminated, or evolved?
Not eliminate: removing it reopens both August incidents (the 30-day replay shows it catching exactly those two real-person turns, 0 unexplained false positives). Not refactor-now: it is pure, measured, registered in the replay harness, and additive. The architecture source of truth's honesty-layer migration plan names increment 0 as "fix the outbound-composition contract that let a correct answer get silently dropped across tool-calling rounds" — #6278 is that increment, for the mechanical-token subset. Evolution path: (a) short term, keep as-is; (b) when the policy gate registry is armed and vocabularies unified (§6), re-home it as a rewrite-class policy so its verdict/trace/measurement ride the same rails; (c) the residual omission classes (dates, unit numbers, answers) belong to the judge's future completeness check + optional bounded regenerate (§3.2), not to more regexes — piling fact-class regexes onto the salvage is how guard sprawl started.
Is a refactor warranted? Yes — but it is an arming and consolidation refactor, not a build. The settled verdicts in the architecture source of truth (§4.5) already prescribe the single-chokepoint, three-way-verdict design; the code already implements it; what's missing is the operational sequence that turns it on without repeating P8 (the Aug 13–16 guard that told three real customers "nothing is scheduled" — reverted, and the reason the guard-change merge gate exists).
Checked per guard against the gate's verdict shape (pass/repair/hold/block) — with one honest finding: the two registries speak different verdict vocabularies. outbound-safety.ts says pass/rewrite/block (text substitution); policy-gate.ts says pass/repair/hold/block (no rewrite/substitution verdict). Consolidation needs the union — add a rewrite (substitute/merge text) verdict to the gate contract, or the fair-housing/promise-backing/turnover-rewrite class cannot migrate.
| Guard | Fits pass/rewrite/repair/hold/block? | Note |
|---|---|---|
| Fair housing | rewrite / block | Needs the rewrite verdict + replacement text; LLM classifier stays its own policy module (fail-closed) |
| Promise-backing | rewrite + deferred repair | Its deferred-escalation pattern is a gate-level primitive worth keeping (act only after text is final) |
| Hallucination guard | block + repair | Block-with-substitute + auto-fire = exactly block + repair composed; its snapshot scoping becomes the policy's appliesTo |
| Turnover trio (fabrication / async / edit) | block / rewrite | Deterministic; three appliesTo-scoped policies |
| Fee-fabrication guard | block | Already registry-shaped; the gate spec explicitly reserves block for exactly this class of high-confidence deterministic hit |
| #6278 salvage | rewrite (additive) | Merge = rewrite with deliveredText |
| Same-turn escalation template + team-inbox silence | rewrite / suppress — borderline | Suppression ("send nothing") is not a gate verdict today; either add suppress or leave this at the call site — it is turn-shape routing as much as safety |
| Anomaly Review Gate | hold | Is the hold mechanism the gate's enforce path already reuses |
| PII / typed-markup / internal-prompt / decision-why strips | Do not migrate | They are unconditional deterministic transforms, not judgments — no verdict, no measurement question. Keep as the fixed pre/post stages around the registry |
| Pre-generation guards (runaway, farewell, escalated-gate, FirstTurnViolation) | Out of scope | They decide whether to reply, not whether the reply is true — a different layer, correctly separate |
Bottom line: every judgment-type guard fits once rewrite is added to the verdict contract; the strips and the reply-suppression layer correctly stay outside. Nothing found that structurally cannot migrate.
POLICY_GATE_MODE=enforce — repair + hold only, block stays reserved — bench property first, then Camellia. Same ladder for LEASING_FEE_FABRICATION_GUARD_MODE.revise verdict — never as a general regenerate.| Stage | What | Proof gate before it ships / arms |
|---|---|---|
| 0 DONE | Deterministic seam fix (#6278) + prompt rules (#6273, FINAL_REPLY_STANDS_ALONE) | Shipped: 30-day replay, 7 would-alter, both real-person incidents caught, 0 unexplained FPs |
| 1 | Guard-framework ADR + verdict-vocabulary union (rewrite added to the gate contract); fix the ADR-0123 miscitations | Docs-only + type-level; drift test pinning the registry contract |
| 2 | Judge input enrichment: inbound message, [[WHY]] proxy, completeness check | Offline only — replay over 60 days of reconstructed traces (the spec's Track C/D machinery, subscription runners); Daniel + 08-19 turns must flag; labeled FP rate within the target before any live wiring |
| 3 | Arm POLICY_GATE_MODE=enforce (repair+hold only) — Willows bench → Camellia. Arm the fee-fabrication guard on the same ladder | The RCA 2026-08-15 merge gate, per guard: ≥30-day prod replay, every real-person would-alter hand-labeled, any unexplained real-person FP blocks. Plus rollback = one env unset (fail-closed by construction) |
| 4 | Migrate existing guards into the registry, one per PR: fee-fabrication → hallucination → turnover trio → outbound-safety policies → #6278 salvage | Zero-verdict-drift parity replay per migration (old vs new path over the same 30-day corpus; any changed verdict blocks) |
| 5 | Annotation → golden set → regression manifest (§4 sketch); resume post-hoc judging (Q1) | The labeled manifest replays green on main before it becomes a required check |
| 6 | If measurement justifies: the revise (push-back-to-Clara) verdict — completeness findings only, 1 retry, deterministic re-check, never voice | Same merge gate + explicit latency budget; falls back to hold on second miss |
| 7 | Voice: post-call reconciliation against the same ledger (settled doctrine, H3) | Own replay over voice traces; correction via callback/SMS only |
Two decisions from this investigation are raised on the Decisions tab: (1) arm the Policy Gate as the centralized layer on the staged ladder above, vs keep guards distributed; (2) build the push-back-to-Clara rewrite loop now, vs auto-heal + hold with revise-later. They compose with the four honesty-layer decisions already open on the architecture source of truth (H1 judge trigger frequency, H2 tool-execution gating, H3 voice mid-call vs post-call, H4 the credentialed nightly replay job) and Q1 (post-hoc judging paused) — this doc deliberately does not re-litigate those; stage 3 above assumes H1's recommended narrow-trigger start, and stage 5 is Q1's option C in miniature.
Both were answered by Gera on 2026-09-01, each matching the recommendation above.
revise verdict does not get built.The four honesty-layer decisions on the architecture source of truth (H1–H4) and Q1 remain open and are unaffected by these two answers.
policy-gate-spec.md, source-of-truth §4.5) matching Fede's verdict vocabulary; "push back to rewrite" absent; the ADR-0123 miscitation (in outbound-safety.ts:59 and fair-housing-policy.ts:13; docs/adr/0123-scheduled-job-placement.md is unrelated — no guard-framework ADR exists).POLICY_GATE_MODE / LEASING_FEE_FABRICATION_GUARD_MODE are set in the Vercel prod env (code defaults are off and fail-closed; no evidence anywhere of an arming flip — the spec, PR #5815 and the code comments all describe them as never armed). The com.propflow.eval-pipeline launchd finding is for the mini only; if the job lives on another machine it may still run, but evals/candidates/ is absent from the repo either way. Guard latency figures (the ~25s fair-housing note) are taken from code comments, not re-measured.778f0ada), on Fable, at Fede's request. Sources: propflowai@6167989bc8 (origin/main), propflow-prod trace trace_de3ff842-… (read-only), docs/planning/policy-gate-spec.md, the architecture source of truth §3.1/§3.3/§4.5/§4.6/§10, PRs #1540, #5815, #6273, #6278, commits c199434602, aea19ca983, ce121fd357.