2.16.0
Released
Features
Everyone with an account on a scheduled Quest is emailed 15 minutes before its window starts, behind
NEXT_PUBLIC_FEATURE_EVENT_REMINDERS. The email gives the start in the recipient's own timezone when they have set one (the Quest's otherwise), how long it runs and a link to the Quest, and its footer link andList-Unsubscribeheaders turn off event reminders without touching stall emails. The profile's Email preferences section now appears with either email feature and adds an event reminders toggle, a timezone prefilled from the browser, and optional quiet hours, explaining that a reminder that would land inside them is skipped while a stall email waits. The unsubscribe page and route name the category the link came from; a link with no category is still read as a stall email, so every email already sent keeps working. (#567)Stall reminders now reach people who are not opening the app. The worker emails one digest per person through Resend when an active quest has gone quiet, linking each quest and the dashboard. Every email carries a footer unsubscribe link and
List-Unsubscribe/List-Unsubscribe-Postheaders for one-click unsubscribe in mail clients;/notifications/unsubscribechanges nothing until its button is pressed, and/api/notifications/unsubscribeaccepts only POST, so link scanners cannot unsubscribe anyone. Signed-in people can turn the emails on or off from the Email preferences section of their profile, shown with the stall detection flag; a failed read of that preference renders an error rather than a toggle in a guessed state. (#566)Added attendance completion to recurring Quests, with self check-in and owner confirmation for members without accounts or guild members who subsequently departed or were removed. Evaluated the existing completion achievements for the Quest owner when attendance completed the Quest, including when another member confirmed last. Displayed consecutive completed cycle streaks on the Quest and dashboard, ignoring future cycles even when marked completed, failed, or archived early, as well as the current open window. These surfaces used the existing recurring Quests feature flag. (#565)
Added
/guildsbehindNEXT_PUBLIC_FEATURE_GUILDS, laid out in the same container every other signed-in page uses and owning its own<main>landmark: guilds you run, guilds you have joined, and invitations waiting on you, with accept, decline and leave available to the person they belong to rather than only to the owner. A guild owner can rename the guild, invite an account holder by email or display name, add someone with no account by name alone, remove a member, hand ownership over, and issue a one-time claim code — shown once, because it is never stored — that links a future account to that member's existing history. A quest owner can bring a guild's whole roster into any Party Quest from the party panel — which is what makes a roster worth building, since the invite path resolves an account and so can never reach a member who holds none. The task assignee picker then offers every party seat, so a member with no account can be given a task and shows real progress counts. A recurring Quest can name one of your guilds as its party, which is what makes the roster-seeded cycle reachable from the product, and Guilds appears in the app navigation whenever the flag is on. Every guild mutation refuses on its own while the flag is off, rather than relying on the pages to hide it — a Server Action is its own entry point and a page-level guard never sees it. (#564)Added a feature-flagged weekly recurring Quest flow with local start, duration, and timezone controls. Dashboard and Quest-list reads keep due cycles available, while a persistent secondary action on the Quests page opens the recurring flow without adding another primary app-bar action. (#563)
Quests can now be scheduled from the create or edit form using a local start, duration, and detected IANA timezone. Upcoming windows lead the
/questslist, appear on quest cards and detail pages in their source timezone, and the nearest one has a dedicated dashboard slot. Unscheduled quests keep their existing presentation and recent-update order. (#562)Added
/admin/ai-spend, which reports platform-funded AI spend per feature per calendar month against each feature's ceiling, net of refunds, with headroom shown as a bar so a feature approaching its own limit is visible at a glance rather than inferred from two numbers. Every budgeted feature appears in every month of the window even when it spent nothing, so a feature that silently stopped running reads as zeroes instead of an absent row, and a feature with no budget row at all is rendered as the absence it is rather than as one that spent to its cap. The month heading reports spend with no denominator, because there is no pooled budget to draw down — each feature is capped on its own, and the footnote says so and states what the combined ceilings bound the month at. The read is admin-gated in the database rather than only in the page, and it fails loudly instead of rendering zeroes: on this surface an empty report and a failed one produce identical, reassuring numbers and mean opposite things. (#541)
Security
check_stalled_quests()is no longer callable byanonorauthenticated. Its grant block revoked only fromPUBLIC, which Supabase's direct default grants survive, so this SECURITY DEFINER scan over every user's quests was reachable through PostgREST with the anon key — and_threshold_days => 0would have flagged every active quest in the database. Only its owner, which pg_cron runs the job as, andservice_rolekeep EXECUTE. (#566)A party member's write reach is now their own seat.
20260524160000_party_quests.sqlgave every member UPDATE and DELETE on every task in the quest, gated only byuser_can_access_quest()— survivable while a party was a few people invited by name, and the wrong default once a guild roster can fill one. A non-owner may now write a task assigned to their own seat or claim one assigned to nobody, and deleting is the quest owner's alone even on the member's own task; owners are unaffected. An unclaimed task stays claimable on purpose, because a shared quest where only the owner can hand out work is worse than the bug being fixed. The routes report the refusal rather than failing oddly: a filtered UPDATE matched no row and answered 500, and a filtered DELETE reported success having removed nothing. (#564)Three guild functions were revoked only from
PUBLIC, which Supabase's default privileges — a direct EXECUTE grant toauthenticatedon every new function — survive.seed_quest_party_from_guild()was the one that mattered: SECURITY DEFINER, no authorization of its own beyond the quest owner's membership, and therefore an unguarded way aroundadd_guild_to_quest_party(), which exists precisely to ask whether the CALLER may manage the party. All three are now revoked by name, as aresync_quest_completion()andtask_owner_editable_columns(), which this migration replaces and which carried the same gap from their own. (#564)
Technical changes· 30 entries for database, tooling, CI, tests and docs
Database
Added event reminders:
enqueue_event_reminders(), scheduled every minute asenqueue-event-reminders, writes onenotification_deliveriesrow for each recipient of every active scheduled Quest starting within 15 minutes — the owner, and every party seat with a live, confirmed account whose guild membership, if any, is still accepted. The table is both the outbox and the dedupe ledger: it is unique on user, kind, channel, Quest and window start, so re-running the job, running it by hand during a scheduled tick, or retrying a failed send can never produce a second message, while a rescheduled window gets a reminder of its own. A recipient who opted out or is inside their quiet hours gets askippedrow with the reason instead of no row, so whether anyone was reminded has an answer. Nothing is sent for a window that has begun: rows expire at the window start,claim_event_reminder_delivery()re-checks that the Quest, its start time, the recipient's place on it and their preferences are unchanged before handing a row to the worker — and that the frozen address is still the account's email on an account that may still be emailed, so a private Quest title and link never go to a mailbox the person has left or to an account banned or unconfirmed since — andfail_event_reminder_delivery()expires a row rather than schedule a retry past the start — which also keeps every retry far inside Resend's 24-hour idempotency window. The job mints recurring cycles before it looks for due windows, becauseensure_quest_recurrence_cycles()only minted when the owner opened the app, so an owner who stayed away for a week left week three with nothing to remind anyone about; the cycle arithmetic moved tomint_quest_recurrence_cycles(owner, now)and the existing function is a wrapper over it. (#567)notification_preferencesgainedevent_reminder_emails(on by default, like stall emails),timezone, and quiet hours.update_my_notification_preferences()changes only the fields it is given, atomically, and refuses an unknown field, half of a quiet-hours setting, quiet hours without a timezone, or a timezone Postgres does not know;set_notification_category_by_token()lets an email's one-click link turn off only its own category. Stall digests now wait out the recipient's quiet hours instead of arriving at 05:15 — only the batch's next attempt time moves, so its body and idempotency key stay valid and the 23-hour expiry still bounds it — and are skipped, like reminders, when their frozen address is no longer the account's email or the account can no longer be emailed. Every new function is revoked fromanonandauthenticatedexcept those two. (#567)Added an outbox for stall reminder emails.
enqueue_stall_email_digests(), scheduled asenqueue-stall-emails-dailyfifteen minutes after detection, writes onestall_email_batchesrow per person listing every open stall no email has mentioned yet, and stamps each notification with that batch's id so a stall is emailed once — not again on a re-run, when a snooze ends, or while the notification stays open. Only confirmed, live, non-anonymous addresses are enqueued. The batch freezes recipient, name, quest titles and idle days, because the worker retries with the batch id as Resend's idempotency key and Resend refuses a retry whose body differs.claim_stall_email_batch()skips a batch whose stalls were all dismissed, snoozed or resolved or whose recipient unsubscribed, expires one 23 hours old — inside Resend's 24-hour idempotency window, so a send that went out but was never recorded cannot be retried as a new email after an outage — and reclaims one abandoned mid-send under the same id;fail_stall_email_batch()backs retries off 5–40 minutes and makes the fifth failure final.notification_preferencesholds the opt-out and the unsubscribe token, is readable only by its owner, and is written only throughset_my_stall_emails()andset_stall_emails_by_token(). Every function is revoked fromanonandauthenticatedexcept those two. (#566)Stall detection now runs.
20260525100000_stall_detection.sqldocumented a pg_cron schedule it never created, and pg_cron was never installed, socheck_stalled_quests()had not run once in production.20260910140000_schedule_stall_detection.sqlinstalls pg_cron — available on the hosted project all along — schedulescheck-stalled-quests-dailyat 09:00 GMT, and adds a weekly job that prunescron.job_run_detailsrows older than 30 days. The function is now safe to run twice: itson conflict do nothinghad no unique constraint to conflict on, so every run would have re-loggedstall_detectedfor every quest still stalled, and it wrote aresolvedevent whether or not anything was deleted. A stall is now recorded once per idle period, re-running over unchanged data rewrites no rows, and a transaction-scoped advisory lock serialises a manual run with the scheduled one. Each run writes acheck_stalled_quests: …summary line to the Postgres log. (#566)Added private attendance attestations keyed by Quest and party seat, including members without accounts. Attendance completion required every expected member to confirm, serialized concurrent check-ins on the Quest lock, blocked manual early completion, and fixed the roster after the first attestation. Recurrence templates carried the selected completion mode into each minted cycle. (#565)
Added persistent Guilds, whose members do not all need an account.
guild_members.user_idis nullable, so a child too young to hold an account under/termsis a real roster row that can be assigned work rather than free text in a task title. Membership is a lifecycle, not a delete:pending/accepted/declinedfor an invitation, andremoved/departedas terminal states that stampleft_atand keep the row, so past attendance and assignment stay attributable. The sole owner cannot leave —guilds.owner_idmust move to an accepted member first, which swaps the owner role between rows — a closed membership cannot be reopened in place, and a member cannot promote themselves. Who is acting is enforced alongside what the transition is, because the policy alone cannot express it: an invitation is answered only by the person invited, leaving is only the member's own act, removal is only the owner's, and a roster row can be created only in the two shapes a membership is ever born in — accepted with no account, or pending with one — so an owner can neither conscript an account holder who never accepted nor mint a second owner. (#564)Task assignment moved from
tasks.assigned_to_user_idtotasks.assigned_to_party_member_id. A party seat, not a user, is now the assignable identity: the account link and the guild link both live on the party row,quest_party_members.user_idis nullable with adisplay_namefallback, and the assignee guard collapses to "this seat belongs to this quest". One assignee shape everywhere beats an exactly-one-of pair, and a Party Quest with no guild is unaffected.task_owner_editable_columns()names the replacement column, without which assigning work on a locked Quest Challenge task would have started failing silently. (#564)Leaving or being removed from a guild revokes future access without erasing history.
user_can_access_quest()anduser_is_quest_party_member()now require a guild-seeded party seat's backing membership to still beaccepted, so departure cuts off reads while the seat and every task attributed to it stay exactly where they were. Deleting a roster row, or the whole guild, strips the account from those seats rather than letting them fall back to looking like ordinary ones — otherwise deletion would have handed a departed member their access back. (#564)Linking an account to an existing accountless member goes through a single-use claim token, and that is the ONLY path that writes a membership's account link — a direct write is refused even for the guild owner, who could otherwise have attached any account, and its history, without a token ever being issued.
create_guild_member_claim()is owner-only, retires any outstanding claim for that member, and returns 32 random bytes exactly once; only the SHA-256 digest is stored, so reading the table cannot mint a usable claim.redeem_guild_member_claim()writes the caller onto the existing row and carries the link onto every party seat that row already occupied — without which the newly linked account would be locked out of the very quests, and the very assignments, the row was kept to preserve — merging a seat the caller already held on the same quest rather than duplicating them. It refuses an expired, revoked or spent token, a member who already holds an account, and anyone already on that roster. (#564)A recurrence template may name a guild, and each minted cycle seeds its party from that roster instead of re-inviting everyone. Accepted members only: a pending invitee has not agreed to be there, and a departed one must not be pulled back by the next mint. Guild access is re-checked at the moment the roster is read rather than only when the template is written, so a template owner who has since left cannot go on minting cycles seeded from a guild they no longer belong to. Joining seeds already-minted cycles that have not started yet — for somebody added without an account too, who is on the roster from the moment they exist and never makes a status transition to fire on. A template without a guild keeps carrying the previous cycle's party forward. (#564)
Added owner-only Quest recurrence templates and a timezone-aware, idempotent cycle engine that mints the current and next private Quest without pg_cron. Recurring cycles cannot overlap, expired windows are skipped, concurrent readers cannot create duplicates, party members carry forward, and deleting a template preserves its detached Quests. (#563)
Added optional timezone-aware scheduled windows to Quests, with an all-or-nothing shape, positive durations, and timezone validation against PostgreSQL's timezone catalog so local wall-clock times survive DST changes. (#562)
The AI credit ledger now distinguishes who funded a model call, and caps what the platform can spend on calls nobody asked for. Every chargeable action so far began with a click, so there was no answer to "who pays for a call the user did not request" — and the default answer, charging them, turns a background job into a support ticket.
ai_credit_entriesgainsorigin('user'or'system', defaulting to the payer already recorded) andsystem_feature, the reason CHECK gains the four system kinds, andai_credits_balance_for()now counts only user-funded rows: a platform-funded action is recorded against the account it was run for, reportable per user, and never debited from them.system_ai_budgetsadds a monthly ceiling per feature, enforced insidecharge_system_ai_credits()under a per-feature advisory lock so two concurrent cron ticks cannot both find room, and a feature with no budget row cannot spend at all — treating a missing ceiling as unlimited would make forgetting a row the way to get an uncapped feature. A call that would cross a ceiling raises53000and is refused rather than recorded, andrefund_system_ai_credits()returns a failed job's credits to the ceiling so a flapping cron cannot eat a month's budget having delivered nothing. (#541)refund_ai_credits()andreconcile_ai_credit_entry()are now scoped to user-funded rows, and have system-funded siblings.ref_idis a correlation id rather than a foreign key, so one quest id can carry both a user's narrative and the platform's recap of the same quest: unscoped, the existing refund would have handed an account credits the platform spent, and the existing reconciler would have written a user call's token counts onto the platform's row — corrupting the one measurement the credit weights are meant to be corrected from. (#541)The worker now reads an investigation's payer only from user-funded ledger rows.
investigationPayer()filtered onref_idalone, which was sufficient while every row had one kind of payer;ref_idis a correlation id rather than a foreign key, so it is not unique across origins, and the account that read names is the onereprice_ai_credits()then debits. A platform-funded row picked up there would have charged a real person for a call the platform made — the failure the rest of this work exists to prevent, arriving through the back door. No background feature references an investigation today, which is why the filter is covered by a test rather than a comment. (#541)
Tooling & Configuration
Added
NEXT_PUBLIC_FEATURE_EVENT_REMINDERS, off by default and registered in.env.example, the web env schema, the web Dockerfile anddocker-compose.yml. The worker drains event reminders in a third loop, polling every 30 seconds (EVENT_REMINDER_POLL_INTERVAL_MS), gated on that same web flag plusRESEND_API_KEY,FROM_EMAILandQUESTIFY_SITE_URL, reusing the Resend client and its 10-second timeout; a send failure is recorded on its row and logged, and never fails anything a person is waiting on. The release workflow now reads both worker flags from Vercel in a single pull, passes both toflyctl deploy --env, and refuses a Fly secret that would shadow either..env.exampledocuments the Resend surface for both kinds of email. (#567)The worker runs a second loop beside investigations that drains stall emails. It is gated on the web app's own
NEXT_PUBLIC_FEATURE_STALL_DETECTIONrather than a worker-only switch, so a deployment with stall detection off never emails people about a feature whose banner and email preference it hides. Production keeps no copy infly.toml: the release workflow reads the flag from Vercel's production environment on every worker deploy and passes it withflyctl deploy --env, deploys emails off with a warning when Vercel cannot be read, and refuses to deploy when a Fly secret of the same name would override it. Emails also needRESEND_API_KEY,FROM_EMAILandQUESTIFY_SITE_URL; with the flag on and any missing, the worker logs that stall emails are not being sent, and the release workflow warns whenRESEND_API_KEYorFROM_EMAILis missing from the Fly app. Both are new Fly secrets to set before the next release. (#566)Added
NEXT_PUBLIC_FEATURE_GUILDS, off by default, and raised theapps/webcoverage ratchet to 57 statements / 57 lines / 71 functions, keeping the margins develop already ran with. Branch coverage is deliberately left at 82: it is the one measure this branch did not raise (82.59 against develop's 82.80), and ratcheting it onto a number the branch went below would red-line the next PR for someone else's reason. (#564)
Tests
Added
supabase/tests/event_reminders.sql(96 assertions, in the migrations-only CI step): who is enqueued and who is not, the frozen payload, that a second run adds and rewrites nothing, claim, complete, retry and permanent failure, reclaiming an abandoned send, that a retry past the window expires, that a rescheduled, started, abandoned or opted-out reminder is not sent, that neither a reminder nor a stall digest goes to an address the account has since changed or to an account banned or unconfirmed since (while a change of case alone still sends), quiet-hours arithmetic across midnight and DST, stall digests waiting out quiet hours, every preference validation, per-category unsubscribe, and a weekly Friday 19:00 Toronto window — minted by the job with the owner away — reminded at 18:45 local on both sides of the November DST change. Worker tests cover the render (with no timezone conversion of its own), byte-identical retries, the category unsubscribe link, the flag gate and the loop;scripts/worker-deploy.test.mjsnow pins every worker email flag from one table. Web tests cover the preference form, action, query and validators, including that a hidden category cannot be changed by posting to the action, and the unsubscribe route and page for both categories.supabase/tests/rls.sqlcovers the access boundary: no client role can read or writenotification_deliveriesor run the reminder job, a user reads and changes only their own preferences, and a one-click link works without a session.apps/web/e2e/notification-preferences.spec.tscovers what the credential-free smoke run can prove — a reminder link naming its category on the page and on the POST, a bare link still meaning stall emails, an unknown category reported broken, and the route refusing a GET and an unknown category — with saving the form, the section following its flags, and a real token unsubscribing exactly one category gated onE2E_SUPABASE_SERVICE_ROLE_KEY. (#567)Added
supabase/tests/stall_emails.sql(53 assertions, in the migrations-only CI step) covering who is enqueued, one digest per person, idempotent re-runs, claim, complete, retry, permanent failure, skip, reclaim, expiry — including that an unrecorded send is not retried once Resend may have forgotten its key — one-click unsubscribe without a session, and that nobody can read the outbox or write a token directly. Worker tests cover Resend's retryable errors, the idempotency header, byte-identical retries, header-injection and HTML escaping in the digest, and the loop's backoff;scripts/worker-deploy.test.mjspins that the worker's email gate is the web stall detection flag, thatfly.tomldoes not hardcode it, and that the deploy copies it from Vercel, removes the pulled environment file, and refuses a shadowing secret; web tests cover the unsubscribe route's one-click and page flows, the absence of a GET handler, the page's states, the profile toggle action and form, and the preference read. (#566)Added
supabase/tests/stall_detection_schedule.sql, which runs insupabase.yml's migrations-only step and asserts that pg_cron is installed and both jobs are registered once and active, that the scan is closed toanonandauthenticated, and that running it twice records a stall once, rewrites no row, does not re-count an expired snooze, leaves a dismissal alone, and records a resolution once.stall_detection_concurrency.sqlruns eight scans at once over dblink and expects onestall_detected; with the advisory lock removed, two of three sandbox runs recorded four or five. (#566)Added attendance authorization, completion, streak, and function privilege coverage, plus a two-session pgTAP control that reproduced missed completion without the Quest locks and verified exactly one completion with them. Added form, action, query, component, and live browser coverage for attendance and feature flag behavior. (#565)
Added
supabase/tests/guilds.sql, 81 assertions over the whole guild lifecycle: that an accountless member cannot be left pending, that a pending invitee sees the guild's name but only their own roster row, that acceptance and declining are one-way, that the sole owner is refused departure until ownership moves, that a transfer to a non-member is refused, that a claim token is single-use and never stored in plaintext, that redeeming one links the existing row without creating a second membership, that a task can be assigned to a seat with no account while the guard still rejects a seat from another quest, that a guild-backed template seeds each cycle from the roster, and that leaving revokes quest access while the seat and its assignment survive. It also pins the boundaries that are invisible until they fail: that an owner cannot attach an account by editing the row, cannot answer somebody else's invitation, cannot add a member already accepted or as a second owner; that a member cannot reopen their own membership or remove anyone else; that redemption reaches the party seats and the linked account can then read its quests; that deleting a roster row revokes rather than restores; that seeding refuses once the template owner has left; and that the roster-size helper is not a client RPC.rls.sqlcovers the access boundary separately — a stranger reading nothing, an invitee reading only their own row, the claim ledger unreachable from a client, and an invitee answering their own invitation without the owner. Web coverage spans the guild validators, the grouping and RPC wrappers, every action's success and refusal path (including that each refuses on its own with the flag off, before touching the database), the list, roster and form components, the recurrence guild selector, the navigation gate, and the assignee picker offering a member who has no account.apps/web/e2e/guilds.spec.tscovers what the credential-free CI smoke run can actually prove — the login bounce with its return path, and a flagged-off deployment answering 404 rather than hinting the surface exists — with the invite-and-claim flow gated onE2E_SUPABASE_SERVICE_ROLE_KEYlike the other authenticated suites. (#564)Covered recurrence ownership and grants, cycle boundaries across DST, closed and future windows, deletion detachment, party carry-forward and fan-out, and idempotence under eight concurrent database callers. Added focused validator, action, query, feature-gate, form, and empty-state UI coverage. (#563)
Covered scheduled Quest constraints and pinned a 19:00 Toronto start on both sides of the November DST transition. Validator, action, query, form, card, detail-window, and dashboard-slot tests cover local-to-UTC resolution, invalid DST gaps, all-or-nothing clearing, next-scheduled ordering, timezone-aware display, and unchanged unscheduled states. (#562)
Pinned the two halves of the platform-funded policy that fail silently. pgTAP covers that a system action is recorded but leaves the account it was run for no poorer, that a free persona books nothing, that the ceiling refuses without leaving a row behind, that an unbudgeted feature cannot spend, that a refund returns room to the ceiling and is idempotent, and that a reference carrying both a user and a platform debit refunds and reconciles only the intended one. Neither charge function accepts the other's kinds, and a signed-in account can neither spend the platform's budget nor read its cost structure.
credits.test.tsnow reads the new migration the way it already read the old one — the system kind guard, the widened reason CHECK, the seeded budget rows, and the three origin predicates that keep the two ledgers apart — so a kind or a ceiling added on one side without the other fails the suite rather than a cron job.rls.sqlcovers that the budget table is admin-read-only, deliberately beside the assertion that the user-facing price list is not: copyingplan_credits' policy across would have published Questify's cost structure to every account with nothing looking wrong. The admin page has its own tests — the first for any/adminsurface — covering the things it can get wrong without erroring: that the month heading carries no pooled denominator and does not move when a feature loses its budget row, that an unbudgeted feature and an exhausted one render differently rather than sharing a full red bar, and that a failed read withholds the figures instead of reporting zero spend. (#541)
Documentation
docs/ops/scheduled-jobs.mddescribes the event reminder job, why it mints cycles, how quiet hours treat the two kinds of email, and the production query for what was sent or skipped;docs/ops/deployments.mdcovers the second worker flag. (#567)The privacy policy now names Resend and says what account emails contain and how to turn quest reminders off.
docs/ops/scheduled-jobs.mddocuments stall emails as the worked example for any job that has to reach a person, with the production queries that show whether batches are being sent, anddocs/ops/deployments.mdlists the two new worker secrets. (#566)Added
docs/ops/scheduled-jobs.md, the scheduler decision for #566. It chooses pg_cron over Vercel Cron, whose Hobby plan runs crons once a day anywhere within the hour and so cannot express a Friday 19:00 reminder, and over a loop in the Fly worker, which would be a hand-written scheduler coupled to AI investigations. It also covers the rules for adding a job, the queries that prove a job ran, and how to pause or run one by hand.AGENTS.mdpoints time-based work there, and the deployments topology now lists the scheduler. (#566)docs/pricing.mdnow records the platform-funded policy and the arithmetic behind each ceiling: 500 credits — US$5.00 — per feature per month, bounding total platform AI spend at US$20/month across the four, sized against what production actually was the day it shipped (10 accounts, 13 active quests, 6 Quest Jams, and 50 credits spent across the ledger's entire life). The worst-case-per-feature table makes plain that no feature should ever reach its ceiling: these are runaway bounds rather than rations, aimed at a scheduled job retrying a call it will never complete — which costs the same as a successful one and has no user waiting to notice it. (#541)