Questify

2.27.0

Released

Features

  • Users can delete their own account from /profile. The new Delete account section opens a form that asks them to type their email address, and optionally why they are leaving; the Server Action then locks what they own against new members (one deletion attempt at a time; a second request is told the account is already being deleted), expires any open Stripe Checkout session and cancels every subscription that can still charge — on the mapped Stripe customer and on any other customer whose metadata carries the account's id, which a pre-idempotency race could have left holding a live subscription — reading every page (and refuses to go on if it cannot, including when it cannot read which Stripe customer is theirs — deleting the rows does not stop Stripe charging, and a Checkout completed after the delete would start a subscription nobody owns), deletes the user with auth.admin.deleteUser(), writes an account.delete audit row with no personal data, removes the attachment and feedback-screenshot files the cascade would orphan (after the delete, so a deletion that fails loses nothing; the worker's reaper now sweeps both buckets for whatever is missed), emails a confirmation to the old address, signs out and lands on the sign-in page with a notice. /api/billing/checkout refuses an account whose deletion is under way, and expires a session it created if the deletion started meanwhile. Once the form is submitted its Cancel button is disabled, since a running deletion cannot be called back. While they own a Party Quest or guild that someone else still belongs to, the section lists each with a link instead of a delete button. Party Quest owners get a Hand over this quest control in the party panel to clear that without deleting the quest; guilds already had transfer. The privacy policy now describes the in-app path and keeps email as the fallback. Comments left on other people's quests, and a membership on someone else's guild roster, stay as "Former member" (the label comments already used) (20261022120000_account_deletion_roster_names.sql renames it and clears its invite address as the account's delete clears the link), and invitations the account accepted or was bound to keep no address (20261023120000_account_deletion_invitation_addresses.sql clears it, names a Party Quest invitee "Former member" and revokes one still pending) (#718).

  • With NEXT_PUBLIC_FEATURE_GUILD_INVITES on, a guild's owner is now also emailed when somebody accepts a legacy in-app invitation — a pre-#661 pending membership moved to accepted by acceptGuildInvitationAction — with the same guild_member_joined email an emailed invitation or a redeemed claim code sends. Before this, that path told nobody. The accept now only moves a row that is still pending, so the owner is emailed once per acceptance even when the button is pressed twice or two submissions race; the second still answers "You joined the guild." The email names the guild the updated row belongs to, not the id the browser sent. A failed send never fails the join, and the owner is still not told when a member declines or leaves. (#674)

  • With NEXT_PUBLIC_FEATURE_JAM_COPY_AI on, each upcoming cycle of a recurring Quest Jam gets its own title, description and theme, generated from its template, the template's theme and copy_guidance, and the titles earlier cycles used — so the third occurrence no longer reads like a cron job. The worker's new jam-copy loop validates every answer (length, at least one letter or digit in every field, no links, no repeated title, a theme only where the template has one) and the database checks bounds again before a public row changes. A failed attempt pauses the loop for at least its idle poll instead of moving straight on to the next cycle, so an outage does not spend every queued cycle's first attempt in a row. Minting never waits on a provider: a cycle always appears with its template copy, and anything short of a valid answer — the flag off, no provider, an invalid reply, an exhausted jam_copy budget, or the jam opening first — leaves that copy in place. JAM_COPY_MODEL_TYPE defaults to the free router, which books nothing, and the loop runs only when it names an explicit provider/model_id and the key for that provider is set — a value with no provider prefix, or any other key alone, reports it misconfigured; a catalogued paid model is charged to the platform's jam_copy ceiling and repriced to whatever actually answered. The flag is registered in .env.example, apps/web/src/lib/env.ts, the web Dockerfile, docker-compose.yml, turbo.json, the Playwright web server, and the release workflow, which copies it from Vercel to the worker like the other worker flags. (#548)

  • Completed quests now show the Quest Chronicle beside the page header, with its generate or regenerate action available without scrolling past tasks. Owners who complete a quest while viewing it are offered a one-time dialog to generate the story; choosing later leaves the action available, and generation still requires an explicit click because it may use AI credits. The offer works after manual, task-driven, or attendance completion when Narrative Mode is enabled; its existing flag now reaches Turbo build and dev tasks and the Playwright web server. (#703)

  • With NEXT_PUBLIC_FEATURE_WEEKLY_DIGEST on, everyone gets a plain weekly progress digest at 17:00 on Sunday in their own timezone: what they finished in the last seven days, what is still open oldest first, and which quests have had no activity for a fortnight, with one link to the dashboard. No model is involved — it is the non-AI baseline the AI review digest (#546) has to beat. A week with no open tasks and no completions sends nothing at all. Quiet hours delay it rather than skipping it, the profile gains a Weekly progress digest toggle, and each email carries its own one-click unsubscribe. (#660)

Fixes

  • An account could not be deleted if it owned a guild, or had a pending, declined or departed membership in anyone else's — even by an operator, and not only through the new self-service flow. guild_members.user_id is ON DELETE SET NULL, and the cascade's update broke guild_members_accountless_is_accepted (a null account was allowed only on accepted or removed rows) or, on the owner's own row, guild_members_owner_is_accepted, rolling back the whole delete. 20261021120000_account_deletion_guild_memberships.sql lets a closed declined or departed row outlive its account, and close_deleted_invitee_membership() closes a pending invitation as declined when the invitee's account is deleted and leaves the owner row of a guild being deleted to that guild's own cascade; both act only once the account is really gone (#718).

  • The Stripe webhook no longer retries forever for a deleted account. When the mapping row is gone, resolveUserIdForEvent() falls back to the user id in Stripe metadata, and after an account deletion that id names nobody — so the customer.subscription.deleted event that the deletion's own cancellation triggers failed apply_subscription_state()'s foreign key, answered 500, and would have been retried for days until Stripe disabled the endpoint. The metadata id is now used only if a profile still exists for it; a failed check still throws so the event is retried (#718).

  • Admin feedback details now wrap long titles, report text, and submitter identifiers inside bounded columns, and the feedback list uses linked records on narrow screens instead of a wide table. This keeps both pages within phone widths while preserving the table on desktop. (#710)

  • The worker's feature flags now reach it under pnpm dev and pnpm build. Turbo passes NEXT_PUBLIC_* variables through only to packages it detects as Next.js apps, so the worker — plain Node — saw none of NEXT_PUBLIC_FEATURE_STALL_DETECTION, _EVENT_REMINDERS, _DEADLINE_REMINDERS, _STALL_AI or _WEEKLY_DIGEST, and ran every one of those loops off whatever .env said. All five are in turbo.json's build and dev lists, and scripts/worker-env-catalog.test.mjs no longer exempts NEXT_PUBLIC_* names, so the next worker flag cannot miss them. Production was unaffected: the release workflow passes the flags to Fly directly. (#660)

Security

  • Production no longer holds an Anthropic API key. Each deployment now authenticates to Anthropic with workload identity federation: the host signs a short-lived OIDC token saying what is running (Vercel's x-vercel-oidc-token request header for the web app, a token the worker mints over Fly's /.fly/api socket, the job's id-token for GitHub Actions), and the app exchanges it at POST /v1/oauth/token for a ten-minute-to-one-hour access token bound to a federation rule in the Claude Console, sent as Authorization: Bearer in place of x-api-key. The new apps/web/src/lib/ai/anthropic-auth.ts and apps/worker/src/ai/anthropic-auth.ts do the exchange (bounded at 30 s, with each caller waiting on it only for its own remaining budget — the web app's provider call then gets only what the exchange left of its timeoutMs, so authentication cannot push a charged narrative past the route's maxDuration, and a caller in a hurry gives up on a shared exchange without cutting it short for the others — and the worker's Fly token request at 10 s, so a stalled endpoint cannot hold the processing loop), cache the token per process, refresh it two minutes before expiry (serving the cached one when an early refresh fails, never one about to expire) and share a single in-flight exchange between concurrent callers; the worker asks Fly for a fresh identity token on every exchange because Fly's carry a single-use jti. A deployment is configured with three identifiers rather than a secret — ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID and ANTHROPIC_SERVICE_ACCOUNT_ID (plus ANTHROPIC_WORKSPACE_ID only for a rule enabled in more than one workspace), registered in both env schemas, turbo.json, .env.example, the dev TUI's MANAGED_KEYS (so pnpm dev:tui -- keys can set them for a local run against a pulled VERCEL_OIDC_TOKEN), the Vercel project, fly.toml's [env] block and the repository's GitHub variables. Federation deliberately wins over ANTHROPIC_API_KEY when both are set, so a stale key cannot mask a broken exchange, and a partial set of identifiers is refused by getServerEnv() / getWorkerEnv() rather than silently falling back to the key. ANTHROPIC_API_KEY still works, for local development, and isProviderConfigured('anthropic') counts a rule as credentials so the persona picker and the worker loops treat a federated deployment as configured. (#726)

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

Database

  • 20261017120000_account_deletion.sql adds what self-service account deletion needs beyond the auth.users cascade. my_account_deletion_blockers() lists the quests and guilds the caller owns that another account still belongs to — quests.owner_id and guilds.owner_id cascade, so deleting an owner would delete the whole Party Quest or guild for everyone in it; accountless seats and soft-deleted quests never block. transfer_quest_ownership() is the one exception to prevent_quest_owner_change(), through a transaction-local is_quest_ownership_transfer() flag: the caller must own the quest and hand it to an account-linked member seat, the outgoing owner stays as a member, and the year theme, recurrence link and stall nudge — all the outgoing owner's — are cleared; a Quest Challenge attempt or Quest Jam entry refuses. account_deletion_feedback holds the optional exit reason with no user id and only a month, so it cannot be joined to the account.delete audit row; only the service role writes it and only audit_log.read reads it. Both functions are closed to anon. 20261018120000_account_deletion_lock.sql makes the blocker check hold until the delete: the delete runs seconds after the check, in GoTrue's own transaction, so someone joining in between would still have lost the shared quest or guild. lock_account_for_deletion() counts the blockers under a per-account advisory lock and, only when there are none, records a row in account_deletion_locks (RLS on, no policies, no client privileges) carrying a fresh attempt id, which it returns; while any lock exists for the account it refuses (55P03) a second attempt, so two deletion requests can neither share a lock nor take one over. While it exists, triggers on quest_party_members, guild_members, quests and guilds refuse (55000) a seat or accepted membership for another account on anything the leaving account owns, a guild seat on its quests becoming accepted, and a quest or guild handed to it; each takes the same advisory lock first, so a join already in flight is counted and one that starts later is refused. The row does not lapse with age, however long the deletion takes: release_account_deletion_lock(_user_id, _attempt_id) — service role only, so the user cannot lift it through PostgREST mid-deletion — ends it after a failed deletion, and only for the attempt that took it (20261020120000_account_deletion_attempts.sql, which drops the signed-in zero-argument release); account_deletion_lock_held() (service role only) is checked immediately before auth.admin.deleteUser(); the row cascades away with the account; and 20261019120000_account_deletion_review.sql adds reap_abandoned_account_deletion_locks(), run hourly as the reap-account-deletion-locks pg_cron job, to release one a day old whose deletion died before doing either. The same migration adds find_orphaned_feedback_screenshot_objects() (service role only), so a feedback screenshot the deletion fails to remove stays findable the way an orphaned attachment does. Deletes outside this flow, such as an operator's, are unaffected. my_account_deletion_blockers() now reads the same account_deletion_blockers_for() the lock uses. supabase/tests/account_deletion.sql covers the blockers, every transfer refusal, the owner lock outside the transfer, and deleting an auth.users row that holds a Quest Jam entry (whose quest FK is RESTRICT), a seat and a comment; rls.sql pins the grants and policies (#718).

  • 20261024120000_transfer_detaches_guild_seat.sql makes a quest handed to a guild seat keep its owner seat. transfer_quest_ownership() promoted the new owner's seat by changing only its role, so a seat seeded from a guild kept guild_member_id; deleting that roster row or the whole guild then ran detach_deleted_guild_member_seats(), which left the quest's one owner seat with no account while quests.owner_id still named the user, and owner flows that find the caller's own seat stopped working. The transfer now clears guild_member_id on the promoted seat, making it a direct seat — the owner belongs to the party by owning the quest. guard_attendance_roster(), which fixes the roster once attendance is confirmed and so refused any change to that column, accepts this one case: inside the transfer, the same seat on the same account losing its guild link. create or replace keeps both functions' grants (#718).

  • 20261016120000_party_invite_cap_on_resend.sql closes a hole in create_party_invitation()'s 100-open-invitation cap. The cap was checked only when a new row was created, so re-sending an existing pending invitation skipped it — harmless for a live row, but an expired pending row is correctly not counted as live (#639 review), so re-sending one beside 100 live links minted the 101st, repeatable for as long as expired rows existed. The function now checks on every send and counts every live link except the row being re-issued, the shape #663 gave create_guild_invitation(): a live invitation can still be sent again on a full quest, while an expired one is refused until a place frees up. Nothing else in the function changes, and create or replace keeps its grants. (#666)

  • 20261015120000_party_invited_user_set_null.sql makes quest_party_invitations.invited_user_id ON DELETE SET NULL. It was ON DELETE CASCADE, so deleting an account erased every party invitation that account had answered, while the same event on guild_invitations only blanked the invitee — and invited_by_id / revoked_by_id were already SET NULL on both tables. The row is the audit of an invitation that was sent and answered, so it now outlives the account on both sides. Nothing that reads the column changes behaviour: it is only stamped on acceptance, so no pending row carries one, and the SELECT policy's invitee arm and the owner-notification lookup simply stop matching a NULL. The address the invitation was sent to stays on the row, as it already did for guilds. No data is backfilled. (#667)

  • 20261014120000_vary_recurring_jam_copy.sql queues recurring Quest Jam cycles for generated copy. An AFTER INSERT trigger on quest_jams puts every template-minted cycle that has not started into quest_jam_copy_requests (service role only, RLS on, no policies), and the worker leases it through claim_jam_copy() → charge_jam_copy_claim() → complete_jam_copy() / fail_jam_copy(), with reprice_jam_copy_claim() for a substituted model and reap_jam_copy_claims() for a dead worker. A failed or reaped attempt is not retried for 15 minutes, then an hour (next_attempt_at, jam_copy_retry_at()), so a provider outage has to last over an hour and a quarter before a request falls back rather than spending all three attempts in seconds. When the jam_copy ceiling refuses a charge, defer_jam_copy_for_budget() parks a cycle that a claim at the next monthly reset could still take — one opening more than jam_copy_claim_margin() (two leases, the claim's own margin) after it — until then, with its attempt returned, and falls back only one that opens earlier — so an exhausted month no longer condemns next month's cycles. The claim mints the credit reference and stores it on the row before any charge, and every path that does not deliver copy — a failure, a lapsed lease, a deleted jam, a jam that opened mid-answer — returns it through refund_system_ai_credits(). quest_jam_copy_still_open() is the one rule the claim, the completion and the reaper share: still attached to its template, active, not started, nobody joined — so a retired template's queued cycles are expired rather than claimed, and a jam's copy never changes under the people who signed up, and quest_jam_entries are never touched. Templates gain an optional copy_guidance for the constraints a variation must respect. ai_credit_entries.user_id is now nullable for origin = 'system' rows only (ai_credit_entries_user_unless_system), and charge_system_ai_credits() accepts no account for jam_copy alone, because a jam is run for nobody; the spend still books against the existing jam_copy monthly ceiling. charge_system_ai_credits() now also refuses (22023) a model the persona catalog does not know, for every platform-funded kind: ai_credit_cost() prices a failed lookup at 0, the same as a free persona, and a background job has no tier trigger in front of it, so a typo'd or not-yet-catalogued paid model would otherwise have run outside the ceiling with no ledger row. complete_jam_copy() takes FOR UPDATE on the jam before it checks for entries, because an entry insert locks its jam only FOR KEY SHARE and a plain copy update does not conflict with that — without the lock a join and a rewrite could both commit. A hand edit wins: an AFTER UPDATE trigger on quest_jams (quest_jams_retire_copy_on_edit) expires a cycle's pending or generating request, refunding any reservation, when its title, description or theme is changed — so a correction made in /admin/quest-jams is not overwritten by an answer built from the template. complete_jam_copy() settles its request before writing the jam, so its own rewrite does not trip that trigger, and locks the jam before the lease, in the same order as an edit, so the two cannot deadlock. ensure_quest_jam_cycles() is unchanged and now also runs hourly as the ensure-quest-jam-cycles pg_cron job, so the next cycle is minted and queued without a page visit, and reap_jam_copy_claims(5, 3) runs every five minutes as reap-jam-copy-claims, so a claim charged by a worker that died is refunded even once the flag is off and no claim will run the reaper. (#548)

  • Timestamps are now the correct instant in every session timezone. set_updated_at() — shared by all 32 *_set_updated_at triggers — wrote timezone('utc', now()), which is the UTC wall clock with no zone; stored into a timestamptz it is re-read in the session's zone, so any session not running UTC stored updated_at off by its offset. 20261012120000_utc_now_timestamps.sql makes it now(), resets the 80 timestamptz column defaults that used the same expression, and re-issues the 21 other functions that stamped or compared a timestamptz with it — quest and jam completion, guild membership, claims and invitations, the free-tier 24-hour window, the credit period, quest-jam cycles — each from its live definition with only that expression changed, so grants, owners and search_path are untouched. The four functions that use the UTC wall clock on purpose, to bucket by UTC calendar day or month, keep it. No backfill: production runs TimeZone = UTC with no role or database override, so its rows were already correct; a local or CI database built in another zone should be reset. supabase/tests/utc_now_timestamps.sql runs as America/Toronto and fails on the old definitions, and pins the four exceptions so a new function cannot reintroduce the pattern. (#699)

  • 20261013120000_weekly_progress_digest.sql adds the weekly progress digest: weekly_digest_deliveries as its outbox and ledger, weekly_digest_content() for the frozen body (finished tasks are read from every quest the person still has, since finishing a quest's last task completes it; open tasks and stalled quests from active ones; "finished this week" is the seven local days ending at the most recent local Sunday 17:00, so the 17:25 and 18:25 runs report the same week and consecutive weeks neither drop nor repeat anything), weekly_digest_candidates() for who is due, enqueue_weekly_digests() for the hourly pg*cron job enqueue-weekly-digests (25 * \* \* _), and the worker's claim_/complete\_/fail_weekly_digest_delivery(). A partial unique index on (user_id, week_start)makes a second report for the same week impossible, so re-running the enqueue sends nothing new.notification_preferencesgainsweekly_digest_emails, on by default, and update_my_notification_preferences()andset_notification_category_by_token()accept it and theweekly_digestcategory. The table holds email addresses and has RLS with no policies and no client grants; every new function is revoked fromanonandauthenticated. (#660)

CI/CD

  • ci.yml runs four jobs instead of six. GitHub bills every job rounded up to a whole minute, and each paid its own checkout, Node/pnpm setup and install (45–60 s) before any work: 📊 Coverage comment cost 195 billed minutes in September for about ten seconds of work a run, and 🧹 Lint 684 for about 31 seconds of lint and typecheck. The coverage comment is now the last step of 🧪 Unit tests, which gains pull-requests: write — handed to the comment action alone, since its checkout no longer leaves the token in .git/config for the install and tests; it reads the report the coverage step just wrote instead of downloading the artifact, and keeps the old guard, so forks and Dependabot still get no comment and it is posted only after coverage passes. Lint, typecheck and build now run on one install in 🏗️ Lint, typecheck & build; typecheck and build are gated on the install rather than on the step before, so a lint failure still shows whether the other two pass. Lint feedback now arrives with the build rather than a minute in, and 🌍 E2E, still needs: build, now waits for lint and typecheck too. ✅ CI pass keeps its name — branch protection needs no change — and now needs build, test and e2e, still accepting a skipped e2e. Every other job now gets a read-only token: the repository's default GITHUB_TOKEN is read-write for nearly every scope, and ci.yml declared no permissions, so lint, build, e2e and the unit tests all ran pull request code holding it; the workflow now sets contents: read and only the unit test job widens it. Roughly 600 billed minutes a month at September's volume; docs/cicd.md explains the layout. (#723)

  • A merge into develop no longer re-runs ci.yml or supabase.yml: both now run on pushes to main only, and on pull requests into develop and main as before. The pull request run had already built the merge commit, so the push run repeated the same tree whenever develop had not moved in between — 520 billed minutes of CI and 85 of Supabase validation from 19 to 30 September, about 1,500 a month. supabase.yml's push to main stays, because db-push needs validate and db-test from that run before it pushes migrations to production. What this gives up is the one case only the push run caught: two pull requests that pass alone but break together. "Require branches to be up to date before merging" stays off for develop, since it would force a re-run each time develop moves; the next pull request into develop, whose run builds on the broken result, and the up-to-date release pull request into main cover it instead. (#722)

  • codeql.yml no longer runs on pull requests into develop or pushes to develop. Those runs were 327 of September's and 1,464 billed minutes, 14% of the repo's Actions bill, and each one ended in a 7-day SARIF artifact and nothing more: this private repository has no code scanning, so the workflow runs with upload: never and a finding reached nobody. CodeQL still analyses both languages on pull requests into main (so every release and hotfix is scanned before it merges), on pushes to main, and weekly, and can now be run by hand through workflow_dispatch. The weekly run now checks out develop: a scheduled workflow always starts on the default branch, main, which only moves at a release that was already scanned, so without the switch develop would go unscanned until its next release pull request. That switch is read from main's copy of the workflow and takes effect from the release that carries it. A finding introduced on develop now surfaces at the next Monday scan or release pull request rather than on its own pull request. Each run writes its findings to the job summary through the new scripts/codeql-summary.mjs: counts by level and security band, then up to 50 findings, security first. It never fails the job: the step runs after the artifact upload with continue-on-error, and a finding does not turn the scan red. This is expected to save roughly 1,300–1,400 billed minutes a month (#721).

  • The 🧪 Unit tests job in ci.yml now runs the web Vitest suite once per CI run instead of twice. It used to run the root pnpm test — whose turbo run test includes apps/web — and then pnpm --filter web test:coverage over the same suite again, although the coverage run already fails on a broken test and on a missed floor. The job now runs pnpm test:dev-tui, pnpm test:scripts and turbo run test --filter='!web' (the worker and any other workspace suite) as separate steps, then the coverage run as the only web run, saving about four minutes of each job — roughly 1,300 billed minutes a month at September's volume. The coverage artifact and the PR coverage comment are unchanged, and the root pnpm test script is untouched, so local runs still include the web suite. docs/cicd.md explains the split, and docs/ops/deployments.md and the precommit-gate skill now describe it. (#720)

  • model-catalog.yml checks Anthropic's model list without a stored key: the job now has id-token: write and a step that exchanges its GitHub OIDC token (audience https://api.anthropic.com) for an Anthropic access token under the rule pinned to repo:12529572-Canada-Inc/questify:ref:refs/heads/main, passed to scripts/check-model-catalog.mjs as ANTHROPIC_ACCESS_TOKEN and sent as a bearer token. The step is skipped when the ANTHROPIC_FEDERATION_RULE_ID repository variable is unset, and a failed exchange is a warning that leaves Anthropic on the OpenRouter proxy for that run, so the monitor never fails for a reason the catalog cannot fix. release.yml's worker deploy fails its preflight when fly.toml carries some but not all of the three federation identifiers (the worker would refuse the partial set at boot and restart-loop), no longer warns that ANTHROPIC_API_KEY is missing from the Fly app when the full set is there, and warns instead when the key is still set beside it, because federation wins and the secret is dead weight. (#726)

  • The pull request Lighthouse audit now runs only when a pull request touches something it measures: apps/web/**, packages/ui/**, packages/typescript-config/**, the root install and build inputs (package.json, pnpm-lock.yaml, pnpm-workspace.yaml, .npmrc, .nvmrc, turbo.json) or its own lighthouserc.json and workflow. Before this, every pull request paid for a full install, build and eight-route audit — 215 runs and about 11% of the repo's billed Actions minutes in September — including ones confined to migrations, the worker, docs or the changelog, which cannot change the pages it loads. Those now start no run and get no sticky comment. Lighthouse is not a required check, so a skipped run blocks nothing; workflow_dispatch and lighthouse-production.yml are unchanged. (#724)

Tests

  • scripts/ci-jobs.test.mjs pins ci.yml's job layout: no standalone lint or coverage comment job; lint, typecheck and build in that order on a single install, with typecheck and build gated only on it; the coverage comment as the unit test job's last step with pull-requests: write and the fork and Dependabot guard; ✅ CI pass by name with needs: [build, test, e2e]; a workflow-wide contents: read with test the only job that widens it and pull-requests: write the only write scope; and persist-credentials: false on the unit test job's checkout. It also runs ci-pass's own script: it passes when every job succeeds or e2e is skipped, and fails on a failed or cancelled build, a failed or skipped test job, a failed or cancelled e2e, or a result left unset. Eight of its eleven tests fail against the previous ci.yml. (#723)

  • scripts/workflow-triggers.test.mjs pins where ci.yml and supabase.yml run: pushes to main only, pull requests into develop and main, and db-push still gated on needs: [validate, db-test] and a push to main, so develop cannot drift back into either push trigger and the production migration push cannot lose its checks. Restoring develop to either push trigger fails it. (#722)

  • scripts/codeql-summary.test.mjs pins codeql.yml's triggers (pull requests and pushes to main only, the weekly cron and workflow_dispatch kept), the develop checkout on the schedule, both languages, and the summary step reading the directory the analysis writes with continue-on-error after the artifact upload. It also covers the script: rule-default levels, suppressed results dropped, GitHub's security-severity bands, security findings sorted first, paths decoded from their SARIF URIs (%5Bid%5D → [id]), table-cell escaping (backslashes before pipes, and code spans for rule ids and paths), the 50-row cap, and a missing SARIF directory giving a warning rather than a failure (#721).

  • Anthropic authentication is pinned on both sides of the worker/web split: anthropic-auth.test.ts in each app covers the key-vs-rule choice, the exchange request body, caching, the shared in-flight exchange, the two-minute refresh with a fresh identity token, serving the cached token through a failed early refresh and refusing one about to expire, the opaque-401 message pointing at the Console's authentication events, and (web) the file → request-header → VERCEL_OIDC_TOKEN source order (the per-request header first, because the environment value is cached for the life of the instance) plus a stalled exchange giving up inside the caller's budget and the provider call being skipped when the exchange spent it all; the provider tests show a federated deployment making the exchange and then the completion with Authorization: Bearer and no x-api-key; the env tests refuse a partial set of federation identifiers; the worker loop tests run an Anthropic persona on a rule with no key at all; check-model-catalog.test.mjs pins providerAuthHeaders() preferring an access token over a key; the worker's Fly path is exercised against a fake machines-API socket (the request shape, a JSON-encoded token, malformed JSON rejected rather than thrown out of the response callback, a refusal, a non-JWT body, a socket that never answers, an unreachable socket); and the dev TUI test checks the shape of each federation id. (#726)

  • scripts/lighthouse-production.test.mjs pins the pull request audit's paths filter: it must be exact files and <dir>/** globs only, must match web app, UI package, lockfile, toolchain and Lighthouse config changes, and must not match migrations, pgTAP, worker, docs, scripts, changelog, ESLint config or other workflow changes. (#724)

  • Account deletion is covered end to end below the browser (#718): lib/account/deletion.test.ts pins the order that matters — every customer carrying the account's id found through Stripe search (all pages, a search failure keeping the account), open Checkout sessions expired and then every page of subscriptions cancelled on each before the delete, a failure in either (or in reading the billing customer) keeping the account with nothing removed, the audit, feedback, attachment and screenshot removal only after the delete — and that the audit row and feedback carry no personal data; actions.test.ts covers the typed-email confirmation, the database lock refusing on blockers, its release after a failed deletion, and the redirect; supabase/tests/account_deletion_lock.sql (36 assertions) shows the lock refusing every way of joining or being handed something while it holds, however old, refusing a second attempt, releasable only by the service role for the attempt that took it, and stopping once released or reaped at a day, account_deletion_cleanup.sql (11) covers the screenshot-orphan finder and the reaper job, account_deletion_cascade.sql (29) deletes an account that sits in every ON DELETE SET NULL column, owns a guild and has pending, declined, departed and accepted memberships elsewhere, each left as "Former member", and invitations linked to it left with no address, and fails when a new such column lacks a fixture, and account_deletion_lock_concurrency.sql (9 assertions, two dblink sessions) proves a join in flight is counted, a join after the lock is refused, and a second deletion attempt racing the first is refused; rls.sql gains eight assertions for the lock table and the new functions; the checkout route's refusal and its expiry of a session created mid-deletion, and the worker reaper sweeping the screenshot bucket, have tests; the party panel, the delete section, the login notice and the privacy page each have tests; billing/customers.test.ts covers the deleted-account webhook path.

  • supabase/tests/quest_ownership_transfer_guild_seat.sql (14 assertions) hands two quests to a member's guild seat — one with confirmed attendance — and shows each promoted seat kept as the same row on the same account with no guild link, a fixed roster still refusing that change outside the transfer, and both owner seats keeping their account when the guild is deleted while a seat that was never promoted is still detached. Six assertions fail against the function before 20261024120000_transfer_detaches_guild_seat.sql, and three fail without its attendance-guard exception (#718).

  • supabase/tests/party_invitations.sql gains two assertions (62 → 64): re-sending an expired invitation on a quest with 100 live links is refused with 54000, and the quest still has exactly 100 live links afterwards. Both fail against the function before 20261016120000_party_invite_cap_on_resend.sql, which let the send through and left 101. (#666)

  • supabase/tests/invitation_invitee_fk.sql (7 assertions) pins every auth.users reference on both invitation tables as ON DELETE SET NULL, and shows an accepted party invitation and an accepted guild invitation surviving the invitee's deletion with only invited_user_id cleared, still readable to the quest or guild owner and not to a stranger; rls.sql gains one assertion for the invited_user_id rule on both tables. Both fail against the schema before the migration. (#667)

  • apps/web/src/lib/guilds/actions.test.ts pins the legacy in-app accept's owner email: sent once when the pending row moved, addressed from the row's own guild rather than the caller's guildId, and never on a repeat accept (which still succeeds), a closed invitation, a failed re-read, a refused update, with email invitations off, or from declining or leaving. actions-roster.test.ts pins the accept's query chain, including the status = 'pending' filter. (#674)

  • supabase/tests/jam_copy_variation.sql (58 assertions) pins the queueing (template cycles only, never one already live, and a retired template's cycles expired rather than claimed), the lease (a stale lease charges nothing), the platform-funded charge with no account and the constraint that keeps user rows strict, the fallback on every failure path with the template copy left intact, the refund on failure, reaping and deletion, the jam that opens mid-answer, the worker-only grants, both cron registrations, the scheduled reaper refunding a dead paid claim with no claim after it, a hand edit retiring a queued or generating request (refunding it and refusing the late answer) while an update that leaves the copy alone does not, that an uncatalogued model is refused rather than priced as free, the 15-minute-then-hour retry spacing for failed and reaped attempts, and a budget refusal deferring a cycle that opens 30 minutes after the monthly reset but falling back one that opens inside the claim margin; supabase/tests/rls.sql gains 10 assertions for the new access rules — the queue table's RLS, no client table privilege, every worker RPC and helper closed to anon/authenticated and open to the service role, and account-less ledger rows invisible to and unwritable by a signed-in user; supabase/tests/jam_copy_concurrency.sql (9 assertions, two dblink sessions) proves a completion waits for a join in flight and then keeps the template copy, that a join waits for a completion in flight, and that a completion waits for a hand edit in flight and then writes nothing. Worker vitest covers the prompt and validator, charge-before-call ordering, repricing, the budget-exhausted fallback, and the flag gate, including that only the configured model's provider key enables the loop; scripts/worker-deploy.test.mjs pins the new flag's deploy path. (#548)

  • The pgTAP fixtures in rls.sql, ai_credit_limits.sql and quest_jam_seed.sql that stamped or compared a timestamptz with timezone('utc', now()) use bare now(), and the add-migration skill's "compare to bare now()" caveat is narrowed to the one thing still true: the wall-clock expression is only for UTC calendar bucketing. (#699)

  • The feedback Playwright flow now checks page width from 320px through 1440px with a long report URL, opens a feedback record from the mobile list, and completes test-user onboarding before checking admin access. (#710)

  • Added component coverage for the Quest Chronicle completion offer and a feature-gated Playwright flow that completes a quest, dismisses the offer, and checks the persistent action after reload. Raised the web statement and line coverage floors to 79.5%. (#703)

  • supabase/tests/weekly_progress_digest.sql covers the digest end to end in 72 assertions: the schedule and privileges, what each section of the report contains, the fourteen-day stall cut, local-date resolution across timezones, who is a candidate at which local hour, the empty-week skip, the opt-out row, quiet-hours deferral at both enqueue and claim, the address and deleted-quests skips, a quest completed during the week (its finishing task is reported, and completing the last active quest before the claim does not skip the report), consecutive weeks meeting exactly at the local Sunday 17:00 whichever hourly run sends, across a DST change, the retry and permanent-failure paths, and the one-click link. rls.sql adds eight assertions for the new table and functions. The worker gains 31 vitest cases across weekly-digest/render, deliver and loop. (#660)

Documentation

  • docs/cicd.md gains a "Why a merge into develop runs no checks" section — what the push runs cost, why supabase.yml keeps its push to main, and the recorded decision to leave "Require branches to be up to date before merging" off for develop and rely on the next pull request and the release pull request instead — with a matching note under the develop branch-protection rule. The workflows table now gives both workflows' real triggers; supabase.yml's row used to say only "push/PR touching supabase/**". The CI/CD inventory in docs/ops/deployments.md no longer lists a develop push among ci.yml's triggers. (#722)

  • docs/cicd.md gains a "Why CodeQL does not run on develop" section: what each trigger scans, why the weekly run checks out develop, the latency trade-off, and where findings now appear. The workflow table row is updated, and the main branch rule now names the two Analyze checks, which run on every pull request into main. The codeql.yml entry in docs/ops/deployments.md also describes the current triggers; it had claimed CodeQL ran on pushes and pull requests to develop only (#721).

  • docs/ops/deployments.md gains an "Anthropic: workload identity federation, not a key" section — the issuer and rule each workload's token is matched against, where the three identifiers live for Vercel, Fly and GitHub Actions, that federation wins over a leftover key, that every denial reads "Authentication failed" on the wire and the Console's authentication events tab names the reason, and the Vercel prerequisites (OIDC federation on in team issuer mode; two-hour production tokens). README.md, docs/ai-investigations.md and .env.example point at it from the provider-key rows. (#726)

  • docs/cicd.md gains a "Why Lighthouse skips some pull requests" section listing the filtered paths and why each is in or out, and warning that a path-filtered check stays pending, so the filter must become a job-level if before Lighthouse can be made a required check. The workflow table row says which pull requests it runs on. (#724)

  • docs/notifications.md covers the legacy in-app guild accept in the owner join email section: why it filters its own UPDATE on pending rather than claiming, what a repeat accept answers, and that the guild comes from the updated row. The "sends nothing" note it replaces is gone. (#674)

  • docs/ops/scheduled-jobs.md gains the enqueue-weekly-digests job and a "Weekly progress digest (#660)" section beside the other three, covering the local-Sunday selection, the week key, the empty-week rule and why the report covers only quests the person owns. (#660)

  • docs/schema.md documents the digest: the migration-order row, a weekly_digest_deliveries section with its service-role-only access model and the weekly_digest_emails opt-out, the six functions under Helper functions, and the RLS coverage entry. (#660)