ADR-0105 — Auto-publish a unit's listing when a tenant files notice (NTV)

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:

  1. 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 via setUnitAvailability (best-effort precondition), then calls setUnitListingPublished({ published: true, force: true }). It never throws; it returns a discriminated status.
  2. A new Temporal activity, publishListingOnNtvActivity, mirroring relistTurnoverUnitActivity exactly (heartbeat → read turnover → hook → echo status; long-timeout + 30s-heartbeat proxy for the two real L4 writes).
  3. Wired into TurnoverWorkflow immediately after recordTurnover, before the durable move-out timer — so the listing goes live during the notice period, not after move-out — behind a new patched() marker.
  4. A new per-property flag, Property.autonomousListingPublishEnabled, defaulting to unset/false.

Publish-timing rationale (the key design decision)

Two hook points were considered:

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.ts fails 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:

  1. Global auto-publish arm — the durable listingAutoPublish field on the CONFIG / RENEWAL_ARMS DDB row, falling back to LISTING_AUTOPUBLISH_AUTONOMOUS_SENDING=armed. REMOVED 2026-08-07.

    Superseded 2026-07-26 (PR #4667). This originally rode the turnover domain's turnoverSending arm 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 mirror turnoverSending, so the split changed no behaviour.

  2. Per-property auto-publish opt-inProperty.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

Failure handling (never silent)

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

What remains to arm Camellia

Nothing ships armed. To turn this on for Camellia an operator must:

  1. Ensure the global auto-publish arm is on. No longer applicable — the global arm was removed 2026-08-07.
  2. Set Camellia's autonomousListingPublishEnabled = true (a single per-property flag write — mirrors how autonomousHoldoverEnabled was 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).