Reservation Health — Implementation Plan
Status: P0–P3 built (engine + 3 nightly crons + both validate scripts folded), tests green, validated against a prod dry run (430 lockouts → checks tuned, see below). [ops] alert rule + deploy pending.
Prod dry run (2026-06-02, read-only, validate-new-lockouts --production)
430 active lockouts. First pass flagged 183 monthly-missing-billing-fields — a false-positive flood. Investigation + tuning:
- Removed monthly-missing-billing-fields/-anchor — startTime is null until activation (vault); billingStartDate is null for ~43% of actively-billing lockouts (Cherry City migration artifact). Not a reliable invariant.
- Grandfathered price drift → INFO (was WARN) — intentional legacy pricing, not a failure.
- Crons skip INFO when alerting (per spec, INFO never alerts).
Final profile: 390/430 healthy, 2 actionable FAILs (lockout-no-payment-method ×1, lockout-transfer-incomplete ×1), 23 past_due WARN (visible, non-paging), 18 grandfathered INFO (silent). Trustworthy alert volume.
Implements spec.md. Builds a reservation-invariant engine and the nightly cron(s) that run it fleet-wide and alert on violations.
Goal
Encode "what a healthy reservation looks like" once, run it nightly across the fleet, and alert (Sentry → watched channel) on any FAIL/WARN. Consolidate the invariant logic currently scattered across the audit scripts — reservation-validate-lockout is the most complete prototype and serves as the de-facto spec for the lockout battery.
Settled decisions
- Cadence: nightly. No minute/hourly pressure; relieves Stripe-rate and timeout concerns.
- Granularity: multiple crons, split by cost/dependency. Not one mega-cron — so a Stripe-heavy check can't starve a cheap DB check, and each route gets its own
maxDurationbudget. - Pattern:
reconcile-access-codes. Query →Sentry.captureMessage({ level, tags, extra })per breached invariant → wrap inSentry.withMonitor(missed-run detection) →runWithAuditContext({ source: 'cron:...' })→CRON_SECRET/QStash auth + preview-skip.
Resolved decisions
- Source of truth — fold
validate-lockout/validate-new-lockoutslogic intoReservationHealthService; rewrite those scripts as thin callers. One implementation of each invariant. - Alert routing — cron emits Sentry
warningissues taggedfeature:reservation-health. Aaron owns a Sentry issue-alert rule on that tag → emailaaron@metrognome.comon failures. The[ops]task is his. - Fleet scan — start full every night. Fleet is ~500 total reservations; lockouts (the Stripe-touching subset) are far fewer. Window later only if
maxDurationever bites.
Execution model
[code] = agent-buildable in-repo. [ops] = Sentry/Vercel config (human or me-on-console).
P0 Engine + DB-only cron ──▶ P1 Billing checks ──▶ P2 Transfer checks ──▶ P3 Consolidate scripts
(ReservationHealthService, (Stripe-touching, (transfer correctness (validate-* call the
data dimension, 1 cron own cron, 300s) + completeness, own engine; MCP tool is
end-to-end + alert routing) cron) a later separate plan)
P0 — Engine + DB-only cron (done; proves the whole path)
Goal: one nightly cron running the cheap DB invariants across the fleet, alerting end-to-end, with the engine shape that P1–P3 extend.
[code] ReservationHealthService skeleton ✅ done 2026-06-02
apps/api/src/services/health/ReservationHealthService.ts,types.ts,index.ts.HealthCheck={ id, dimension, appliesTo(reservation), run(reservation, deps) → Finding[] }.Finding = { check, dimension, severity: 'FAIL'|'WARN'|'INFO', message, context }.ALL_CHECKSregistry;runForReservation(id)andrunFleet({ where, checks, deps })load reservations once (viareservationHealthInclude) and dispatch applicable checks.runFleetreturns only reservations with findings.- Prisma + Stripe injected via
HealthCheckDepsso the engine is testable without live Stripe.
[code] Data-dimension checks (DB-only, no Stripe) ✅ done 2026-06-02
apps/api/src/services/health/checks/data-checks.ts:
monthly-missing-billing-fields←reservation-audit-missing-billing(data half).monthly-unlinked-subscription←reservation-audit-unlinked-subs.monthly-dead-subscription— terminal cachedstripeSubscriptionStatus(webhook-maintained; lags only on a missed webhook, which the P1 Stripe check catches authoritatively).paid-payment-missing-stripe-linkage←audit-payments-no-stripe-linkage(zero-amount comps excluded).hourly-missing-acuity←reservation-audit-acuity-drift(WARN).- (mgId integrity left to existing
audit mgid-integrity, not re-ported.)
[code] Cron route reservation-health-data ✅ done 2026-06-02
apps/api/src/app/api/cron/reservation-health-data/route.ts, mirrorsreconcile-access-codes(auth, QStash, preview-skip,withMonitor). Read-only — no remediation.- Runs the data checks fleet-wide; aggregates findings per check; one
captureMessageper breached check,levelerror(FAIL) /warning(WARN),tags: { feature: 'reservation-health', dimension, check },extra: { count, mgIds, reservationIds }. apps/api/vercel.json: cron0 8 * * *+maxDuration: 60.
[code] Tests ✅ done 2026-06-02 (23 passing)
- Check unit tests (
data-checks.test.ts) + engine dispatch tests (ReservationHealthService.test.ts, Prisma/Stripe mocked). - Cron route test (
tests/api/cron/reservation-health-data.api.test.ts): auth, per-check aggregation, level/tags, clean-fleet → no alerts.
[ops] Alert routing (Aaron — pending)
- Sentry issue-alert rule:
feature:reservation-health→ emailaaron@metrognome.comon new issues. Not yet created; the cron is inert as an alarm until it exists.
[ops] Deploy (pending)
- Ships on merge.
CRON_SECRET/ QStash already configured for sibling crons; no new env. Verify the first nightly run fires (Sentry cron monitorreservation-health-data).
P1 — Billing checks (Stripe-touching) (done 2026-06-02)
One consolidated lockout-billing-health check does a single memoized sub-retrieve and emits findings with distinct codes.
- ✅
lockout-subscription-unreadable— sub can't be retrieved → FAIL. - ✅
lockout-subscription-status— terminal status FAIL,past_dueWARN (Stripe-authoritative; complements the DB cached check). - ✅
lockout-subscription-metadata—purchaseType/reservationIdwrong → FAIL. - ✅
lockout-no-payment-method— no effective default PM → FAIL. - ✅
lockout-price-mismatch— sub price vs effective PM ←reservation-audit-pm-gapviaCheckoutService.calculateMonthlyCheckoutPrice(paying wrong-PM price → FAIL; grandfathered/legacy → INFO, non-alerting). - ⏸ Deferred: explicit
billing_cycle_anchorconsistency check and the ADR-008 setup_intent-vs-invoice-flow check (reservation-audit-setup-intent) — not yet ported.
[code] Cron route reservation-health-billing ✅
- Nightly
0 9 * * *,maxDuration: 300. Sequential per-lockout (one memoized retrieve each).tests/api/cron/reservation-health-billing.api.test.ts.
P2 — Transfer checks (Stripe-heavy) (done 2026-06-02)
- ✅
lockout-transfer-config—metadata.locationIdpresent + matches reservation location + location has a connected account → FAIL otherwise. (Pure; reuses the memoized sub.) - ✅
lockout-transfer-incomplete— latest paid invoice's charge has a transfer to the connected account. Resolves invoice→charge via the app'sinvoicePaymentspath (StripeWebhookService), checkscharge.transferthen thetransfer_grouplookup; conservative skip if the charge can't be resolved. - Design change: dropped the
transfer_data.amount_percentreverse-math check (stripe-audit-sub-transfers) —transfer_datais the legacy model (validate-lockoutflags it INFO-only); modern subs transfer via thecharge.updatedwebhook, so amount_percent would false-alarm. Completeness is the authoritative signal. - ⏸ Deferred: anchor-poached payment detection (
audit-lockout-anchor-poached-payment); only the latest paid invoice is checked (recent-payout health, not full history). - Engine hardened: a throwing check becomes a
<id>-errorFAIL finding instead of aborting the fleet scan.
[code] Cron route reservation-health-transfers ✅
- Nightly
0 10 * * *,maxDuration: 300.tests/api/cron/reservation-health-transfers.api.test.ts.
P3 — Consolidate + investigative surface
[code] Rewrite validate-lockout over the engine ✅ done 2026-06-02
- Engine exposes
evaluateReservationHealth+createSubscriptionLoaderso callers own their Stripe/Prisma clients (scripts must — the app singletons bind to env at import, before script-runner loads env).reservation-validate-lockoutnow fetches with script-runner IO and runs the engine battery; invariant logic single-sourced. Verified against local HOURLY + MONTHLY reservations. - ✅
reservation-validate-new-lockoutsfolded — now a thin fleet runner over the engine (keeps the--this-month/--unbilled/ all-active scoping + per-reservation reporting; bespoke fee-math replaced byevaluateReservationHealth). This is the prod-dry-run vehicle.
MCP investigative tool — separate plan
- A read-only
check_reservation_healthtool on the staff MCP that calls the engine for one reservation — the "drill into the alert" surface. Tracked in mcp-server, not here.
Out of scope
- Closed-loop auto-remediation (this is detect-and-alert; fixes stay manual scripts).
- Access dimension (owned by
reconcile-access-codes). - Webhook health (owned by
sweep-stuck-stripe-webhooks). - Schema changes (none).