v0.66.0 — Library Overhaul, phases 0–2
The Library (/library) is the platform's stash of reusable art, and it was built for one
person pointing a server at a folder on their own disk. Everything since — hosted
deployments, per-user accounts, cloud object storage, the presigned upload pipeline — grew
up around it without it changing. Two consequences, both live in production today: the
full-resolution asset is not in storage at all (the import worker uploads a thumbnail
and writes the original's local filesystem path into source_path, which the UI then
uses as an image URL), and there is no ownership model (GET /api/library applies no
user_id filter, the importer writes userId: null, and POST /api/library reads
req.user?.id, which this codebase never populates). The second is the same class of
defect as the three data-exposure items in v0.59.0's Security section.
Spec: 15-library-overhaul — read § What is actually there today first, then § What already exists that this should reuse for why this is mostly wiring rather than new infrastructure. Tasks are requirements.md, one commit each, in order.
Phases 0, 1 and 2 are in this release. Phases 3–6 (cloud-drive connectors, the background scheduler, the Electron uploader, non-image content) are deferred — Phase 3's long pole is three OAuth app registrations, which is lead time and not engineering.
Decisions taken (§11):
- Backfill — existing ownerless rows become
visibility = 'server', preserving today's everyone-sees-everything behaviour for self-hosters exactly. Rows written bytextures.tsalready carry a realuser_idand becomevisibility = 'user'owned by it. - Quotas — ship the mechanism at presigned-URL issuance with a generous default and the tier numbers in config. Presigned uploads bypass the API server, so issuance is the only enforcement point there will ever be.
campaignvisibility — in scope, in the Phase 0 migration. Far cheaper now than a second migration later.- Hosted classification — in-browser is the primary path, the server ladder is the fallback for low-confidence results where the user has supplied a key. This makes task 2.4 load-bearing rather than a nice-to-have.
- External campaign files — deferred with the rest of Phase 6.
Phase 0 — Foundation (blocking)
Ownership and assets-actually-in-storage are load-bearing for every other phase, and this is the only phase with a migration and a backfill.
- [x] 0.1a. Store the original, stop storing disk paths. Add
asset_urlandoriginal_urltolibrary_items(both schemas +db:generate:pg); the import worker uploads the full-resolution WebP, not just the thumbnail.source_pathsurvives as a provenance field for the dedupe check only, with a comment saying so. Migration backfillsasset_urlfromsource_pathonly where it is already a URL; filesystem-path rows getNULLand surface in 0.4. (Done. Both columns on both dialects,migrations-pg/0019, both of db.ts's sqlite CREATE TABLE copies and the postgres one, and the worker now uploads the original.skipResize: trueon the asset upload is the load-bearing flag. Categorylibraryfalls throughshouldResizeForCategory's default totrueandgetMaxSizeForCategory's default totokenMaxSize, so the plain call would have stored a battle map capped at token size — replacing a broken full-res image with a small one, which is harder to notice.skipResizeskips the resize and keeps the WebP re-encode, which is exactly "full res with medium-high webp compression".* The backfill's discriminator is not "starts with a slash": a POSIX filesystem path does too. It matcheshttp://,https://, and the local provider's configured prefix read fromSTORAGE_LOCAL_URL_PREFIX— hard-coding/imageswould silently skip any deployment that changed it. Rows holding a real disk path stay NULL on purpose; a URL synthesised from/Users/someone/art/goblin.pngwould 404, and a 404 that looks like data is worse than a row 0.4 can list. Guarded bysqliteColumnExistsrather than run as a sweep, becausesqliteApplyColumnMigrationsruns on every boot and would otherwise re-fillasset_urlon a row an admin had deliberately cleared. Postgres gets the backfill only in 0019, since the drizzle migrator runs it exactly once — the same division as v0.61.0'scampaign_members.accepted. The two writers that were already storing URLs —POST /api/libraryand the texture upload — writeasset_urlnow too, so no row created between here and 0.1b lacks a location. The store endpoint falls back tosourcePathwhen the caller sends noassetUrl, but only when it actually looks like a URL (isFetchableUrl); the Map Generator would otherwise break on the release that added the field. One existing test had to change:libraryModuleApi.test.tsslicedStoreLibraryItemSchemaout of the source by a fixed 800 characters, so adding a comment near the top pushedthumbnailUrlout of the window it was asserting against. It is now bounded by the schema's own closing brace — an assertion that no longer reaches its target is worse than no assertion.) - [x] 0.1b. Switch every consumer from
sourcePathtoassetUrl.LibraryMain.tsx,LibraryContext.tsx,MainMenu.tsx,SourceOptionPane.tsx,MapGenerator.tsx,textures.ts(both list routes),mapGenerations.ts. Contract test asserting nosrc=/imageUrl:binding readssourcePath— this renders fine in dev, where the path resolves, and breaks only in prod. (Done, all seven, through one helper:libraryAssetUrl(item)in@purple-cat/shared, which isassetUrl || thumbnailUrl || ''. The fallback is the whole design. A row imported before 0.1a has no full-resolution asset anywhere in storage, so the thumbnail is the most that exists — small beats broken, and 0.4 is what lists those rows for re-import.sourcePathis never a candidate. One function means one place where that degrade is decided instead of seven??chains that will drift.* Two consumers deliberately keep thumbnail-first.MapGenerator.tsx's slot grid is a preview showing many at once, and the library grid tiles are tiles; what changed there is only the fallback, which used to be a filesystem path.mapGenerations.tsflipped the other way — full res first — because it PAINTS the asset onto a map. Its old order was correct for its era:sourcePathwas only sometimes a URL, so preferring the thumbnail was the only way to be sure of a fetchable one.assetUrlis fetchable or null, so the painter can have the resolution it always wanted. The contract test's fifth catch is the one the task description would have missed.SourceOptionPane's call site wasresolve(item.sourcePath, item.name)— a function argument, matching neithersrc=norimageUrl:, and just as broken. So alongside the two binding guards there is a third: inapps/,sourcePathmay be declared and may be described in a comment, and may not be read. The browser has no use for provenance; the dedupe check that does is server-side, and a fourth assertion pins that it still happens — "delete the column" would satisfy the other three.* All three guards were run against the pre-fix sources and fail there, which is the only way to know a guard is guarding anything. It lives inpackages/shared, not beside the components. CI runspnpm test→pnpm -r run test, andapps/web's test script isecho "No tests yet". The web app has 803 test files and 8,635 tests that CI has never run; 104 of them currently fail. That is now v0.67.0, and this is the finding that scheduled it — but here it is just the reason a guard placed next to the code it guards would have been decorative.)* - [x] 0.1c. Fix the thumbnail: 512×512,
fit: "inside", WebP. Today's 200×200fit: "cover"centre-crops, so a wide battle map's thumbnail is a crop of its middle. Square grid tiles letterbox in CSS. (Done, pluswithoutEnlargement—insidealone would upscale a 300px source to 512 and store it blurry. The CSS half is not optional, and the task reads as though it is. Four library grids rendered with Mantine'sfit="cover", which re-crops in the browser exactly what the server just stopped cropping — the stored bytes would have been fixed and the user would have seen no difference at all. They arefit="contain"now, so a wide map reads as a wide map in the tile.* The 512 lives inLIBRARY_THUMBNAIL_SIZEin shared rather than as a literal in the resize call, because task 1.1b has the browser generating thumbnails for client-side uploads while the server keeps generating them for imported and cloud-fetched files. Two pipelines with two literals drift, and nothing fails when they do — you just get a grid of mismatched tiles. The test asserts the source and then runs the actual resize: 4000×1200 → 512×154, 1000×3000 → 171×512, 2048×2048 → 512×512, 300×200 → unchanged. Readingfit: "inside"out of the source proves someone typed it; running it proves it means what we think. Existing rows keep their 200×200 crops. Regenerating thumbnails needs the original, and for locally-imported rows the original is the thing that was never uploaded — the same rows 0.4 lists. Re-import is the fix, and it is the fix for both problems at once.)* - [x] 0.2a. Add
owner_user_id,visibility,campaign_id, with the backfill above.visibilityis'server' | 'user' | 'campaign', default'user';owner_user_idisNULLonly forserverscope. (Done —migrations-pg/0020, both sqlite DDL copies and the postgres one, all three writers, andLibraryVisibilityin shared.owner_user_idis the olduser_id, renamed, not a new column beside it. The spec names a column the table already had under a worse name, and adding a second one would have left every texture row's real owner behind inuser_idwhile every new check read NULL — ownership silently lost on upgrade, for exactly the rows that had it. Two columns for one fact can disagree; one cannot. What is genuinely a second question is who can SEE the row, and that isvisibility.* drizzle-kit's default answer to its own rename prompt is "create column", which generatesDROP+ADD. 0020 was generated with the rename answered explicitly, and a test asserts the migration renames and does not drop — this is a one-keystroke difference between migrating owners and discarding them.* Postgres has noALTER ... RENAME COLUMN IF EXISTS, so the boot-path rename is aDO $$block checking foruser_idpresent andowner_user_idabsent: an install that already ran 0020 has exactly the reverse. The backfill is the §11.1 decision, and it preserves today's behaviour rather than improving it. Ownerless rows becomeserverscope — everyone keeps seeing them, which is whatGET /api/library's complete absence of a filter has always meant in practice. Texture rows carry a real owner and keep theuserdefault, which is whatGET /api/textures/useralready treated them as. Writers now state a scope rather than leaving one to be inferred: a folder import isserverwith no owner (the deployment's own library), a texture upload and a generated asset areuserscope owned by the person who made them.POST /api/librarywrites the renamed column but still reads its user id from(req as any).user?.id, so its rows stay ownerless until 0.2c — renaming a column does not make an owner appear. Two more fixed-window source assertions had to be re-bounded, inlibraryModuleApi.test.ts. Third occurrence this release; the pattern issrc.slice(idx, idx + N), and it fails silently in the direction of passing.) - [x] 0.2b. Apply the read and write rules through one shared helper.
libraryVisibilityFilter(userId)onGET /api/library,GET /api/library/:id,POST /api/library/query,GET /api/textures/library,GET /api/textures/user; ownership required onPATCH/PUT/DELETE /api/library/:id,visibility='server'requiresisAdmin. Today any authenticated user can retag or delete anyone's assets — call that out separately in the commit message. (Done —utils/libraryAccess.ts, all five reads and all three writes.GET /api/textures/librarywas the worst of the eight and is not in the task's list of concerns. The/api/texturesrouter is mounted without auth ("textures are served from CDN/static files"), and that route selected every texture row in the table. After 0.2a every browser-uploaded texture is a user-scope row it would have handed to an unauthenticated caller. This is why the helper takesuserId?and collapses to server scope rather than throwing: the public route needed the rule as much as the private ones, and a helper that refused to answer without a caller would have been left out of it.*GET /api/libraryhad two query paths and only one of them could be filtered. Search terms produced aWHERE; no search terms producedSELECT * FROM library_items. Putting the predicate in asconditions[0]fixes the first and makes the second unreachable, so the second is deleted rather than left for a refactor to find. A test asserts theif/elseis gone and that every select in the handler carries the where clause.*PATCH/PUT/DELETEwere bare mutations by id. They now read, decide, then write, and a test asserts that ordering by index — a check after theUPDATEis an audit log, not a gate. 404, not 403, for a row you cannot see. A 403 confirms the id exists, andlib_<timestamp>_<9 chars>is guessable. 403 is reserved for rows the caller can genuinely see but not write — which today means server scope, since that is the one visible to everyone.* An unrecognisedvisibilityvalue returns false. Unreachable through the API (the column is NOT NULL with a default) but it is the exact failure mode being fixed: the old NULL meant "no restriction", and the replacement should fail closed. Pending campaign invitations do not grant sight. v0.61.0 addedacceptedprecisely so an offer and a grant could stop being the same row, and an invitation that handed over someone's token pack before they answered it would undo that. The verdict type is not a discriminated union, deliberately.server-corecompiles withstrict: false, soif (!verdict.allowed)does not narrow{allowed: true} | {allowed: false, status}— the compiler rejectsverdict.statusin the branch where it is guaranteed. One shape with optional fields costs a??per call site and compiles under the settings this package actually has.* The tests are behavioural rather than source-scanning: the rule is exercised across the scope matrix with the db and the admin lookup mocked, and the generated predicate is serialised through drizzle's own sqlite dialect so a change to it shows up as a change to the SQL.inArray(col, [])is avoided because some drivers renderIN (), which is a syntax error rather than "matches nothing".) - [x] 0.2c. Fix
POST /api/libraryto readreq.auth.userId, not(req as any).user?.id. 81 call sites usereq.auth.userIdand this file is the sole outlier, so every row this endpoint has ever written is ownerless. (Done — throughcallerId(req), the helper 0.2b already introduced for the read rules, so the endpoint learns who is calling the same way its neighbours in the file do. The?? nullis the reason this lasted.req.useris populated by nothing, anywhere, so the lookup could never succeed — and the default turned that permanent failure into a valid-looking ownerless row instead of an error anyone would have noticed. A wrong value gets reported; an absent one behind a default does not.*?? nullstays, and is still correct: the column is nullable forserverscope. What changed is that the expression feeding it can now produce a user. The guard pins the distinction by asserting the absence of a fallback that would invent an owner —callerId(req) ?? "anonymous"would make every write look like one person's library, which is worse than none. The guard is repo-wide rather than about this file: no server source readsreq.user/(req as any).user/request.userat all, with comments stripped first so it does not trip on the paragraph explaining what it replaced. Its inverse assertsauth.userIdstill appears 50+ times — if that ever goes to zero the convention has moved and the guard is enforcing a dead one. This is also what makes 0.2a'svisibility: "user"mean anything here. Before it, the pair (ownerUserId: null,visibility: 'user') described a row nobody could see, which is the honest reading of what the endpoint was writing — which is why the two tasks land one after the other.) - [x] 0.2d. Gate
POST /api/library/importto self-hosted admins. RequireisAdminand an explicitLIBRARY_LOCAL_IMPORT=1opt-in absent from the hosted task definition; confine the path to a configured root and reject anything that escapes it afterrealpath. Collapse the "does not exist" / "not a directory" error strings into one, so the endpoint stops answering filesystem questions. (Done. Two env vars —LIBRARY_LOCAL_IMPORT=1to exist at all andLIBRARY_LOCAL_IMPORT_ROOTto say where — documented in both.env.examplefiles and set by neither hosted task definition. The gate answers 404, not 403, and checks the flag before the admin flag. A 403 confirms the endpoint is there, and the entire complaint about this endpoint is that it answered questions about the server; checkingisAdminfirst would leak the same fact to every non-admin through the difference between the two codes. The operator gets the reason in the log, where an attacker does not.*realpathon both sides is the load-bearing part, not the string comparison. Textual containment lets a symlink INSIDE the root point anywhere on the disk, and that case has a test. So does the adjacent-sibling case —/srv/art-othermust not pass for root/srv/art, which astartsWith(root)without the separator allows.* All three refusals — missing, not-a-directory, outside-root — return one identical 400. Keeping "outside the root" distinguishable would have replaced one oracle with another. The worker's two throw strings are collapsed the same way, since they were the original source. Status and cancel are gated too, though the task names only the starter. All three share the module-level singleton:statusreturnsfolderPath, a path on the operator's disk, andcancelcancels whoever is running. Gating one of three would have left the disclosure and the denial-of-service exactly where they were.* The UI hides the control rather than leaving it to fail:/import/statusanswering 404 is the only signal the browser gets, and on hosted that is the true answer for every user. It defaults to hidden, because showing it and then retracting it is worse than never showing it.ImportPathResultdiscriminates on astatusstring rather thanok: boolean— server-core compiles withstrict: false, and withoutstrictNullChecksTypeScript will not narrow a union by a boolean literal, so every failure branch still saw the whole union.) - [x] 0.3a. Extract
ingestLibraryAsset(input)in server-core and route all three writers through it. It owns the dimension probe, WebP re-encode, thumbnail, storage upload, dedupe, id generation and insert. This is what makes Phases 1 and 2 cheap: each new source becomes a fetcher plus a call, not a fourth divergent copy of the same 80 lines. (Done.services/ingestLibraryAsset.ts, and the three writers are callers. Net −97 lines. The input is abytes | storedunion, not a single shape. Two of the three callers hold bytes and want everything derived;POST /api/libraryholds URLs its caller already uploaded through the platform pipeline. Forcing one shape would have meant either re-downloading bytes that are already in storage or making every field optional and the function a pile of branches.* Routing the texture upload through it fixed two things nobody had filed. It calledstorage.uploadwithoutskipResize, so a tiling texture — whose whole job is to be sampled at whatever resolution the map needs — was silently capped attokenMaxSize. And it had no thumbnail of its own: it mirrored the full-size URL intothumbnailUrl, so every texture grid downloaded full-size images to draw 80px tiles. Both are fixed by construction now, which is the argument for one writer in miniature — those two defects existed only because this path was a copy that had drifted.* Dedupe is opt-in, and that is deliberate. Only the folder importer wants it: it re-walks the same tree every run.POST /api/libraryalso stores a URL insource_path, and making that an implicit dedupe key would silently refuse a caller legitimately storing the same image twice. It also short-circuits before the decode, so a re-run of a 700-file import costs 700 queries rather than 700 decodes.* Two things it deliberately does not own. The image-work kind stays with the caller — v0.65.0's lane distinguishes bulk from interactive, and which this is depends on who called; the tag ridesAsyncLocalStorage, so the worker'swithImageWorkKind("library")still reaches the decode two files away. Authorization stays inlibraryAccess.ts.mapGenerator/adapters.tsis a fourth writer and is deliberately left alone: it builds its row through an injectable db/schema pair so the Map Generator can be tested without a database. Folding it in is 0.3b's business. The guard asserting "nothing else inserts intolibrary_items" names it as the one allowed exception, so it cannot quietly become two. Nine guards from 0.1a, 0.1c and v0.65.0 broke, all by reading code that moved. Each was repointed at the service rather than deleted, after checking the invariant it names still holds — the gate one is now stronger than before, asserting the tag in the worker and the lane in the writer, since either half alone is not the property.)* - [x] 0.3b. Delete the
TextureEntryshadow shape, or define it as a projection ofLibraryItem. The parallel texture route family exists only because the library API could not be scoped per user; after 0.2b it can. (Projection, not deletion — the task offers both and only one of them is true.TextureEntryhas a second source with no library row behind it. Textures also come from a module's owntextures.jsonmanifest, read off its build output bybuildTextureList(). Deleting the type in favour ofLibraryAssetwould have required inventing library rows for files that are not in the Library, so the shape survives — what it stops being is a shadow.* The actual defect was duplication rather than existence: the same five fields were declared verbatim inroutes/textures.tsand again inhooks/useTextures, and three hand-written literals turned rows into them. That is how one of the three came to readurl: item.sourcePathand hand a picker a filesystem path. Now: one declaration in shared, onelibraryAssetToTextureEntryused by both list routes and the upload response, and it defers tolibraryAssetUrlfor the thumbnail degrade rather than repeating the||. The web hook re-exports the shared type so the six components importing it from there did not have to change — but it mustimport typeas well as re-export, since a bareexport … fromdoes not bring the name into local scope and the hook uses it in four signatures of its own. A guard now fails if anything outside shared declares its ownTextureEntry, which is the duplication itself and not merely one instance of its consequences.) - [x] 0.4. Make the damage visible. An
/admin/datasurface listing library rows with no reachableasset_url, with bulk delete and a re-import hint. On a hosted deployment that may be every row; better to show the number than to guess. (Done.GET /api/admin/data/library-broken-assetsand a bulk.../delete, with a panel above the existing projection backlog on/admin/data. "Broken" is one predicate shared by the listing and the delete, and it counts an emptyasset_urlas well as a NULL one — an empty string is exactly as unreachable and a caller can store one. Two definitions of broken would let the delete remove a row the listing never showed. The delete re-checks brokenness rather than trusting the ids it is handed. The list is a snapshot; between the scan and the click an import can finish, and a row that now has an asset has to survive a stale click. It reports what it skipped rather than dropping it silently. It takes an explicit id list rather than deleting "everything currently broken" for the same reason — deleting what the admin was shown is the only version of this that is safe to click.* The re-import hint issource_path, which for these rows is the folder on whichever machine ran the import. This is the only surface in the app that renders that column, and it is the one place that should: it is showing provenance as provenance, which is what 0.1a demoted it to. The panel says re-import rather than offering a repair button, because there is nothing to repair from — the bytes were never uploaded. It also mentions that re-importing replaces the 200×200 centre-cropped thumbnails, since 0.1c cannot fix those for the same reason, and that deleting first avoids duplicates because the importer skips paths it has already seen. The table caps at 50 rows and says so, with the delete still covering all of them — a truncated list that looks complete is how someone concludes the problem is smaller than it is.) - [x] 0.5a. Implement the
tags=filter that the client already sends and the server already accepts and discards into_tagsParam. (Done. Required-not-preferred: every tag must be present, which is what a facet filter means and what the UI implies by letting you add a second one. Each tag is pushed into the sameand(...)the visibility predicate lives in, so a tag filter can never widen a result set. An accepted-and-ignored parameter is worse than an unsupported one. A tag-filtered request returned the unfiltered list and the UI displayed it as the answer — no error, no warning, nothing to notice. An unsupported parameter at least fails.* Matched as a quoted JSON token (%"orc"%), not a bare substring, so it cannot return the orchard — and%and_in a tag are escaped, since they are LIKE wildcards and a lone%as a tag would otherwise match the entire table. Both cases are tested against a real SQLite database rather than by reading the SQL, because what matters here is what the pattern matches. The free-textquery=search still matches substrings across name and tag text, and a test says so on purpose — that is 0.5b's job, and a boundary that is written down is a decision rather than an oversight.) - [x] 0.5b. Separate name search from tag search, and match tags exactly. Today
LIKE '%orc%'over the JSON text of the tags column matchesorchardand cannot tell a name hit from a tag hit. Postgres getstags ? 'orc'with a GIN index; SQLite has no equivalent, so write alibrary_item_tags(item_id, tag)join table alongside the JSON column — which also turns the Map Generator'sPOST /api/library/queryfrom a pull-everything-and-score-it-in-JS into a real query. Response shape unchanged. (Done — the join table on both editions,migrations-pg/0021, and a backfill from the JSON column every existing row already carries. The join table for BOTH editions, nottags ? 'orc'for one of them. A GIN index is the better postgres answer in isolation and it would have meant two search implementations to keep in agreement, with the sqlite one — the one developers run — being the weaker. One mechanism, tested once.* Two representations of one fact needs a justification, and it is that they answer different questions. The JSON column is the display form: original casing, original order, one read to render a row. The table is the matching form: lowercased, indexed. What makes it safe is that one function writes both —syncLibraryItemTags, called by ingest, by retag, and (as a delete) by both delete paths. A retag that wrote only the JSON column would leave an item displaying its new tags and matching its old ones, which is the worse failure because the screen says the write worked.* Free-textquery=is now a substring match on the name OR an exact match on a tag. Partial typing still finds "Orc Warrior"; typingorcno longer drags in the orchard.%and_are escaped there too — a search box must not be a pattern.POST /api/library/querynarrows its candidate set through the tag table but keeps scoring in JS. An item sharing no tag with the query scores zero and was always discarded, so fetching it was pure waste; but Jaccard in SQL would be written twice, once per dialect, and float arithmetic differing in the last bit would reorder ties in a response this endpoint is a contract for. Narrowing the input is the win. No foreign keys, because this schema has none anywhere and a cascade here would be a convention of one — so deletes remove tag rows explicitly, including the admin bulk delete from 0.4. Orphans would otherwise accumulate and a recycled item id would inherit them. The sqlite backfill is guarded on the table being empty rather than on a column existing:createTableruns every boot, so the table is never "new" and the usual probe has nothing to ask about.)
Phase 1 — Client-side upload
- [x] 1.1a. Add a real upload control to the Library view — file picker, multi-select,
drag-and-drop onto the grid — not just the modal's texture-only button. Reuse
uploadImage()exactly as the artwork path does; do not write a second uploader. (Done —uploadLibraryAssetis a fetcher plus a call toPOST /api/library, which is what 0.3a's single writer was for. No bytes touch the API server:uploadImagehandles presigned direct-to-storage, the LOD worker and the multipart fallback already, and a second uploader would have to reproduce all three and would get the fallback wrong first. Until this, a hosted user could not put anything in their own library. The only routes in were a server-side folder path — which 0.2d has now confined to self-hosted admins — and a texture button buried in the modal.* Addinglibraryto the web'sImageCategoryimmediately failed the build, on aRecord<ImageCategory, number>of per-category dimension caps with no entry for it. That union is a hand-kept copy of the server's, which has hadlibrarysince the Library was built; nothing in the browser had ever uploaded to that category, so the omission cost nothing until now. A parity test in shared pins the two lists together. The cap itself is mostly moot — the asset uploads withskipResize, because the library is where originals live — but TypeScript was right to demand an answer.* The Dropzone wraps the grid rather than sitting above it as a strip, because the grid is what people aim at, and it isactivateOnClick={false}so clicking an asset still selects it. Uploads land in the tab you are looking at rather than asking for a type the screen has already answered; 2.4's classifier will answer it properly. One bad file does not abandon the batch: a folder of 300 with one unreadable JPEG imports 299 and names the one that failed. Failed rows survive "clear finished", since they are the only record of what went wrong. 0.1b's contract test fired on this, and it was the guard that was wrong. It banned every mention ofsourcePathinapps/, but this code legitimately sets it — the browser is the only party that knows where a file came from, which is what provenance is. The rule is about direction: writing it as an object key is fine, and any member access on it is not. A second assertion pins that the loosened guard still fails onitem.sourcePath, so it cannot have been widened into a no-op.)* - [x] 1.1b. Generate the 512×512 thumbnail in the browser via
compressImageand upload it alongside, so the server never decodes a client-held image. The server keeps its own path for non-browser sources. (Done — and the substance turned out to be agreement, not generation. "Both use 512 and preserve aspect ratio" is not the same as "both produce the same thumbnail". Rounding alone can differ by a pixel between sharp and a canvas, and a grid mixing 512×154 tiles with 512×153 ones is the kind of discrepancy nobody debugs because nobody believes it. SolibraryThumbnailSizein shared is the one definition: the browser sizes its canvas from it directly, and a test runs sharp against it for six shapes — including 4000×7, where the short side scales to 0.38 and both sides must clamp to 1 rather than one of them producing a zero-height canvas that throws.canvas.toDataURLdoes not fail when it cannot encode WebP — it silently returns a PNG. Storing PNG bytes under a.webpname is a lie that surfaces only as a mysteriously large thumbnail, so the result is checked rather than assumed.*skipResizeon the thumbnail upload too: it has already been sized to the shared geometry, and letting the category rules touch it again would undo exactly the parity this task is about. One honest caveat. "The server never decodes" holds for the presigned path, which is every hosted deployment. On a local-storage backenduploadImagefalls back to multipart and the server still decodes the asset — that fallback isuploadImage's and predates this task, and v0.65.0 bounded it deliberately. What this task removes on every backend is the server decoding to produce a thumbnail for bytes a tab was already holding.)* - [x] 1.2a. Support whole-folder selection via
webkitdirectory, with directory drag-and-drop falling back to the File System Access API where available and to multi-file select where not. Preserve relative paths intosource_path— folder names are the best free classification signal there is, andguessTypeFromPath()already reads them. (Done — and the directory-drop half needed no code at all, which is worth recording because the obvious implementation was a hand-rolledwebkitGetAsEntrytraversal. Mantine's Dropzone runsfile-selector, which already walks a dropped directory, flattens it, and keeps each file's path. Writing the traversal would have been a second, divergent implementation of something already in the bundle. So the three routes in — directory input, dropped folder, plain multi-select — differ only in where they put the path:webkitRelativePath,path, and nowhere.libraryRelativePathreconciles them, because the value lands insource_pathand a column meaning three things means nothing. It normalises to a relative POSIX path with no leading slash, and that is not tidiness: 0.1a's backfill treats a/images/-prefixedsource_pathas a URL, so a provenance value that can be mistaken for a location is the precise confusion this release exists to end...is dropped rather than resolved — nothing here can resolve it — and the result is never empty, sincesource_pathis NOT NULL and every empty one would collide in the dedupe check. Feature-detected and degraded, not hidden, as the ask asks: wherewebkitdirectoryis unsupported the Folder button opens the file picker instead and its tooltip says why. A control that is silently absent teaches the user the feature does not exist.) - [x] 1.2b. Add a queued, resumable upload UI — per-file state, overall progress,
cancel, retry of just the failures. Cap concurrency at 4–6; a folder drop of 2,000
files must not open 2,000 sockets. Persist the queue in IndexedDB so a reload does
not lose a half-finished import of someone's entire art collection.
(Done.
createUploadQueuein shared, IndexedDB underneath it, and a hook that is now React state and a bridge. The scheduler is inpackages/sharedand takes its worker as an argument. Two reasons, both load-bearing. The scheduling is where the bugs are — peak concurrency, what one failure does to the rest, what cancel means to work already in flight — and it is pure, so CI can test it; apps/web's tests do not run there. And Phase 5's Electron uploader needs the same queue with a different worker.* Peak concurrency is measured in the test, not inferred from having passed a cap. Twostart()calls join one run rather than doubling the workers, which is the shape that would silently defeat the limit. An aborted item iscancelled, neverdone. The upload may have been stopped part-way, and calling that success leaves a half-written row nobody knows is half-written. Cancel also drops what has not started, and retry re-queues failures and cancellations — but never anything that finished, since re-uploading what already landed is the one thing "retry" must not do.* IndexedDB rather than localStorage because the payload isFileobjects: they survive its structured clone, whereas localStorage would mean base64 inside a 5 MB budget. Every path degrades to a no-op — includingonblocked, where another tab holds an older version and waiting would hang the upload. Losing resumability is a worse import, not a broken one. Only unfinished items are persisted, and the store is replaced rather than appended: an item absent from the queue's list has to mean deleted, or the store only ever grows. It does not auto-resume. A page that starts uploading two thousand files because you happened to reload it is worse than one that asks, so the restore adds the items and stops; a banner offers Resume. The id is path + size + mtime, so a restored queue plus a fresh drop of the same folder collapses to one set rather than uploading everything twice.* Follow-up: the report was unreadable at real scale. An inlineAlertlisting 25 of 49 rows hid most of it — and hid it unpredictably, because the list was in upload order, so whether the one failure was visible depended on where it happened to sit in the queue. A report whose most important row is the one most likely to be cut is not a report. It is a drawer now: it scrolls, failures sort to the top, the distinct reasons are collected once at the head, and the failing file's full relative path wraps instead of truncating.)* - [x] 1.3. Enforce per-user asset-count and total-bytes caps at presigned-URL issuance,
by account tier, with the generous default above. Presigned uploads bypass the API
server entirely; skip this now and it cannot be retrofitted without a storage audit.
(Done —
services/libraryQuota.ts, abyte_sizecolumn (migrations-pg/0022), the check at issuance, and env-overridable limits defaulting to 20,000 items / 50 GB. Writing this found that Phase 1's uploads had never used the presigned path at all.PresignedUrlSchemare-declares the categories as a zod enum — a third hand-kept copy ofImageCategory— and it did not havelibrary. So 1.1a's upload asked for a presigned URL, got a 400, and fell back to multipart: every library upload went through the API server and was decoded there, which is exactly what 1.1b says does not happen. Nothing failed. It was slower and heavier, on the path with the memory limits, and completely invisible. 1.1a's parity test did not catch it because it compared the two types and this is a validator; it reads all three now.* A 413 had to be made fatal on the client.tryPresignedUploadends in a barecatch { return null }meaning "this backend has no presigned uploads, use multipart" — correct for every other failure and exactly wrong for a quota refusal, which would upload the entire file down a second transport to be refused again at registration. It throws a distinctStorageQuotaErrorthat the catch re-raises.* Enforced at both ends, though the task names only issuance. Issuance is the only point for a presigned upload, but it is not the only path: a local-storage deployment never issues one, andPOST /api/librarycan be called directly. Checking only at issuance would leave self-hosted deployments with no quota and the hosted one with a bypass. Only rows the user owns count. Server-scope assets are ownerless by construction (0.2a), so a self-hoster's folder import is not charged to the admin who ran it — it is the deployment's library, not theirs. A NULLbyte_sizecounts as zero: rows predating this task have no recorded size, and refusing to serve a presigned URL until someone backfills a number nobody has would lock existing users out of their own libraries. The declared size is the honest bound of this design. At issuance nobody but the client knows how big the file is, so a client that under-declares can exceed its quota by one file — after which the registered row carries the true size and the next check sees it. That is still the difference between a cap and none.*tierFor()resolves everyone todefaultand is the single seam for real tiers. There is no account-tier field yet, and inventing one here would have to agree with whatever billing eventually introduces. Follow-up: the fix above was half a fix, and a real upload found the rest.routes/images.tshad five hand-typed copies of the category list, not one —librarywas missing from four of them andartworkfrom a fifth. Fixing onlyPresignedUrlSchemaleft every client upload failing on a local-storage deployment:presigned-urlcorrectly refuses (no S3), the client falls back toupload-file, and that rejected the category with400 Invalid metadata. On an S3 deployment it would have failed atregisterinstead — same cause, different symptom, and neither is the one that was fixed.IMAGE_CATEGORIESinstorage/types.tsis now the single list, withImageCategoryderived from it, and all five validators read it. The parity test no longer names a schema: any literal category enum in the image routes is the defect, whatever it happens to contain.)*
Phase 2 — Classification (tagging v3)
-
[x] 2.1a. Persist what the classifier already knows. Add
confidence REALandclassification_status TEXT('confirmed' | 'uncertain' | 'unclassified'); stamptagging_version = '3.0'.classifyImage()already returns a confidence and the insert discards it, so the ask's "uncertain bucket" is unbuildable until this lands. (Done — both columns on both dialects,migrations-pg/0023, and oneclassificationStatusForderiving the bucket.unclassifiedanduncertainare different states and the queue depends on it. An unclassified row needs a classifier run at it; an uncertain one needs a person. So a NULL confidence isunclassifiedand a real 0 isuncertain— the absence of a score means nothing looked, not that something looked and scored zero.* The 0.6 threshold is not arbitrary: the existing ladder reports 0.8–0.9 from vision models and 0.3 from the dimension heuristics, so it sits between "a model looked at the pixels" and "we guessed from the shape", which is where the review line belongs. A caller that STATES the type is confirmed, not queued. The texture upload knows it is uploading a texture; putting that in a review queue teaches the user to ignore the queue. The trap in that rule is the no-AI fallback, which also supplies a classification — with no score it would have read as "stated" and landed inconfirmed, so the folder-name guess carries an explicit 0.2. It is the least confident thing in the system and now says so.* Existing rows default tounclassified, which is true of them: whatever tagged them did so under a scheme that recorded no score.3.0is stamped only when something actually classified the row — claiming the v3 scheme for untouched tags would make a later reclassification pass skip exactly the rows that need it.) -
[x] 2.1b. Add a "Needs review" filter listing uncertain items with one-click type correction and tag editing. Corrections set
tag_source = 'user', which already exists and already means exactly this; a later reclassification pass must never overwrite them. (Done —?status=needs-reviewon the list route, and a review mode in the Library with an inline type Select and TagsInput per row. It is a mode, not a tab, and it does not filter by type. The thing most likely to be wrong about a row in this queue is exactly which type it is, so filing the queue under a type would hide the miscategorised assets it exists to find.*needs-reviewisuncertainORunclassified: both need a person eventually, and someone looking at a queue does not care which kind of not-sure a row is. The individual values stay queryable, so 2.1a's distinction survives. Each row says WHY it is there — "low confidence (30%)" or "never classified" — because a review queue with no stated reason is a list the user has to take on faith. The "never overwrite a user correction" rule is enforced at the write, not described in a comment. There is no reclassification pass in this release, which is precisely why: a rule that lives only in prose is not a rule, and whoever builds that pass will not read this paragraph. Aderivedwrite onto a row a person has corrected is a 409. An explicituserwrite still goes through — that is someone changing their mind, not a machine overruling them.* A correction stampstag_source = 'user'only when the caller does not state its own provenance. The Map Generator promoting its assets toauthoritativeis not a person correcting anything, and relabelling it would make a future pass treat generated assets as hand-curated.) -
[x] 2.2a. Define the two-level tag vocabulary — global (
global:fantasy) and asset-type-scoped (map:forest) — with the namespace stored on the tag, which survives in both the JSON column and 0.5b's tag table without a schema change. Ship a curated starter vocabulary and constrain the model to it: free-form vision-model tags produceorc,orcish,orc-warriorandOrcfor four images of the same thing, and search never recovers. Small free-form overflow list stored separately. (Done —libraryVocabulary.tsin shared, onetagInstruction()shared by both model paths, and the overflow inmetadata.overflowTags. Two levels becauseforestmeans something for a map and nothing for a portrait. A flat vocabulary makes "forest token" a sensible-looking search that can never match what the user meant.*partitionTagsaccepts the bare form as well as the namespaced one: the prompt asks formap:forestand a model will sometimes answerforestanyway, and rejecting a correct answer over its punctuation throws away the classification we paid for. A bare tag resolves against context —stoneis a texture tag and not a global one — and a tag namespaced to a different type goes to overflow, becausemap:foreston a token is the model contradicting itself and silently re-namespacing it would manufacture a claim nobody made. The starter lists are deliberately short. A vocabulary that tries to be complete is one nobody reads, and a model picks worse from four hundred options than from thirty; 2.1b's review queue is where the gaps surface. The overflow is capped at 2 per item even though the prompt already asks for at most 2 — a model that ignores the instruction must not be able to fill the record with noise. The heuristic path stopped emittingunknown-map. One pseudo-tag per branch, not in the vocabulary, describing nothing — and indistinguishable in the tag table from a tag someone meant, so a real search returned them and "find everything untagged" could not. A dimension heuristic's honest output is a type and a low confidence, which is what 2.1a'suncertainbucket is for.)* -
[x] 2.2b. Encode the classification signifiers, and the deterministic priors that beat the model at them. Aspect ratio, absolute resolution, alpha presence and edge transparency, and an opposite-edge tesselation check for the
tiles-cleanlytag — milliseconds insharp, and more reliable than any model on exactly these questions. Feed them as hints and keep them as the no-model fallback, replacingguessTypeFromPath()'s coin-flip default of'token'. (Done —imagePriors.ts, measured on a 64×64 reduction, used as hints and as the fallback. Testing it found a real bug, and it was the interesting kind. A cut-out object classified as a texture: every cut-out has four identical edges — they are all nothing — so the edge comparison reported "tiles perfectly" about the one shape that tiles least, and the texture branch swallowed every object. Matching emptiness is not continuity, so tiling now requires an opaque border. It is exactly the failure a deterministic check invites: the arithmetic was right and the question was wrong.* Both axes must match before anything is called a tile — an image that repeats horizontally and not vertically is a border or a strip, andtiles-cleanlywould put it in front of someone looking for a floor. The hints state measurements, not conclusions. "Transparent background around the edges" lets the model weigh it; "this is an object" would be us classifying the image and asking the model to agree, which is worth nothing and costs the same. Andtiles-cleanlyis added by us rather than asked for: the model is shown one copy of the image and asked whether four copies would line up.guessTypeFromPathwas not the problem — its default was. Art filed underbattlemaps/has already been classified by its owner, so the folder name still answers first. What is gone is'token'for anything unrecognised, a coin flip that was wrong for most of a folder of maps. AguessTypeFromPathStrictreturns null instead, and the measurements answer. Every score this path produces sits below 2.1a's threshold by construction, so all of it lands in the review queue rather than being presented as fact.* The signifiers went in verbatim rather than as the one-line gloss the prompt had. The differences that matter — "not usually more than one of them", "might have a grid, but do not assume", "no identifiable large-scale creatures in the foreground" — are precisely what a summary drops.) -
[x] 2.3a. Add Gemini Flash and Claude Haiku to the classifier ladder, make the ladder per-deployment configurable instead of hard-coded fallthrough, and record the provider and model that produced each classification in
metadata. Keys come through the existingx-ai-keysheader, not a new channel. (Done — four rungs,LIBRARY_CLASSIFIER_LADDER, per-provider model overrides, and the provenance on the row. The hard-coded fallthrough was one opinion baked into the code, and the wrong one for most deployments. A hosted server has no Ollama and was spending a connection attempt discovering that for every image in a 5,000-file import.* Gemini Flash is ~10× cheaper per image thangpt-4o-mini, but the number that actually decides a bulk import is OpenAI Tier 1's 10,000 requests/day — for a 5,000-image library that cap is the difference between finishing today and finishing on Thursday. An unknown provider name is dropped with a warning; an override that names nothing valid falls back to the default, because an empty ladder classifies nothing, forever, silently. One prompt and one downscale, shared by all four providers. Four copies of the prompt would drift, and the drift shows up as one library tagged differently depending on which rung answered — indistinguishable, in the data, from the models simply disagreeing. The 512px JPEG is shared for the same reason it exists: no provider needs four thousand pixels to say what an image depicts.* A user-supplied key beats the deployment's, not the other way round. On a hosted deployment with no env keys the user's own key is the only one there is, and reading env first would let a server-wide key silently override what the user is paying for. The provenance is recorded because otherwise a reclassification pass cannot tell what is worth redoing — "everything llava tagged, now that a real model is configured" is the obvious first pass and it is unanswerable if every row looks the same. The no-model fallback records no provider rather than claiming one, or that pass would skip exactly the rows most worth redoing.) -
[x] 2.3b. Batch where the provider supports it, and stream the download straight into
sharprather than buffering whole originals —pipeline(stream, sharp().resize(512))caps peak memory at ~10–20 MB regardless of source size, which is what makes a 50 MB TIFF safe on a small box. (Done, as three separate things, because they turned out to be three separate costs. Batching is about the request cap, not the token bill. OpenAI Tier 1 allows 10,000 requests a day and a 5,000-image library spends half of it one image at a time; ten per Gemini call turns that into 500. Results come back positional, and a short answer is padded with nulls rather than shifted — a model returning nine results for ten images has not said which one it dropped, and sliding the tenth answer onto the ninth mislabels every asset after the gap. A null is a file the caller retries; a shift is a library of wrong tags.* Ollama is deliberately not batched: it runs locally, one image at a time, so a batch is the same work with a longer stall and one failure taking ten images with it. The streaming path is built for Phase 3 rather than retrofitted into it. The local importer cannot use it — it needs the full bytes anyway, to upload them — but every path that FETCHES an image can, and the cloud connectors are entirely made of those: download, classify, discard. It is tested including the truncated-download case, which is whatstream/promisespipelinebuys over hand-rolled piping: the transform is torn down instead of left holding the stream open.* And a full decode disappeared where nobody was looking.computeImagePriorscalledsharp(buffer).resize().stats()for its uniformity number — a second complete decode of the source, to compute four values from pixels it was already holding. On a 50 MP TIFF that is the whole image decompressed twice for a standard deviation over 4,096 samples. The priors now do one decode; a test counts them, so the third cannot quietly come back.)* -
[x] 2.4. Add the lazy-loaded in-browser classifier as the primary hosted path, with the server ladder as the low-confidence fallback. A CLIP-family image encoder scoring against 2.2a's fixed vocabulary is the right tool — it is similarity ranking against known labels, not open-ended captioning. Load on first upload only and cache in the Cache API; a 30–90 MB model must never enter the initial bundle. (Done, in two tiers — and the first tier is the one that makes the §11.5 decision viable. It classifies with no model at all. The same aspect-ratio, transparency and tesselation measurements the server makes, from a canvas instead of sharp, judged by the same shared function — free, instant, offline, and enough on its own to tell a seamless tile from a cut-out from a battle map. Had the encoder been the only tier, "in-browser is the primary path" would have meant "no classification at all" until someone stood up a model host.* So the judgements moved to shared, and only the pixels stayed platform-specific. Each side supplies a 64×64 RGBA probe and the source's real dimensions;
priorsFromProbedecides everything else. Two implementations of "does this tile" is how one file gets classified one way from a folder import and another way from a drag-and-drop, which to the user is indistinguishable from the feature being broken. The dimensions travel separately because the probe is squashed to a fixed square — reading aspect ratio off it would report 1.0 for every image in the library.*onnxruntime-webis imported dynamically, which is the single most important line in the file: a static import puts megabytes in the main chunk for every visitor, including everyone who never opens the Library. The session is memoised as a promise, not a value — 1.2b's queue runs four uploads at once, and memoising the resolved session would let all four start their own 90 MB download before the first finished. Cache API failures fall through to a plain fetch: classifying slowly beats not classifying. The subtle one: a stated type wins, but it caps the confidence when the classifier disagrees. Dropping files into the Tokens tab is a statement, not a guess, so the tab decides the type — but the confidence describes the CLASSIFIER's answer, and storing a confident score beside a type the classifier did not choose would mark the rowconfirmedon the strength of a judgement about something else. Capped, the disagreement goes in front of a person, which is what 2.1b's queue is for.* Classification never fails an upload. Tags are an enhancement to a working file store — principle 6 — so a classifier that throws costs its own contribution and nothing else. Follow-up, from a 48-file folder import: every file came back "map". Three causes, and the first was a rule this task introduced.* 1. The tab overruled the classifier. "A stated type wins — dropping files into the Tokens tab is a statement, not a guess" reads well and is wrong about what people do, which is drop a folder of mixed art and expect it sorted. The Maps tab was open, so all 48 became maps. The classifier decides now; the tab is a fallback for when nothing classified the file, and an explicit override survives for callers that genuinely know, like the texture upload. 2. The server fall-through was never built. This task described "the server ladder as the low-confidence fallback" and shipped only the browser half, so an upload got the measurements and nothing else — on a deployment with an OpenAI key sitting configured and unused.POST /api/library/classifycloses it, with the BROWSER sending a 512px JPEG rather than the server re-fetching the asset it just stored: the tab is holding the file, and a server re-downloading its own storage to classify would be the one thing v0.65.0 spent a release removing. 3. The priors called square art a map. Resolution alone sent every 2048×2048 character portrait tomap, because 4 MP genuinely is "higher resolution than token art typically requires" — if you are picturing a 512px token. Squareness was the missing half: battle maps are 4:3, 3:2, 16:9 and character art is square or portrait, so a large NON-square image is a map and a large square one has to clear 12 MP. Tests now cover the shapes a real collection contains rather than only synthetic ones. The tesselation check was the one part that worked, which is what a deterministic measurement buys over asking a model. Second round, from the same 48 files: 0 of 13 maps were classified as maps. Reading the rows out of postgres explained it in one line —2-Asylum-night, a 5600×4200 battle map, was stored as a token.* The cause: the server was measuring the thumbnail. The browser sends a 512px JPEG, which is all a model needs, and the server measured THAT — so every resolution rule saw 0.2 MP and JPEG, having no alpha, made every cut-out look opaque. The party holding the original is the only one that can measure it, so the client's priors now travel with the image and the server uses them. And no model had answered at all. The ladder ran, Ollama 404'd, and OpenAI returned429 insufficient_quota— the account was out of credits. Every one of the 48 was filed on shape alone while the row recorded that a classifier had run. The fallback now recordsprovider: "priors", and the review queue says "shape only — no classifier available", because a deployment whose credits have run out looked exactly like one with a working model.* Two rules were wrong on real data and are now pinned against it. A flat matte read as a seamless tile — four identical edges, which is also what character art on a plain background has, and it cost four creature tokens. The first attempt at that fix pooled R, G and B into one sample set, which measures the distance between the channels rather than variation along the edge, so a flat teal border scored 0.10 and sailed through; it is per-channel now. And a transparent surround is not evidence of an object: a dwarf and a barrel on transparent fields are the same measurement, so proportion decides — square is a token, wide is a prop. Distribution over the real files: 13 map / 15 texture / 13 token / 7 object against a ground truth of ~13 / 15 / 12 / 7.crateanddoorstill read as tokens, because a 128×128 cut-out prop and a 128×128 cut-out dwarf are genuinely the same measurement — that is the judgement a model is for, and their scores keep them in the review queue. Third round: a user's own Gemini key was never sent. The classify request carried an auth token and nothing else, so the server could only ever resolve a server-level key — a user with a perfectly good key in their settings got shape heuristics and no explanation. The channel has existed since v0.48.0 (x-ai-keys) and classification simply never used it;getClassifierAIHeaders()attaches it now.* It needed its own provider list rather than reusing the image-generation one. The two overlap by accident:grokgenerates and cannot classify,claudeclassifies — Haiku is a rung — and is absent from the generation list. And the store calls Anthropicclaudewhile the ladder, its env var and its API host all call itanthropic; the server filtersx-ai-keysagainst its own vocabulary, so a key sent under the wrong name is dropped in silence. That rename is unit-tested, because a valid key that nothing uses reports nothing at all. And the ladder now records why it produced nothing. "No model classified this" has had two entirely different causes in two days — an API account out of credits, and a user key that never left the browser — and from the outside they were the same silence. Both took a session reading rows out of postgres to identify. Each rung now recordsanswered,no-keyorfailedwith the provider's own message, that lands on the row, and the review badge reads "shape only — gemini failed: …" or "shape only — no provider key configured". The distinction is the whole diagnosis: one means add a key, the other means the key you added stopped working.* Fourth round, and the diagnostics paid for themselves: the key was fine and the MODEL was gone.gemini-2.0-flashreturns 404 — "This model is no longer available. Please update your code to use models/…". Two reporting bugs had hidden it: the badge named only the FIRST failed rung, andollamaheads the default ladder and fails on any machine not running it, so every badge blamed a service the user had never configured; and the error was truncated at 300 characters, which fell mid-sentence and clipped the replacement model name off the end. The badge lists every rung with its own message now, and the provider'serror.messageis extracted before truncating so the tail survives.* Hard-coding the replacement would just reset the clock. Google retires these on its own schedule, and the next expiry would land silently in a library classified by shape. So a 404 makes the classifier ask the key what it can actually reach and pick the newest stable Flash that supportsgenerateContent— one extra call, only on failure, cached for the process. A model pinned inLIBRARY_CLASSIFIER_GEMINI_MODELis never second-guessed: an explicit choice is a choice, and silently using a different one is worse than failing.* Fifth round — and the last cause was a setting, not a name. With the model resolved, Gemini failed on "Expected property name or '}' in JSON at position 1", which is whatJSON.parse("{")says: the response was truncated to its opening brace. Gemini 2.5 Flash reasons before answering and bills the reasoning against the same output budget, so a 300-token ceiling was spent thinking.thinkingBudget: 0— sorting a picture into one of four buckets does not need deliberation — and 1024 tokens for a four-field object.*finishReasonwas being discarded, and it is the whole explanation when a model returns nothing:MAX_TOKENSmeans the budget was too small andSAFETYmeans the image was refused, and "returned nothing" covers both indistinguishably. Every provider now shares oneparseModelJson: fences stripped, outermost object taken, and the offending text quoted back in the error. A parse failure that does not show what it failed to parse is a dead end, and this one cost a round trip. The reader also stopped takingparts[0].text— a thinking model can put its reasoning first, and parts markedthoughtare explicitly not the answer.)
Documentation
- [x] 10a. Rewrite
docs/help/library-and-artwork.md, which currently attributes the Artwork pipeline's drag-and-drop upload, tokenizing and originals-are-kept behaviour to the Library, which does none of them. After Phase 1 most of it becomes true, which is the moment to fix it — and to split Library and Artwork into two pages. (Done —library.mdandartwork.md, with the ten inbound links repointed at whichever of the two the linking page actually meant. The four action-API pages all go to Artwork;maps.mdand Getting Started go to the Library. The old page was not merely inaccurate, it was inverted: everything it said about uploading, tokenizing and keeping originals was true of Artwork and described under the Library's name. Splitting them is what stops that recurring — the two are separate subsystems that both handle images, and one page covering both invites exactly this. The Library page documents what this release actually built, in the terms a user has them in: folder upload and the resumable queue, why folder names are worth keeping, what "shape only" means on a review row and what to do about it, and that a hand correction is permanent. The classification section says plainly which parts are measured and true and which are a model's judgement, because that distinction is the one that decides whether to trust a tag.checkHelpLinksgates this in CI and checks for orphans as well as broken links, so a split that stranded a page would fail rather than sit there.) - [x] 10b. Add a Library row to
docs/committed/architecture/api-index.md. The Library REST surface is undocumented despitePOST /api/library/querybeing an effective contract with the Map Generator. (Done, in the sub-parts table — it belongs to the REST API and is reachable by modules throughPlatformAPI.library, so it is not a primary API of its own. Two observations went in with it, because the row alone would not have said the useful part. ThatPOST /api/library/queryis a contract: the Map Generator ships separately, so its response shape is owed to a consumer that cannot be refactored in the same commit — which is why 0.5b rewrote everything around it and left the shape untouched, scoring included. And that the Library is per-user as of this release, where it previously applied no filter at all.)