Skip to content
All incidents
medium2026-05-01duration weeks (Build 1900 introduced the 2→5 raise; Build 1949 fixed the four drift paths; SQL migration applied 2026-05-02)

Free-tier signup granted 13 songs/month instead of the published 5

New free-tier signups were silently receiving 13 songs/month (3 base + 10 soft-launch promotional bonus from a long-retired migration) instead of the 5 songs/month claim published on /pricing, /scoring/standard, and homepage copy. Three other paths (self-heal profile insert, referral fallback, schema column default) had their own stale defaults — 2, 3, and 1 respectively. Four code paths, four different wrong numbers, none of them matching the documented free-tier amount.

Impact

New users got more than they were promised — over-grant, not under-grant. No user was harmed in the direction of receiving less than the published amount, but the trust contract between published claims and live behavior was broken: anyone signing up between Build 1900 (when free was raised 2→5) and Build 1949 (when this was fixed) experienced a different reality than what the marketing said. Cumulative duration: weeks. Detected by an operator-requested audit ("check that the correct number of songs is credited for FREE accounts") that ran across all songs_remaining write paths.

Severity

medium

Prevention

Two structural changes. (a) The four hardcoded defaults (schema DEFAULT 1, /api/me self-heal hardcoded 2, /api/referral fallback hardcoded 3, soft-launch trigger hardcoded 13) all now read from TIER_LIMITS.free.songs in src/lib/constants.ts. Future free-tier reprices propagate through one constant. (b) A new migration (supabase/migrations/fix_free_tier_default_5.sql) supersedes the soft-launch trigger so the database-level default agrees with the application-level default. The migration is idempotent + non-destructive (existing user balances unchanged; only future signups land at 5).

Timeline

  • **Build ~100s** (long ago): the soft-launch migration (`add_referral_and_soft_launch.sql`) set the `profiles.songs_remaining` column DEFAULT to 13 (3 base + 10 promotional bonus) and updated the `handle_new_user` trigger to grant 13 songs to every new auth.users row. The promotional period ended; the trigger was never reverted.
  • **Build 1900** (~weeks ago): the FREE_TIER constant in `src/lib/constants.ts` was raised from 2 → 5 songs/month. Pricing page, homepage, FAQ, and scoring-standard copy all updated to reflect "5 songs/month." The SQL trigger and column default were NOT updated; the four code-side fallbacks were NOT updated.
  • **Build 1949** (2026-05-01): operator question — "check that the correct number of songs is credited for FREE accounts (no free 10)" — triggered an audit across every write path that touches songs_remaining for free-tier users. Audit found four divergent defaults:
  • `supabase/schema.sql:15` — column DEFAULT 1
  • `src/app/api/me/route.ts:120` — self-heal hardcoded 2
  • `src/app/api/referral/route.ts:122` — fallback hardcoded `?? 3`
  • `supabase/migrations/add_referral_and_soft_launch.sql` — trigger granting 13 No path granted exactly 5. Initial operator concern was over-grant ("free getting 10"); actual state was worse drift.
  • **Build 1949 (same day)**: shipped the fix. Schema default → 5; new migration `fix_free_tier_default_5.sql` overrides the trigger to grant 5; `/api/me` reads from `TIER_LIMITS.free.songs`; `/api/referral` fallback reads from the same constant.
  • **2026-05-02**: operator confirmed the SQL migration applied to production Supabase.

Root cause

The contract for what "free tier" means lived in five places:

1. The published copy ("5 songs/month" on /pricing, homepage, FAQ). 2. The application-level constant (`TIER_LIMITS.free.songs` in `src/lib/constants.ts`). 3. The Supabase schema column default. 4. The SQL trigger that creates the profile row on auth.users insert. 5. Multiple in-code fallbacks for self-heal + referral redemption paths.

When the value at (1) + (2) was raised in Build 1900, the values at (3) + (4) + (5) were not. Each fallback location had its own historical default — 1, 2, 3, 13 — that pre-dated the raise and was never re-pointed at the central constant.

The deeper root cause: there was no single source of truth for the free-tier grant. Five places, five values, no test asserting they agree. The B1949 fix collapses all four code paths to read from `TIER_LIMITS.free.songs`; the new migration aligns the database default. Future reprices flip one constant.

Detection

Operator audit, not an automated alert. The system has no telemetry that says "newly-signed-up free users have songs_remaining ≠ 5"; if there were one, this would have fired on every signup since Build 1900.

Mitigation

The B1949 commit shipped immediately. The SQL migration was applied the next day. No data loss occurred; existing user balances were left untouched (the over-grant is honored as a courtesy — no user retroactively loses songs they had already received).

Prevention

1. **Single source of truth**: `TIER_LIMITS.free.songs` is now the canonical value. Schema default, trigger body, self-heal fallback, referral fallback all read from it. 2. **New migration shipped**: `fix_free_tier_default_5.sql` is idempotent + safe to re-run; future deploys always converge to the right value. 3. **The fix landed across the same B1949 commit** so no intermediate state was visible to users.

What this incident reveals about discipline

Build 1900 raised free tier from 2 → 5. The operator's intent was clear; the engineer (me, on a prior session) updated the application-level constant and the published copy. The database-level state was not part of the changeset because the project's working assumption was "the application reads its own constants." That assumption fails when a SQL trigger writes a profile row before the application sees the user.

The audit cadence (Trust Decay Audit every 14 days) caught the corpus-version mismatch in B1665. It did not catch this drift because the audit walks public claims against shipped surfaces; it did not historically walk database defaults against constants.

A future audit improvement: `scripts/check-free-tier-grant.ts` that runs in CI, queries the profiles schema for the column default, and asserts it matches `TIER_LIMITS.free.songs`. The next drift trips the alarm at PR time.

Broader takeaway

We publish "5 songs/month." For weeks, new users got 13. They got more than promised, not less, so the harm was muted. But the trust contract is between what we say and what we do. If we say 5 and we do 13, the contract is broken in either direction. Both directions matter.

The receipts page lists 11 trust artifacts. This postmortem is the receipt for the drift this audit found.