ADR-069: Trial-cancel semantics — free at trial end, guarded in SQL at all four activation writers
Date: 2026-07-02 Status: Accepted (shipped in P2b-b; designed by the second P2b build-time panel — 3 lenses, SHIP-WITH-FIXES — which amended the plan’s sketch; see plan §4.2)
Summary
Section titled “Summary”Cancelling a trial before it converts never bills, even when the job that activates trials runs late. Scheduling a cancel with at_period_end on a trialing subscription ends it at trial end with no invoice, and an immediate cancel of a trial that never activated is also free. An explicit cancel_at must be the trial end or on or after the end of the first paid period; a date in between returns 400. All four places that activate a trial check for a pending cancel inside their SQL UPDATE, so a cancel that commits during activation is never billed. Status: active.
Context
Section titled “Context”Canceling a trial before it converts was broken three ways: the schedule
flag’s meaning on a trialing sub was undefined (Hardcount’s
current_billing_period_end while trialing is the end of the first paid
period — unlike Stripe, where it equals the trial end), cancel_at values
before period-end were rejected outright (making cancel-at-trial-end
inexpressible), and the four trial-activation writers — the wall-clock
scan, the clock-catchup scan, operator EndTrial, and the billing engine’s
trial branch — would activate and bill a sub whose cancel committed
between their snapshot and their UPDATE. That last one is the original audit
bug, re-armed on a timer.
Decisions
Section titled “Decisions”AtPeriodEndon a trialing sub means cancel FREE at trial end (Stripe semantics). It stays a flag — never eagerly converted to a pinnedcancel_at— soExtendTrialmoves it with the trial.cancel_aton a trialing sub has exactly two legal values:trial_end_at(free cancel via the guard) or≥ current_billing_period_end(activate, bill period 1, cancel at its close). The open interval between them is a 400 naming both boundaries — no machinery honors a date inside it, while GET would have echoed it.- Guards live in SQL, not just Go. Both activation UPDATEs carry
AND cancel_at IS NULL AND cancel_at_period_end = false; a blocked activation returns the typeddomain.ErrTrialCancelDueand the caller routes. Go-level pre-checks remain as fast paths; the SQL predicate is the atomicity. CancelAtTrialEndis a dedicated CAS transition: trialing→canceled,canceled_at = trial_end_at(never the observing site’snow— the engine path fires up to a full interval late), schedule fields cleared, no invoice (trials are free). The CAS predicates on the observedtrial_end_atand a schedule actually due, so aClearScheduledCancelorExtendTrialcommitting in the gap wins — a customer who rescinded or was extended is never terminated off a stale snapshot (domain.ErrTrialCancelConflict= not handled this pass).ScheduleCancellationCAS-es on the status the intent was validated under. The flag is status-polymorphic; landing a “free at trial end” intent on a just-activated sub would silently invert the promise. Drift → 409, re-read.- Winner fires once. Terminal emissions —
subscription.canceledwithcanceled_by=schedule,reason=trial_end_cancel, plus anAuditActionCancelrow — fire only from the CAS winner, via one shared service helper (three service-side sites) and one engine helper. Deliberately nosubscription.trial_ended(consumers read it as “billing begins” and would provision a canceled sub) and no invoice. - The engine never bills on an activation conflict. The pre-fix branch fell through to normal billing with its stale trialing snapshot on ANY activation error; once concurrent scans can cancel, that meant a full cycle-close invoice + advanced watermark on a terminal sub. Now it re-reads and bills only a genuinely-active row.
- Day-1 in_advance billing rides the activation transaction at every
site. The clock-catchup scan and the engine branch previously billed
after the flip, WARNing the fee “will be deferred” — false: the cycle
scheduler skips the just-elapsed segment, so the fee was simply lost.
Both sites moved onto the
WithBillcoordinator-tx shape. EndTrial409s on any pending schedule (“clear the scheduled cancel first” — no silent precedence);ExtendTrial409s on a pending explicitcancel_at(extending past a pinned timestamp strands it; nothing fires inside a running trial). Both enforced at service level for fast feedback AND in the store UPDATEs’ WHERE for atomicity.- Immediate
Cancel()of a never-activated trial is a write-off. During-trial cancels were already free; the post-trial-lag window (trial elapsed, activation scan behind, stillstatus=trialing) used to bill prorated base + usage for a sub that never activated. Decided: no invoice (activated_at IS NULL ∧ trial_end_at IS NOT NULLguard) — matching the “you won’t be charged” promise rather than billing on scan lag the customer can’t see. cancel_effective_at— a derived, read-only field on the subscription JSON and thecancel_scheduledevent payload (trialing+flag → trial_end; active+flag → period_end; else cancel_at), computed at the store’s scan choke point. Both existing UI surfaces showed the wrong date by re-deriving from the flag; they now read this.
Accepted residuals
Section titled “Accepted residuals”The spec’s “one shared helper across all four sites” is implemented as one
service helper + one engine helper with verified-identical vocabulary — the
zero-peer-import rule forbids the engine importing the subscription service;
drift risk is future-facing and covered by the winner-once tests. OpenAPI
documentation for schedule-cancel / end-trial remains fully outstanding —
api/openapi.yaml documents neither endpoint today; it belongs to the
pre-existing OpenAPI-backfill deferral, not this change.
The UI-time race (dialog open while the trial expires) is inherent; the handler surfaces the real post-write state and the status-CAS closes the API-side window. A crash between the CAS commit and the post-commit webhook loses that one event — consistent with the repo-wide dispatcher posture (no Dispatch-in-tx; 19 justified instances, ADR-062 deferred).
Test locks (mutation-verified)
Section titled “Test locks (mutation-verified)”Real PG: TestActivateAfterTrial_SQLGuardBlocksScheduledCancel (predicate
stripped → activates), TestCancelAtTrialEnd_CASSemantics (CAS stripped →
the rescinded customer gets terminated),
TestScheduleCancel_RacesActivation_ExactlyOneMeaning (forbidden
active+trial-flag state unreachable). Unit: scan routing (one canceled,
zero trial_ended, zero bills), boundary matrix, EndTrial/ExtendTrial
guards, engine no-bill regression (single-route mutation stays green —
defense in depth — double-route mutation fails), write-off both timings.