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.