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
- Status: Accepted
- Date: 2026-11-04
- Deciders: Gera (the PMS-agnostic plan, docs.propflowai.co/a/pms-agnostic-architecture-2026-11, chapter 4, tab A7 and the settled rows of tab D; Fede's 2026-10-05 calendar decision is carried, not reopened)
- Plan rows: R 1.8a (the door and receipts), R 1.8b (a Tenio lead through the door), R 1.9 (the hold rail's generic write envelope). Later rows that build on this one: R 1.10b (calendar writes), R 1.11 (robot grants), R 2.4 (first Yardi writes), R 3.5 (the bypass writes move behind the door).
- Builds on: ADR-0149 (an emergency is never a mode question), ADR-0144 (one switch registry), ADR-0143 (the held-write rail), ADR-0030 (the PMS owns work-order ids).
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:
- The work-order writer's
attachDocumentandaddNotereturnvoid. The AppFolio robot answers a photo upload withverified: falsewhen its read-back could not be made ("the upload stands on the hops' answers; re-read before re-driving"), andintegrations/appfolio/work-order-writer.tsthrows that answer away: the photo is markedsynced. - A note's "could not confirm" comes back from the robot as an error indistinguishable from a refusal, and the dispatch retries it. The robot's
append_remarksis not idempotent, so a retry after a write that landed stacks a second copy. - The guest-card outbox (
PmsWriteIntent) has its own state machine and requires a person and an inquiry; work orders carryWorkOrderSyncStatus, notes and photos carryappfolioSyncStatus, immediate writers carry nothing. A second PMS (Tenio now, Yardi next) would have to learn all four.
Decision
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'srefusalbecomes the receipt's reason. It adds, in order: the hold check, the connection-capability check, the marker policy, the adapter call, and aWriteReceipt. One copy of the mode rule stays incapabilitiesFor, so an emergency work order is never refused by the door.A receipt says only what happened.
WriteReceipt.statusis one of:applied: the PMS shows the write, and the receipt carries the read-back evidence. A 200 alone is neverapplied.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. Carriesfailure: { 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.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 movesaccepted → applied, orunconfirmed → appliedafter a marker lookup, gets a new row; nothing is overwritten. An event that says what the latest one says (the same status and, fornot_written, the same reason) is not appended (one event per status change). A write the log shows asappliedoracceptedis never touched again, by a send or by a refusal; a refusal leaves asendingorunconfirmedwrite as it is, so it is still looked up once the building writes again. Just before the adapter call the door appends asendingevent, so a process that dies between the call and its answer leaves a trace: a write whose latest event issendingis 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. LikeApplianceandVendorCompany, 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.
- connection is the stored binding's connection when the building has one (
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
appfolioSyncStatusare 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".saveWorkOrderis 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.Confirmation by the sync goes through the door. For a capability whose adapter confirms
viaSync, the sync engine's match-back callsconfirm(ref, evidence), which appendsapplied.WorkOrderSyncStatusis 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, andsyncedreached through the door'sconfirm(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.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) oridempotentVerb(assign, cancel: safe to repeat). After a timeout, an ambiguous failure, or asendingevent 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 staysunconfirmed; an adapter reports "may have landed" by throwingWriteUnconfirmedError, 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.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 existingwritesHeldForApprovaland routes to the existing rail. Gera's 2026-10-30 ruling (Supervised: PMS writes go) is tracked outside this plan.Grants and the kill switch. The robot's
writePolicystays 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.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 | cancelon the outbox, executed by this door, and the tour stores the appointment ref from theappliedreceipt. The envelope carriesdependsOn, 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
- One table to redrive from, to count parked writes at a founder go (R 2.4), and for the exit test (R 3.9: every write kind through the door).
- Every adapter answers with evidence or says it has none. A writer that returns nothing gets
accepted, neverapplied. - An extra read of the receipt log before each send through the door.
appfolioSyncStatuskeeps its stored name: renaming it to a neutralpmsPushStatusis a data migration (every stored note and photo carries it) and is its own step, not part of this ADR's slices.- A note's "could not confirm" has a distinct robot answer since appfolio-browser-agent #655/#656:
ok:truewithdetail.writeStatus: "WRITE_UNCONFIRMED"isunconfirmed(settled, paged, never re-sent blind);ok:false(NOT_APPLIED,UNEXPECTED_RESPONSE) means nothing was written and is retried as before; and the robot replays a same-key note for 24 h, so a retry after a lost answer cannot stack a duplicate. (Until #655 landed, the interim rule read everyUNEXPECTED_RESPONSEon a note as unconfirmed; it never shipped.)
Alternatives considered
- Grow
PmsWriteIntentinto the log. Rejected: it is a mutable guest-card queue that requires a person and an inquiry; work-order notes have neither, and a mutable row cannot holdaccepted → applied. - One receipt row per write, updated in place. Rejected (plan round 10): it cannot show
accepted → appliedor a redrive, and an update loses the evidence of the earlier state. - A fourth factory that re-decides mode. Rejected (plan rounds 8 and 9): two copies of the mode rule drift, and the copy without the subject would refuse an emergency work order.
- The receipt and the record status in one DynamoDB transaction. Deferred, not rejected: see Decision 4. The log-first order plus the door's read-before-send gives the same outcome without restructuring
saveWorkOrder.