0127 — Tenant-facing self-help tips are catalog-derived, never generated
- Status: Proposed
- Date: 2026-08-04 (drafted) · 2026-08-07 (last revised)
- Deciders: Gera, Fede
- Related: ADR-0126 (message 1 carries the blended first-contact introduction), ADR-0030 (the PMS owns work-order identifiers — why a cancel is a one-way door), ADR-0089 (language), ADR-0092 (suppression)
📍 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_TYPESparity 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:
- Classify. An LLM reads the PMS free-text description and returns an
issue_typefromVALID_ISSUE_TYPESplus a confidence. This is the only generative step, and its entire output space is a closed enum. - 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. - Render. One line derived from the playbook's first step, plus its
warningIfApplicablewhen present. Deterministic templating from catalog fields — no second LLM call, no paraphrase. - 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:
VALID_ISSUE_TYPESis a TypeScript constant (clara-personality.ts). A genuinely new issue type is a code change and a deploy, whatever the catalog holds.- The prod catalog is a HAND-SYNCED MIRROR.
scripts/sync-playbooks-to-dynamo.tsis 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:
Message 1 was not delivered. Message 2 presupposes message 1 — it opens by re-anchoring to
CAM-2057, a work-order number the recipient learns from message 1. Nothing else in this list asserts that, and nothing upstream does either: ADR-0126 never stamps a message that provably did not reach the carrier, so a message 1 refused by consent, quiet hours, the touch budget or ADR-0092 suppression leaves the ledger cold.(That is the direction this bullet uses. The converse does not hold — delivery alone does not stamp; ADR-0126 gates the stamp on a first-contact rendering having been SELECTED, so a delivered
neverFirstContactsend writes nothing. Message 1 mintsintroducedInBody, so the distinction does not change the conclusion here. Nor is it "only on a delivered result" — that is ADR-0119's rule, which ADR-0126 explicitly disowns as an earlier-draft error of its own. The corrected rule also stamps on a throw at or after the carrier call, which is why the gate below asks about the ledger rather than inferring it from message 1's delivery alone.)Without this gate, message 2 ships as the recipient's actual first SMS from PropFlow — un-introduced, and opening with an identifier they have never seen. This is a correctness requirement independent of disclosure: "One quick thing you could try on CAM-2057" is incoherent to someone who was never told CAM-2057 exists.
⚠️ Delivered is the cheap half of this gate; the load-bearing half is that the recipient has actually been INTRODUCED. The two come apart on the cohort ADR-0126 Open questions #3 names as most likely to be first contact — a tenant who exists only as a PMS row, with no claim row to stamp. Message 1 delivers, carrying its first-contact rendering; the stamp lands nowhere; the ledger stays cold. A gate that only asks "did message 1 deliver" passes, and message 2 either introduces the recipient a second time seconds later or ships un-introduced — which is the state this gate exists to prevent.
So the condition is: message 1 delivered AND the disclosure ledger is warm for this number. Where the ledger cannot be confirmed warm — no claim row, or the org-scoping question unresolved — ambiguity resolves to SUPPRESS the tip, matching ADR-0126's own rule for its cold path. Suppressing an optional insight is the recoverable failure; a second introduction to a real person is not. This is a dependency on ADR-0126 Open question #3, and PR 3 cannot ship message 2 before it closes.
It is worth being explicit about why this seam is new. The two messages now carry different
OutboundMessageKinds and different countability grades by this ADR's own decision (see below) — which is exactly the condition under which their gates evaluate differently and one can pass while the other is refused. Fused, the question could not arise.Priority is
emergencyorhigh, or the description trips the existing safety triage (gas, flooding, no heat, burning smell, sparking). A tenant with a gas smell gets escalation, not a checklist.The playbook does not carry
selfServeOverSms: true— a new, explicit, opt-in field onPlaybook. Catalog-sourced is not automatically safe-to-text; an author decides per row, and the default is silence.A handyman visit is already confirmed for a window today. Standing someone down for a truck already dispatched is worse than the truck roll.
Quiet hours, consent, and the touch budget apply — as for any outbound. See the countability note below: message 2 is a separate send, so this is a real gate that can refuse it, not a formality inherited from the ack.
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 firstContact — introducedInBody, 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.
The confirmation question is graded
transactional, notoutreach. 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.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.
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-2057under 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.