Questify

2.8.0

Released

Features

  • Running out of AI credits degrades instead of stopping. resolveEntitledPersona() is now credit-aware, and exhaustion is deliberately the same shape as a plan downgrade — the persona is substituted at generation time and the quest's stored persona is never rewritten, so a quest configured for Opus survives a flat balance and comes back on Opus when the period resets. It is deliberately not the same message: an inherited persona is substituted silently, an explicit request for a model the balance will not cover returns 402 saying so, and neither tells a paying subscriber to upgrade. The substitute is a free persona rather than merely a cheaper one, because at a balance of zero a 1-credit persona is equally unaffordable and picking one would turn exhaustion into a database error instead of a story that gets written. Image count is priced in, since images are a surcharge on the persona weight and a ten-image investigation is 3.3x a text one (#493).

  • The balance is visible. The top bar carries a credit chip — remaining actions rather than a credit balance on the free tier, which has never had credits and nothing it can do costs one — the persona picker shows what each model costs in the same unit the chip counts down, and an account that has actually run out gets a banner that leads with what still works rather than with the failure. Never a modal: exhaustion is not an outage, and interrupting someone to tell them their work will continue is worse than a quiet chip. A Pro account is offered no upgrade link, because there is nothing above Pro to upgrade to (#493).

  • The free tier's daily cap is consumed before the provider is called, unlike the credit charge, which is booked after — a rate limit that fires once the request has gone out has not limited anything. Every coach turn counts toward it, not only the charged first one, because the cap is about how many requests reach the shared key and a twenty-turn interview is twenty requests whatever it cost in credits. Reading the balance fails open, which is the opposite of the plan-tier read and worth stating: a tier read failing closed shows a paying user free models until it recovers, while a balance read failing closed would tell a subscriber with 380 credits that they are out of them (#493).

  • The worker records what an investigation really cost. usageTokenCounts() normalises the three provider shapes in play — Anthropic and OpenAI's Responses API report input_tokens, every OpenAI-compatible /chat/completions provider reports prompt_tokens — and writes them onto the single newest unreconciled entry for that investigation — a re-queued row has one entry per attempt and made one provider call per attempt, so writing to every match would stamp one call's counts across all of them and double the measurement. Nothing reads those columns to decide anything; they exist so the credit weights, which began as estimates from prompt shapes and rate cards, can be replaced with measurements. A provider that reported nothing usable records nothing rather than recording zero, because a ledger full of zeroes reads exactly like a very cheap model. chargeAiCredits() draws the same distinction on the way in: it returns null when a charge could not be booked and 0 only when the action genuinely cost nothing, so an account that slipped the meter is not indistinguishable from one on a free model (#492).

Fixes

  • initiated_by_id no longer decides who pays or whose rate limit applies. It is an ordinary client-writable column — "Owners can insert investigations on their tasks." checks user_owns_task(task_id) and says nothing about who the row claims initiated it — and both credit triggers read coalesce(new.initiated_by_id, auth.uid()). Against the new daily cap that let a free account name a Pro account, whose cap is NULL, and skip it. Against the charge trigger shipped in #492 it was worse: the named account is the one debited, so an owner could spend a stranger's credits or dodge their own charge by pointing the column at somebody else. Both now use auth.uid(), and nothing legitimate is lost — that policy resolves to quests.owner_id = auth.uid(), so the inserter is always the owner and the two expressions already agreed for every write the policy permits. One consequence is deliberate and now asserted: a service-role requeue charges nobody, because the worker never writes pending (it claims pending and stalled running rows into running), so such a write can only be one of our own retries (#493).

  • No coaching interview had ever been charged. An interview is billed once, on its first turn, and #492 decided which turn that was by looking for an assistant message in the history the client sends — but the client seeds that history with a static opening message, so the test was false on the very first request. The predicate could not be repaired, only moved: the history is the client's to write, so "already paid for" is a claim it makes rather than a fact the server holds. public.coach_interviews is now that fact, created and charged in one statement, so an interview that cannot be paid for rolls back and leaves no id to replay, and an id the server did not issue is treated as a new interview and charged as one (#493).

  • Narrative and coach turns are charged before the provider is called, and refunded if it fails. Reading the balance without reserving any of it meant two requests made at once both saw enough, both called the provider, and the loser's charge came back 42501 and was swallowed — so the ceiling was something a burst of parallel requests could walk through. Debiting first is what investigations already do and what docs/pricing.md prescribes. chargeAiCredits() now reports three outcomes rather than a number that might be null, because "cost nothing", "balance will not cover it" and "database unreachable" need three different answers and the first two were being conflated (#493).

  • Credit exhaustion degrades to a genuinely free persona rather than any affordable one. With a credit left, a <= balance search picked a 1-credit paid persona and called that degradation — the substitution then depended on the exact balance, the next action failed anyway, and neither the comment above it nor the pricing doc described that behaviour (#493).

  • free_tier_ai_actions prunes its own expired rows. Rows outside the trailing 24 hours can never affect a count again, and were being kept forever at up to 30 a day per active free account. Deleted before counting, under the per-user advisory lock the check already holds (#493).

  • The credit chip and exhaustion banner refresh after an AI action. They are rendered by the (app) layout, a Server Component that renders once, so the balance shown after generating a narrative or running a coach turn was the balance from before it — stale, and convincingly so (#493).

Technical changes· 15 entries for database, tooling, CI, tests and docs

Database

  • Added AI credit metering. model_personas.credit_cost is what one action on a persona costs, where a credit is US$0.01 of budgeted model spend; public.plan_credits holds a monthly allowance per tier; and public.ai_credit_entries is a signed ledger of every spend and refund. Images add ceil(base / 5) each, a coach interview is 1.25x and charged once on its first assistant reply, and a free-tier persona costs nothing — which is what will let an exhausted account keep working on free models rather than stop. Nothing is enforced yet: the Pro allowance is 100,000, which nobody reaches, so every surface behaves exactly as it does today while the ledger records what an account actually spends. Sizing a quota from prompt-shape estimates instead of from real traffic is the mistake this staging exists to avoid, and lowering monthly_credits is what turns the meter into a limit — a row, not a deploy, and reversible just as fast (#492).

  • There are deliberately no credit-grant rows. The allowance is derived at read time and the balance is that allowance plus the signed sum of the period's ledger, which removes a scheduled job, the idempotency problem of a grant that fires twice, and every way an account can reach the end of a period without its allowance because something did not run. It also gets the annual subscriber right — they generate one Stripe webhook a year and are metered twelve times. The period is a monthly anniversary of the account's oldest Pro-granting subscription rather than the calendar month, so subscribing on the 28th does not draw a second full allowance three days later (#492).

  • Investigations are charged by a trigger on task_investigations rather than by the route handler, for the same reason the plan-tier check is: that table is user-insertable through PostgREST, so a charge made in the route would be optional. Re-queuing a row charges again, because it bills the model again — the update path guard_investigation_model_type() already had to close. A failure refunds everything ever charged against the row, exactly once and back into the periods that paid for it — a row charged in one period and failed in the next spans two, and returning the total to the newer one would leave the older holding a debit that never comes back. Only a failure written by the WORKER refunds: "Owners can update their investigations." lets an account holder PATCH its own succeeded row to failed, and refunding that would be paying them back for work the model did (#492).

  • AI credits are now enforced. The Pro allowance drops from the 100,000 placeholder to 400 credits a period — around 100 Claude Opus actions, which caps worst-case model spend at about US$4.00 against US$8.99 of net revenue on a C$12.99 subscription. Stage 1 built the whole enforcement path and made it inert purely by setting that number high, so this really is one UPDATE and not a deploy. An UPDATE and not an INSERT, because stage 1 seeded plan_credits with on conflict (tier) do nothing and an insert would have applied cleanly, changed nothing, and left every account unmetered while /pricing advertised a cap — a failure with no symptom, which is why a pgTAP assertion now pins the number (#493).

  • The free tier is capped at 30 AI actions per rolling 24 hours, counted in a new public.free_tier_ai_actions table. Free personas cost no credits, so the ledger cannot limit a free account — and check (amount <> 0) on ai_credit_entries means it cannot even count one, since a ledger row is always a movement. The cap protects a shared resource rather than rationing a user: OpenRouter rate-limits its free tier per API key at roughly 20 requests a minute and every free account is behind one key, so one scripted account can degrade the free tier for everyone. It is counted per dispatched action rather than as a count over task_investigations, which would count a row once however many times it ran — re-queuing a finished row through PostgREST buys another provider call, which is the path guard_investigation_model_type() already exists to close. Rolling rather than midnight-reset, because a midnight boundary hands a script the whole allowance twice within two minutes of it, and check-and-insert happens under an advisory lock, because a limit beatable by issuing requests in parallel is not a limit. Both numbers live in plan_credits as rows, so either is a dial rather than a migration (#493).

  • Every credit function that names a payer, refunds, or writes reconciliation is revoked from anon and authenticated by name, because Supabase's default privileges grant EXECUTE on new functions directly and a direct grant survives revoke ... from public. What a signed-in account may do is read the price list, read its own balance, and spend its own credits: the granted forms take no user id at all, so there is nothing to pass to read someone else's (#492).

CI/CD

  • Lighthouse audits a production build served from the runner on every pull request, replacing a deployment_status trigger that waited on Vercel preview deploys this repo never receives. The Vercel project has no Git integration — an org-owned private repository needs a paid plan — so vercel[bot] creates no GitHub deployment, and the workflow had recorded 74 runs and 74 skips since it was added without ever auditing a page. Even had a preview existed, three further faults would have surfaced on the first real execution: the environment gate compared against 'Preview' while Vercel names the environment after the project (Preview – questify, en dash); the concurrency group was keyed on deployment_status.target_url, which is empty on pending and queued statuses and collapsed every such run into one group whose members cancelled each other; and the summary read steps.lhci.outputs.manifest, an output treosh/lighthouse-ci-action does not define, so the score table would have been empty regardless. The job now builds with pnpm build, serves with next start, waits for the app to answer and fails fast if it dies, checks that each audit target answers 200 before handing it to Lighthouse, then audits /, /privacy and /quests/public and posts a sticky comment. Those are the routes a signed-out visitor actually reaches: /pricing calls notFound() unless NEXT_PUBLIC_FEATURE_BILLING is set, and /login, /quests and /challenges all redirect to /auth/login, which robots.txt disallows. The pre-flight check turns that class of mistake into one annotation naming the URL and its status code, rather than a Lighthouse runtime-error dump. Performance on a runner is a trend rather than an absolute, which the comment says; accessibility at 0.9 remains the only assertion that fails the job, and it is the one that does not depend on where the app is served from (#491).

  • .github/lighthouserc.json drops assert.preset: lighthouse:no-pwa, keeping the four category assertions the file already declared. The preset asserts every individual audit at error level, not just the categories, so a build scoring 99 performance / 100 accessibility / 100 best-practices / 100 SEO still failed on eleven of them — bf-cache, canonical, unused-javascript, legacy-javascript-insight, network-dependency-tree-insight and friends — none of which the file's own assertions mention. The two off overrides for uses-http2 and uses-text-compression went with it: they existed only to silence preset audits and are inert without it. Verified by running LHCI against a real production build — 22 assertions, all passing, where the preset produced 11 error-level failures on the same run (#491).

  • Added a Lighthouse (production) workflow that audits the live site after each successful release, weekly, and on demand. It targets https://questify.co rather than any deployment URL on purpose: the project's Vercel Authentication is set to all_except_custom_domains, so every *.vercel.app address — including the one vercel deploy --prod prints and hands to the deployment's environment.url — answers with an SSO login wall that Lighthouse would score in place of the application (#491).

  • The deployment comment now finds its pull request. It gated on github.event.deployment.payload.prId, a field no provider promises: Vercel does not document it, and a deployment created by a job's own environment: key carries an empty payload — which is every deployment_status event this repo currently produces, because staging.yml and release.yml deploy through the Vercel CLI. The condition was therefore null on every event, and all 74 runs were skipped. The pull request is now resolved from the deployment's commit through listPullRequestsAssociatedWithCommit, which works no matter who created the deployment, and the job exits quietly when a commit has no pull request (#491).

Tests

  • 12 new pgTAP assertions on the enforcement fixes, 6 of which fail against the pre-fix function bodies — verified by restoring them in a live database. ai_credits.sql gains the deliberate service-role case and moves its requeue assertion onto the owner path, which is the one the charge exists for; it had been passing through the initiated_by_id fallback that turned out to be the bug. The coach route's test file was silently loading zero tests for part of this work, which is its own lesson: a suite that reports "no tests" passes just as quietly as one that reports success (#493).

  • Added supabase/tests/ai_credit_limits.sql, 15 pgTAP assertions on the numbers and on the new cap — 10 of which fail against the unenforced behaviour, verified by restoring the 100,000 allowance and dropping the two triggers in a live database. "400 credits buys exactly 100 Claude Opus actions and refuses the 101st" is written as an executable claim rather than a sentence in a document, because the margin argument depends on that exact conversion and a weight change that quietly halved it would break nothing visible. The cap is covered on both the insert and the requeue path, in both directions — that a requeue at the cap is refused, and that one below the cap is allowed and consumes its action, so the assertion cannot pass because requeues are simply broken. 12 new unit tests cover the credit-aware resolution, the two failure postures, and the tier-versus-credits distinction, which is the one a future change is most likely to collapse; e2e/pricing.spec.ts asserts the allowance copy, which is closer to a compliance check than a copy one (#493).

  • Added supabase/tests/ai_credits.sql, 12 pgTAP assertions on the meter itself. A meter reading nothing looks identical to a meter reading zero, and both look identical to a product that works, so nothing in the application would go red if charging silently stopped. It covers a paid action being charged and a free one not, the image surcharge, re-queue charging again, a worker failure refunding exactly once while an owner-written failure refunds nothing, and — the one behaviour stage 1 cannot observe in production — that lowering the allowance actually refuses the action and rolls the row back with it. rls.sql gains nine assertions that an account cannot name another payer, refund itself, rewrite reconciliation, read another balance, write the ledger, or raise its own allowance; plan raised to 172. credits.test.ts reads the migration so the TypeScript weight mirror cannot drift from the SQL, the same guard plans.test.ts puts on the subscription-status rule. Two of the metering assertions are regressions with teeth: both fail against the obvious implementations of reconciliation and refunding (#492).

Documentation

  • docs/pricing.md records stage 2 and two decisions taken on 2026-08-28. The founding-member price is not being created — the section is kept rather than deleted, because "we considered a founding price" is a question that gets asked again and the answer should not have to be reconstructed. The allowance was set from the document's own arithmetic rather than from stage 1 data, which stage 2 explicitly says not to do; the reason that is safe here is structural rather than analytical, in that the allowance is a row and correcting it is an UPDATE. Also records that the free-tier cap was implemented as a count of dispatched actions rather than the count over task_investigations the Design section proposes, and why (#493).

  • docs/pricing.md records stage 1 as landed and corrects three decisions the implementation changed: there are no grant rows, the allowance is a table row rather than a feature flag, and the published weight table now shows what the code charges rather than what the estimate suggested — a single multiplier cannot reproduce every estimated figure exactly. docs/ai-investigations.md gains a Credits section, and its long-standing claim that "there is no per-user quota in Questify" is no longer the whole truth (#492).