0139 — Gatekeeper: one switch per module, and OFF means nothing from PropFlow reaches AppFolio for it

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

Open items:

Alternatives considered