The preview ladder is a published contract, not a shared constant
Ce contenu n’est pas encore disponible dans votre langue.
0071 — The preview ladder is a published contract, not a shared constant
Section titled “0071 — The preview ladder is a published contract, not a shared constant”Context
Section titled “Context”The preview pipeline renders a ladder of raster variants per asset. The default is
col (320² cover), preview (1024 contain), screen (1920 contain), hires (4096
contain) — but the ladder is operator-configurable via sysconfig, and
DefaultPreviewConfig() is a default, not a contract.
For most of the project’s life the client did not know this. Cards hardcoded
/variants/col as their only image URL, because col was the one rung they could assume
existed. That assumption cost real features:
- Responsive
srcsetwas deliberately disabled inPostCard— the code and itssizesmachinery were left in place with a comment saying they awaited a signal that did not yet exist. Every card served a 320px square regardless of viewport (#502). - Widescreen art displayed as a square centre-crop, visibly disagreeing with its own hover-scrub animation, which used true aspect (#589).
- A proposal existed to add a landscape-specific rung to fix this — solving a knowledge problem with more storage.
The naive fix — publish the four default keys as a shared constant — reproduces the bug
one layer out. An install that drops hires to save storage, or adds a rung, would have
a client confidently requesting URLs that 404.
Decision
Section titled “Decision”The ladder is published data, and neither side hardcodes it.
-
The server states what exists per asset.
ladder_availableis true iff every configured rung is stored for that asset AND the caller passes the content plane (ADR 0064 — a restricted asset reportsfalse, never 403, so the flag cannot become an oracle). It is computed againstsysconfig’s configured variants threaded into the query as a parameter — never a literal list. One exported SQL fragment serves all call sites so the guard cannot be dropped in one of them. -
The server states what the ladder is.
GET /previewsreturns each rung’s key, fit andmax_dim. A flag saying “the whole ladder exists” is not actionable without knowing what the ladder contains:srcsetneeds the keys for URLs andmax_dimfor width descriptors. -
GET /previewsis public-mode governed, not unauthenticated. It is registered inauth.PublicSurfaceRoutes: anonymous on a public install, 401 on a private one. Deliberately not excused the way/appearanceis — fonts render the login card, so an install that refused them could not draw its own sign-in page, whereas nothing before sign-in needs image rungs. -
The client caches the ladder and degrades to
col. A 401, an offline failure or a malformed response all mean the same thing: no ladder, usecolonly. The failure direction costs a feature, never a 404. -
fit: coverrungs are excluded fromsrcset.colis a square crop; offering it as a width candidate for a contain-mode slot would letterbox or distort. The grid’s contact-sheet mode still usescoldirectly and deliberately. -
The ladder’s SOURCE shape is recorded per asset, in
asset_field_valueunder the existingpixel_width/pixel_heightdefinitions,set_by = 'computed'(#757).The client can size a tile before any byte arrives only if the server states the shape. Points 1–5 tell it which rungs exist and what they are; none of them says what shape the picture is, and a layout that has to wait for
naturalWidthreflows every tile on load. Masonry rendered a wall of identical squares for three merged PRs because that fact had a reader, an API field, a bucketer and a CSS rule — and no writer.The quantity is the shape of the image the contain rungs are built from, not “the source file’s pixels”. Half a catalogue has no source pixels: a 3D model, a font, an audio file and a plain-text document have none, yet each produces exactly one image on its way through the pipeline — a turntable frame, a glyph specimen, a waveform, a rendered plate — and fans it across the ladder. That image is what a card renders, so its shape is what a tile reserves. A 2048×384 waveform is a 5.33:1 tile; nothing in the file it came from says so.
It is not recorded on
storage_variants. That table is keyed by object hash and describes stored rungs, and the ladder source is not one of them —colis 320² for everything, so a per-variant row would either need inventing for an object that does not exist or would answer with the crop’s shape rather than the picture’s.
Consequences
Section titled “Consequences”- Adding or removing a rung is an operator action with no code change. Both sides discover the ladder at runtime.
- A hardcoded rung key anywhere is a bug, on either side of the wire. This is the rule most likely to be violated by a future change that “just needs the hires URL”.
- Landscape tiles required no new variant and no backfill — #589 collapsed into “request a different rung”, which is the clearest evidence the diagnosis was right.
ladder_availableandpreview_availableare different questions and both are needed: the latter means “a servablecolexists” (render a thumbnail at all), the former means “the full ladder exists” (safe to build asrcset).- The flag is nearly co-extensive with
preview_availableon a healthy install — 1004 of 1007 assets on the reference dataset have both. That is expected and not a reason to alias them: the distinction is what lets the client stop guessing, and it degrades correctly for partially-rendered or failed assets. - A handler that never reaches the ladder never records the shape. Eight handlers
short-circuit a non-forced re-queue before decoding anything (the same early exit that
leaves a pre-#645 thumbhash unhealed), so the backfill for those is
aa rebuild-previews --force. Raster and video are the exceptions — both reach the stamp on an ordinary re-queue, so they backfill without re-encoding a single rung. - The recorded pair is post-EXIF-rotation, because the ladder source is. That is the shape a viewer sees and the shape the rungs were cut from; the EXIF extractor’s own write is the on-disk pair, and where both have run the preview value is the better one.
- Related trap, recorded in ADR 0008’s amendment: a derived variant under a stable content hash is exactly what preview regeneration rewrites, so ladder URLs addressed by asset id are not immutable. Cache validators must derive from the stored bytes.
Amendment 2026-08-11 — §3 generalises: “public-mode governed” is the default shape for a new anonymous read (#709)
Section titled “Amendment 2026-08-11 — §3 generalises: “public-mode governed” is the default shape for a new anonymous read (#709)”§3 decided this for GET /previews. #709 needed the same call for GET /browse-views — the
operator’s enabled browse layouts — and the reasoning transferred without modification, which
makes it a rule rather than a one-off. Recording it here so the next anonymous read does not
re-derive it, and because the brief for #709 got it wrong in the direction that looks safer.
The rule. A new read that anonymous callers need takes security: [] plus registration in
auth.PublicSurfaceRoutes — anonymous on a public install, 401 on a private one. Being excused
from that governance, the way /appearance and /site-text are, requires a specific
justification: the endpoint renders the login card. Fonts, branding and UI strings qualify
because an install that refused them could not draw its own sign-in page. Nothing else does by
default.
Where the #709 brief went wrong. It specified unconditional security: [], citing /site-text
as the precedent, on the reasoning that “a logged-out visitor browses too.” The premise is true
and the conclusion does not follow: a logged-out visitor browses only on a public install,
which is exactly the case public-mode governance answers. On a private install the browse switcher
is already behind auth, so there is no anonymous surface for the layout set to serve — and
answering anyway would hand an unauthenticated caller the operator’s configuration for nothing.
The coding agent caught this and applied §3 instead. Verified on a live stack: public install +
anonymous → 200, private install + anonymous → 401, private install + signed in → 200.
The guard that makes this hard to get wrong is a build-time one, and it is worth knowing about.
security: [] on an operation that is not named in PublicSurfaceRoutes (or in
notGovernedAnonymousOps) fails TestPublicSurfaceCoversAnonymousOperations. So the brief’s
version would not have shipped quietly — it would have failed CI in front of the author. That is
the property publicmode.go describes as recovering the allowlist guarantee “at BUILD time instead
of request time,” and #709 is the first time it fired on a genuinely mistaken instruction rather
than an oversight.
⚠️ The corollary for anyone reading security: [] in openapi.yaml: it does not mean
“ungated.” It means “this operation’s gate is not a credential” — the gate may still be the
public-mode toggle. The /browse-views operation carries a comment saying so at its security
block, because the two-word declaration reads exactly like the thing it is not.
Amendment 2026-08-02 — the announcement flags are DB-first BY DESIGN, and reconcile keeps them truthful (#829)
Section titled “Amendment 2026-08-02 — the announcement flags are DB-first BY DESIGN, and reconcile keeps them truthful (#829)”preview_available / ladder_available are answered from storage_variants rows, not
from backend stats — that is load-bearing for the zero-console-404 contract (the server
must be able to answer cheaply on every list row) and is not changed. What #829 adds is
the missing half of the bargain: the render/skip path now heals the rows (ADR 0008’s
amendment), so a restored backup or any bytes-without-rows state converges back to
truthful announcements on the next requeue instead of deadlocking every card into the
placeholder. Serving itself was and remains backend-first (Download → Backend.Get);
only the announcements were ever at risk.
Amendment 2026-08-02 — scrub_available extends the announcement pattern to the hover sheet (#835, #832)
Section titled “Amendment 2026-08-02 — scrub_available extends the announcement pattern to the hover sheet (#835, #832)”The hover sprite-scrub was the one preview surface still doing what this ADR exists to
stop. Its gate was the file extension (isVideo || is3D), and its geometry was a
grid hardcoded per extension (10×10 for video, 6×6 for the 3D turntable). Both were
guesses about storage made from a filename, and both were wrong in both directions:
- A video whose expensive
preview.videojob has not drained yet has acol(from the cheap poster job, #818) and no sheet, so the card requested one and 404’d — the exact class ADR 0064 / #471 removed everywhere else. - A format that DOES have a sheet but was not on the list could never scrub. Animated GIFs (#832) are the case that forced this: they now render a sheet and the extension list would have hidden it.
- The grid is not always full.
writeSpriteSheetfloors the cell interval at 0.2s, so a clip shorter thancells × floorcannot fill it and ffmpeg’stilefilter pads the remainder with black. A 5s clip fills 25 of 100 cells; the client cycled all 100 and spent three quarters of the hover on padding it could not distinguish from a dark frame.
Two additions, both instances of rules this ADR already states.
-
scrub_available— “a servablesprites.vttexists for this asset AND the caller passes the content plane”. Identical ADR 0064 contract topreview_available/ladder_available, computed from the sameContentReadabledecision in the same pass at every site that produces one (browse list, single GET, collection members, post members), so the three can never disagree for a restricted asset. It is keyed on the cue file, not the sheet: both are written together by all three producers, so either would answer “is there a sheet”, but only one answers “can I drive from it”. -
The cue file is the geometry, and the client reads it.
sprites.vttalready declared one cue per populated cell with an exact#xywhrect. Cycling the cues rather than a grid makes video, 3D and GIF one code path, gets short clips right, and needs no constant on the client at all — the same “ask, don’t assume” move rung keys made in the original decision.spriteCellBoxhas already moved once (160 → 240, #811) without the client noticing; nothing about the grid may be knowable to it either.
Deliberately not extended to FeaturedItem. The featured rail draws its own tile and
has no hover scrub, so the flag would be a field nothing reads. When the rail adopts
CardThumb it inherits the requirement, and the required-field contract in cardAsset.ts
is what will make that a type error rather than a missing animation.
The client-side compatibility half is the point. The cue-driven cycle fixes every
sheet already in storage with no re-render, because the truncation was already
encoded in the stored VTTs — the client simply was not reading them. One filter carries
that: pre-#835 VTTs end with a zero-length cue (the old writer emitted the cue and
then broke on start >= duration), which addresses ffmpeg’s first padding cell, so the
parser drops any cue whose window is empty. The writer no longer emits one; the filter
stays for the sheets that already have it.
Amendment 2026-08-02 — the scrub cycles what the VTT declares, and GIFs are first-class (#836)
Section titled “Amendment 2026-08-02 — the scrub cycles what the VTT declares, and GIFs are first-class (#836)”The hover scrub’s geometry contract is now cue-driven: the client fetches
sprites.vtt and cycles exactly the cues it declares, using their #xywh
rects. The per-kind hardcoded grids (10×10 video, 6×6 turntable) are gone from
the client; a sheet’s real frame count is whatever the VTT says, which is what
lets a short clip stop at its last real frame and an animated GIF’s sheet
(new preview.gif kind, #832) join the same code path with no third special
case. The parser drops empty-window cues — pre-#836 writers emitted a trailing
zero-length cue on short clips, and dropping it client-side is what fixed
existing sheets without any re-render. Scrub availability is signalled
(scrub_available) rather than probed, preserving the zero-console-404
contract this ADR established.