ADR-0122 · Architecture decision
Evidence-Gated PM Notifications
A claim made to a property manager binds to observed evidence — never to an internal phase or an LLM extraction.
Strategy
Goal: a PM must be able to act on every PropFlow notification without double-checking it. That means every factual claim we push is backed by the evidence class it asserts: things the PMS observed may be stated as fact; things a tenant said (or an LLM read) are attributed as speech; things we merely started are described as in progress. One rule, enforced structurally — typed provenance on intents, a premise check before any send, and copy that re-reads the record at render time — rather than 11 one-off copy edits.
The incident that exposed the class
2026-07-31, a resident at a live customer property emailed that she'd renew if rent dropped by roughly 13% (a ~$1,150 → ~$1,000 ask). Within seven seconds the PM received: "<Tenant> agreed to renew their lease… It just needs your signature to wrap up." AppFolio at that moment (verified live): "Out For Signing — 0/1 Tenant(s) Signed." Nothing existed to countersign, and the counter-offer — the actual revenue conversation — surfaced nowhere actionable.
The chain: a Haiku extraction stamped intent_to_renew (the schema cannot express a conditional acceptance) → the signal bridge fired tenantIntent{kind:'renew'} → one line in the renewal workflow OR'd that LLM belief with a PMS-verified signature → the countersign cadence started, and its copy asserts a signature.
// renewal-workflow.ts:1279 — the load-bearing OR
if (pms() === 'tenant_signed' // PMS-verified signature
|| (opts.countersignOnRenewIntent
&& intent()?.kind === 'renew')) // …an LLM's reading of an email
Not one bug: 11 of 19 audited surfaces
A four-stream inspection (reminder-system map, 19-surface claim/evidence audit, intent-vocabulary audit, industry research) found the same defect across two domains. The full trace and per-surface citations live in the landed audit, docs/planning/evidence-audit-pm-notifications.md.
| # | Surface | Claim pushed to the PM | Actual evidence | Severity |
|---|---|---|---|---|
| 1 | renewal_countersign reminder | "agreed to renew… just needs your signature" | LLM-extracted email intent | critical |
| 2 | Saga activity log | "Tenant signed — waiting for the lease to be countersigned" | internal workflow phase alone | critical |
| 3 | countersign_timeout escalation (email + SMS) | "agreed to renew, but the lease hasn't been countersigned" | may be an intent-only path | high |
| 4–5, 7 | Vendor call outcome notices | "{Vendor} is scheduled for {day/time}" / "moved off {old day}" | LLM extraction over ASR of a call; no confirmation, no calendar/PMS write | high |
| 6, 9 | Holdover office heads-up | "We've sent them a fresh renewal offer" | a workflow was started; it can still fail or hold | medium |
| 8 | application_review reminder | "applied for Unit 204" | targetUnitId is the unit of interest, not the applied-for unit; empty values already degrade safely to "unit unknown" | medium |
| 10 | forwarded_question reminder | verbatim-attributed question | an LLM one-line summary | low |
| 11 | Move-out draft at inspection complete (the wired path) | "is moving out on {date}" asserted as fact | PropFlow's own record; the stale-date guard waves the draft through when the PMS has no date at all — zero PMS corroboration in exactly that case | partial, low/medium |
The mirror-image gap: the voice lane correctly refuses conditional acceptance ("Conditional acceptance is NOT acceptance… escalate, don't accept") but handleRenewalEscalateImpl never signals the workflow — so it keeps nudging a tenant who already counter-offered, then escalates for "no response." Email over-commits; voice under-records — the same missing concept on both sides. And a counter-offering tenant currently projects to RENEWED on the board, which excludes them from churn risk at exactly the moment they're negotiating.
The structural flaw: three things must agree — trigger evidence, resolution predicate, copy assertions — and the ADR-0104 reminder engine enforces only one edge of that triangle (completion-evidence ↔ resolution predicate). There is no premise predicate: nothing verifies that the fact a notification asserts was true when it fired. The codebase already states the right principle on the vacate lane — the formal-notice parse bar is deliberately conservative because "a false positive fires move-out machinery," and the backstop refuses to fabricate signature evidence — but the renew lane got neither. The read models are already honest (board and detail gate on the PMS-verified signature reference); only the push notifications skip the gate. The in-house exemplar: new-lease countersign reminders double-confirm e-sign parties before any email.
The four decisions
1 · Evidence class is a typed, first-class property of every intent
TenantIntentPayload gains evidence?: 'pms_verified' | 'stated' and a third kind, negotiating. OperationalSignal gains intentQualifier: 'unconditional' | 'conditional' | 'question' plus free-text intentCondition (e.g. "rent reduced to $1,000"). The extractor prompt gets a conditional bar on the renew side symmetric to the formal-notice bar on the vacate side, plus eval fixtures for counter-offers — today the eval's expected type cannot even express one. A conditional or question-qualified renew intent maps to negotiating, never renew.
Replay contract: the union members are additive (every existing read is === 'renew' / === 'vacate'), and evidence is optional on the wire — absent means 'stated' at every read site. A signal recorded before this lands can never have been PMS-verified, so the fail-closed default is also the historically-correct one. Slice 3 adds a pre-change tenantIntent history fixture to the replay corpus (ADR-0108 D5) to pin exactly that.
2 · Stated intent and verified signature are different workflow states
The load-bearing OR is split. Only tenant_signed — the L4 ready-to-countersign feed, which checks per-party signatures — enters awaiting_countersign and triggers the countersign cadence (#5173 already enforces the cadence half).
A bare renew intent enters a new commitment_stated posture: tenant outreach stops (they said yes — don't nudge them), the board projects "Says they'll renew — awaiting signature," and the nudge ladder switches to sign-reminder copy. It is the sales-pipeline "verbal commitment" stage: real, valuable, and not renewed. negotiating routes to PM review — the same posture voice's escalate path takes — projects RENEWAL_NEGOTIATING on the board rendered as plain English ("Negotiating terms"; an enum must never be the PM-facing string), and counts toward churn risk. The voice escalate handler fires this signal too, closing the under-record gap.
Splitting the OR removes its right-hand side entirely: countersignOnRenewIntent is deleted from the terminal-ladder opts along with its four call-site arguments and its comment block — no permanently-false parameter left behind as scaffolding. This mirrors the vacate lane's proven stated-vs-formal split.
3 · Every PM-action kind gets a premise predicate, enforced fail-closed
The handler grows a premise predicate — the mirror of its existing resolution predicate: what observed fact must be true for this ask to exist at all (countersign: a tenant signature observed in the PMS; application review: an application on file for the named unit). Re-checked fail-closed at cadence start and before every send, exactly like the existing pre-send resolution re-check. An unverifiable premise means no send — the same "we do not fabricate evidence" rule the backstop already applies.
Predicate-design hazard to encode: coalescing readers make absent fields look present. The application_review premise must treat targetUnitId === '' as absent, not as a unit on file, because the type's reader contract coalesces missing to the empty string.
4 · Copy binds to re-read evidence, and stated evidence renders as attributed speech
Every factual sentence in a PM-facing template binds to a typed field on the authoritative record re-read at render time — never to the triggering event or a workflow phase (the Stripe thin-event rule). Evidence class then selects the register: pms_verified may assert ("The tenant signed"); stated must attribute ("Their email says they intend to renew — nothing is signed yet"); LLM-extracted content is always attributed, never verbatim-quoted as the counterparty's words. A regression suite pins claim↔evidence for every PM-facing template — today no test anywhere asserts that copy matches trigger evidence.
Industry precedent
DocuSign / Dropbox Sign separate per-signer events from all_signed and document acting only on the latter. Stripe events are "thin" — the event schedules the message; a re-read of the object supplies the claim. Sales pipelines keep "verbal commitment" as its own stage whose exit criterion is a counterparty artifact (a signed contract), never a system-side action.
Consequences
- The stated-intent-as-signature class dies at three independent layers — typed intent, phase split, premise predicate — instead of one string edit.
- Counter-offers become visible revenue events: routed to a human, on the board, in churn risk, instead of being silently marked won.
- The vendor-call surfaces adopt the same register rule: "The vendor said Tuesday works" (attributed) until a calendar or PMS write exists.
- Cost: extractor prompt + eval additions; one workflow patch gate (same discipline as #5173); premise predicates for four kinds; copy edits on 11 surfaces.
Rollout (by blast radius)
- Slice 1 — shipped (#5173): countersign cadence + timeout copy gated on signature evidence (replay-safe patch).
- Slice 2: activity-log copy and the
countersign_timeoutfan-out read PMS state off the same input object they already receive;commitment_statedboard projection. - Slice 3:
negotiatingend-to-end — extractor qualifier, bridge mapping, voice escalate signal,RENEWAL_NEGOTIATINGprojection + churn-risk inclusion. Same slice ships theevidencefield (absent ⇒'stated') and the pre-change replay fixture that ADR-0108 D5 makes a merge gate. - Slice 4: premise predicates on all four PM-action kinds; copy-register sweep of the remaining audited surfaces (vendor calls, holdover, application review, forwarded question); claim↔evidence regression suite.
Adjacent defects found by the same audit
Filed separately — not gated on this ADR.
- The new-lease countersign resolve signal is keyed on the lease id while the cadence anchors on the e-sign document id, so the resolve never lands; cadences close only by budget or expiry, and the recorded resolution timestamp is just "now".
- Collections chase reuses
forwarded_questioncopy ("Clara passed along a question") for a past-due-balance escalation. - The renewal bridge stamps
openedAt = now()and compares at date granularity — same-day races are possible. - A messaging test fixture carries a real tenant name and unit (almost certainly minted off a real row) — anonymize per the no-PII rule.
- The email-NTV move-out draft has no production caller; wiring it as-is would clone this failure class into turnover — its own header already calls the date an "email-derived estimate".
Immediate remediation already done
- The false reminder cadence for the incident renewal was resolved (
cancelled) via the system's own signal — no further false nudges. - The resident's counter-offer (a ~13% reduction ask on a 12-month renewal) is a live pricing decision awaiting a human answer.