0139 — Gatekeeper: one switch per module, and OFF means nothing from PropFlow reaches AppFolio for it
- Status: Accepted
- Date: 2026-09-28 (amended 2026-09-29: drafts, §9; amended 2026-10-01: the middle mode is named Supervised, §9; amended 2026-10-10: one short ack while a reply is held, §9)
- Deciders: Gera (ruling, 2026-09-28); deciders b89856042 and f0e573d99 (disclosure), f1fb25777 (one switch), b90643493 (Gera's go on the data move)
- Related: ADR-0134 (a guard binds to the resource, here the building), ADR-0136 (the one settings page), ADR-0126 (the dispatcher owns send obligations)
Context
Customers onboard one product at a time. A company may want Clara on renewals while its own team
keeps maintenance, or may need to pause a module mid-contract. Before this work there were two
overlapping answers to "is PropFlow doing X here?": the company's enabledModules and a per-building
Property.capabilityStage. The places that act (the tools, the queues, the sagas, the send door,
the AppFolio writers) did not all ask either one, so a module "off" in settings could still reach a
resident or write into AppFolio. The fixes cited below each close one such path.
Decision
1. One switch per module per customer. The modules are maintenance, leasing.prospects,
leasing.renewals and collections. Every reader asks resolveModuleEntitlement
(src/lib/domain/modules/resolve-module-entitlement.ts): the company's enabledModules, with a
building-level override.
2. OFF means PropFlow stops everything for that module at that building. Clara and every piece of automation stop, and nothing from PropFlow reaches AppFolio for that module, staff send-now included. The customer's own team takes the work over. Emergencies always get through. "That building" is the RESIDENT's, not the line's (decider fb927aa83): on a company or front-door line the front door's Maintenance-off read and the send door both ask the resident's own building.
3. Only a stored OFF refuses. A read that cannot resolve (missing row, failed load) behaves as
today. An outage in the settings read must never switch a customer's product off. The text door applies
this to every lane (2026-09-29; it had been a posture only Clara's reply carried). A send that declares a
module but names neither a company nor a building still holds: that is an incomplete declaration, not
a failed read. When the company cannot be read, a building whose own row stores OFF still refuses.
Superseded at the text door 2026-10-30 (Gera, direct ruling on the path-mode audit, finding A1): "when
a module's mode can't be read, a routine send FAILS CLOSED (held); emergencies still go." The text door
no longer sends on an unresolved read: it holds the text as module_unresolved, the same answer the
email and voice doors already gave and the one capabilitiesFor('unresolved', routine) gives in
module-mode.ts. An emergency subject passes before any read (ADR-0149), so an outage never silences
an emergency text. Readers outside the send doors that still cite this section (tool handlers,
activities) are brought onto the one rule by their own PRs.
4. Disclose, do not go silent. A resident or vendor who reaches PropFlow about an OFF module gets a
fixed "this is not handled here" line pointing at the team (DISCLOSURE_RULES,
src/lib/domain/maintenance/module-disclosure-rules.ts; deciders b89856042, f0e573d99). The wording
is fixed, not generated.
Amended 2026-10-30 (Gera's mode model, "OFF = entirely off", via the path-mode audit 2 coordinator):
the vendor and handyman form of this line is retired. A vendor or handyman texting about a job at
a building whose Maintenance is OFF gets nothing: the one rule (capabilitiesFor, module-mode.ts)
no longer carries the named Off exception #23832 gave it (MODULE_OFF_DISCLOSURE_SUBJECT, deleted), the
vendor-lane reply declares Maintenance so the send door refuses it at Off, and the relay to the
handyman asks Maintenance's mode like any handyman text. Where Maintenance is ON (a job refused under
the turned-on rule) the line still goes under that mode's own row. The resident-facing disclosure
(maintenance_module_off) is not changed by this amendment.
Amended 2026-10-27 (Gera, rent-reply-fail-closed; his 2026-10-26 per-module release rule: "an unreleased
topic is silent or held, never sent"): a Clara reply whose reply-time topic is collections (money the
resident owes) at a building where Collections is OFF is held on the existing held queue for a person,
not sent (supervised-reply-rule.ts, rule R1-off). Approve declares Collections at the send door, so by §9 it
still refuses there while OFF: the hold is a card a person reads and answers from the thread. There is no
fixed Collections disclosure line yet. Whether the resident should get the §4 disclosure instead of a
held reply is open for Fede (who owns the reply layer). Only a resident's reply is held for OFF, and a
reply the router sent down another Supervised module stays held under that module.
Amended 2026-10-27 (Gera, rent-reply part 4, #22972 and its follow-up): a reply with no mapped topic
(other) follows who is writing (R9-role). A prospect's sends only where Leasing is Live, and a
vendor's only where Maintenance is Live; otherwise it is held under that module. A resident's sends (Resident
Services, Live by default until #22935 lands). An unknown sender's reply is held even where every module is
Live, and with no topic verdict as well. It waits under the first module at the building that is Live or
Supervised (Leasing, then Maintenance, Renewals, Collections), so approving it can send. If every module is Off,
it waits under Leasing, and by §9 approving it closes plainly ("Not sent: Leasing is Off"). This narrows the
2026-10-04 "general replies are not held" ruling to residents and teammates. Risk flags, and a person already on
the thread, hold as before.
5. A fixed set of kinds is always on: login_code, emergency, team_alert, staff_reply
(ALWAYS_ON_KINDS, src/lib/domain/modules/always-on.ts). Nothing else bypasses the switch.
6. A refusal is not a failure. A module-off refusal is classified SOFT_MODULE_OFF
(src/lib/domain/wo-tool-log/soft-outcome.ts) and does not count toward the tool failure-rate alarm.
7. Turning a module back on is not a replay. The switch-on moment is stamped, and only work started after it is picked up. Anything from before stays with the team (#13405, #13645, #13742).
8. There is one switch. Property.capabilityStage is retired (decider f1fb25777, #13549); its 73
buildings (every one Maintenance off) were moved onto a building maintenance.module = off row on
Gera's go (b90643493, 2026-09-29, 73/73 read back). No code on main reads it.
9. Drafts is a third setting, stored as a marker beside the switch (Gera: off, drafts, live;
Amended 2026-10-01 (Gera): the middle mode is shown to people as Supervised (Off · Supervised · Live),
and every module gets it, one module per PR as its hold path lands; the stored value stays 'drafts'
and the gate stays draftsAllowedFor (now in src/lib/domain/modules/drafts-allowed.ts).
decision b90675488; design docs.propflowai.co/a/gatekeeper-drafts-mode-2026-09). The stored
true/false stays the only on/off answer; mode: 'drafts' sits next to it on the building's
<module>.module row and in Organization.settings.moduleModes, and counts only while the module
resolves ON. So every OFF refusal above is unchanged: drafts is a kind of ON. resolveModuleStateForOrg
answers off | drafts | live | unresolved, building first, then the company; unresolved is treated
as today (§3), so when neither the building nor the company can be read as drafts the reply sends as live (a company drafts marker still applies if only the building's row cannot be read). In drafts, a prepared
item is held on the existing held-message queue (EscalationHold) instead of sent; approve sends it
through the normal send door declaring its module, so a module switched OFF since the hold refuses
there. An approved draft is Clara's words on Clara's row (triggerActor: 'agent'), never
staff_reply, so approving does not trip the human-replied hold. Once a thread is escalated or a person has
replied on it, its pending drafts close unsent (Driver ruling, 2026-09-29, following Gera's rule that
once the team takes a thread the team has it): approve still goes through the send door, the escalated
and human-replied gates refuse it as they refuse any Clara send, and the draft is closed with "Not
sent. The team has this thread now." rather than raised as a failed send. An approved draft never
passes the escalated gate, and the escalating turn's own reply is still held. Amended 2026-10-02 (Driver ruling,
lane B, #15161 review): one carve-out — an approved automatic tour receipt (the T-24h and T-1h tour
reminders and the lost-slot notice, Leasing Supervised) is an earned receipt a team member approved, so it
still sends on an escalated thread, exactly as it does in Live (earnedReceipt). Clara's own drafted
replies still close unsent on an escalated thread. Before any approved re-run the tour workflow re-asks
the leg's own checks (its scope checkpoint, and for the lost-slot notice whether the slot is still gone),
and a run stopping on an enforced scope refusal withdraws every held text. Extended 2026-10-02 (Driver ruling,
#15302 review): the same carve-out covers the after-call tour receipt (the confirmation, request or
cancellation text after Clara's own call) — Live already sends it on an escalated thread as an earned receipt,
and here a team member approved it, so the approved re-run still sends. Its household copies go out only when
the principal's own text did. The always-on kinds (§5) are never
held. Part 11.1 ships maintenance SMS replies only: a reply the topic check calls maintenance is held
as clara_reply_sms; untopiced replies are not held; the resident hears nothing while it waits (amended
2026-10-10, Gera's D13: a resident who texts AGAIN while an earlier reply is still held gets ONE fixed, fact-free
acknowledgement per held thread, held-reply-ack.ts; the door's backstop lets exactly that text through, Off
still refuses); a draft
expires after 24 hours (Gera, 2026-09-29), closed on approve rather than sent; a newer draft on the same
conversation withdrawing the older pending one is its own 11.3 PR. Drafts holds replies to RESIDENTS only (superseded: Supervised holds every recipient, Gera 2026-10-04,
and the 2026-10-27 §4 amendments add prospect, vendor and unknown-sender holds) (Driver
ruling, 2026-09-30): a turn from the company's own staff or a vendor is never held and sends as today.
Which building decides (Driver ruling, 2026-09-30): the building of the conversation the turn landed
on (the resident's home) whenever it is known, on any line, since a company's shared or front-door line
maps to one anchor property; the texted line's building only when the conversation names none; the
company alone when neither is known. The reply carries that building as its own field
(entitlementPropertyId), and the send door resolves the module ONCE from it: both checks, the OFF
hold and the drafts backstop, read the resident's building (decider fb927aa83, option c), so the hold
and the door always agree. By §2 a resident whose own building is OFF is refused as OFF even on a line
whose building is Live (always-on kinds and the disclosure exempt, no drafts hold; a maintenance reply the
door refuses there pages the team, never silent), and one whose building is Live is texted even on an OFF
line. The building that picks the carrier is unchanged. The send-door
backstop (11.3): the text door refuses any resident-bound maintenance-declared text at a drafts
building that carries no approval id, records it as a module_drafts refusal and pages (logCritical):
reaching the door means a lane missed its hold, and the door has no thread to build a reviewable draft
from. Router acks (state_first_rating, state_first_resident_answer), and replies the topic check
misses on a turn the front door declared maintenance, are therefore refused, never sent live. Only the approve send carries the id (pinned in
text-send-module-fence.test.ts); it never outranks OFF; the always-on kinds, 'none', and sends
declared 'staff' or 'vendor' never reach the read; an unreadable setting sends. Still not held:
a reply the front door declared as no module (default_lane_clara, low_confidence_split → 'none')
that the topic check misses or times out on — it sends live, because the door never sees a module on it;
lanes that declare 'none' (the rating request, the work-order-created notice, vendor relays to the
resident — 11.3b), maintenance email and calls (11.4), and write tools (11.2). Known gap, safe
direction: who the sender is comes from the router's resolution, so staff texting from a number that is
not their current one on file (an old number, knownStaffByOldContact in inbound-router.ts, or one we do
not have) are treated as residents: Clara's replies to them are held for approval on the hook path, or
refused and paged at the door when the topic check misses on a turn declared maintenance. Never sent live. The script refuses
--enabled drafts for any module but Maintenance. Before arming a customer building or company: a
real-path SMS test at a test building; and topic-check recall (#14143) — measured 2026-09-30 on 35
maintenance and 26 non-maintenance SMS replies (scrubbed from prod, filled out with synthetic everyday
cases), recall is 34/35 (97.1%) and precision 100%, but the everyday categories score 16/17 (94.1%, below
95%), and prod had zero real resident-facing maintenance replies from the agent loop in the prior 30
days, so real-world recall is unmeasured: re-harvest and re-measure once the first test building runs in
drafts. The one miss, the thank-you after a maintenance rating ("Thanks so much for the 5! We'll pass
that along to the tech."), is a router ack and is refused by the backstop (pinned in
module-gate-sms-drafts-backstop.test.ts). Three writers set it, all refusing Drafts for any module
but Maintenance: scripts/set-building-module.ts --enabled drafts (a building), the back-office company
Modules card (#13909, PropFlow platform administrators only), and the staff Buildings table
(PATCH /api/settings/buildings, #14174, an explicit mode per building). A PropFlow staff member
setting Drafts through any of them IS the turn-on for that company or building (a prod census before
#13906 merged found 0 companies and 0 buildings in drafts). Actions (11.2), automation (11.3b), email
(11.4) and the fold of the two company review switches (11.5, a data move needing a go) are later parts.
Where it is enforced
Every layer asks the same resolver. No layer trusts that an earlier one already refused.
| Layer | What refuses | Evidence |
|---|---|---|
| AppFolio writers | The per-building PMS work-order writer, and the module-off writer | #13402 |
| Admin Tools catalog | A write whose module is OFF, for every module, via TOOL_MODULE (src/lib/tools/tool-modules.ts) |
#13444, #13510 |
| Tool handlers and the action gate | Each write tool asks the switch before acting; a syntax-tree test proves every write tool reaches its own module's refusal, so a new ungated write is red | #13438, #13637, #13693, #13743 |
| Send door and promises | Sends declare their module; a promise whose module is OFF is recorded, not sent or paged | #13638, #13647 |
| Queue workers and sagas | Renewal jobs queued before the switch-off send nothing; a prepare timeout at an OFF building ends quietly | #13707, #13710 |
| Front door, voice and replies | Intake, email, identify_caller and voice routing hand OFF work to the team with the disclosure; the reply-module map is defined in the deployed bundle |
#13459, #13484, #13562, #13634, #13661, #13756, #13761 |
Consequences
- A new write tool, queue or send path must declare its module. The action gate turns a new ungated write red, so the rule is mechanical, not a review habit.
- "Off" is safe to use for a customer who wants a module paused: the team sees no PropFlow write in AppFolio for it, and residents are told where to go rather than hearing nothing.
- Because only a stored OFF refuses, a broken settings read fails open to today's behaviour. That is deliberate. The cost is that the switch is only as reliable as its stored value.
Open items:
- Drafts mode is decided (§9) and ships per module in parts; 11.1 covers maintenance SMS replies only. Until 11.2 and 11.3, maintenance write tools and automation still act live at a drafts building.
- The settings page comes last. The customer-facing control lands after the enforcement layers, on ADR-0136's one settings page.
- Staff-desk tools are not in
TOOL_MODULE. They are outside the action gate until they are added. - In flight, not yet merged: everyday Clara replies declaring their module (#13779); the leasing refusal not reading as a failed booking (#13580); the voice agents past Triage honouring Maintenance off (#13695). The two voice-prompt PRs wait on a test caller at an OFF building.
Alternatives considered
- Keep
capabilityStagebeside the module switch. Not chosen: decider f1fb25777 made the module switch the one switch. Two switches that can disagree is the problem this ADR removes. - Go silent when a module is OFF. Not chosen: b89856042 and f0e573d99 rule that Clara answers and discloses. Silence leaves a resident or vendor assuming PropFlow is handling it.
- Refuse on any unresolved read (fail closed). Not chosen, because a settings outage would switch every customer's product off at once.
- Pick up the backlog on switch-on. Not chosen, because work from the OFF period belongs to the team, and picking it up would handle it twice.