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.

Accepted — implemented

Accepted by Fede 2026-08-01 and merged via PR #5175. Implemented and verified through #5274, #5288 and #5291 — the evidence-gate harness scores all 12 audited surfaces honest, up from 0 of 12 before the change. Dated 2026-07-31 · tactical containment shipped earlier as #5173.

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.

#SurfaceClaim pushed to the PMActual evidenceSeverity
1renewal_countersign reminder"agreed to renew… just needs your signature"LLM-extracted email intentcritical
2Saga activity log"Tenant signed — waiting for the lease to be countersigned"internal workflow phase alonecritical
3countersign_timeout escalation (email + SMS)"agreed to renew, but the lease hasn't been countersigned"may be an intent-only pathhigh
4–5, 7Vendor 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 writehigh
6, 9Holdover office heads-up"We've sent them a fresh renewal offer"a workflow was started; it can still fail or holdmedium
8application_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
10forwarded_question reminderverbatim-attributed questionan LLM one-line summarylow
11Move-out draft at inspection complete (the wired path)"is moving out on {date}" asserted as factPropFlow'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 casepartial, 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

Rollout (by blast radius)

  1. Slice 1 — shipped (#5173): countersign cadence + timeout copy gated on signature evidence (replay-safe patch).
  2. Slice 2: activity-log copy and the countersign_timeout fan-out read PMS state off the same input object they already receive; commitment_stated board projection.
  3. Slice 3: negotiating end-to-end — extractor qualifier, bridge mapping, voice escalate signal, RENEWAL_NEGOTIATING projection + churn-risk inclusion. Same slice ships the evidence field (absent ⇒ 'stated') and the pre-change replay fixture that ADR-0108 D5 makes a merge gate.
  4. 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.

Immediate remediation already done

PropFlow Docs