2.6.1
Released
Nothing in this release changes what you see; it is all under Technical changes.
Technical changes· 4 entries for database, tooling, CI, tests and docs
Database
apply_subscription_state()now records the Stripe event id itself, in the same transaction as the write it guards, and returns false when that id was already recorded. Recording it from a separate statement — as the webhook did — left a window in which a process killed between the record and the write leaves a record with no work behind it, and every Stripe retry then reads as a duplicate: a subscription that is paid for, never granted, and reports no error anywhere. A crash now rolls back both (#486).apply_subscription_state()takes a row lock on the owner's profile before it writes. Two events for different subscriptions of the same account each derived the tier from a snapshot that could not see the other's uncommitted rows, so whichever committed last won — a newactivesubscription racing an old one's cancellation could leave the account on Free while Stripe charged for Pro (#486).Added
profiles.plan_tier_source, recording whether the current tier was granted administratively or by billing, and taught reconciliation to revoke only what billing granted. The billing panel already surfaced accounts "granted Pro directly rather than through a subscription", and without an owner recorded on the tier the next lifecycle event for such an account — an abandoned checkout goingincomplete, a retry of an old cancellation — computedfreeand silently revoked the comp.set_user_plan_tier()marks the tier administratively owned, and the existing self-service guard now covers the new column, so an account holder cannot re-label a billing grant as permanent (#486).Seeded the two free-tier model personas (
free-router,free-gpt-oss-20b) from a migration instead ofsupabase/seed.sqlalone.20260816120000_ai_plan_tiersgavemodel_personas.required_tiera default of'pro'but added no row the free tier covers, so a database that was migrated without the seed being replayed — production — ended up with every persona behind Pro and every profile on the default free tier. That is not a degraded AI experience but no AI at all: the model picker renders an empty list (and nothing whatsoever withNEXT_PUBLIC_FEATURE_PAID_MODEL_UPSELLon, sinceModelPersonaPickerreturns null when the tier covers no selectable persona), whileresolveEntitledPersona()returns null and quest narratives and task investigations answer 422 "No AI model persona is available". Catalog rows the product cannot run without belong in the migration history, where every environment gets them exactly once. Both copies areon conflict do nothing, so the seed and the migration compose.