3.7.0
Released
Features
A pinned answer can be found across Quests from the task list (#902). The task list (on
/tasksand 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, besidetaskStatusandtaskSearch, 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/tasksQuest 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 throughcountPinnedTasksByQuest()— 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/tasksnow 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 newPATCH /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 thanreprice_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()inapps/worker/src/processor.tsnow 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).
LinkThumbnailput the link a user saved straight into<img src>and checked the protocol only for its favicon fallback; it now applies the sameparseUrlcheck first, so ajavascript:,data:or relative value goes straight to the link icon instead of reaching the image. CodeQL reported it asjs/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 inapps/web/src/lib/achievements/server.tspass theircontextas a%sargument instead of building the format string from it, so a%sor%oin 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 ninejs/log-injectionfindings and thejs/tainted-format-stringone; 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.sqladdstask_investigations.pinned_atandpinned_by(FK →auth.users,on delete set null, so a deleted pinner leaves the pin in place), a partial index ontask_idwhere pinned, and two CHECKs: only asucceededrow 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 newtask_investigations_pintrigger makes the stamp the server's — a signed-in pin recordsnow()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 leavessucceeded, so a re-run that fails is not the answer any more.guard_investigation_model_type()now lets an update whose only changed columns arepinned_at,pinned_byandupdated_atpast 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 returnpinned_atand 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
.envfor rewriting with a single open (#730).readEnvForUpdate()inscripts/dev-tui/env-file.mjsused tolstatthe file and thenreadFileit, which left a window in which.envcould be swapped for a symlink between the check and the read (CodeQLjs/file-system-race). It now opens withO_NOFOLLOW(a symlink fails at the open) andO_NONBLOCK(a FIFO cannot hang it) and checksisFile()on the open handle before reading through it. Native Windows has noO_NOFOLLOW, so there the link islstated and refused first, and the opened file must have the same device and inode as the onelstatsaw, so a swap between the two is refused too. Behaviour is unchanged: a symlinked or non-regular.envis refused and a missing one reads as empty. The narrative route'sservedModelTypealso 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.txtrecords each accepted finding as<rule id> <path> <flagged source line>with its reason above it, andscripts/codeql-summary.mjsmoves 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.mjscovers 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 newenv-file.test.mjscoversreadEnvForUpdate()with a missing, regular, symlinked, directory and FIFO.env, and runs the no-O_NOFOLLOWpath (as on Windows) for the missing, regular, symlinked and directory cases and a file swapped in after thelstat;link-thumbnail.test.tsxcovers ajavascript:,data:and relative URL never reaching an image.stripe-setup.test.mjsdropped 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 bothapps/workerandapps/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.tsexpects the context as its own%sargument (#729).quest-details-card.test.tsxpins the clock for the row-list case. Its fixture's due date, 2026-10-20 12:00 UTC, was read against the real clock byDueDateBadge, 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.tscovers 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 realloadFallbackModels()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.tscovers thetaskPinnedparam 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 newtask-list-toolbar.test.tsxthe 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.tsxthe 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,jand the bulk bar with the chip on, and the Quest list's badges, dimming and phone picker;task-quest-list.test.tsxpinned mode and the live count;pinned-answers.test.tscountPinnedTasksByQuest()'s ids-only select and per-task counting;tasks/page.test.tsxthe counts passed and an unreadable read left unknown. The live Playwright spectasks-pinned-filter.spec.tsseeds 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.tsxcovers 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.tsthe two reads' filters and narrowed select, the model fallback and the mapping from the task list's state;task-deep-link.test.tsbuilding and reading the link; the newtask-manager-deep-link.test.tsxa 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.tsthe 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 specpinned-answers.spec.tspins 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.sqladds a member pinning, a visitor and a departed member refused, and the owner unpinning;account_deletion_cascade.sqladdstask_investigations.pinned_byto its SET NULL fixtures;task_investigation_history.sqlexpects the newpinned_atcolumn. The new[investigationId]/route.test.tscovers 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.tsxthe 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.tsxthe chip glyph and its label;task-manager.test.tsxa pin from the card end to end, and a pin that survives an investigation poll which started before it;investigation-prompt-dialog.test.tsxthe "Pinned answer from Cycle N" note; andtypes.test.ts/investigation-history.test.tsthe ordering helper and row mapping. Theapps/webcoverage floors rise to 95.65 / 91.85 / 95.4 / 95.65 (#900).
Documentation
docs/cicd.mdadds "Accepted CodeQL findings": what.github/codeql-accepted.txtis 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.mdadds "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.mddescribes 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.mdadds the pin route and its 409/403;docs/ai-investigations.mdnotes the pin-only exemption to the model-type guard (#900).