0127 — Tenant-facing self-help tips are catalog-derived, never generated

📍 This document is a CONSUMER of rules ADR-0126 decides, and it restates several of them in full where they are load-bearing for a decision made here — the stamp write rule, the mint table, neverFirstContact's premise, the emergency prohibition, ambiguity resolves to SUPPRESS. Those restatements are deliberate and should stay. The obligation they create is the grep: ADR-0126 carries a Where each rule is DECIDED index that lists this file as a restatement site, so changing one of those rules means editing here in the same commit.

The content below has been re-derived through 2026-08-07 and cites evidence dated after the drafting date — notably the #5391 VALID_ISSUE_TYPES parity correction, merged 2026-08-05 and discussed in the Context. The drafting date is kept because it is when the decision was proposed; the revision date is what the citations are current as of.

Context

When a PM creates a work order by hand in AppFolio, PropFlow already texts the tenant an acknowledgement (notifyTenantOfPmsCreation, gated by decideWorkOrderDispatch's source === EXTERNAL && !tenantNotifiedAt). The acknowledgement states facts and nothing else:

Work order CAM-2057 created.
Issue: Electrical Outlet
Our handyman has taken this job.
I'll text you the details as soon as the handyman's timing is confirmed.

A meaningful share of these work orders do not need a handyman. CAM-2057's description was "Outlet by the WiFi coaxial not working" — very often a switch-controlled outlet with the wall switch off. Clara knows this class of thing; the tenant does not; and the acknowledgement is the moment the tenant is paying attention. Saying nothing costs a truck roll.

The obvious implementation is to hand the description to an LLM and ask it for a tip. That is the implementation we are ruling out. A tenant-facing SMS asserting "try X" is PropFlow speaking with the property's authority about the tenant's home. Free-form generation on that surface can produce advice that is merely plausible — reset a breaker that should not be reset, run a disposal that has a hand in it, bleed a radiator on a system that does not have bleed valves — and there is no reviewable artefact afterward explaining why the tenant was told that. It is the same class of hazard as the no-fabrication rule the reviewer already enforces on numbers, applied to instructions.

PropFlow already has the right artefact: the Playbook catalog, read via getPlaybook(issueType) and already used by get_troubleshooting_steps on both the voice and SMS agents. Each playbook carries steps[] (each with instruction, expectedOutcome, optional warningIfApplicable), safetyWarnings[], and whenToEscalate[] — curated, reviewable, versioned in one place.

The catalog already covers the motivating example. VALID_ISSUE_TYPES (agents/clara/lib/agent/clara-personality.ts) carries 16 issue types, and one of them is outlet_not_working"title": "Electrical Outlet Not Working", "category": "electrical" in data/seed/playbooks.json. Its first step is the switch-controlled-outlet check, verbatim the advice CAM-2057's tenant needed:

First, check whether a wall switch controls the outlet. In some rooms a light switch on the wall powers an outlet (often the one a lamp plugs into) — flip any nearby wall switches and see if the outlet comes to life.

So the gap this ADR closes is not authorship and not coverage — it is delivery. The advice existed and nothing carried it to the tenant: no sender renders a playbook into a work-order acknowledgement. Until #5391 (fae751440, merged 2026-08-05) the row was not even reachable by the classifier — the catalog held 16 playbooks while VALID_ISSUE_TYPES held 13, so outlet_not_working, clogged_toilet and heat_not_working could never be named or served. That drift is closed, and src/__tests__/playbook-issue-type-parity.drift.test.ts now pins enum ⇄ catalog parity.

This reframes what the ADR is arguing for. The decision below is a rendering pipeline for advice that is already written and already reachable — not a proposal to start writing it.

Decision

A tenant-facing self-help tip is rendered from a Playbook row. An LLM may choose which playbook applies; it may not author the advice.

The pipeline for an EXTERNAL-origin work-order acknowledgement:

  1. Classify. An LLM reads the PMS free-text description and returns an issue_type from VALID_ISSUE_TYPES plus a confidence. This is the only generative step, and its entire output space is a closed enum.
  2. Look up. getPlaybook(issueType). No confident match, or no playbook → emit no tip. The acknowledgement ships exactly as it does today. Silence is the correct default; a tip is an enhancement, never a requirement.
  3. Render. One line derived from the playbook's first step, plus its warningIfApplicable when present. Deterministic templating from catalog fields — no second LLM call, no paraphrase.
  4. Invite the reply. The tip closes by telling the tenant that replying closes the work order.

TWO messages, not one — the ack and the insight are different obligations

The acknowledgement and the insight are two separate sends.

Message 1 — the acknowledgement. Time-critical, factual, must-send, and it carries the blended first-contact introduction when the recipient is new (ADR-0126). It fires the instant the WO syncs and depends on nothing slow.

Message 2 — Clara's insight. Optional, best-effort, suppressible. It may require a classifier hop and a catalog read, and it is the message that can be wrong. Splitting it means a slow, failed, or suppressed insight can never delay or block the fact the tenant is waiting for.

Touch cost was the wrong axis to decide this on. The two messages are not one message chopped in half — they have different failure modes, different urgency, and different send obligations, and the only way message 2's risk stays off message 1's critical path is for them to be separate sends.

The gas-smell case is the test. Emergency priority ⇒ message 1 blends the introduction with the urgency and message 2 never fires at all. A tenant reporting a fire gets acknowledgement and escalation, never a checklist. Under the fused design the suppression would have had to reach inside a single body; split, it is simply "do not send the second message," which is a decision a pure function can make and a test can pin.

What was actually observed, stated as what it is. On 2026-08-04 the CAM-2057 tenant received the automated ack at 18:49:56 and an operator-composed insight at 20:57:01 as a separate message (Twilio SM023d55…), and no complaint or confusion followed. That is the whole of the evidence, and it is worth having: a two-message sequence was sent to a real recipient and nothing went wrong.

⚠️ It is not evidence that this design reads well, and the ADR should not claim it is. The gap is 2 hours 7 minutes; the design being decided here is an automated insight landing seconds behind the ack, behind a classifier hop and a catalog read. A human-composed follow-up two hours later is a different artefact arriving in a different context — the recipient had long since finished reading message 1. And "read correctly" was never observed: no reply, no reaction, no read receipt. Per hot rule 2 that is an inference, and it is labelled as one.

Producing TWO messages. Message 1's default rendering — the one a recipient who has already been introduced receives — is byte-for-byte what formatTenantConfirmationSms emits today:

Work order CAM-2057 created.
Issue: Electrical Outlet
Our handyman has taken this job.
I'll text you the details as soon as the handyman's timing is confirmed.

⚠️ That is ONE of two renderings this sender must author, and the formatter is extended by construction — do not read the block above as "message 1 needs no work". notifyTenantOfPmsCreation is emergency-capable (WorkOrder.priority is required — src/lib/data/types.ts:3036 — and formatTenantConfirmationSms receives the whole work order), and ADR-0126 forbids cannedIntro on any emergency-capable lane, naming this sender as the one most likely to reach for it. So message 1 must mint introducedInBody, which means a second, first-contact rendering that blends the introduction with the urgency. The dispatcher picks between them on the ledger; this sender writes both.

What is unchanged is the copy above as the warm-ledger case, and the rule that the tip never fuses into it. "Do not extend formatTenantConfirmationSms" would contradict ADR-0126 and is not what this ADR says; the prohibition is against fusing the tip in, not against the lane gaining a first-contact rendering.

Message 2, sent only if every suppression below passes, stands alone:

One quick thing you could try on CAM-2057 while you wait: some outlets are wired
to a wall switch — flip the switches in that room and see if it comes back. If
that's it, just reply here and I'll close it out.

Message 2 has to open by re-anchoring itself, and that is a design constraint, not a copy nicety. Fused, "One quick thing to try:" was a continuation of a body the reader had just seen. Standing alone it is an unheralded second text, so it names the work order (on CAM-2057) and the situation (while you wait) in its first clause. A second message that assumes the first is still on screen is the failure mode of splitting them.

Re-anchoring is not sufficient on its own — it assumes message 1 arrived. Naming CAM-2057 helps a reader who saw it and helps nobody who did not, so the constraint has two halves: the copy re-anchors, and the suppression list gates on message 1 having been delivered. The second half is in Suppressions below, and without it the first half makes the failure worse rather than better — a bare identifier is more confusing than no identifier.

The Issue: line is workOrderIssueText(workOrder) verbatim — the PMS's own title, never a paraphrase of the free-text description. Paraphrasing it would be generated text on the single line this ADR exists to keep un-generated. The tip is the only new line, and it is templated from catalog fields.

Coverage grows as data, not code — ASPIRATIONALLY. Today it does not, and the gap is a prerequisite, not a footnote. The headline case needs no new row — but it does need one FIELD SET on the existing row, and shipping without it means this ADR's own motivating example sends nothing. selfServeOverSms defaults to absent/false (see Entity classification), so outlet_not_working classifies ✓, looks up ✓, and is then suppressed by the opt-in gate — a correct, silent, fail-closed no-op that is indistinguishable from the feature not being built. selfServeOverSms: true on outlet_not_working is therefore a required step of PR 3, in the source catalog and through the prod sync below; neither half is optional, because the row that renders is the synced mirror, not the file.

That aside, the case genuinely needs no new authoring: outlet_not_working already carries the switch-controlled-outlet check, and every consumer of the catalog — this acknowledgement, get_troubleshooting_steps on SMS, the voice specialists — reads the same row. Do not add an electrical_outlet key. It would duplicate outlet_not_working's intent, and with both in VALID_ISSUE_TYPES the classifier would split dead-outlet descriptions across two enum members — so SMS and voice could return different advice for the same problem, which is the cross-channel divergence the "hardcode a handful of tips in the sender" alternative is rejected for.

What still holds is the rule: a new self-help capability should never require a code change, and a hardcoded troubleshooting string in a sender is the signal that this ADR has been violated.

⚠️ Two things make "data, not code" false as written:

  1. VALID_ISSUE_TYPES is a TypeScript constant (clara-personality.ts). A genuinely new issue type is a code change and a deploy, whatever the catalog holds.
  2. The prod catalog is a HAND-SYNCED MIRROR. scripts/sync-playbooks-to-dynamo.ts is in no pipeline — no cron, no deploy step, no CI job. A row added to the source of truth reaches production only when a human remembers to run a script.

So the honest statement is: coverage grows as data once the catalog is automatically synced and the issue-type list is data. Automating that sync is a prerequisite of PR 3, not a follow-up — without it, "add a Playbook row" is an instruction that silently does nothing in production, which is the most expensive kind of documentation error.

Suppressions — all unconditional, evaluated fresh on every send

No tip is sent when:

Why an explicit field rather than inferring from safetyWarnings. The tempting rule is "suppress when safetyWarnings is non-empty AND the issue is electrical or gas." It does not work, and outlet_not_working is the proof: safetyWarnings: string[] is a required field on Playbook (see interface Playbook in src/lib/data/types.ts), and that row populates it with two entries — "If the outlet is sparking, smells like burning, or feels warm — do not touch it…" and "Do not insert anything into the outlet to test it". Its category is electrical. So the inferred rule would match, look up, and then suppress the exact row this ADR says makes the CAM-2057 case work — the headline example, cancelled by the ADR's own safety clause. Inferring a policy decision from a required field plus a category string is the bug; an explicit opt-in is the fix, and it fails closed.

Message 2 needs a NEW OutboundMessageKind, and its grade is outreach

Message 2 is a separate send, so it does not inherit the acknowledgement's kind and it does add a touch. Two things follow.

It cannot be silently omitted. OUTBOUND_KIND_COUNTABILITY is a Record<OutboundMessageKind, …>, so a kind missing from the countability table fails to compile — stronger than any assertion (touch-budget-coverage.drift.test.ts says so explicitly). The type system will force the choice at authoring time.

The grade is outreach, and it is NOT the ack's. The maintenance block is graded transactional on the rationale that "every one of these is an update on a work order the tenant reported, or a question about their own appointment" (kind-category.ts). A PM-typed AppFolio work order is precisely one the tenant did not report, and message 2 is not a fact of record at all — it is unsolicited advice. That is outreach.

Stated consequence: with the touch ceiling now actually binding (#5232, merged), an outreach grade means the insight can be refused by the touch budget. That is consistent with "optional, best-effort, suppressible" — but it is a real behavior of the shipped feature, and it belongs here rather than being discovered during PR 3.

What message 2 mints for firstContactintroducedInBody, and the thunk is not decorative

ADR-0126's firstContact field is required and brand-sealed, so message 2 must mint something, and after that ADR's narrowing there is exactly one honest member. It is not neverFirstContact: that mint is now legal only on a prior inbound SMS premise, and message 2's premise is a prior outbound one — the acknowledgement this ADR just sent. Nor is it cannedIntro, which would bolt a 130-character identity paragraph onto a message whose whole value is being short.

Message 2 mints introducedInBody(() => blendedTipBody, reason). The lane authors a first-contact rendering of the tip, and the dispatcher selects it only on a cold ledger.

That may read as dead code given the suppression above — if the tip is suppressed whenever the ledger is not warm, the thunk should never be invoked. Write it anyway, and do not collapse it to neverFirstContact. The suppression is a property of this lane's gate; the mint is the dispatcher's own check, and the whole point of ADR-0126 is that the ledger, not the caller, is the authority on whether a number has been introduced. A lane asserting "my gate guarantees the ledger is warm" is asserting a property of the RECIPIENT, which is the one thing ADR-0126's mint table forbids. If the two ever disagree, the mint is what makes the disagreement safe instead of silent.

Counted honestly, that is a real editorial cost this ADR is choosing to pay. A first-contact rendering of the tip has to be written, reviewed, and kept language-correct (ADR-0089) like any other tenant-facing copy — while, if both gates work, never being sent. It is the one piece of copy in this design whose correctness cannot be verified by observing production. That is the price of keeping the ledger rather than the lane as the authority on introductions, and it is small: one short rendering on one lane. Naming it here so nobody discovers it during PR 3 and quietly deletes the thunk to avoid writing it — which would convert a paid cost into an unguarded assumption.

And the cost has a second half added by ADR-0126's copy-version rule: that unreachable rendering also carries a version literal that must be bumped whenever it is edited. So the one piece of copy this ADR admits cannot be verified by observing production also carries a maintenance obligation nothing in production will ever exercise. Still worth paying, for the reason above — but counted, because a section whose whole virtue is counting costs should not leave this one implicit.

The reply path is part of the decision, not a follow-up

A tip that invites "reply and I'll close this out" and then does not close it out is worse than no tip: the tenant fixed their own outlet, told us, and the handyman still knocks. Shipping the tip requires that an affirmative reply cancels the work order in AppFolio and stands down the already-paged handyman.

The cancel takes TWO affirmatives, and here is why that is not ceremony

A false affirmative is not recoverable into the same work order. "No, still broken" read as yes ⇒ WO cancelled, handyman stood down, tenant told it is closed, outlet still dead. Under ADR-0030 PropFlow cannot re-create that work order — a new one gets a new AppFolio number, so CAM-2057 as the tenant knows it is gone. A false negative costs only the truck roll already being paid for. The failure is asymmetric and the primitive is a one-way door, so:

The classifier proposes; a second affirmative disposes. The tip already invites a reply, so the thread is live: "Great — want me to close CAM-2057?" → cancel on the second yes. It converts an irreversible external mutation into a two-key turn on a lane that is already open. If the extra touch is judged too expensive, that must be stated as the trade it is — the same touch axis this ADR already says was the wrong one for fusing the ack.

The confirmation question is itself a gated send, and awaiting confirmation needs a stated exit

A live lane is not an ungated one, and this is the cost the two-affirmative decision adds that the one-hop version did not have: it turns a one-hop promise into three, and the new middle hop is an outbound send subject to quiet hours, consent and suppression like any other.

The failure it introduces: a tenant flips the switch at 22:40, texts "that fixed it!", and gets silence — with the handyman still scheduled. From inside the message thread that is indistinguishable from the promise not being kept, which is the standard this ADR sets for itself when it says a tip that invites a reply and then does nothing is worse than no tip. So awaiting confirmation may not be a state the design enters without saying how it leaves.

Three rules, and they follow from the same asymmetry as the cancel itself.

  1. The confirmation question is graded transactional, not outreach. It is a direct answer to a message the tenant just sent about their own work order — the maintenance block's stated rationale ("a question about their own appointment") fits it exactly, and it is the one thing in this feature that is genuinely not unsolicited. That removes the touch budget as a refuser. It does not remove quiet hours or consent, and it should not.

  2. A question that cannot be sent now is DEFERRED to the next permitted window, never dropped and never sent silently late. The state carries that deadline explicitly. The tenant's "that fixed it!" is already on the thread and already visible to the PM, so a deferred question is a delay, not a loss.

  3. The deadline is the handyman's visit window, and expiry ABANDONS the confirmation — the work order stays open. If the question cannot be asked before the technician is due, the pending state is discarded and the visit proceeds. This is the cheap failure by the same argument the two-affirmative rule rests on: an abandoned confirmation costs the truck roll that was already being paid for, while a cancel fired on an unconfirmed guess destroys CAM-2057 under ADR-0030 with the outlet still dead. When the two exits are asymmetric, expiry takes the recoverable one.

And the copy must never run ahead of the state. Nothing is said to the tenant in the present tense until the AppFolio cancel returns — no "closed it out" on the strength of a reply, and no "we'll close it" before the second affirmative. The one thing worse than the silence above is silence following a confirmation the tenant was told had landed.

⚠️ This shares the open question two paragraphs down about who owns the turn: the deferred question and Clara's normal agent loop are both live on the same thread, so a deferral must not be re-asked by the other path. The implementing PR closes both together or neither.

Which work order. The outbound record carries regardingId (notify-tenant-of-pms-creation.ts) and that is the anchor. A tenant with two open work orders who replies "yes, fixed" is ambiguous, and the rule is REFUSE AND ASK — never guess the most recent.

Who classifies, and when the window shuts. The inbound reply also reaches Clara's normal agent loop, so "the classifier cancels" and "Clara answers conversationally" can both fire on one turn; the implementing PR must say which owns the turn. And the tip is guarded on a confirmed visit window today — the cancel needs the matching guard, or a reply arriving after the handyman has already been out cancels a completed job.

Cancel, never delete — and not because delete is broken. appfolio.delete_work_order works: runState: 'live', policy: 'preferred', riskLevel: 'high', scope: 'test-property-only' (tools/appfolio.ts:383). It is scoped to test properties because of what it does — a hard delete makes the WO disappear entirely, and when it is the only WO on its parent service request AppFolio destroys the SR along with it. Cancel preserves status=Canceled with the full audit trail, which is the whole point on a tenant-resolved work order: we need the record that it existed and why it closed. Do not record delete as "broken" — it works, and a reader who believes otherwise goes to "fix" it, finds it working, and hard-deletes a customer WO plus its service request with no audit trail.

If the stand-down cannot ship in the same change, the tip ships without the closing invitation, stating only the thing to try.

Authoring: an LLM may DRAFT a playbook, offline, for a human to approve

The constraint this ADR enforces is not "no LLM near troubleshooting text." It is every string that reaches a tenant has a reviewable artefact behind it. Those are different, and the difference is where the LLM sits relative to the human.

So: drafting Playbook rows with an LLM — fed the description corpus of unmatched work orders, proposing issueType, steps, safetyWarnings — is explicitly in scope, provided a human approves the row into the catalog before it can ever render. Coverage then grows at authoring time, reviewed once, instead of at send time, unreviewed every time.

This matters because the likeliest way this decision fails is not a safety incident. It is the catalog stalling because tending it is nobody's job (see Consequences).

The evidence is not the row count — it is that three of the sixteen rows sat unreachable and nobody noticed. outlet_not_working, clogged_toilet and heat_not_working were in the catalog but absent from VALID_ISSUE_TYPES, so the classifier could not name them and get_troubleshooting_steps could never serve them. They were written, shipped, and dead on arrival until #5391 found them. Someone paid the editorial cost and the advice still never reached a tenant. Drafting is how that cost gets paid down without moving the review gate; the parity drift test is what keeps the payment from being wasted again.

Entity classification

No new entities. New Playbook rows are data in an existing canonical catalog-like entity; selfServeOverSms: boolean is a new optional field on it, defaulting to absent/false so existing rows stay silent until reviewed.

⚠️ That default silences the motivating example too. outlet_not_working is an existing row, so it inherits absent/false like every other — meaning PR 3 must set selfServeOverSms: true on it explicitly and sync it to prod, or the feature ships correct and sends nothing. The default is right; being surprised by it on the headline case is not.

But there IS a new OutboundMessageKind, plus its mandatory OUTBOUND_KIND_COUNTABILITY entry graded outreach — see the countability section above. It is called out here because this is the section a reader checks for exactly this question, and "no new entities" would otherwise read as "nothing to register."

Consequences

Easier. Every self-help claim PropFlow makes to a tenant is traceable to a reviewable catalog row. Adding coverage is a data change a PM could review. Voice and SMS give the same advice for the same problem because they read the same row.

Harder. Coverage is bounded by the catalog, so early on most acknowledgements will carry no tip at all — the deflection rate starts near zero and climbs only as playbooks are written. That is the intended trade: a small number of correct tips beats broad coverage of plausible ones. Someone has to own writing playbooks, which is ongoing editorial work this ADR creates and does not staff.

The classifier is now a tenant-facing surface, so it needs eval coverage (evals/datasets/maintenance.yaml) and every misclassification found in prod becomes a regression case, per the repo's standing rule.

Follow-up implied. Tip copy is tenant-facing and needs ADR-0089 language handling — a playbook step rendered in English to a Spanish-speaking tenant is a worse outcome than no tip. Either Playbook gains localised step text or the tip is suppressed for non-English recipients until it does.

Alternatives considered

Free-form LLM tip from the description. Broadest coverage and zero editorial work — it reaches the issues the catalog's 16 rows do not. Rejected — PropFlow would be giving unreviewed instructions about a tenant's home in the property's voice, with no artefact afterward explaining why. Coverage is not worth that, and the failure is invisible until it is an incident. Note it would not have helped the motivating case at all: outlet_not_working was already written, so free-form generation would have replaced reviewed advice with unreviewed advice.

LLM generates, second LLM judges before sending. Rejected — a judge raises the floor on average but the output space stays unbounded, so the tail risk is unchanged, and it doubles latency and cost on a path that runs on every synced work order.

Hardcode a handful of high-value tips in the sender. Fastest path to covering the outlet case. Rejected — it is unreachable from the voice agent and from get_troubleshooting_steps, so the same question gets two different answers depending on channel, and it makes the sender the second home for troubleshooting knowledge.

Send no tip; keep the acknowledgement factual. The status quo, and genuinely safe. Rejected as the end state — a real share of PM-created work orders are self-resolvable, and the acknowledgement is the one moment the tenant is engaged. It remains the correct behaviour for every case the catalog does not confidently cover, which is the point of step 2.