Skip to main content

ADR 0028 — Prepaid Hours: Soft Exhaustion Gate, Distinct from Suspension

Status: Accepted Context date: 2026-07-27 Amends: ADR 0026 §3 and §4

Context

ADR 0026 deliberately built no second access-control path: its reconciler computed one blocked/allowed verdict per managed tenant — delinquency or hour exhaustion — and drove ADR 0025's suspension machinery for both. For a prepaid balance, that is the wrong product behavior:

  1. Suspension blocks login. A tenant whose hours run out is locked out of the dashboard at the exact moment they need it — they cannot see their balance, cannot see why they were cut off, and cannot reach the top-up flow. The enforcement designed for "banned" was answering the question "out of credit".
  2. Suspension sweeps live rooms. Running out of hours mid-call hard-terminated every ongoing session — a destructive response to a recoverable quota, indistinguishable from an operator ban.
  3. The two causes deserve different verdicts. Delinquency ("you owe us money and won't pay") is an administrative state where full suspension remains correct. Exhaustion ("your prepaid block ran out") should stop new consumption while leaving the tenant able to observe, wind down, and pay.

The suspension path stays exactly as ADR 0025/0026 built it — this ADR carves hour exhaustion out of it.

Decision

1. The reconciler's verdict splits: delinquency suspends, exhaustion soft-gates

In admin/gateway/internal/paymentrecon/paymentrecon.go, blocked is now delinquency only. The suspension path (SetStatusForPayment, suspend_cause='payment', reasons payment_delinquent/payment_restored, payment_status mirror into billing_db) is unchanged for delinquent tenants. Hour exhaustion (hoursExhausted(): prepaid balance ≤ 0, postpaid balance ≤ −cap) no longer touches auth_db at all — no status change, no login block, no room sweep. Exhausted tenants keep full dashboard and API access except where §3 gates them.

2. Transition-only hours events through the existing outbox, deduplicated by notify_state

The reconciler classifies every non-delinquent managed tenant's balance as ok, low (0 < balance ≤ LOW_HOURS_THRESHOLD_MINUTES, default 60), or exhausted, and emits an event only on a state transition:

  • tenant.hours_low — balance dropped into the warning band.
  • tenant.hours_exhausted — balance crossed the plan's exhaustion rule.
  • tenant.hours_restored — a top-up (or usage-definition change) brought it back to ok.

Events ride the same rails as everything else: written to the auth_db outbox (emitHoursEvent, source admin-gateway, key tenant_id, occurred_at stamped from the auth_db clock), relayed onto platform-events, payload enriched in the same transaction with tenant name/slug, plan type, balance_minutes, granted_minutes, and admin_emails — the ADR 0024 IDs-plus-data stance.

Dedup lives in billing_db: tenant_subscription.notify_state (ok | low | exhausted, migration 004_notify_state.sql) records the last announced verdict, so each crossing warns once, not every 60-second tick. Ordering is deliberate — outbox row first, notify_state update second, across two databases without a distributed transaction — so a crash between the two duplicates a warning on the next tick rather than losing it. Warnings are at-least-once by construction.

3. Room service: a targeted 402 gate on the projection — live rooms are never swept

Migration 007_tenant_hours.sql adds hours_exhausted (bool) and balance_minutes (bigint) to the room service's existing tenant_status projection. The tenantstatus consumer projects all three hours events — tenant.hours_exhausted, tenant.hours_restored, and tenant.hours_low (which clears hours_exhausted: a topped-up-but-still-low tenant exits exhaustion via the low event, not restored) — under a dedicated hours_event_at ordering guard, separate from the lifecycle events' event_occurred_at so the two independent state machines can never suppress each other's events. Consumer-side, envelope timestamps are clamped to now-plus-small-skew so a forged far-future timestamp cannot wedge either guard. Redelivery semantics match the status path.

The gate (hoursGate in services/room/internal/api/api.go) is targeted, not blanket:

  • createRoom and joinRoom only return HTTP 402 with error code hours_exhausted, plus remaining_minutes and a top_up_hint so clients can render a useful paywall instead of a dead end.
  • Everything else stays open — list/get, leave, moderation, recording stop — so in-flight calls wind down gracefully and dashboards keep working while exhausted.
  • Live rooms are deliberately not swept. A session running at the moment of exhaustion finishes on its own terms; the overrun lands as a negative balance — visible in the ledger and the dashboard, recovered by the next top-up.

Fail-open matches the suspension gate: a missing projection row (unmanaged tenant) or a DB error allows the request, counted by the alertable metric room_hours_gate_fail_open_total.

4. Trial seeding: every provisioned tenant starts payment-managed

Billing gains a second platform-events consumer (group billing-subscriptions, services/billing/internal/subs/subs.go): every tenant.provisioned event seeds, in one transaction (SeedTrialSubscription), a tenant_subscription row (prepaid) plus an hour_grants row of TRIAL_GRANT_MINUTES (default 120) with reason='trial_grant' and idempotency_key = the provisioning event id — Kafka at-least-once redelivery can never double-grant, and the subscription upsert is a no-op for an already managed tenant.

The consumer starts at FirstOffset (the shared consumer default), so tenants provisioned within Kafka retention are retroactively enrolled. Accepted, not accidental: recent signups receiving a trial grant is correct product behavior. Tenants older than retention stay unmanaged — ADR 0026's no-row-no-enforcement rollout guarantee holds for them.

5. Notifications and the tenant dashboard

  • tenant.hours_low and tenant.hours_exhausted → tenant in-app + email (to the payload's admin_emails; email kinds tenant_hours_low / tenant_hours_exhausted).
  • tenant.hours_restoredin-app only — good news does not need an email.
  • The tenant dashboard's Billing page (admin/dashboard/src/app/billing/page.tsx) shows an Available Hours card: remaining/granted/consumed hours with an ok/low/exhausted badge; unmanaged tenants see Unlimited.

Why ADR 0026's "one enforcement path" rule is relaxed

ADR 0026 §4 rejected a room-side balance check precisely because it would be a second access-control path. This ADR relaxes the letter of that rule while keeping its substance: the soft gate is a second flag on the existing path, not a second path. Same transport (platform-events via the auth_db outbox), same projection table (tenant_status, with its own hours_event_at ordering guard alongside the lifecycle guard), same fail-open posture with the same style of alertable counter. No synchronous cross-service call was added, and the room service still owns no balance data — it projects a verdict computed where the data lives.

What forced the split is a product distinction the suspension machinery cannot express: 402 (recoverable quota — pay and continue, dashboards keep working) versus 403 (administrative ban — locked out, rooms swept). One status bit cannot carry both meanings, and overloading it was the bug.

Consequences

  • Positive: Exhausted tenants can log in, see their balance, and top up — the recovery loop the suspension approach severed. Live calls end gracefully; the bounded overrun is visible as a negative balance instead of a dropped call.
  • Positive: Warnings fire once per crossing (notify_state machine), through the existing outbox/notification rails; no new topic, table pattern, or delivery mechanism.
  • Positive: Every self-serve tenant is now payment-managed from birth with a trial block, idempotently seeded off tenant.provisioned — payment control stops being opt-in-per-tenant for new signups.
  • Behavioral (migration): Tenants the pre-split reconciler suspended for exhaustion are auto-reactivated on the reconciler's next tick — exhaustion no longer computes as blocked, so the suspend_cause='payment' activate branch releases them into the soft gate. Delinquency-suspended tenants stay suspended.
  • Negative / accepted: A delinquent tenant's notify_state freezes (the notify machine is skipped to avoid a confusing double message), so an hours transition that happens during delinquency is announced only after delinquency clears — on the first post-clear tick, from whatever state was last recorded.
  • Negative / accepted: The overrun past zero is bounded by live-session duration × participants + one reconciler tick — larger than ADR 0026's sweep-based bound, by design. It is ledger-visible and recovered on top-up.
  • Negative / accepted: Hours warnings are at-least-once — the outbox-first ordering means a reconciler crash between outbox and notify_state re-sends the same warning next tick. Duplicate emails are the accepted cost of never losing an exhaustion notice.
  • Follow-up (unchanged from ADR 0026): Sessions that never emit session.ended under-meter consumption; the balance is optimistic until the metering follow-up lands. The soft gate inherits, not worsens, this.
  • Follow-up (unchanged from ADR 0026): The forgeable-Kafka-boundary risk now also covers hours events — an in-network producer forging tenant.hours_restored reopens the gate. Same remediation track: SASL/mTLS with per-service produce ACLs on platform-events.