2.17.0
Released
Features
Added local-time deadline controls to quest and task forms, spaced, colour-coded overdue and seven-day due-soon badges, and deadline sorting in the task workspace. (#570)
Party members can attach files to a quest or to any of its tasks, behind
NEXT_PUBLIC_FEATURE_ATTACHMENTS. Each task gets a strip listing what has been handed in — thumbnail, filename, size and who uploaded it — and the quest carries its own above the task list, for the brief or rubric that belongs to no single task. Everyone on the party can attach and can open what anyone else attached; the Remove control appears only where the viewer may actually remove it. Selecting an attachment views it — in place for an image, in a new tab for anything the browser renders itself — and the download icon is the only thing that saves it, so glancing at a screenshot is no longer a download. The file goes from the browser straight to the private bucket and only then is the row recorded, because a Server Action buffers its whole body and a 10MB upload through one would hold an invocation open for the entire transfer; when the row fails to record, the object just written is removed again, so a failed attach leaves nothing behind that nobody can see and nobody can delete. Nothing is publicly linkable: thumbnails are signed in one batch per quest and expire, and opening or downloading mints a fresh URL per click. A thumbnail retries once before falling back to a file icon, because the first request for a freshly minted signed URL was seen returning 503 while every later one returned 200 — and an<img>never retries on its own, so a single bad request left a broken glyph on the page for good. A non-collaborator viewing a public quest sees no strip at all. (#569)An AI investigation can point at what is already on the quest instead of re-uploading or re-pasting it. The Investigate dialog lists this task's attachments, the quest's own attachments, and the quest's saved reference links, each costing the same as a link typed into the question and saying so. Ticking one is opt-in, which is the whole difference from the behaviour #558 removed: the quest's links used to ride along on every investigation and charge a per-image surcharge on questions that had not asked about them. A link both typed and ticked is counted once, and typed links and ticked ones draw on one ten-reference budget rather than a cap each — two separate limits would have let twenty references through a validator written to allow ten. The dialog is now bounded and scrolls its body, the pattern #557 established on the feedback modal — it was named in that issue as still carrying the bug latent, and these new sections are what made it fire, pushing Cancel and Investigate off the bottom of a
position: fixedoverlay where nothing could scroll to them. The reference sections can also shrink now: a<fieldset>refuses to go below its widest unbreakable child, so one long URL had been widening the panel past its own edge. (#569)A quest's “reference images” are now reference links and accept any URL. The hidden field carrying the list is split on newlines alone, where it used to split on commas as well — which silently tore a signed URL or a CDN transform such as
.../w_200,h_200/...into two broken halves, and was why the editor refused a comma outright. Because most links are not pictures, each one now degrades in two steps instead of showing a broken-image glyph: the URL itself in case it is an image, then the site's own/favicon.ico, then a link icon. The favicon is fetched from the site and nowhere else — a favicon service would mean handing every link a user saves to a third party — and it is drawn at its own size over the hostname it belongs to rather than cropped to fill the tile, because a 32-pixel icon stretched across a 160-pixel thumbnail is a blurred smear that reads as a rendering fault. The scheme check stays, since these URLs are rendered into an<img>and forwarded verbatim to an AI provider. (#569)An investigation now shows what its question carried — the files it referenced and the links it was given. Without it, one that failed because of an attachment read exactly like one that never had any, and “did it see the picture?” had no answer anywhere on the page. A referenced file that has since been deleted is named as such rather than quietly disappearing from the record. (#569)
Referencing an attachment is refused, before any credits are spent, when storage is only reachable from the local machine. A model fetches images from the AI provider's own servers, so a
localhostor Docker-internal Supabase URL gives it a connection it cannot make; the provider returns nothing and the investigation used to surface as “the model returned an empty response” — an error pointing at the model, for a problem that had nothing to do with it. The route refuses it up front and the worker, which is the real boundary since a row can be written straight through PostgREST, fails with the same explanation. (#569)An investigation can now be asked about a document, not only a picture. Referencing a PDF, a Word file, a spreadsheet, a CSV or a text file used to cost a credit each and send the model nothing: only image attachments were ever resolved, so the answer was produced without the file and the charge stood anyway. PDFs go to the provider as native document parts —
input_fileon OpenAI, a base64documentblock on Anthropic,fileon OpenRouter — so the model reads the pages rather than a flattening of them. Everything else is turned into text in the worker and appended to the prompt in named, fenced blocks, so the answer can say which file it came from:.docxthrough Mammoth,.xlsxsheet by sheet with the sheet names kept, and text and CSV decoded as they are, each truncated at a bound with the truncation marked rather than silently cut. DeepSeek has no document input at all, so the dialog refuses the combination at submit — before the row is written and before the credits are taken — rather than charging for a request the provider will reject. The unreachable-storage check narrowed to images at the same time, since a document is fetched by the worker itself and never by the provider. (#569)Deleting a quest or a task now takes its attachment files with it.
quest_attachmentscascades from both, so the rows naming the objects vanish in the same statement — and with them the only record of which files belonged to that quest and the storage policy's only clause for authorizing their removal, leaving the objects behind with nothing able to reach them ever again. Both delete paths now clear the bucket first. It is best effort by design: a Storage outage must not stop an owner deleting their own quest, so the worst case is a leak rather than an undeletable quest. A new hourly worker loop collects what that misses — a cascade from an account deletion with no app code in the middle, a task deleted straight through PostgREST, or an upload whose record never landed — in bounded batches, unflagged, because files orphaned by a deleted quest still need collecting after the attachments feature is switched off. (#569)
Fixes
Fixed Well Played recap generation to use the valid investigation success status, and stopped masking computation failures as missing pages. (#588)
Enforced party task insert permissions at the database boundary and returned a clear error for assignments to another member. (#595)
Rejected guild-backed recurrence submissions while Guilds was disabled and forwarded the Guilds flag into Docker builds. (#595)
Deferred reminders through quiet hours when they could still arrive before the event, including preference changes before delivery. Used the scheduled local start in email subjects so delayed reminders did not display a stale countdown. (#595)
Scoped system AI refunds to the failed feature, preserving successful work charged against the same reference. (#595)
Deleting a Quest that is linked to a Quest Jam no longer destroys its attachments on the way to being refused. The attachment objects have to be cleared before the delete — the rows naming them cascade away with the quest, taking the storage policy's only authorization clause with them — so the purge happened first and then the Quest Jam's
ON DELETE RESTRICTforeign key refused the delete, leaving the quest exactly where it was with every attached file irreversibly gone. The same was true of a caller who did not own the quest at all. Both reasons a delete can be refused are now settled first, against the quest row and the jam entry, and the purge runs only once the delete is going to happen. The constraint handler stays as the backstop for someone joining a jam in between, and a jam probe that fails rather than answering skips the purge and lets the database decide — the reaper collects what is then orphaned. (#600)A reference link that is not an image is refused before any credits are spent.
imagesreaches the model as image parts —input_image, animageblock,image_url— so a saved link that serves a web page was rejected by the provider with the charge already taken, the trigger on the insert having fired long before. The editor accepts anyhttp(s)URL by design, and the shape of a URL says nothing about what it serves, so the host is asked directly and only a positive "this is not an image" refuses: a probe that times out, is declined or answers without acontent-typelets the link through unchanged, because a false refusal blocks a picture the user can see in their own browser and can do nothing about. Sending a page to be read, rather than looked at, remains to be built. (#600)NEXT_PUBLIC_FEATURE_ATTACHMENTSnow reaches the container build. EveryNEXT_PUBLIC_*flag is inlined bynext build, so one the Dockerfile does not declare and docker-compose does not forward is false in the image no matter how it is set in.envor passed with--build-arg— attachments looked shipped and behaved as though the feature had never landed. A test now derives the flag catalog from.env.exampleand fails when any documented flag is missing from the Dockerfile's ARG list, its ENV promotion, or the compose build args, so the next flag cannot ship the same way. (#600)SIGTERM now ends the worker's waits instead of being noticed whenever they happen to elapse. Each loop checks a shared flag between iterations, so a signal arriving while one slept was not acted on until the timer fired — and the attachment reaper's interval is an hour, which meant
main()stayed blocked and the container was killed by the orchestrator's grace period rather than stopping cleanly. The sleep the loops share is now interruptible: a shutdown request clears every pending timer and resolves its wait at once, and a sleep started after the signal returns immediately rather than arming another. (#600)
Technical changes· 4 entries for database, tooling, CI, tests and docs
Database
Added optional task and quest deadlines, due-soon indexes, and consistency triggers that serialize concurrent edits and allow template tasks to be scheduled. (#570)
Added quest and task file attachments: a
quest_attachmentstable and a privatequest-attachmentsstorage bucket, both governed byuser_can_access_quest()so an attachment is exactly as visible as the quest or task it hangs off and never more. The quest is the anchor andtask_idis nullable, because a brief or a rubric belongs to no single task; one table rather than two, since every rule — the access predicate, the bucket, the limits, the lifecycle gate — would otherwise be copied and kept in step. Anyone on the quest party can attach a file and read what others attached; only the uploader or the quest owner can remove one, which is narrower than "the assignee" on purpose — reassigning a task must not hand one member the power to delete another's work. Nothing is updatable, and theUPDATEgrant is withdrawn rather than merely left unpoliced. Non-members and anonymous visitors get nothing even when the quest is public: a publicly readable quest does not publish the files its party exchanged. The bucket itself enforces the limits — 10MB, images plus everyday document types, no archives — so a client that lies about a file's type or size is refused by storage rather than by the form. Object paths are{quest_id}/{task_id|quest}/{uuid}.{ext}: the quest id is what the storage policies authorize against, so the row'squest_idis derived from the task whenever one is named rather than trusted, a CHECK pins the path to it, and the stored name comes from the mime type rather than the uploaded filename. Attaching a file is work on a quest, so an archived or completed one refuses new attachments through the sameassert_quest_accepts_task_writes()gate that governs its tasks; removal stays open, because an owner who archives a quest must still be able to take something down. (#569)An investigation may reference quest attachments, through a new
task_investigations.attachment_ids. Ids rather than signed URLs: a URL baked into the row expires, so the stored record of what an investigation was asked about would decay into dead links. The worker mints one per id at fetch time with the service role — which bypasses RLS, making these ids an instruction to a privileged process to read a file and show it to a model, not merely data.assert_investigation_attachments_in_quest()is therefore the boundary: it requires every referenced attachment to belong to the same quest as the investigation's task, on UPDATE as well as INSERT, and refuses a list that names one twice. Without it, naming any attachment id from any quest would have had the worker read that file out and hand the answer back to the caller, with the attachment's own policies never consulted.ai_credits_charge_investigation()counts referenced attachments alongside image URLs, so uploading a picture first is not a cheaper way to send one. (#569)Storage keeps no orphans on purpose, through two changes and a sweep. The bucket's DELETE policy admitted only the quest owner, or a caller whose attachment row already existed — which is nobody at the one moment it matters: a file is uploaded first and recorded second, so the client that has to clean up after a failed record was refused by the very policy meant to let it. It now admits the object's own uploader as well, both as Storage records ownership (
owner_id, and the legacyowner) and through the attachment row, so a half-finished upload can always be taken back by the person who made it and never by anyone else.find_orphaned_quest_attachment_objects()is the backstop: SECURITY DEFINER, revoked fromanonandauthenticated, it lists bucket objects older than a grace period that noquest_attachmentsrow points at. The grace period is not politeness — an object legitimately exists with no row for the moment between the upload and the record, and a sweep inside that window would delete a file out from under someone doing nothing wrong. The comparison is againstnow()rather thantimezone('utc', now()), which returns a timestamp without a zone and would have been coerced using whatever timezone the calling session happened to be in. (#569)