Questify

Product

Patch notes

What shipped in Questify, release by release, from the changelog we keep on every change. The database, test and tooling work behind each release is folded under Technical changes.

49 releases since Questify 2.0.0 on . Latest: 3.8.0 on .

3.8.0

Released

Features

  • A public Patch notes page at /patch-notes renders the released sections of CHANGELOG.md, newest first, and is linked from the site footer beside Privacy and Terms (#658). The six newest releases are shown in full — Features, Design, Fixes and Security as the entries were written, with Database, Tooling, CI/CD, Tests and Documentation folded under a closed "Technical changes" disclosure (a <details>, so the toggle needs no client code) — and every older release is a row in an "Earlier releases" list (version, date, "4 features · 2 fixes") linking to its own page at /patch-notes/[version], which also links to its newer and older neighbours. The page covers every release from Questify 2.0.0 (2026-05-26) up; the 1.x sections describe the retired Nuxt application and are not parsed. ## [Unreleased] is never shown. The Markdown is inlined at build time (import … from '../CHANGELOG.md?raw', a webpack asset/source rule in next.config.mjs), parsed by apps/web/src/lib/patch-notes/parse-changelog.ts and rendered with react-markdown as HTML in a Server Component, so the page reads no database and ships no client bundle. The parser is strict — a subsection heading outside its table, a released section with no subsections, a subsection with no entries, a malformed release heading or date, or prose that is not an entry throws — and generateStaticParams() on the release page runs it during next build, so a changelog the page cannot render fails the build rather than rendering blank. Registered as a public route: canonical URL and robots metadata, the sitemap (the index weekly at 0.5, and every release page yearly at 0.3, since a released section is never edited), the two Lighthouse audit lists and the footer; the middleware already leaves it public. A page-level toggle, a search, and an RSS feed were left for later (#658).

Fixes

  • A page that crashed with no error boundary of its own — a public, auth, onboarding or invitation page, or the root layout — now shows the branded "Something went wrong" panel with Try again and a link home, instead of Next.js's unbranded "Application error: a client-side exception has occurred" screen. apps/web/src/app/global-error.tsx reuses the (app) and admin boundaries' RouteErrorPanel, so the raw error message is still never shown (#657).

Security

  • A failed AI investigation no longer sends the name of the attachment or the reference link it could not read to Sentry. The worker's failure messages name them (Could not read <file name>: …, Cannot send <file name> to a model: …, Cannot read reference link <url>: …), and the shared scrubber (scrub.ts in both apps) now replaces the name or link with [Filtered] up to the last : in the message, so a name that itself contains : or a line break is still masked and the report keeps the reason that follows it. Found by review of the 3.8.0 release before any DSN was set, so nothing was sent (#657).

  • Every third-party GitHub Action is now pinned to a full commit SHA, with its release as a trailing comment, instead of a movable tag or branch — 15 references across eight workflows: pnpm/action-setup (v4.3.0, what v4 pointed at), davelosert/vitest-coverage-report-action (v2.13.1), marocchino/sticky-pull-request-comment (v2.9.4), treosh/lighthouse-ci-action (12.6.2), supabase/setup-cli (v1.7.3) and superfly/flyctl-actions/setup-flyctl (1.6). Before this, whoever controlled one of those tags controlled the step that ran it; setup-flyctl was on @master in the production release job, beside the deploy secrets. Each pin is the commit its tag already resolved to, so no step runs different code; newer majors (pnpm v6, sticky-comment v3, setup-cli v3) are left for Dependabot to propose one at a time. GitHub-owned actions/* and github/codeql-action/* stay on tags. A local CodeQL actions security-and-quality run goes from 15 unpinned-tag findings on develop to none; the missing-workflow-permissions half of the issue was already closed by ci.yml's read-only default token (#723) (#728).

  • The PRs Only repository ruleset now targets main and develop as two patterns. It had one malformed pattern, refs/heads/"main", "develop", that matched no branch, so it would have protected nothing even once rulesets are enforced. This is a repository setting changed in GitHub, not a file in this diff; the rulesets stay unenforced while the repository is private on GitHub Free (#731).

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

Database

  • Every table now has one permissive policy per command and role (#659). 20261130120000_merge_permissive_policies.sql merges the 34 overlapping sets the performance advisor reported (multiple_permissive_policies), where Postgres evaluated every policy of the set for every row: each merged predicate is the literal OR of the originals — nothing is simplified away, since user_owns_quest() and user_owns_task() ignore quests.deleted_at while user_can_access_quest() checks it — and where a to public policy overlapped a to authenticated one the result is split by role, anon keeping only the arms that can hold without a user (so a signed-out read of a public quest's tasks no longer calls user_owns_quest() per task). The admin for all policies on the catalogs (packs, personas, Jams, Challenges, roles, privileges, role assignments) become one policy each for INSERT, UPDATE and DELETE with the admin term folded into the merged SELECT, and the two auth.role() = 'service_role' policies on the stall tables are dropped: service_role bypasses RLS and never evaluated them, so the only session they could admit was a non-bypassing role with a forged claim. With no anon-evaluated policy calling them, user_owns_quest() and user_owns_task() lose their anon EXECUTE (two anon_security_definer findings gone). Policy names change where the rule changed shape; no row that any caller could read or write before is admitted or refused differently, which supabase/tests/permissive_policies.sql proves arm by arm and the unchanged rest of the suite confirms (#659).

  • The 55 RLS policies that compared a column to a bare auth.uid() (or auth.role()) now wrap the call as (select auth.uid()), and the 20 foreign keys without a covering index have one (#659). 20261129120000_rls_initplan_fk_indexes.sql alters each policy's USING / WITH CHECK in place — predicates, names, roles and commands are otherwise the live ones, so nothing about who can read or write what moves, which the full pgTAP suite passing unchanged confirms — because Postgres treated the bare call as per-row work (the function is STABLE, not IMMUTABLE), re-reading the JWT claim for every row a policy filtered; wrapped, it is one InitPlan per statement. The indexes are the <table>_<column>_idx the rest of the schema uses, partial (where … is not null) on the nullable columns, and are what a delete of a user, task, quest or catalog row walks to enforce the constraint instead of scanning the child table. Production holds too few rows for either to show today; this is done before it does. The advisor's 25 "unused" indexes were left alone, and the overlapping permissive policies (0006) are a separate change (#659).

Tooling & Configuration

  • Production errors from the web app and the AI worker are now reported to Sentry — @sentry/nextjs in apps/web (browser, Node and edge) and @sentry/node in apps/worker — so a web 500, a client-side React crash or a failed investigation is grouped, kept and alertable instead of living only in Vercel's or Fly's logs, or nowhere. It is off unless a DSN is set: NEXT_PUBLIC_SENTRY_DSN for the web app (public by design, and inlined at build time — with it unset next.config.mjs skips withSentryConfig, so no client SDK or wrapped handler exists, and register() returns before importing anything) and SENTRY_DSN for the worker, so local, Docker, CI and preview runs send nothing. The web app initialises in src/instrumentation.ts (with experimental.instrumentationHook now always on, because next start re-reads next.config.mjs at run time, where the build-time DSN is usually absent) and src/instrumentation-client.ts, withSentryConfig wraps route handlers and Server Components, and the error.tsx boundaries report client-side render errors through reportBoundaryError(), which loads the SDK on demand and also reports a Server Component error's redacted copy tagged server_digest, because on Next 14 a throw from a nested async Server Component is not wrapped server-side and that copy is its only report. The worker reports a failed investigation before marking the row, tagged with its stage, failure reason and queued model and with the investigation id, task id and every model the fallback chain tried as context; a poll-loop outage once per streak; and a crash out of main(), flushed before exit — its Sentry settings are read on their own before the rest of the env, so an invalid env var is reported too. Every event goes through one deliberate beforeSend / beforeBreadcrumb scrubber (apps/web/src/lib/monitoring/scrub.ts, copied byte for byte to apps/worker/src/monitoring/scrub.ts): only user.id survives, request bodies, cookies, query strings and all but five headers are dropped, the value of any content or credential key (title, details, prompt, body, email, token, …) is replaced at any depth, and email addresses, tokens, URL query values and the row values Postgres quotes in constraint errors are masked in every string; tracing, replay and sendDefaultPii are off. Both apps report as questify@<root package.json version>. The variables are registered in .env.example, both env schemas (getSentryDsn() reads the web DSN on its own so monitoring never depends on the Supabase keys parsing), the web Dockerfile, docker-compose.yml and turbo.json; the worker's SENTRY_DSN is in the dev TUI's MANAGED_KEYS (empty by default), while the Playwright web server stays without a DSN. Server Actions and the worker's email, nudge, jam-copy and attachment loops are not reported yet (#657).

  • New apps/web/turbo.json extends the root config and adds $TURBO_ROOT$/CHANGELOG.md to the build and test inputs of the web workspace, so a changelog change now changes both task hashes. Turbo's default inputs stop at the package boundary, and CI caches .turbo (and vercel.json builds through Turbo), so without it a release that promoted only the changelog and the version files could have restored the previous .next output — the new release missing from /patch-notes and the parser's build-time check never run — and a cached web#test could have hidden a malformed entry (PR #920 review) (#658).

  • apps/web/Dockerfile copies CHANGELOG.md into the build stage, because /patch-notes inlines it at build time and the standalone image never carried the repository root; next.config.mjs gains the ?raw webpack rule (asset/source) that makes import text from './file.md?raw' a string, typed by apps/web/src/types/raw-imports.d.ts, and Vite serves the same suffix natively under Vitest (#658).

  • New .github/dependabot.yml keeps the action pins current: weekly on Mondays it opens one pull request against develop (the default branch is main) with every minor and patch release of a GitHub Action, rewriting each SHA and its version comment together, and a pull request of its own for each major release. It covers github-actions only; npm dependencies are unchanged (#728).

  • supabase/advisor-allowlist.txt's BACKLOG section is empty: the 34 multiple_permissive_policies_* entries and the two anon_security_definer_function_executable_* lines for user_owns_quest() / user_owns_task() are gone with their findings, so a second permissive policy for one command, or a new anon grant on either helper, fails the advisor step (#659).

  • supabase/advisor-allowlist.txt lost its 55 auth_rls_init_plan_* BACKLOG entries, so a new policy that compares a bare auth.uid() fails the advisor step rather than being waved through; the remaining backlog is the 34 multiple_permissive_policies entries (#659).

CI/CD

  • The E2E job in ci.yml runs on ubuntu-24.04 instead of ubuntu-latest. When ubuntu-latest moved to Ubuntu 26.04, Playwright 1.60 refused to install Chromium there, so the job failed before any test ran (#657).

  • release.yml passes SENTRY_AUTH_TOKEN (a production environment secret) and the SENTRY_ORG / SENTRY_PROJECT variables to vercel build, so a release with a Sentry DSN uploads its browser source maps under questify@<version> and deletes them from the output before deploying; without the token the build still succeeds. The worker deploy adds --env SENTRY_RELEASE=questify@<version> from the root package.json, so the worker reports under the same release name as the web app (#657).

  • The Playwright web server (apps/web/playwright.config.ts) now starts next dev with NEXT_TELEMETRY_DISABLED=1. On a fresh runner next dev sends its first-run telemetry session event before printing "Ready", and when telemetry.nextjs.org did not answer that wait outlived the 120 s the server is given, so the E2E job failed with "Timed out waiting 120000ms from config.webServer" before a single spec ran — twice in a row on PR #920 with no change to the app under test, and reproducibly in a sandbox where that host is unreachable. The Docker image already opted out; CI now does too (#658).

  • /patch-notes and one release page, /patch-notes/2.0.0 (the oldest, so its URL never moves), are audited by both Lighthouse workflows (lighthouse.yml, lighthouse-production.yml) and pinned in scripts/lighthouse-production.test.mjs, so both public pages — the index and the separately rendered release page with its own heading, disclosure and neighbour links — are held to the same accessibility floor as the legal pages (#658).

Tests

  • The Sentry scrubber is pinned by apps/web/src/lib/monitoring/scrub.test.ts — the user reduced to its id; the request body, cookies, query string and non-allow-listed headers dropped; content and credential keys replaced at any depth whatever their casing; emails, JWTs, bearer credentials, URL query values and Postgres Key (…)=(…) / Failing row contains (…) values masked in exception messages; invitation tokens and the /recap/[shareId] share UUID masked in paths while other UUIDs stay; stack-frame locals, UI breadcrumb selectors and console arguments dropped — and by apps/worker/src/monitoring/scrub.test.ts for the worker's job context and PostgREST URLs. New scripts/monitoring-scrub-twins.test.mjs fails pnpm test:scripts if the two copies drift. sentry.test.ts and instrumentation.test.ts cover the DSN gate (nothing initialises unset or blank), the options and the boundary reporter; worker sentry.test.ts covers init, the investigation context and the no-DSN no-op; processor.test.ts asserts a failed investigation is reported with its ids, stage, reason and attempted models and no prompt text, and a successful one is not; loop.test.ts asserts one report per failure streak; route-error.test.tsx, global-error.test.tsx and env.test.ts cover the rest (#657).

  • scripts/turbo-inputs.test.mjs fails pnpm test when apps/web/turbo.json stops extending the root config, drops $TURBO_ROOT$/CHANGELOG.md or $TURBO_DEFAULT$ from the build or test inputs, or changelog.ts stops importing the repository-root file; scripts/lighthouse-production.test.mjs says why CHANGELOG.md still does not start the Lighthouse audit now that it feeds a page (#658).

  • apps/web/src/lib/patch-notes/parse-changelog.test.ts parses the repository's real CHANGELOG.md through the page's parser and floor, so a pull request whose entry the page could not render fails pnpm test before next build gets to fail on it, and pins each malformed shape by line number against a fixture; changelog.test.ts checks the newest release matches the root package.json version and the oldest is the floor; release-notes.test.tsx covers the article, the Markdown rendering (emphasis and code kept, raw HTML and images dropped), the closed technical disclosure and its count, a release with nothing user-facing, the heading levels on the index versus a release page, and the archive rows; page.test.tsx and [version]/page.test.tsx pin the index split, the cadence line, valid heading order, the neighbour links, the 404, generateStaticParams(), dynamicParams = false and the metadata; page.render.test.tsx renders the index and every release page from the real file. site-footer.test.tsx, sitemap.test.ts and redirects.test.ts pin the footer link, the sitemap entry and the route staying public, and e2e/patch-notes.spec.ts loads the page signed out, opens the disclosure, follows the footer link to a release page and expects a 404 for a version never released (#658).

  • New scripts/action-pins.test.mjs fails pnpm test when a workflow references a third-party action by anything but a 40-character commit SHA, when a pin lacks its # vX.Y.Z comment, when one action is pinned to two different commits, or when .github/dependabot.yml stops covering github-actions or targeting develop — so an unpinned action is caught in its own pull request rather than in the summary of a CodeQL run, which no longer runs on pull requests into develop. All five checks pass on this branch; the two pin checks fail on develop's workflows. scripts/ci-jobs.test.mjs now expects the coverage comment action as a SHA with a v2.x comment (#728).

  • supabase/tests/permissive_policies.sql (111 assertions) pins part 3 of #659: no table has more than one permissive policy for one command and role (splinter 0006, with for all and to public expanded the way the lint does), and then each merged policy arm by arm — owner, party member, stranger, Admin, Support and anon against public, private, deleted and foreign quests, tasks, investigations, party seats, achievements, feedback, profiles, personas, packs, Challenges, Jams, role assignments, stall rows and recaps — including that the owner arms still reach a deleted quest's rows where the party arm does not, that a forged service_role claim no longer reads the stall tables while the real service role still does, and that anon can no longer call user_owns_quest() / user_owns_task() yet still reads a public quest's tasks. Its 106 behavioural assertions pass on the schema before the merge as well; only the invariant and the forged-claim assertion fail there. security_advisors.sql's pinned anon-callable set shrinks by the two helpers (#659).

  • supabase/tests/performance_advisors.sql restates splinter's two performance lints as pgTAP assertions — no policy in public evaluates auth.*() or current_setting() per row (0003), and every foreign key has a covering index (0001, which is INFO-level and so never failed the CI advisor guard) — pins the 20 indexes by name, and shows a signed-in read of quests planning the owner term as an InitPlan parameter rather than a per-row claim read. The 0003 check is stricter than splinter's: it strips every wrapped (select …) occurrence and flags any call left, so a bare auth.uid() beside a wrapped one is caught (splinter's like / not like pair lets it through), and the file proves that on a probe table with one policy per shape. Each of its 25 assertions fails on the schema before the migration (#659).

  • The Quest page (/quests/[id]) is now split so its logic is testable without mounting an async Server Component (#683). loadQuestDetailPage() in apps/web/src/lib/quests/detail-page.ts owns every read and access decision (sign-in for a signed-out visitor, 404 for anyone else on a missing or private quest, which reads run for the owner, a member or a visitor, and what each optional read degrades to), taking its feature flags as a parameter (questDetailFlagsFromEnv() builds them from env) and the clock as an argument; buildQuestMetadata() builds the <head> beside it. QuestDetailView in components/quests/quest-detail-view.tsx is the page's markup as a synchronous Server Component, and page.tsx is now only the glue that maps the loader's result to redirect() / notFound(). Nothing a user sees changed: the 31 existing whole-page render tests pass unchanged through the split, and the source-order layout test now reads the View's file (quest-detail-view.layout.test.ts, moved from page.test.ts). The new detail-page.test.ts covers each viewer's result, the reads a public visitor or party member must not trigger, invitation expiry against the given clock, null-versus-empty degradation for attendance, the streak and the recurring template, earlier-cycle pins, the tier-narrowed personas and the metadata; quest-detail-view.test.tsx covers the visitor, member and owner views, the breadcrumb, the owner toolbar's upgrade link, the party panel's roster lock, attendance confirmation around the scheduled start, the streak, the Details card's model and theme, and the chronicle; page.render.test.tsx adds the redirect, the 404 and generateMetadata. The loader, the View and the page are each fully covered by line, up from the page's 72% (#683).

Documentation

  • New docs/ops/error-monitoring.md records what Sentry captures in each app, what the scrubber removes, how releases are named, the setup steps (two Sentry projects, NEXT_PUBLIC_SENTRY_DSN in Vercel Production only, the GitHub SENTRY_AUTH_TOKEN secret and variables, the worker's Fly secret) and how to verify each surface; docs/ops/deployments.md lists the new Vercel variable and Fly secret (#657).

  • docs/cicd.md records that CHANGELOG.md is a Turbo input of web and why, and no longer says a changelog-only pull request cannot change a byte of the pages Lighthouse measures — it says why the audit still skips one (#658).

  • AGENTS.md → Commit, PR, and Changelog Guidelines and the changelog-entry skill say that CHANGELOG.md is now the public /patch-notes page and what its parser rejects — an unknown ### heading, an empty subsection, prose that is not an entry — so an entry is written knowing which audience sees it and a new heading goes into PATCH_NOTES_HEADINGS deliberately (#658).

  • docs/cicd.md gains "Third-party actions are pinned to commits": why a tag is not enough, which actions are exempt, how Dependabot's weekly pull request should be reviewed, how to pin a new action by hand with git ls-remote, and what scripts/action-pins.test.mjs enforces. The comment above release.yml's flyctl step no longer claims superfly publishes no version tags (#728).

  • docs/cicd.md → "Branch protection" no longer describes rules as if they were in force. No branch rule is enforced on this repository: it is private, the organization is on GitHub Free, and GitHub enforces neither classic protection nor rulesets there below GitHub Team, so a red ✅ CI pass does not block a merge and nothing stops a direct push to main or develop. The section now says so, lists the conventions that hold the line instead (pull requests only, merge on a green ✅ CI pass, update a release pull request from main before merging it), records the three dormant rulesets and what enforcing them would still lack, and keeps the old rules as a target. The target main rule drops "Require linear history", which would block every release since releases land on main as merge commits, and notes that with one Code Owner the approval rules need a bypass. The deploy diagram's required arrow, AGENTS.md, README.md, the pull request template, .github/CODEOWNERS and the comments in ci.yml, lighthouse.yml and scripts/ci-jobs.test.mjs that said branch protection requires ✅ CI pass were brought in line (#731).

  • docs/schema.md gains the 20261130120000 migration-order row and the permissive_policies.sql coverage entry; docs/ops/database-advisors.md records the backlog as empty and the merge pattern for a for all admin policy; two code comments and docs/notifications.md stop naming policies that no longer exist (#659).

  • docs/schema.md's Indexes section now says a new FK column gets its covering index in the same migration and names the pgTAP file that fails without one; docs/ops/database-advisors.md records auth_rls_initplan as cleared and the permissive-policy lint as what remains of the backlog (#659).

  • docs/testing-server-components.md records how to test an async Server Component page (#683): a page whose reads decide access, run per viewer or flag, or degrade on failure is split into a load*() in src/lib, a synchronous View and a thin page, with flags and the clock passed in; a page that only reads and renders may keep the render(await Page(props)) pattern the existing page tests use. It covers what each of the three tests asserts and where to pin layout order, with /quests/[id] as the worked example. AGENTS.md's Testing Guidelines point to it (#683).

3.7.0

Released

Features

  • A pinned answer can be found across Quests from the task list (#902). The task list (on /tasks and the Quest page) gained a counted Pinned chip after the status chips, shown while the Quest has any pinned answer: on, it lists only tasks with at least one, each card with its Investigations panel open (pinned answers lead it, so no card scrolls the page) and the "N completed" group opened for the visit when a finished task is among them, without changing the remembered preference, and it composes with the status chips and the search — a pinned task that is done is one chip away, and with Pinned on the status chips count pinned tasks. It is kept in the URL as ?taskPinned=1, beside taskStatus and taskSearch, so a link to "my pinned answers in this Quest" works, and "Clear filters" clears it with the rest. The search now also matches the question and answer text of a task's pinned investigations (for an older answer recorded without its full text, the summary the card shows instead) — not every investigation, so a word the model used once does not list every task — and a card the search found only inside a pinned answer opens its panel (and, for a finished task, the completed group) so the hit is visible; a panel the user closes stays closed until the filter changes. While the chip is on, the /tasks Quest list shows each Quest's number of tasks with a pinned answer in place of its progress and dims Quests with none (the phone's Quest picker appends "· N pinned"), read once per page through countPinnedTasksByQuest() — ids only, RLS-scoped, bounded at 1,000 pins — and kept live for the open Quest; an unreadable count shows no badge rather than zero. The search still covers only the open Quest, and the bulk bar and keyboard shortcuts act on the filtered list as they do under a status chip. A cross-Quest "pinned answers" page and a Quests-index badge were left for later (#902).

  • The Quest page's rail gained a Pinned answers section, after Discussion, listing every pinned answer on the Quest's tasks newest pin first — task title, the question (one line), the date and the model that answered — so "where was that answer about the supplier?" no longer means opening each task's chip in turn (#901). On a recurring cycle a From earlier cycles group lists the pins of the cycles below this one that the viewer can still access, each labelled "Cycle N" and linking to that cycle's page. A row goes to the card rather than repeating the answer: it links to ?panel=investigations&answer=<id>#task-<id>, and the task list clears a search or status chip that hides the card, opens the "N completed" group for a finished task (without remembering it), opens the Investigations panel and scrolls to that answer, focused and outlined — the one clicked, even when its task holds several pins; the same link works on arrival, so a reload or a copied link lands the same way. The section follows pins and unpins made on the cards without a reload (and drops a deleted task's pins), and is absent when nothing is pinned. The list shows at most 50 pins. GET /api/tasks now also reads a Quest's pinned investigations on their own, newest pin first, and adds any its newest-first read missed, so on a Quest with more investigations than PostgREST's row cap a pinned answer still reaches its card and the rail. Investigations stay private: a visitor to a public Quest gets no section and the page issues no investigation query for them. The optional "N pinned" count on the Details card was not added (#901).

  • An answer that turned out to be the answer can be pinned on the task card (#900). Each completed investigation has a pin button (aria-pressed, "Pin this answer" / "Unpin"); a pinned one shows a Pinned badge and moves to the top of the task's list, latest pin first, with the rest in their usual newest-first order, and the investigations chip carries a pin glyph before its count ("2, 1 pinned") so the pile's best answer is visible from a closed card. Pending, running and failed runs offer no pin. The pin is shared by the whole party, through the new PATCH /api/tasks/[id]/investigations/[investigationId] with { pinned: boolean } (409 for an answer that has not succeeded). On a recurring Quest the From earlier cycles rows carry the same badge and come pinned-first within the group, but are pinned only on their own cycle, and the Ask dialog's note prefers a pinned earlier answer over the most recent one, reading "Pinned answer from Cycle N" (#900).

Fixes

  • A reclaimed investigation's model fallback is now capped by what its charge still holds, not by what it was first charged. When a worker died after settle_investigation_credits() had booked a partial refund but before marking the row succeeded, the stale-row reclaim could offer a substitute model whose full hold was more than reprice_investigation_attempt() would refund, so the account paid more than the attempt was holding, or the reprice was refused and the row kept naming the failed model. currentAttemptCharge() in apps/worker/src/processor.ts now reads the reference's ledger in one query and applies the RPC's own arithmetic: the spend, less refunds that settle it (settles_entry_id), bounded by what the reference's user-funded rows still net. The settle and reprice functions are unchanged, and there is no migration (#740).

Security

  • Reference-link thumbnails only load an http(s) URL as an image (#730). LinkThumbnail put the link a user saved straight into <img src> and checked the protocol only for its favicon fallback; it now applies the same parseUrl check first, so a javascript:, data: or relative value goes straight to the link icon instead of reaching the image. CodeQL reported it as js/xss-through-dom; an <img> does not run script, so this closes a finding rather than an exploit (#730).

  • Text the worker did not write can no longer forge a line in its logs (#729). Error messages from providers, the database and Fly's machines API, a model's Quest Jam title, and the errors the eight poll loops and the crash handler print now pass through logSafe() (apps/worker/src/log-safe.ts), which escapes line breaks and other control characters (\n, \r, \uXXXX, U+2028/2029) instead of dropping them, so each entry still says what the value held but stays one line. An error is printed as its stack on that one line, so the poll loops keep their frames. In the web app the three achievement wrappers in apps/web/src/lib/achievements/server.ts pass their context as a %s argument instead of building the format string from it, so a %s or %o in a quest id can no longer swallow the real error, and log both through the same helper (apps/web/src/lib/log-safe.ts). This closes CodeQL's nine js/log-injection findings and the js/tainted-format-string one; the rest of the web app's server logging is unchanged (#729).

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

Database

  • A task's investigations can be pinned, by anyone on the Quest for everyone on it (#900). 20261128120000_pin_task_investigations.sql adds task_investigations.pinned_at and pinned_by (FK → auth.users, on delete set null, so a deleted pinner leaves the pin in place), a partial index on task_id where pinned, and two CHECKs: only a succeeded row is pinned, and there is no pinner without a pin. The pin is written through the existing investigation UPDATE policies (user_can_access_quest()), so a departed party member or removed guild seat cannot pin or unpin, and a visitor to a public Quest still sees no investigations at all. A new task_investigations_pin trigger makes the stamp the server's — a signed-in pin records now() and the caller, an unpin clears both, a pin that stays keeps its pair (no forward-dating one to the top, no crediting it to someone else), a signed-in insert arrives unpinned — and unpins a row whose status leaves succeeded, so a re-run that fails is not the answer any more. guard_investigation_model_type() now lets an update whose only changed columns are pinned_at, pinned_by and updated_at past the plan check; before, every signed-in update re-checked the row's model, which would have stopped a free member pinning a Pro owner's answer and anyone pinning an answer from a retired persona. Any other change in the same statement is still checked. get_task_investigation_history() is re-created to return pinned_at and list pinned answers first (latest pin first), keyset-paging on the pin as well; its grants are unchanged. The worker's earlier-answers read (get_shared_prior_cycle_answers()) deliberately keeps its newest-cycle-first order (#900).

Tooling & Configuration

  • The dev TUI reads .env for rewriting with a single open (#730). readEnvForUpdate() in scripts/dev-tui/env-file.mjs used to lstat the file and then readFile it, which left a window in which .env could be swapped for a symlink between the check and the read (CodeQL js/file-system-race). It now opens with O_NOFOLLOW (a symlink fails at the open) and O_NONBLOCK (a FIFO cannot hang it) and checks isFile() on the open handle before reading through it. Native Windows has no O_NOFOLLOW, so there the link is lstated and refused first, and the opened file must have the same device and inode as the one lstat saw, so a swap between the two is refused too. Behaviour is unchanged: a symlinked or non-regular .env is refused and a missing one reads as empty. The narrative route's servedModelType also lost an initial value that was always overwritten (#730).

CI/CD

  • The CodeQL job summary lists findings that were already triaged apart from new ones (#730). .github/codeql-accepted.txt records each accepted finding as <rule id> <path> <flagged source line> with its reason above it, and scripts/codeql-summary.mjs moves the findings an entry matches out of the main table into a collapsed Accepted list with those reasons. It also reports entries that match nothing (fixed, or the flagged line changed and needs triaging again), entries that match more than one finding (which accept none) and malformed lines. Keying on the line's text rather than its number keeps an entry valid when code above it moves. The four seeded entries are the auth callback's onboarding redirect (js/user-controlled-bypass, by design: middleware still enforces onboarding), the feedback screenshot preview (js/xss-through-dom, an object URL of the user's own file) and the two workload identity exchanges (js/file-access-to-http, by design since #726). The nine log-injection and format-string findings are tracked separately and still show in the table. Nothing here can fail the scan (#730).

Tests

  • codeql-summary.test.mjs covers parsing the accepted file (a reason only from the comments directly above, malformed lines), a finding accepted by rule, path and line text together and by no two of them, an entry following its line when it moves and lapsing when it changes, an unreadable source line never accepted, an entry matching two findings accepting neither, staleness reported only in its own language's run, the collapsed list and its warnings, the all-accepted summary, and the CLI reading the file and sources from its working directory. It also checks the real .github/codeql-accepted.txt: every entry has a reason, is a CodeQL rule id, is unique and still names a line in its file, so editing an accepted line fails in that PR. The new env-file.test.mjs covers readEnvForUpdate() with a missing, regular, symlinked, directory and FIFO .env, and runs the no-O_NOFOLLOW path (as on Windows) for the missing, regular, symlinked and directory cases and a file swapped in after the lstat; link-thumbnail.test.tsx covers a javascript:, data: and relative URL never reaching an image. stripe-setup.test.mjs dropped an unused import and checks the Stripe CLI docs link by comparing whole links, since both an unanchored regex and a substring check on a URL are CodeQL findings (#730).

  • log-safe.test.ts, in both apps/worker and apps/web, covers ordinary text, tabs, quotes and backslashes left alone, line feeds and carriage returns escaped rather than dropped, vertical tab, form feed, NEL and the Unicode separators, NUL, escape sequences and DEL, an error as its one-line stack and one with no stack, a plain error object serialised, a circular or prototype-less object, and other values stringified. achievements/server.test.ts expects the context as its own %s argument (#729).

  • quest-details-card.test.tsx pins the clock for the row-list case. Its fixture's due date, 2026-10-20 12:00 UTC, was read against the real clock by DueDateBadge, so from that moment the Due row said "Overdue" and the test failed on every run. It now runs at 2026-10-06 and also asserts the row is not overdue (#910).

  • processor.test.ts covers the fallback's hold after a partial settlement (#740): the spend less a settlement refund pointing at it, a refund that does not point at it netting the reference to zero, an earlier attempt's settlement leaving this attempt's hold alone, the partial settlement still subtracted when earlier attempts lift the net above it, and a newer platform-funded spend neither read as the payer nor counted in the net. One case runs the real loadFallbackModels() price filter from the ledger read and shows a substitute whose hold sits between what is left and what was charged is no longer offered. The fake ledger now serves the whole reference rather than one pre-filtered row (#740).

  • list-filters.test.ts covers the taskPinned param read, written and round-tripped, the pinned-answer search (a phrase only in a pinned answer, the summary of one with no recorded text, unpinned and failed answers ignored, the task's own text preferred, words split across both, an answer with no text) and the Pinned and status counts composing; the new task-list-toolbar.test.tsx the chip's count, toggle, pressed state beside a status, zero count, absence on a Quest with no pins and presence when on from a link; task-manager.test.tsx the chip filtering and opening panels, composing with Done, a finished task's group opened by the chip and by a search hit without being remembered, a new search revealing its hits again while Pinned stays on, the next Quest's finished hits revealed after a switch, the URL round trip, a closed panel staying closed, a search hit in a pinned answer opening its panel (and not in an unpinned one), Clear filters, j and the bulk bar with the chip on, and the Quest list's badges, dimming and phone picker; task-quest-list.test.tsx pinned mode and the live count; pinned-answers.test.ts countPinnedTasksByQuest()'s ids-only select and per-task counting; tasks/page.test.tsx the counts passed and an unreadable read left unknown. The live Playwright spec tasks-pinned-filter.spec.ts seeds two Quests with pinned and unpinned answers and covers the chip (filtering, open panels, the finished task's group, the URL across a reload), the Quest list's count, a switch revealing the next Quest's finished hit, and the search finding a phrase only in a pinned answer but not one in an unpinned answer. Three #900/#901 assertions on the Pinned badge now name the badge's element, since the chip carries the same word (#902).

  • page.render.test.tsx covers the Pinned answers section present (task, question, date, model, card link), absent with no pins, with no tasks (no query) and on a failed read, absent with no query for a visitor to a public Quest, and earlier-cycle rows with their cycle number and link, not read on a first cycle or with recurring Quests off; pinned-answers.test.ts the two reads' filters and narrowed select, the model fallback and the mapping from the task list's state; task-deep-link.test.ts building and reading the link; the new task-manager-deep-link.test.tsx a link revealing a finished task's group and clearing a hiding search, a link to another Quest's task ignored, a handled link not reopening on a filter change, a rail row opening the card (twice) without a navigation, a closed panel staying closed when its row remounts, the rail following a pin and an unpin on the card and dropping a deleted task's pins, and the server's pins holding until the list loads; server-reads.test.ts the pinned rows added once each, newest first, when the newest-first page missed them; the deep-link tests also cover a link naming one of a task's several pins, focused once. The live Playwright spec pinned-answers.spec.ts pins on a card, sees the section, reloads and opens the card from the row and from a fresh visit to the link (#901).

  • pinned_investigations.sql (29 assertions) covers the partial index; a free party member pinning a Pro answer and an answer from a retired persona, while a pin combined with a re-queue or any other change is still refused by the plan guard; the server-side stamp (no re-crediting, no forward-dating, now() and the caller on a new pin, both cleared on unpin, a signed-in insert arriving unpinned); the succeeded-only CHECKs, the unpin when a pinned row is re-queued, and no pin coming back when it succeeds again; a visitor, a departed member and a deleted pinner; and the pinned-first earlier-cycle history with paging inside and after the pinned rows. rls.sql adds a member pinning, a visitor and a departed member refused, and the owner unpinning; account_deletion_cascade.sql adds task_investigations.pinned_by to its SET NULL fixtures; task_investigation_history.sql expects the new pinned_at column. The new [investigationId]/route.test.ts covers 200 pin and unpin, 400, 401, 403 (filtered or refused write), 404 (malformed ids, inaccessible task, investigation not on the task), 409 (pending, running, failed, or a status change before the write) and a plain 500; task-investigations.test.tsx the order, badge, control (succeeded only, read-only without a handler, pin, unpin, a refused pin explained on the row) and the earlier-cycle badge; task-footer.test.tsx the chip glyph and its label; task-manager.test.tsx a pin from the card end to end, and a pin that survives an investigation poll which started before it; investigation-prompt-dialog.test.tsx the "Pinned answer from Cycle N" note; and types.test.ts / investigation-history.test.ts the ordering helper and row mapping. The apps/web coverage floors rise to 95.65 / 91.85 / 95.4 / 95.65 (#900).

Documentation

  • docs/cicd.md adds "Accepted CodeQL findings": what .github/codeql-accepted.txt is for, the entry format, why it is keyed on the flagged line's text, and what happens when that line changes (#730).

  • docs/ai-investigations.md adds "Pinned answers on the Quest page": the two RLS-filtered reads and who they run for, the live hand-off from the task list, and the ?panel=investigations#task-<id> link and what it undoes to reveal a card (#901).

  • docs/schema.md describes the pin columns, the stamping trigger, the plan-guard exemption and the pinned-first history, with the migration-order row and the RLS coverage line; docs/api-endpoints.md adds the pin route and its 409/403; docs/ai-investigations.md notes the pin-only exemption to the model-type guard (#900).

3.6.0

Released

Features

  • Recurring Quests have a settings screen, /quests/recurring/[templateId]/settings, linked from a cycle's Details card ("Schedule and pause"): the owner changes the title, goal, context, constraints, schedule, party and completion rule with the same preview as the create form, and pauses or resumes the series; the Repeats row says when a series is paused. The task-list screen offers "Also apply to the current and next cycle", which brings their untouched copies up to date and leaves anything already ticked, reworded, assigned, dated, discussed or investigated alone (#868).

  • Asking about a recurring Quest's task now builds on what earlier cycles already found (#895). Ask about this task opens with "Answered in Cycle N (date) — Show answer" above the starters when the task has an earlier answer; Show answer expands the most recent one in place, as Markdown, without spending credits, and Investigate is unchanged. The note is read from the #894 history route on open (?limit=1), and only when the task card's count says there is something to find, so a one-off Quest's dialog makes no request and looks as before; a failed read shows nothing. When the worker runs a question on such a task it adds the summaries of the two most recent succeeded earlier answers with a summary — from cycles every current reader of that cycle can access — in a What you found in earlier cycles: section just before the answer instructions (one dated bullet each, 1,500 characters together, with an instruction to build on them rather than repeat them), in memory only: the route stores the prompt without them, and only their ids and cycle numbers are recorded, in details.prior_answers ([{ id, cycleIndex }]). So nothing from an earlier cycle is ever stored where a member of the asking cycle who cannot read that cycle — including one seated later — could read it; an owner whose cycle has a member seated part-way through gets only the cycles that member shares. No surcharge: the summaries are a few hundred tokens (see docs/pricing.md). A one-off Quest's prompt is sent exactly as stored, and a failed read costs the section, never the answer.

  • A recurring Quest's task card shows what earlier cycles already found out about the same task (#894). The investigations chip now appears on a cycle task with none of its own when its earlier copies have answers ("2 earlier"), and alongside the cycle's own count when both exist ("1 · 3 earlier"); the number is still the cycle's own. In the open panel a collapsed From earlier cycles (N) group follows the cycle's list — or stands in for its empty state — and loads only when expanded, from the new GET /api/tasks/[id]/investigations/history, ten at a time with Show older; each answer reads "Cycle 3 · Mon, Oct 13 · model", renders as Markdown with the question that was asked, and links to that cycle ("Open Cycle 3"). The counts come from one get_quest_investigation_history_counts() call per cycle page (useInvestigationHistoryCounts), made only when a loaded task carries a template lineage, so a one-off Quest and a cycle of hand-added tasks make no extra call and look exactly as before. If that count cannot be read, a task with a template lineage still shows the chip and the group, without a number ("earlier"), and expanding the group reads the history itself — a failed count never hides the only way to the answers. The answers are read-only; asking again is unchanged, and offering the last answer before a new question is the next issue in the batch.

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

Database

  • A recurring Quest can be edited, paused and resumed, and a task-list edit can reach the cycles already created (#868). 20261122120000_recurrence_template_edit_pause.sql adds quest_recurrence_templates.cycle_index_offset — a minted cycle's cycle_index is now the offset plus its local index on the template's grid, so numbering carries on after a schedule change or a pause instead of colliding with (and silently skipping) or jumping past the cycles already minted; every existing template has offset 0 and nothing minted moves. mint_quest_recurrence_cycles() is the live body with only that split. New owner-only RPCs: update_quest_recurrence_template() saves the settings and schedule, refusing a next start before the cycles that remain end; set_quest_recurrence_template_active() pauses, or resumes from the next free slot on the grid; save_quest_recurrence_template_tasks() wraps set_quest_recurrence_template_tasks() and, when asked, rewords, adds and removes the untouched copies in the open cycles (status active, window not over) through tasks.template_task_id. Saving settings or pausing removes the template's cycles that have not started and that nobody has touched (recurrence_cycle_is_untouched(): worded as the template, every task an unedited todo copy, no comment, attachment, attendance or pending invitation); a worked-on cycle stays and the new schedule starts after it. Clients keep UPDATE on the template's descriptive columns only, so the schedule, is_active and the offset move only through the RPCs. 20261123120000_recurrence_edit_pause_review_fixes.sql (Codex, PR #904) narrows "untouched": a task copy counts only while nobody has edited it (updated_at = created_at — a task ticked and reopened no longer passes on matching wording), and tasks_set_updated_at skips an apply's own reword (questify.template_apply); a waiting cycle with any invitation, a seat that joined after it was minted, or edited constraints or due date is kept; and the first index after a re-base or resume skips the weekdays the mint skips, so a weekdays series resumed at the weekend numbers Monday's cycle next. 20261124120000_recurrence_edit_pause_review_fixes_2.sql (Codex, PR #904) settles what "untouched" means for a cycle: quests.updated_at still equal to created_at, so any edit of the Quest (images, schedule, persona, Year Theme…) or of its tasks keeps it; a seat added or removed stamps the waiting cycle through touch_waiting_recurrence_cycle(), so a removed member can no longer come back on a re-mint; quests_set_updated_at skips an apply's own writes; mint_quest_recurrence_cycles() takes each template for share, so it cannot re-mint a cycle a concurrent pause just removed; and get_quest_recurrence_next_start_floor() gives the settings form a suggested start after any worked-on waiting cycle. 20261125120000_recurrence_apply_two_cycles.sql (Codex, PR #904) limits an apply to the first two open cycles, as the checkbox says, even when a kept waiting cycle leaves more open. 20261126120000_recurrence_solo_copies_direct_seats.sql (Codex, PR #904) makes the mint copy only direct seats from the previous cycle of a template without a guild, so switching a guild-backed series to "Just me" no longer turns the guild's members into direct seats on later cycles. 20261127120000_recurrence_first_index_and_reorder.sql (Codex, PR #904) makes a resume between windows (a daily one-hour cycle resumed at noon) number the next cycle straight after the last, and makes reordering a waiting cycle's tasks count as a change. The four RPCs are allowlisted in supabase/advisor-allowlist.txt (owner-only, checked in the body). Covered by the new supabase/tests/quest_recurrence_edit_pause.sql and rls.sql; quest_recurrence.sql moves its clock as postgres.

  • Earlier cycles' answers for an investigation prompt are read by the worker at run time, under a shared-access rule, and never stored (#895). 20261120120000_prior_answers_shared_access.sql adds get_shared_prior_cycle_answers(_task_id, _limit) — the succeeded answers with a usable summary (summary ~ '\S', applied before the limit) of the other copies of the same template task on earlier cycles of the same template, newest cycle first, only from cycles that every current reader of the asking cycle can access — and quest_reader_ids(_quest_id), the user_can_access_quest() rule as a set; 20261117120000_prior_cycle_answers.sql adds quest_accessible_to(_quest_id, _user_id), the same rule for a named user. 20261121120000_prior_answers_run_time.sql makes the read SECURITY INVOKER and service role only, because task_investigations.prompt and details are readable by every reader of the asking cycle — including one seated after the question was asked — and any of them can requeue a shared investigation, so the summaries must neither be stored nor chosen by the asker's access or initiated_by_id (Codex, PR #903). It also replaces the queue's requirement check with investigation_worker_requirements(_page_urls, _pricing, _task_id), which adds a prior_answers capability requirement to investigation_row_capabilities() for every row on a cycle task, and claim_next_capable_task_investigation() (its live body, grants kept) checks it, so a worker from before this change never claims such a row during a rolling deploy; rows on other tasks require what they did before. All four functions are service role only. (20261117120000–20261120120000 also created, revised and dropped an initiator-scoped get_prior_cycle_answers() and a four-argument investigation_worker_requirements(), which 20261121120000 drops.) Covered by the new supabase/tests/prior_cycle_answers.sql (35 assertions: the shared rule from both sides — a member seated part-way through keeps the owner's earlier cycles out, removing them lets them in — blank and null summaries, the queue requirement for prompt-less and stored-prompt cycle rows alike, worker-only grants, and that quest_accessible_to() and quest_reader_ids() agree with user_can_access_quest() for every fixture user and quest, an ended guild seat and a deleted quest among them) and two rls.sql grant assertions.

  • Removing a task from a recurring Quest's template no longer deletes its row (#894). 20261116120000_recurrence_template_task_soft_remove.sql adds quest_recurrence_template_tasks.removed_at, and set_quest_recurrence_template_tasks() now marks the rows a save leaves out as removed instead of deleting them. Before, the delete fired tasks.template_task_id's on delete set null (#893) on every cycle's copy — today's and tomorrow's already-minted cycles included — so those copies lost their lineage and, with it, their earlier cycles' answers. A removed row is skipped by mint_quest_recurrence_cycles(), hidden by the owner's SELECT policy (so the editor lists live tasks only), and no longer holds a position: quest_recurrence_template_tasks_position_unique is now a unique index over live rows. Listing a removed row's id again restores it with its lineage, so a list saved from a page loaded before the removal does not create a duplicate. Deleting a whole template still cascades to its rows. Both functions are their live bodies with only these changes, and keep their grants. Covered by new assertions in supabase/tests/task_investigation_history.sql, recurrence_template_task_lineage.sql (whose removal assertions now expect the link kept) and rls.sql.

  • A recurring Quest cycle task can now read the answers its earlier copies got (#894). 20261115120000_task_investigation_history.sql adds get_task_investigation_history(_task_id, _before_id, _limit), which follows tasks.template_task_id (#893) to the other copies of the same template task on cycles of the same template with a lower cycle_index and returns their succeeded investigations, newest cycle first and keyset-paged on (cycle_index, created_at, id), each with its cycle's id, index and scheduled start; and get_quest_investigation_history_counts(_quest_id), the per-task count of the same rows for one cycle. Failed and pending runs are left out, since an earlier failure has nothing to reuse. Both return nothing unless the caller can access the asking cycle, and filter every earlier cycle by user_can_access_quest() on that cycle, so a party member sees only the cycles they hold a seat on, a removed seat or a deleted cycle drops its answers, and a visitor to a public cycle sees none — investigations stay private. They are SECURITY DEFINER only because template_task_id is off the #570 client column allowlist, return the card's columns and never prompt, error, images or initiated_by_id, and are closed to anon; both are listed in supabase/advisor-allowlist.txt. Covered by the new supabase/tests/task_investigation_history.sql (35 assertions) and one rls.sql grant assertion.

  • A recurring Quest's cycle tasks now record which template task they were copied from (#893). 20261114120000_recurrence_template_task_lineage.sql adds tasks.template_task_id (FK → quest_recurrence_template_tasks, on delete set null, partial index), so the same task can be found across cycles. Removing a task from the template keeps every cycle's copy and drops only the link. mint_quest_recurrence_cycles() sets the column inside a declared template clone (questify.template_clone) and restores the flag after its insert. The copies stay ordinary origin = 'user' tasks, not origin = 'template', which means a locked checklist (#477). tasks_guard_template_origin() nulls the column on a client insert and keeps the stored value on a client update, as it already does for origin_ref, so only the mint can assert a lineage. The column is not on the #570 client SELECT allowlist; collaborators read it through get_tasks_with_deadlines(), and mapTaskRow() maps it to templateTaskId. set_quest_recurrence_template_tasks() now edits the list in place: an element carrying the id of one of the template's rows updates that row, rows left out are deleted, and elements without an id are inserted. Before, it deleted and re-inserted the whole list, which would have nulled every cycle's link on every save. The function refuses an id from another template, a duplicate id or a malformed one with 22023. The recurring-Quest task editor sends each saved task's id back and remounts on the saved ids after a save. tasks_set_updated_at no longer stamps a write that changes only template_task_id, so neither the backfill nor the delete cascade moves a done task's completion time or counts as quest activity. Existing cycle tasks are backfilled where (sort_order, title) matches exactly one template task and exactly one task in that cycle; ambiguous or edited rows stay null. Covered by the new supabase/tests/recurrence_template_task_lineage.sql (38 assertions) and three rls.sql assertions on the column.

Tests

  • Unit coverage for the settings and pause actions, the apply-to-cycles save, the settings read and grid helper, the settings page, the pause card, the form's edit mode and the Details card's Repeats row, and a gated Playwright spec (quest-recurrence-settings.spec.ts) that applies a task edit, renames, pauses and resumes a daily series (#868).

  • The new worker prior-answers.test.ts pins the earlier-answers section and its cap — two answers at most, blank summaries skipped first, whitespace collapsed, 1,500 characters shared with a short summary's unused share passed on, cycles numbered from one with their date, none without any — its placement just before the answer instructions (or at the end of a prompt without them, and no change with nothing to carry), the loader's read by task, row mapping, unscheduled-cycle date and failed read degrading to none, and a re-queued row's list replaced (#895). processor.test.ts pins the worker's advertised capabilities (now including prior_answers) and covers the section in a fallback prompt and in a stored one (placed before its instructions, kept in the no-search retry prompt, never written back), its get_shared_prior_cycle_answers call, a row with no initiator, details.prior_answers on a succeeded and a failed run, and one-off prompts sent exactly as stored. route.test.ts pins that the route neither reads earlier answers nor stores them in prompt or details. investigation-prompt-dialog.test.tsx covers the note's request and placement above the starters, Show/Hide answer, the summary fallback, no request at a zero count or without a task id, a lookup when the count is unknown, and nothing shown for an empty or failed read; fixtures.test.tsx the fixture's Ask dialog. In a browser, e2e/earlier-cycle-investigations.spec.ts now opens Ask about this task on the e2e-fixtures/earlier-cycles route and checks the note, the ?limit=1 read and the answer expanding without another request, and that the dialog opens normally when the real route answers 401.

  • investigation-history.test.ts covers the row mapping, the one-extra-row page and its cursor, the defaults, the error path and the counts' failed fallback; history/route.test.ts covers a page and its cursor, the cursor passed through, 404 for a malformed id or an inaccessible task, 400 for a malformed cursor or page size, 401 signed out and a plain-language 500. task-investigations.test.tsx covers the group's absence without earlier answers, collapsed until expanded with no fetch before it, loading, listed (cycle number, model, Markdown answer, question, link), standing in for the empty state, Show older and its failure, collapsing without re-fetching, retry after an error, an empty page and the summary fallback; task-footer.test.tsx the chip's earlier-only and own-plus-earlier labels and the unchanged chip without; use-investigation-history-counts.test.tsx the gated and stale reads; task-manager-earlier-cycles.test.tsx that the counts read is enabled only once a task with a lineage has loaded, and that a failed read keeps an unnumbered chip on lineage tasks only; task-footer.test.tsx and task-investigations.test.tsx cover the unknown-count chip and group; quest-recurrences/queries.test.ts pins the editor read's removed_at filter. In a browser, e2e/earlier-cycle-investigations.spec.ts (smoke mode, on the new e2e-fixtures/earlier-cycles route) checks the chip on a cycle task with none of its own, that opening the panel fetches nothing until the group is expanded, ten answers then Show older for the eleventh, collapsing without a second read, the link to the answer's cycle, and a retry after the real route's 401; the live e2e/cycle-investigation-history.spec.ts drives the same flow against a real stack — a recurring Quest created in the UI, eleven settled answers and a failed one on its first cycle, listed, paged and linked from the second. e2e/quest-recurrence-tasks.spec.ts now reads only live template tasks, since a removed one is kept (#894).

3.5.0

Released

Features

  • The Quest Coach interview has a Draft it now button, so the user can stop answering and have the coach draft with what it has (#878). It sits under the answer field — on the keyboard hint's row in the transcript, full width in the phone panel's one-question view (#853/#854) — from the first answer until the conversation has a draft, disabled while a turn is in flight or an earlier answer is being changed, and it sends a visible user turn ("Please draft the quest now with what you have, and fill in anything I haven't said.") flagged draftNow: true; anything typed in the field stays there. POST /api/coach/interview then ends its system prompt with a server-side nudge (COACH_DRAFT_NOW_NUDGE, never stored in the history) — in the one leading system message, not as a second system turn after the history, which the free router and DeepSeek forward as-is and some models ignore — asking for the closing reply — one sentence and the complete JSON block, keeping what the user said or accepted and filling in the rest — and skips the runaway retry; a reply that is still a question goes straight to the structured repair (extractCoachDraft()) under the same MIN_EXTRACTION_MS / RESPONSE_RESERVE_MS budget, told to fill in what was never said, and its question is replaced by a closing sentence rather than kept as the draft's lead-in — likewise when the question came with a broken payload or alongside a draft that parsed, since the composer is gone once a draft lands. The draft lands as any closing turn does — Continue / Start over, "Your quest is ready.", "Ask for changes" — in the same interview, with no additional charge. If the repair cannot draft, the question is returned and the interview says the coach couldn't finish the draft. The fill-in-the-gaps wording and the task-details rule are now one shared sentence each in coaching.ts, used by "Draft it for me" (#876) and "Draft it now".

  • The Quest Coach interview now drafts the quest for the user instead of asking them to write it (#877). After the goal — and, only when it is unclear, whether this is a one-off or a routine — every turn is a proposal: a short lead-in, a numbered list (2–4 points for the context, 2–4 for the constraints, 3–5 steps for the tasks, or the 2–4 steps of one session for a routine) and "Does that fit, or what would you change?". The system prompt's "Do not answer on the user's behalf" is replaced with "propose on the user's behalf and let them correct you; never explain the subject, only propose the quest", and the closing draft carries the proposals as accepted — no step added, dropped or reworded; a change that would leave fewer than two or more than five steps is not accepted but answered with steps proposed again within that range; for a routine, a change that puts a frequency into a step ("stretch every morning") is taken as the cadence and the steps are proposed again without it, so the cadence stripping from #858 never alters an accepted step — with details written by the coach for every task, never null; COACH_QUEST_DRAFT_SCHEMA now asks for details as a string, and its parser rejects a draft that does not fit the schema's whole shape — a blank goal, context or constraints, fewer than two or more than five tasks, or a bare step (coachDraftGaps()), judged on the raw payload as well as the normalized draft, so a task entry the parser would drop (no title, or not an object) and a missing or unknown repeat cadence count as gaps rather than reading as a shorter one-off draft — which matters most on the free router's prompt-and-parse path, where that parser is the only validation; the lenient parser still reads an older draft with a null. A closing reply whose draft parses but has any of those gaps goes through the same structured repair as a malformed one, with a request naming each gap; if that repair cannot finish it, the parsed draft is returned as it is rather than leaving the interview with nothing to continue. A repaired draft — incomplete or a broken payload — is also written back into the returned reply (withDraftPayload()), so the coach's stored turn, which the transcript shows and "Ask for changes" redrafts from, matches the draft in the form. The runaway guard learned the difference between a proposal and a lecture: CoachReplyParts.kind is 'proposal' when a reply ends in a question, holds one numbered list of 2–5 single-line items of at most 200 characters each — five, the most tasks a draft holds, and 200, a task title's limit, so every step the user accepts survives into the draft verbatim — with the question after it, and has no heading or bullet list, and isRunawayCoachReply() lets a proposal carry its list and up to 1,400 characters, while a reply with no question, a heading, a bullet list, a numbered list that is not a proposal (a single point included), or a question over 700 characters is still regenerated. A broken proposal is retried with its own nudge and a 400-token ceiling (coachRetryPlan()), since the lecture retry's 200 tokens would cut a list off; the lecture nudge no longer forbids lists, so a lecture where a proposal was due can come back as one. COACH_MAX_TOKENS stays at 1,200, which still covers a proposal turn and a five-task draft with details. In the transcript and in the one-question view a proposal's list renders as a numbered list (the bubble renderer reads lists with the same coachTextBlocks() the guard does), and Looks good (sends "Yes, that works.") and Change it (focuses the answer field) sit above the composer under a proposal, withdrawn while a turn is in flight or an earlier answer is being changed; a quick reply leaves anything typed in the field where it was. In the one-question view an answered proposal is listed under what was proposed ("Here's what I'd suggest for the constraints") rather than under its "Does that fit?", and a correction to it names the proposal the same way. The progress copy keeps its five steps.

  • The New Quest page's AI Coach can draft a whole quest from a title alone (#876). The coach card is title-first: a Title field with Draft it for me as the primary action and Start interview below it as the other way in ("Or answer a few questions"), the theme button where it was. Beside the form's own Title field — and below it on phones — Fill in the rest with AI appears while the title is 3–120 characters — the range the route drafts from, so a longer title (quests allow 200) gets no button rather than one certain to be refused — and only while the coach is idle and no draft is in the form. Either one fills the goal, context, constraints and 3–5 first steps, every step with details, in one call, keeps the title as typed, and collapses the card to "Quest drafted by AI Coach", with the Year Theme the draft was requested under — the theme itself, not the toggle — carried into the interview that continues it even if the toggle changed mid-request; anything typed in the form while the draft was being written is kept over the draft — and in the closing turn "Ask for changes" continues from, so a redraft does not put the replaced values back — and the form's own fields (status, due date, links, schedule) survive it landing; a substitute model is followed in the persona chip only if the chip still shows the persona the draft was sent with, so a pick made mid-request is not overwritten; a routine-shaped title ("Daily stretching") drafts with a cadence and lands in the recurring form as an interview's routine does (#858). Ask for changes then continues the same interview the call opened, so a redraft is not charged again. POST /api/coach/interview takes this as a second body shape, { seed: { title, note? }, modelPersonaKey?, useYearTheme? } with no messages, sharing the interview's free-tier cap, persona selection with entitlement and affordability, charge (one coach_interview via start_coach_interview — no new credit kind), refund on failure, repricing on a substitute and route budget, and responding { draft, interviewId, servedPersonaKey, messages }, where messages is the request and a closing reply carrying the draft that the interview continues from. The draft comes from generateStructured() directly against the new COACH_SEED_DRAFT_SCHEMA — the interview's schema with 3–5 tasks and details required — so a draft with fewer steps or a bare one walks the fallback chain rather than reaching the form. The title (3–120 characters) and note (at most 500) are quoted in the user turn and never put in the system prompt. A 402 or 429 is shown under the field that asked, with the interview's copy; a 500, after the charge is refunded, offers "Try the interview instead". The interview prompt's payload example, task rules and Routines-vs-projects section are now shared with the new COACH_DRAFT_FROM_SEED_PROMPT, so the two cannot drift; the seed prompt's copy of the example shows every step with details, as its schema requires. There is no new feature flag: it ships with NEXT_PUBLIC_FEATURE_COACHING_MODE.

  • The investigation dialog keeps every number and warning in view and moves its explanations one tap away. The footer's five lines of cost prose are one line — "2–19 credits · up to 3 web searches", "Free · no web search" for a free persona, "no web search" for one without a search tool — with a "How it's priced" toggle that expands the two explanations as they read before; the short-of-hold warning, the "if your balance can't cover it" caveat and the free-model fallback stay always visible, since each changes what clicking Investigate does. Investigate carries the ceiling the user is agreeing to — "Investigate · up to 19 credits", or "Investigate · free" for a free persona and for an inherited one the balance cannot cover, which the route runs on the free model. Reference links are a "+ Add reference links" row that opens the editor, with the explanation of what can be read behind a "What can be read?" toggle on the editor's own "N of 10 added." line (ImageUrlList gained a helpAction slot); the quest's saved links and attachments are folded, counted rows ("Links on this quest (3)") that share one cost sentence once open. A section with something chosen in it cannot be folded: its toggle becomes a count ("Reference links (2)", "Links on this quest (1 of 3 chosen)"), because those references are being sent and charged for, and the range and ceiling move as they are added. Nothing is gated on a breakpoint, so the phone and the desktop card are the same layout (#881).

  • The investigation dialog's starters take one row on a phone, and fold away once the question has text (#880). Below sm the four starters are a single row that scrolls sideways, snapping to each chip, with the next one peeking past the edge; wrapped, at 390px none of them shared a line and the four-row stack was the tallest thing in the dialog, above the field it serves. Once the field has text — when the keyboard is up and room is scarce — the row becomes one "Starters" toggle in the same slot; expanding it and picking a starter still replaces the text and focuses the field, never submits, and folds the row away again. An empty field shows the starters as before, and from sm they wrap as they did. The row stays above the field, so a starter is still the first thing a touch device focuses and the keyboard waits.

  • On a phone, Investigate stays on screen with the keyboard up (#879). Below lg the task investigation dialog is now a full-screen sheet sized to the visual viewport (--vvh / --vvt from useVisualViewportVars, as the coach panel has since #853), so its footer — the cost note, Cancel and Investigate — sits on top of the keyboard and only the body scrolls; iOS Safari does not shrink the layout viewport for the keyboard, so the old card, bounded by a fixed inset-0 overlay, kept its footer underneath it. The page behind is locked while the sheet is open, the body keeps the field being typed in on screen as the viewport resizes, and a ✕ in the header closes it (refused mid-submit, like Escape). On a touch device the dialog no longer focuses the question field on open, so the keyboard waits until a starter or the field is tapped; with a mouse and keyboard it still does. The overlay also ignores a parent's space-y margin: on the quest page it sat 20px down, with its bottom edge — Investigate included — off the screen. Closing the dialog now returns focus to the Ask button that opened it; it used to be lost, because focus was recorded after the field had taken it. At lg it is the centred card it was.

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

Tests

  • route.test.ts covers Draft it now: the nudge ending the one system message and never echoed back, a compliant reply returned without a repair, a question reply replaced by a structured extraction told to fill in the gaps (with the closing sentence in place of the question, including a question that came with a broken payload, a complete draft or an incomplete one, while a broken reply's own closing sentence and a compliant reply are kept, and an ordinary turn is left alone), no runaway retry spent on it, an incomplete draft's repair told both its gaps and to fill in, the question returned when the extraction fails, no new charge and the same interview id on a continuation, and a non-true flag read as an ordinary turn. coaching.test.ts pins the nudge's messages and its shared fill-in and details rules with the seed prompt. coaching-interview.test.tsx covers the button disabled before the first answer and mid-turn, its flagged request and the draft it holds, the field kept on failure, the error when a question comes back, its absence once a draft exists and on a title-only draft, and its place in both views; quest-coach-draft.spec.ts and quest-coach.mobile.spec.ts drive it at desktop width and in the phone panel with the keyboard up (#878). The apps/web coverage floors move up to 95.6 / 91.8 / 95.4 / 95.6 (statements / branches / functions / lines), measured at 95.88 / 92.27 / 95.75 / 95.88 across 5381 tests.

  • coaching.test.ts covers the proposal kind and the guard on fixtures: a context, a constraints (spaced out with blank lines) and a four-step task proposal read as proposals and pass, a proposal between 700 and 1,400 characters passes and one over 1,400 does not, and the Everest article, a list with no question, a headed reply, a one-point and a six-point list (five points still pass), a point over 200 characters (200 still passes), steps running over several lines, a question inside the list and a bullet list are all still regenerated; it also pins coachTextBlocks(), the proposal's question, preamble and label from splitCoachQuestion(), the retry plan for a lecture and for a broken proposal, the prompt's new propose-on-their-behalf rule and draft rules, the re-proposal of a step list a change pushed outside 2–5, the re-proposal of a routine step a change gave a frequency (with a cadence-free accepted step surviving a routine draft verbatim), and the schema's string details — coachInterviewDraftFromValue rejecting a null or blank one, zero, one or six tasks, a blank goal, context or constraints, a missing, null, empty or unknown repeat, and a task the parser would drop even with 2–5 left, with coachDraftGaps() naming each gap — with the parser still reading a null. route.test.ts also checks a closing draft with a bare step is repaired with a request for the details, that a one-task draft with blank constraints is repaired with a request naming both gaps, that a draft with an unknown cadence and a titleless task is repaired with a request naming both, and that a complete one is not repaired, that blank details count as missing, that the parsed draft is still returned when the repair fails, and that a repaired draft — details or broken payload — replaces the payload in the returned reply. route.test.ts checks a proposal is returned without a retry and a broken one is retried on the proposal nudge with a ceiling above the lecture's and below a full turn; coaching-interview.test.tsx checks the list renders as an <ol> in both views, Looks good and Change it appear under a proposal and not under the opening question, Looks good advances the interview by sending "Yes, that works." after the proposal and leaves the field's text alone, Change it focuses the field, the quick replies are withdrawn mid-turn, and an accepted proposal is listed under its lead-in (#877).

  • route.test.ts covers the draft-from-title path: one structured call on the seed schema and no interview turn, the typed title kept, a returned history whose closing turn parses back to the same draft, the title and an instruction-shaped note quoted in the user turn only, exactly one start_coach_interview charge, "Ask for changes" continuing the same interview uncharged on the interview prompt, a routine draft, the Year Theme section, 400s for an unusable seed, 402 for an unentitled persona, an unaffordable one and a refused charge, 429 at the free-tier cap, refund and 500 when every model fails, repricing when a cheaper model answers, and the route budget. coaching.test.ts runs five sample titles' payloads through the seed schema (schema-valid, 3–5 tasks with details, no frequency words in task titles, the title absent from the system prompt), pins the schema to OpenAI strict mode, the seed validation, the quoting and the shared prompt sections. coaching-entry-lifecycle.test.tsx, coaching-quest-creator.test.tsx and coaching-interview.test.tsx cover the title-first card, the form's button and its three-character threshold, errors under the control that asked, the 120-character upper bound, the request-time Year Theme surviving a toggle flipped mid-request, form edits made during the request surviving the draft and its closing turn (landDraftOnEditedForm in form-carryover.test.ts too), the continuation's theme after the toggle goes off, the seed prompt's example never showing a null details, a persona picked mid-request surviving a fallback (and a fallback still followed otherwise), and the seeded conversation; e2e/quest-coach-draft.spec.ts drives both buttons, Ask for changes, a routine title and a 429 on the coach fixture route (#876).

  • investigation-prompt-dialog.test.tsx covers the one-line cost note and its toggle (folded by default, aria-controls only while expanded, the old wording behind it), the ceiling on Investigate for paid, free and fallback personas, the warnings showing without expanding, the dialog holding only the starters, the field and one reference row while nothing is chosen, each reference disclosure opening, staying open and counted once something is chosen and offering its toggle again when emptied, the shared cost sentence, the "What can be read?" explanation on the editor's count line, and every toggle locking mid-submit; the shared ten-link budget tests open the sections and still pass. image-url-list.test.tsx pins the helpAction slot, and e2e/investigation-dialog.mobile.spec.ts checks at 390px that the dialog opens folded with a one-line cost note and "Investigate · up to 19 credits", that the pricing explanation expands, and that ticking a saved link keeps its section open, counted, and moves the ceiling to 20 (#881).

  • investigation-prompt-dialog.test.tsx covers the sheet's classes, the scroll lock and viewport variables (and their cleanup), the header close and its mid-submit refusal, no autofocus on a touch device, the focused field being kept in view as the body shrinks, the overlay's margin reset and focus returning to the trigger; e2e/investigation-dialog.mobile.spec.ts (the mobile project, on the new e2e-fixtures/investigation route) opens the dialog on a Pixel 7, taps a starter, shrinks the viewport to a keyboard-up height and checks the field and Investigate are inside the visual viewport before submitting, closes it with ✕ and Escape back to the Ask button, and checks the card is unchanged at lg (#879).

  • investigation-prompt-dialog.test.tsx covers the starters' one-row-below-sm classes, the fold into a "Starters" toggle once the field has text (not for whitespace alone), expanding, collapsing, a pick replacing the text and focusing the field without submitting, the row returning when the field is cleared, and both controls locking mid-submit; e2e/investigation-dialog.mobile.spec.ts checks at 390px that the four starters share one row that scrolls sideways without widening the body, that typing folds them and a pick from the toggle fills and focuses the field, and that they wrap at 768px (#880).

3.4.0

Released

Features

  • The admin Audit Log can now answer "what changed last Tuesday, and who did it?". It gained From/To date inputs (UTC days, inclusive), Action and Target type selects listing what the ledger holds with counts, and an Actor field that takes a whole user id or the first characters an id chip shows — a pasted prefix used to match nothing. Rows name the actor and the target instead of eight-character ids, say what happened ("deleted role Moderator"), link the target to its role or persona drawer, account or feedback report while it still exists, and fall back to the name the entry recorded once it is gone; clicking an action or an actor narrows the current view to it, and each row's metadata opens in a collapsible block. Export CSV (this filter) downloads the filtered, sorted list — every page, up to 10,000 entries, ids and names side by side — from the new GET /api/admin/audit-log/export, which requires audit_log.read, defuses spreadsheet formulas in cells, and marks a file cut at the limit with a header and a closing line rather than passing it off as the whole log. An unusable filter is ignored with a message saying which, rather than silently returning a different list. The sticky header, relative times and shared pager already came with #783 (#793).

  • The admin Feedback list opens on the open queue and is searchable, sortable and bulk-editable, and a report's page keeps the list it was opened from (#792). /admin/feedback now defaults to Open, with every status option counted for the current search, type and submitter ("Open (12)", "All statuses (51)", left uncounted rather than zeroed when a count fails); "All statuses" is ?status=all, which the dashboard's "total" link and the Users row menu's "Feedback by user" now use, so the latter still lists every report from that account. A search box matches title text, or a whole user id or feedback id; Status and Submitted sort the table (as sort links on phones), status in triage order; status and type apply as soon as they change, so the Apply button is gone. Each row's title is its only link. A checkbox column and a bar pinned to the bottom of the screen mark a selection reviewed or resolved — resolved asks first, since each submitter who takes feedback emails is told — sending only the reports not already at that status, keeping any that failed selected, and announcing the result in a live region. On a report, the Feedback breadcrumb returns to the same filtered, sorted page (?return=), Previous and Next step through that list (the open queue when the page was opened from elsewhere) and keep their place after the report's status is changed, the status badge sits in the header and follows a save, Triage is a card like its neighbours with an inline "Saved." in an always-present status region, and Submitter and Metadata are one Details card — the submitter linked to the Users list, a link to their other reports, the page URL and last update, with the feedback id, user id and screenshot path under "Developer details". GET /api/admin/feedback takes ?q=, ?sort=created_at|status and ?dir=, returns counts, and no longer echoes database errors.

  • The admin AI Spend page shows every platform ceiling before anything else, keeps both reports on one month, and lets the account table be searched and sorted (#791). A status strip under the header carries one chip per budgeted feature for the month shown — "10% used", "90% used" in amber, "ceiling reached" and "no budget" in red, in the colours the table's bar uses — each a link to #platform-spend, so the two states that mean calls are being refused are visible without scrolling instead of ~1,500px down; a failed read, an empty budget table and a month the report has no row for each get a chip saying so, because a strip that vanished on a failed read would hide the one thing it exists to show. One month picker, in the header, drives both reports through ?month= — before this each section had its own picker and could quietly show a different month — and switching month restarts the account pages while keeping the search and sort; a stale ?platformMonth= or ?user= link is read as the month or the search. The account table has a GET search box (?q=, a display name in part or a user id in whole; the Users row menu now links here with ?q=<id>), sort links on Actions, Refunds, Gross and Net (?sort=&dir=, largest gross first by default, the caption naming the order), the shared Pagination counting the matches, and a totals footer that stays the whole month's and says so when a search is on. Below md, where the table's rows are cards with no header row, a "Sort by" row of links (the new shared SortLinks) carries the same sort, so a phone can sort at all; a searched page past the end says so and offers page one with the search kept, rather than claiming nothing matched. The ~300px of explanatory prose between the admin and each table is one sentence plus a collapsed "How these numbers work". stall_checkin, which the label map had missed, is now "Stall check-ins" rather than its raw key. (#791)

  • The admin Personas list can be narrowed to one plan tier, provider or availability, and the admin Quest Jams list keeps ended Jams on their own tab and names the timezone its times are shown in (#790). On /admin/personas, a GET form above the table filters by ?tier= (Free · Pro), ?provider= (one option per provider in the catalog, matched case-insensitively) and ?show= (Active · Inactive · Recommended), so "which models are currently free to every account?" is the link ?tier=free rather than a scan of a dozen rows; the header counts "N of M personas · Free · openrouter", an empty filtered list says the filters matched nothing rather than that there are no personas, and an edit link to a persona the filters hide still opens it. parsePersonaFilters() drops a tier or show value the list does not offer, so a stale link lands on the full list. On /admin/quest-jams, the one flat list that accumulated every past Jam is split into "Live & upcoming (n) · Past (n)" tabs on ?view=, defaulting to the first; a paused Jam stays on the first tab, since it has not ended and can be switched back on, and a Jam whose status could not be decided is shown there rather than on neither. Two admins in different zones saw different "Starts" times with nothing saying which zone either was in: the list now says "Times are shown in America/Toronto" (from ViewerTimeZone in local-datetime.tsx, which prints UTC until the browser's zone is known — the same zone LocalDateTime prints before mount — so the page never names a zone its times are not in), and the drawer's Starts and Ends inputs are described as being in that zone and saved as UTC. The #784 tier badge, Recommended star and Live/Upcoming/Paused/Ended badge already covered the rest of the issue.

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

Database

  • 20261113120000_admin_audit_log_facets.sql adds admin_audit_log_facets(), which returns every distinct action and target_type in admin_audit_log with how many entries carry it, so the Audit Log's filters can be selects built from what the ledger actually holds — PostgREST cannot select distinct, and a list kept in app code would miss actions written by SQL functions. It is SECURITY INVOKER: the ledger's own audit_log.read policy decides what is counted, so a caller without the privilege gets no rows, exactly as a direct read does. Closed to anon; supabase/tests/admin_audit_log_facets.sql pins the grouping, counts, order and access, and rls.sql asserts the grants (#793).

  • 20261112120000_feedback_status_rank.sql adds feedback.status_rank, a stored generated column — open 0, reviewed 1, resolved 2, wontfix 3 — and a (status_rank, created_at desc) index, so the admin feedback list sorts by status in triage order rather than alphabetically, which put resolved ahead of reviewed (#792). It is derived from status on every write and no role can set it; neither of feedback's column grants names it. supabase/tests/feedback_status_rank.sql (9 assertions) pins each rank, the order, a rank following a status change and the column refusing writes; rls.sql pins its INSERT and UPDATE column privileges.

  • 20261111120000_ai_spend_report_search_sort.sql drops and recreates user_ai_spend_report() as (_month, _limit, _offset, _search, _sort, _dir), so the admin account table can be searched and sorted in the database rather than over one 50-row page (#791). _search narrows the rows to one whole user id or to the accounts whose display name contains the fragment (case-insensitive; %, _ and \ typed into the box match literally) and replaces the _user_id argument #786 added, since a pasted id is a search; _sort is gross (the default) · net · refunds · actions and _dir desc (the default) · asc, ties still broken by user_id, and an unknown value raises 22023 rather than quietly rendering the default order under a header that says it is sorted. The total_* columns stay the month's over every account, as they did for _user_id, so a searched row keeps its context and the dashboard tile reads the same figure; the new last column matching_accounts counts the rows the search matched over all pages, which is what the pager counts, and equals total_accounts without a search. Still admins only and closed to anon; rls.sql pins the single signature. (#791)

Tests

  • The Audit Log's filter parsing (calendar-day validation, the uuid-prefix range, inclusive UTC day bounds), the batched and capped export, the facets read, the CSV writer (quoting, formula defusing, the truncation line), target names and links, chunked name lookups, the export route's 401/403/400/500 paths and the page's selects, links, metadata and ignored-filter message are covered in Vitest (#793).

  • admin-list.test.ts pins the feedback list's URL parsing and fallbacks, the hrefs and ?return= round trip (including that only its query string is read), the API params, the literal title pattern, the tie-broken order and the keyset Previous/Next filters; admin-detail.test.ts the neighbour and other-report reads, including a status-filtered list after the report's status is saved as another; a test pins FEEDBACK_STATUS_RANK to the migration's CASE; the list route tests cover search, id search, the status sort, the counts and a failed count; the list component tests the counted, default-open status filter, search, URL-kept sort and row links, and the bulk bar (skipping reports already at the status, the resolve confirmation, partial failure); the triage tests the always-mounted status region and the refresh; and a new detail page test covers Back, Previous/Next, the header status and the Details card (#792). The apps/web coverage floors move up to 95.4 / 91.6 / 95.2 / 95.4 (statements / branches / functions / lines), measured at 95.75 / 92.10 / 95.50 / 95.75 across 5163 tests.

  • user_ai_spend_report.sql grows from 35 to 53 assertions: the single signature, a whole id in any case and a trimmed name fragment narrowing the rows and matching_accounts but not the totals, a literal wildcard, an id nobody has matching nothing, each sort ordering the fixtures as the arithmetic says, a tie on the sorted figure between accounts of different gross spend broken by id in both directions, and an unknown sort or direction raising 22023; the page tests cover the status strip's four chip states and its failed, empty and missing-month chips, the one picker driving both reports and keeping search and sort, the old ?platformMonth= and ?user= links, the sort links and aria-sort, the search form's hidden fields, the phone sort row and its links, a search that paged by its matches while the footer kept the month, a search nothing matched, a searched page past the end, a pasted essay being cut, and both notes being collapsed; links.test.ts and user-ai-spend.test.ts pin the URL builder and the RPC arguments; the live Playwright spec walks the strip, a search by id, a sort and the single picker (#791).

  • persona-filters.test.ts pins the persona filter parsing, matching, provider menu and count line; persona-list-filters.test.tsx and the panel tests cover the filter form, a filtered and an empty filtered list and the drawer opening a hidden row; the Quest Jams panel tests cover both tabs, the counts, a paused Jam staying current, a Jam with no status, the tab links keeping the rest of the query, the empty-tab messages and the timezone lines; queries.test.ts pins questJamAdminView() and parseQuestJamAdminView(); local-datetime.test.tsx covers ViewerTimeZone server-rendering UTC, naming the zone once mounted and falling back when Intl cannot say (#790).

3.3.1

Released

Fixes

  • The dashboard's Today section keeps two cycles of one recurring Quest apart when both overlap today (#859). Since 3.3.0 judges a cycle's window as instants, a 24-hour cycle that started before a spring-forward midnight runs an hour into the next local day, and a window longer than its cadence overlaps the next cycle, so get_my_today_cycles() can return two cycles of the same template. Cards and their heading ids were keyed by the template, so those two cards shared a React key and an id, and a refresh after a tick could reconcile the wrong card. They are now keyed by the cycle's quest, and a daily cycle's window is labelled when its template has a second card today, so the two can be told apart. today-cycles.test.tsx renders the spring-forward Sunday-and-Monday pair and checks two cards, distinct heading ids, each window and no duplicate-key warning.

Earlier releases

  • 3.3.05 features
  • 3.2.05 features · 2 fixes
  • 3.1.06 features · 6 fixes
  • 3.0.035 features · 9 fixes · 2 security changes
  • 2.27.13 fixes
  • 2.27.05 features · 4 fixes · 1 security change
  • 2.26.04 features
  • 2.25.02 features · 1 fix · 1 security change
  • 2.24.02 features · 1 fix
  • 2.23.01 feature
  • 2.22.01 feature
  • 2.21.02 features · 4 fixes · 2 security changes
  • 2.20.03 features · 6 fixes
  • 2.19.04 features · 4 fixes · 1 security change
  • 2.18.23 technical changes
  • 2.18.11 technical change
  • 2.18.03 fixes · 2 security changes
  • 2.17.08 features · 9 fixes
  • 2.16.07 features · 3 security changes
  • 2.15.06 fixes
  • 2.14.11 fix
  • 2.14.02 features · 6 fixes
  • 2.13.04 fixes
  • 2.12.02 features · 1 fix
  • 2.11.12 fixes
  • 2.11.010 features · 2 fixes · 5 security changes
  • 2.10.11 fix
  • 2.10.01 feature · 3 fixes
  • 2.9.01 feature · 1 fix · 1 security change
  • 2.8.04 features · 6 fixes
  • 2.7.010 features · 11 fixes · 4 security changes
  • 2.6.14 technical changes
  • 2.6.02 features
  • 2.5.059 features · 31 fixes · 5 security changes
  • 2.4.02 features · 1 design change
  • 2.3.11 fix
  • 2.3.07 features · 1 security change
  • 2.2.07 features
  • 2.1.03 features · 6 fixes
  • 2.0.37 fixes
  • 2.0.28 fixes
  • 2.0.110 fixes
  • 2.0.040 features · 7 design changes