ADR-0105 — Auto-publish a unit's listing when a tenant files notice (NTV)
- Status: Accepted
- Date: 2026-07-20
- Deciders: Fede
- Related: ADR-0034 (turnover workflow), ADR-0044 G/R18 (
setUnitAvailabilityre-list hook, Slice 8), PR #4206 (PMSWriter.setUnitListingPublisheddual-surface port), appfolio-browser-agent #255 (/api/post-vacancy)
Context
When a tenant files a notice to vacate (NTV), the durable TurnoverWorkflow
(ADR-0034) already fires: it records the turnover, computes
targetReadyDate = moveOutDate + turnoverPolicy.unitAvailabilityDays (default 7
days), holds on a durable move-out timer, and — once the make-ready work is done
and the turnover reaches "Ready to lease" — writes the unit's marketing
Available On date via the setUnitAvailability port (ADR-0044 Slice 8, the
fireReadyForListing hook).
Setting Available On is the syndication precondition, not the post itself.
Making the vacancy actually go live requires flipping AppFolio's independent
Posted-to-Internet and Posted-to-Website switches. Until now that final
step has been manual (or the PM/admin-triggered POST /api/listings/publish
route). Nothing auto-posts a listing.
Fede's ask: when a tenant files NTV, automatically publish the unit's listing
(internet + website, matching) with a buffer for getting the unit ready per the
property's turnover policy. The buffer is exactly the unitAvailabilityDays
already encoded in targetReadyDate: the ad goes live now, during the notice
period, advertising a move-in date that already accounts for make-ready time.
Why market during the notice period (domain grounding)
The PM-domain make-ready research (~/.claude/pm-domain-knowledge/turnover-make-ready-process.md)
puts the NTV moment first in the canonical seven-stage turn and is explicit that
pre-booking and early action during the notice period is "the single
most-cited lever to compress total days vacant." Industry average total
vacancy (key-return → re-lease) is ~34 days; every extra day is ~$66 of lost
rent (Rent Ready). Listing during the notice period — rather than waiting until
the unit is physically ready — is the marketing analogue of vendor pre-booking:
it starts the leasing funnel (inquiries, tours) while the turn is still in
flight, so a qualified applicant can be lined up for the buffered move-in date.
The same doc flags the tension we encode below: fresh make-ready photos don't exist yet at NTV time (photography is stage 7, leasing handoff). A listing published at NTV is therefore "thin" — it rides the prior tenancy's photos, or none — until the quality walk produces new ones. That is a deliberate, PM-visible trade-off, not a defect (see Quality gate).
Decision
Add a new, disarmed-by-default, per-property capability that publishes the
unit's listing to both surfaces (internet + website, in sync) at NTV
time, with the listing's Available On date set to targetReadyDate (the
policy buffer). It reuses the just-merged setUnitListingPublished port
(PR #4206) — no new port is invented.
Concretely:
- A new best-effort domain hook,
firePublishOnNtv(turnover)(src/lib/domain/turnover/auto-publish-listing-hook.ts), that: checks the arm, resolves the PMS unit identity, sets Available On = targetReadyDate viasetUnitAvailability(best-effort precondition), then callssetUnitListingPublished({ published: true, force: true }). It never throws; it returns a discriminated status. - A new Temporal activity,
publishListingOnNtvActivity, mirroringrelistTurnoverUnitActivityexactly (heartbeat → read turnover → hook → echo status; long-timeout + 30s-heartbeat proxy for the two real L4 writes). - Wired into
TurnoverWorkflowimmediately afterrecordTurnover, before the durable move-out timer — so the listing goes live during the notice period, not after move-out — behind a newpatched()marker. - A new per-property flag,
Property.autonomousListingPublishEnabled, defaulting to unset/false.
Publish-timing rationale (the key design decision)
Two hook points were considered:
- (a) Publish at NTV time, with Available On =
targetReadyDate(listing live early, markets during the notice period). ← chosen. - (b) Publish when the turnover reaches "Ready to lease" (the current
fireReadyForListinghook point; the unit is provably ready).
We chose (a). It is what Fede asked for ("publish when someone files NTV,
with a buffer"), it matches the domain best-practice above (early marketing
compresses days-on-market), and it matches AppFolio's own model: Available
On is the ad's advertised move-in date, so publishing early with
Available On = targetReadyDate shows prospects an honest, buffered move-in
date while the turn completes. Option (b) leaves the whole notice period —
often 30–60 days — un-marketed, which is the vacancy cost the feature exists to
avoid.
Option (b) is noted as a supported policy point but NOT built (one path,
well-tested). The workflow already re-writes Available On to the actual
ready date at "Ready to lease" via the existing fireReadyForListing hook
(Slice 8), which naturally refines the buffered date to reality once the
unit is done — so the two hooks compose cleanly (publish + buffered date at NTV;
date refined at ready). A future property that prefers "only publish when
provably ready" could gate firePublishOnNtv at the ready-for-listing point
instead; the hook is point-agnostic. We are not speculatively building both
paths.
Arm / gating model (mirrors the holdover gate)
AMENDED 2026-08-07 — the global arm was REMOVED. This shipped two-factor and the global half was never armed, so the hook ran in pure shadow for every property: Camellia sat with five vacant units unposted while the hook logged "would publish" on every NTV. That is precisely the failure mode CLAUDE.md hot rule 13 ("no arms by default") names, and the same call already made for the four prospect follow-up arms (#4694) and the maintenance send-gate (2026-06-11). Control now lives in ONE place: the per-property flag. A drift pin in
arms-registry-coverage.test.tsfails CI if the global arm is reintroduced. The section below is kept for history.
Publishing a listing is autonomous outbound action, so it is fail-closed and
per-property gated (listing-publish-gate.ts). A publish fires IFF:
Global auto-publish arm — the durableREMOVED 2026-08-07.listingAutoPublishfield on theCONFIG / RENEWAL_ARMSDDB row, falling back toLISTING_AUTOPUBLISH_AUTONOMOUS_SENDING=armed.Superseded 2026-07-26 (PR #4667). This originally rode the turnover domain's
turnoverSendingarm on the reasoning that auto-publish is a turnover-domain action. In practice that meant arming turnover COMMS also armed listing PUBLISHING for any opted-in property — two different blast radii behind one switch, invisible at the point of arming. The arm was split and the durable field backfilled to mirrorturnoverSending, so the split changed no behaviour.Per-property auto-publish opt-in —
Property.autonomousListingPublishEnabled(NEW).
Deliberately DECOUPLED from autonomousTurnoverEnabled — the flag that gates
the turnover workflow's charges + tenant comms — exactly as
autonomousHoldoverEnabled is decoupled from autonomousRenewalEnabled. A
property can auto-publish its vacancies WITHOUT running autonomous turnovers.
This is the crucial property: Camellia (turnover autonomy off, Willows-only)
can be armed for auto-publish by flipping ONLY autonomousListingPublishEnabled
— it does not drag Camellia's whole turnover pipeline live. (The turnover
workflow's internal transitions — recordTurnover, scope, WO dispatch — already
run for every property regardless of the arm, so the workflow reaches the
auto-publish command on a turnover-disarmed property; only the arm-gated
side-effects like charges/comms stay off.)
This PR arms nothing. Both factors default to disarmed/false. Go-live is an operator action (global arm + flip the property flag), never a code deploy.
Idempotency
- The workflow guards the publish command with
patched('turnover_slice10_autopublish_ntv_v1')and a per-activity idempotency key, so a Temporal replay does not re-issue it. - The hook's writes are keyed by the turnover id
(
autopublish-avail:<turnoverId>,autopublish-listing:<turnoverId>; the adapter appends:internet/:websiteper surface).setUnitListingPublishedis idempotent on the target posted-state — re-posting an already-posted listing returnsalreadyApplied, never churns syndication.
Failure handling (never silent)
setUnitListingPublishednever throws on a per-surface PMS failure — it returns per-target outcomes. The hook maps a one-surface failure tostatus: 'partial'carrying the failed targets, and logs alogWarn. A PM therefore sees "internet posted, website failed" rather than a collapsed success. The workflow records the status on its snapshot (autoPublishStatus) for ops visibility.- The
setUnitAvailabilityprecondition write is best-effort: if it fails, the hook logs and still attempts the publish (withforce, AppFolio clicks through the missing-date warning), so a transient availability-write blip doesn't block the listing.availabilityAppliedis surfaced so the PM knows whether the buffered date landed. - The whole hook is wrapped best-effort and returns
status: 'error'(never throws) on an unexpected fault; a missing turnover row is the only thing the activity propagates (retryable), matchingrelistTurnoverUnitActivity.
Quality gate (thin-listing warning)
At NTV time the unit has no fresh make-ready photos. force defaults to true,
so the runner clicks through AppFolio's pre-post warnings (e.g. "unit has no
photos") and returns them per-surface in forcedThroughWarnings. The hook
collects these and surfaces them on the outcome, and the activity logs them, so
a PM can see that a listing posted thin. We deliberately publish thin rather
than block on photos: the alternative (wait for photos) defeats the
compress-days-on-market goal, and a thin-but-live listing still opens the
inquiry funnel. A property that doesn't want thin posts leaves the arm off.
Unpublish story (NTV cancelled / tenant stays)
If a tenant rescinds notice or the turnover is cancelled, the early-published
listing should come down. The TurnoverWorkflow has no NTV-cancelled /
turnover-cancelled signal today (its only signals are workOrderCompleted,
inspectionCompleted, chargeConfirmed). Per scope discipline we do not
invent one in this PR. The unpublish path today is the existing PM/admin
POST /api/listings/publish route with { published: false }, which pulls the
listing down on both surfaces via the same port. When a cancel/rescind signal is
added to the turnover domain (future ADR), an auto-unpublish activity (a
firePublishOnNtv-shaped call with published: false) can hang off it —
noted here as the natural extension, not built.
Verification plan
- Unit tests: the gate (all three factors, fail-closed permutations), the hook (disarmed / unsupported / no_ref / published / partial-target-failure / availability-write-failure-still-publishes / thin-listing forcedThroughWarnings / error), and the activity (heartbeat-before-write, echo, missing-turnover propagation).
- Workflow test:
turnoverWorkflowcallspublishListingOnNtvActivityonce, before the move-out timer, and only when itspatched()marker is on; recordsautoPublishStatuson the snapshot. - Live harness (
scripts/canary/auto-publish-ntv-e2e.ts): drives one synthetic NTV → workflow → publish on Willows (appfolio-45, a TEST unit) only, arms auto-publish for Willows just for the run, verifies the listing posted via the runner's read of the "Posted to the Internet" datapair, then unpublishes + disarms. Hard-gated NEVER-Camellia and reversible; dry-run by default,--liveto actually publish.
What remains to arm Camellia
Nothing ships armed. To turn this on for Camellia an operator must:
Ensure the global auto-publish arm is on.No longer applicable — the global arm was removed 2026-08-07.- Set Camellia's
autonomousListingPublishEnabled = true(a single per-property flag write — mirrors howautonomousHoldoverEnabledwas armed). This is now the ONLY step.
Camellia's autonomousTurnoverEnabled stays off — auto-publish is decoupled
from it. Absent either factor, the hook NOOPs in shadow (logs the intent, no PMS
write). A worker redeploy is required first so the ECS renewal worker picks up
the new activity code (same as any workflow change).