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 reportinput_tokens, every OpenAI-compatible/chat/completionsprovider reportsprompt_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 returnsnullwhen a charge could not be booked and0only 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_idno longer decides who pays or whose rate limit applies. It is an ordinary client-writable column — "Owners can insert investigations on their tasks." checksuser_owns_task(task_id)and says nothing about who the row claims initiated it — and both credit triggers readcoalesce(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 useauth.uid(), and nothing legitimate is lost — that policy resolves toquests.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 writespending(it claimspendingand stalledrunningrows intorunning), 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_interviewsis 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.mdprescribes.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
<= balancesearch 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_actionsprunes 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_costis what one action on a persona costs, where a credit is US$0.01 of budgeted model spend;public.plan_creditsholds a monthly allowance per tier; andpublic.ai_credit_entriesis a signed ledger of every spend and refund. Images addceil(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 loweringmonthly_creditsis 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_investigationsrather 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 pathguard_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
UPDATEand not a deploy. AnUPDATEand not anINSERT, because stage 1 seededplan_creditswithon conflict (tier) do nothingand an insert would have applied cleanly, changed nothing, and left every account unmetered while/pricingadvertised 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_actionstable. Free personas cost no credits, so the ledger cannot limit a free account — andcheck (amount <> 0)onai_credit_entriesmeans 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 overtask_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 pathguard_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 inplan_creditsas 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
anonandauthenticatedby name, because Supabase's default privileges grant EXECUTE on new functions directly and a direct grant survivesrevoke ... 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_statustrigger 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 — sovercel[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 ondeployment_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 readsteps.lhci.outputs.manifest, an outputtreosh/lighthouse-ci-actiondoes not define, so the score table would have been empty regardless. The job now builds withpnpm build, serves withnext 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/,/privacyand/quests/publicand posts a sticky comment. Those are the routes a signed-out visitor actually reaches:/pricingcallsnotFound()unlessNEXT_PUBLIC_FEATURE_BILLINGis set, and/login,/questsand/challengesall redirect to/auth/login, whichrobots.txtdisallows. 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.jsondropsassert.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-insightand friends — none of which the file's own assertions mention. The twooffoverrides foruses-http2anduses-text-compressionwent 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 targetshttps://questify.corather than any deployment URL on purpose: the project's Vercel Authentication is set toall_except_custom_domains, so every*.vercel.appaddress — including the onevercel deploy --prodprints and hands to the deployment'senvironment.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 ownenvironment:key carries an empty payload — which is everydeployment_statusevent this repo currently produces, becausestaging.ymlandrelease.ymldeploy 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 throughlistPullRequestsAssociatedWithCommit, 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.sqlgains 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 theinitiated_by_idfallback 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.tsasserts 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.sqlgains 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.tsreads the migration so the TypeScript weight mirror cannot drift from the SQL, the same guardplans.test.tsputs 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.mdrecords 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 anUPDATE. Also records that the free-tier cap was implemented as a count of dispatched actions rather than the count overtask_investigationsthe Design section proposes, and why (#493).docs/pricing.mdrecords 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.mdgains a Credits section, and its long-standing claim that "there is no per-user quota in Questify" is no longer the whole truth (#492).