Questify

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 new active subscription 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 going incomplete, a retry of an old cancellation — computed free and 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 of supabase/seed.sql alone. 20260816120000_ai_plan_tiers gave model_personas.required_tier a 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 with NEXT_PUBLIC_FEATURE_PAID_MODEL_UPSELL on, since ModelPersonaPicker returns null when the tier covers no selectable persona), while resolveEntitledPersona() 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 are on conflict do nothing, so the seed and the migration compose.