0155 — One write door for every PMS, with an append-only receipt log; grants never reach the kill switch; a PMS calendar is read-only inside the engine

Context

Every write to a property-management system leaves Propflow through one of three writer factories in src/lib/domain/pms/writer-registry.ts: getPMSWriterForProperty (leases, units, charges, renewals), getPMSGuestCardWriterForProperty (guest cards) and getPMSWorkOrderWriterForProperty (work orders). Each already makes the mode decision, and the work-order one takes the act's subject, so an emergency work order gets the real writer in every mode (ADR-0149).

What happens AFTER the call is not recorded in one place, and the answers are AppFolio-shaped:

Decision

  1. One door. Every write to a PMS goes through writeThroughDoor (src/lib/domain/pms/write-door/). The door does NOT decide mode: it takes the writer one of the three factories resolved, and a refusing writer's refusal becomes the receipt's reason. It adds, in order: the hold check, the connection-capability check, the marker policy, the adapter call, and a WriteReceipt. One copy of the mode rule stays in capabilitiesFor, so an emergency work order is never refused by the door.

  2. A receipt says only what happened. WriteReceipt.status is one of:

    • applied: the PMS shows the write, and the receipt carries the read-back evidence. A 200 alone is never applied.
    • accepted: the PMS took the request and the confirmation comes later (by the sync's match-back, see 5).
    • unconfirmed: the request may have landed and nothing proves it either way. Never re-sent blindly: a redrive first looks up its marker.
    • refused: the PMS (or the adapter, before sending) said no. Carries failure: { kind, retryable, detail }.
    • not_written: nothing left Propflow, with a reason: preview, off, mode_unresolved, held, not_supported, unproven, test_property_guard, test_call, binding_mismatch, expired.

    The three factories' refusal words map onto those reasons once, in the door: module_off / leasing_off / maintenance_module_off → off; module_preview / leasing_preview / maintenance_preview → preview; module_unresolved / leasing_mode_unresolved / maintenance_mode_unresolved → mode_unresolved; TestPropertyRealPmsWriteRefusedError → test_property_guard.

  3. The receipt log is a new append-only table, one row per status EVENT. Key: capability # connection # idempotencyKey, plus a zero-padded sequence. The current receipt is the latest event. A write that moves accepted → applied, or unconfirmed → applied after a marker lookup, gets a new row; nothing is overwritten. An event that says what the latest one says (the same status and, for not_written, the same reason) is not appended (one event per status change). A write the log shows as applied or accepted is never touched again, by a send or by a refusal; a refusal leaves a sending or unconfirmed write as it is, so it is still looked up once the building writes again. Just before the adapter call the door appends a sending event, so a process that dies between the call and its answer leaves a trace: a write whose latest event is sending is read as unconfirmed (amended 2026-11-04, from the review of this ADR).

    • connection is the stored binding's connection when the building has one (connectionFor, R 1.7: <provider>:<instance>), else the provider and the account the adapter acts on (AppFolio: appfolio:<database subdomain>). It is computed by one function in the door so a redrive always finds its own rows.
    • idempotencyKey is the key the adapter already sends (AppFolio: work-orders:note:<noteId>, work-orders:attach:<messageId>), so the receipt and the robot's own cache name the same write.
    • No person. A receipt is about a write, not a human, so it carries no personId: a Clara note on a work order with no reporter still gets its receipt. Like Appliance and VendorCompany, it is outside spine-stamp.
    • PmsWriteIntent stays the guest-card queue. It is not grown into the log: it is a mutable queue row that needs a person and an inquiry. It reads its status from the door.
  4. Each record's push status is a copy only the door writes. Outbox state, a work order's push state and a note's or photo's appfolioSyncStatus are mapped from the receipt by one door-exported function; a fence test fails on any other writer. Deviation from the plan's wording, deliberately: the plan says "in the same transaction". saveWorkOrder is an unconditional whole-row put shared by every work-order writer, and folding a receipt into it would restructure the one save every pipeline uses. Instead the log is written FIRST and is the truth; the record copy follows; and the door reads the latest receipt before every send, so a record that missed its copy (a crash between the two writes) converges on the next flush without a second send. A reader that needs certainty reads the log.

  5. Confirmation by the sync goes through the door. For a capability whose adapter confirms viaSync, the sync engine's match-back calls confirm(ref, evidence), which appends applied. WorkOrderSyncStatus is NOT split (amended 2026-11-04, lane coordinator; supersedes the plan's "split into a push state and a mirror state" wording): a split changes what is stored on every work order, which needs either a backfill or a read that accepts both, and neither is taken. The one enum stays, and its writers are fenced instead. The door is the only writer of the PUSH transitions (unsynced/syncing → awaiting_confirm, unconfirmed, synced, sync_failed, parked); the sync's match-back is allowed exactly two moves: removed_in_pms, and synced reached through the door's confirm(ref, evidence). A fence test lists the allowed (writer, status) pairs and fails on any other; the writers that are not behind the door yet are named on it as a shrink-only ratchet, each row pointing at the plan row that moves it (R 3.5). No stored value changes, no migration, no dual read.

  6. Marker policy before any retry. An adapter capability declares how a write is found again: inRecord (a marker written into the record), naturalKey (e.g. a lead's property and email) or idempotentVerb (assign, cancel: safe to repeat). After a timeout, an ambiguous failure, or a sending event with no answer after it, the door looks up the marker first and never falls back blind; with no marker, or a lookup that cannot tell, the write stays unconfirmed; an adapter reports "may have landed" by throwing WriteUnconfirmedError, and an adapter whose transport replays a same-key request (AppFolio's robot for notes) may leave a timeout as a retryable refusal, since re-sending IS its marker lookup; a write may move to another transport (API → robot) only after a refusal that proves nothing was sent.

  7. Any write can be held. The hold rail (held-pms-write.ts, approve-held-pms-write.ts) learns one generic payload, { capability, request, idempotencyKey, dependsOn? }, and a person's approval runs it back through the door, which then skips the hold check for that one approval. Which modes hold is unchanged by this ADR: the door asks the existing writesHeldForApproval and routes to the existing rail. Gera's 2026-10-30 ruling (Supervised: PMS writes go) is tracked outside this plan.

  8. Grants and the kill switch. The robot's writePolicy stays an emergency kill switch only (Gera, robot #639): it is out of reach of grants and of caller configuration, and with it on, everything is blocked except the requests a driver lists as read-only and its declared login and MFA class. Robot grants (R 1.11) sit beside the door: the door asks for a grant per write, never for a way around the switch. Tenio enforces grants from day one; AppFolio runs log-only until R 3.6, so no AppFolio login or write changes because of this ADR.

  9. A PMS calendar is read-only INSIDE the calendar engine. Fede's 2026-10-05 decision stands: a PMS calendar is read and written. Reads go through the calendar engine like Google or Outlook. Writes never go through the engine (its write methods refuse for a PMS provider): a tour write is appointment.create | update | cancel on the outbox, executed by this door, and the tour stores the appointment ref from the applied receipt. The envelope carries dependsOn, and the drain merges a create with its cancel so neither is sent.

Byte-identical for AppFolio. Until a capability's slice lands, AppFolio behaviour is unchanged. The first AppFolio callers (work-order notes and photos) change in exactly two ways: a photo whose upload the robot could not read back is unconfirmed (settled, alerted, not re-sent) instead of synced, and a note the robot sent but could not read back (WRITE_UNCONFIRMED) is unconfirmed instead of synced (see Consequences). Each slice proves the rest with the pipeline's golden replay.

A piece of code violates this ADR if it writes to a PMS without going through the door (after its capability's slice), if it sets a record's push status anywhere but the door's mapper, if it calls a write applied without read-back evidence, if it re-sends an unconfirmed write without a marker lookup, if it re-decides mode in the door, or if it lets a grant reach the robot's kill switch.

Entity classification

Entity Class Naming Spine trace
PmsWriteReceipt canonical bare name None: a receipt is about a write, not a person (precedent: Appliance, VendorCompany). Keyed by capability, connection and idempotency key; append-only.

Consequences

Alternatives considered