Create or replace the operator promo band (GitHub
PUT /admin/featured/promo
Upserts THE band. v1 renders one band, so this is a singleton
write rather than an id-addressed create — the table admits
several (ADR 0030’s slot inventory is plural) and the reader
takes the enabled one with the lowest after_page, but the API
does not pretend to a plurality the release does not ship.
A REPLACE, not a PATCH: every field the form owns is written on every save, so clearing the blurb is expressible.
The read that decides insert-vs-update runs in the same transaction as the write, so two operators saving at once cannot both find no band and both create one.
400 for a cta_label/cta_url supplied without its partner,
for an after_page below 1, and for a cta_url that is not an
absolute http(s) URL or a site-relative path — see
PromoBand.cta_url for why that last one is a security check.
Authorizations
Section titled “Authorizations ”Request Body required
Section titled “Request Body required ”The band definition an operator saves (#1118). Every field the form owns is written on every save — this is a REPLACE, not a PATCH, because a partial write would make “clear the blurb” indistinguishable from “leave the blurb alone”.
object
Absolute http(s) URL or a site-relative path. See
PromoBand.cta_url — the restriction is a security
constraint and a javascript: value is refused with a 400.
The band’s audience. org — the signed-in audience — is the
default for the same reason POST /admin/featured defaults
to it: publishing to anonymous visitors is a deliberate act,
not the shape you get by leaving a field out.
Responses
Section titled “ Responses ”The saved band and its cards.
An operator-authored full-width promo strip rendered BETWEEN pages of the browse feed (#1118): a headline, a blurb, an optional call-to-action, and an ordered row of curated cards.
THE CARDS ARE ORDINARY PLACEMENTS. They are featured_items
rows carrying this band’s id, resolved through the same query,
the same ADR 0063 predicate splices and the same ADR 0020
sensitivity and ADR 0090 mature gates as the featured rail — so
a card pointing at content this caller may not see produces
nothing at all, and a band is never a way to surface something.
THE COLLAPSE IS DECIDED SERVER-SIDE. GET /featured/promo
answers with NO band object at all when the install has none,
when the band is disabled, when it has no headline, or when
every card filtered away for this reader. That is ADR 0030’s
rule for a banner between content, which is the geometry a
full-width band has; ADR 0079 §2’s “an unfilled slot becomes
ordinary content” is scoped to IN-GRID sized slots, where
collapsing would leave a hole in the middle of a wall. Between
two walls there is no hole to leave.
object
The strip’s headline. A band with an empty one does not render — a button and a blurb with no headline is an unfinished draft, not a banner.
The short line under the headline. May be empty.
The call-to-action button’s text. Empty means the band has
no button; cta_label and cta_url are written and
validated as a PAIR, so one is never present without the
other.
Where the button goes. Restricted to an absolute http(s)
URL or a site-relative path beginning with a single /.
THIS IS A SECURITY CONSTRAINT, not a formatting one. The
value becomes an href on the browse page of every reader
the band is shown to, and a javascript: URL there is
stored XSS. A scheme-relative //host/x is refused too — a
reader cannot tell it from a local link. Enforced by the
handler (a 400) and by a CHECK constraint in migration
00053 (the backstop).
The operator’s switch. A disabled band is invisible to readers — it is filtered out by the query, not returned with a flag for a client to honour.
Where the band falls, counted in whole feed pages: 1 renders it after the first page.
The PAGE SIZE is the client’s (browse requests 36 posts at a time), so this is a position in the reader’s scroll rather than a row count — deliberately, because “after the first page” is something an operator can predict without knowing the API’s limit.
THE WHOLE BAND’S AUDIENCE (ADR 0065’s placement scopes,
narrowed to the two a band can hold). public includes
anonymous visitors on an install that has opened its public
surface; org is the signed-in audience.
It lives on the BAND and not on each card. A band is one
authored unit with a headline and a button, and “half these
cards are for logged-out visitors” is not a state an
operator can hold in their head — nor one worth a
denormalised copy of a visibility input on every card row.
A card’s own scope column is left at its default and is
never read.
The cards, in curation order, filtered to what THIS caller may see. Never empty in a response: a band whose cards all filtered away is not returned at all.
GitHub #341 — one admin-curated featured entry pointing at an
asset, a collection or a team, with an explicit ordering
position and the resolved subject title. Assets and collections
carry thumbnail hints so a tile can render a cover: assets from
themselves, collections via ADR 0027’s hero-card fallback
(#559). A team subject (#1084) resolves its NAME only and
carries no hints — a team’s picture is admissible solely through
the render-time hero re-check (#982), and resolving it here
would be a second copy of that rule inside a query whose gates
are deliberately weaker than the public rail’s.
object
The featured_items row id (used for remove/reorder).
Must list every value FeaturedItemInput.subject_kind
accepts. It is a separate schema and was the place this was
nearly missed in #1084: the server would have gone on
serialising team (Go emits the string it holds), while the
generated client’s type narrowed it away and a strict
response validator rejected the row — a placement the
operator could see in the database and not in the UI.
0-based display order.
Resolved subject title (asset title or collection name). Empty when the subject was deleted out from under the row.
The wide card’s second line (#1110): the COLLECTION’s own
description, empty when it has none — and empty for every
other subject kind.
WITHHELD EXACTLY WHEN title IS, structurally rather than
by a second rule. It is selected from the same c join
that produces title for a collection subject, and that
join carries the caller’s ADR 0063 collection predicate in
its ON clause: a collection this caller may not see fails
to join, contributes neither name nor description, and the
placement row is then dropped entirely. There is no
arrangement in which a description arrives without the name
it belongs to.
ASSET SUBJECTS CARRY NO SUBTITLE, deliberately. ADR 0020
makes a non-public asset title-only on this surface — the
rail already suppresses its thumbnail hints for exactly
that reason — so sourcing this from assets.description
would widen an embargo tile from “title” to “title and the
author’s notes”. #1110 specifies the collection’s
description; the asset half of that widening is not a
rendering choice this field gets to make.
How many members of a COLLECTION subject THIS CALLER can
see — the wide card’s subtitle fallback when subtitle is
empty (#1110). Null for every non-collection subject.
Caller-scoped, not a raw COUNT(*). The membership is the
same one the mosaic composes from — pinned, unexpired
collection_posts rows, gated by the caller’s own post read
predicate. A bare count would publish the SIZE of the
withheld part of a public collection, which is the
derived-copy leak class #902 closed for search text and
#1066 closed for embeddings, arriving through a subtitle.
#1236 dropped the collection_resources half that used to
be added to this. The tile describes what the page shows,
and the page shows posts; a tile advertising more items than
the collection opens to is the count disagreeing with the
thing it counts.
The asset whose col variant renders this tile (#559).
For an asset subject this is the subject itself. For a
COLLECTION it is the hero-card fallback of ADR 0027 — the
most-recent post’s cover asset — which is why the id is
needed separately: subject_id is the collection, and the
variant endpoint is keyed by asset.
Null when nothing is servable to this caller, in which case
the tile is title-only. Subject to the same gating as
asset_file_hash, so it never names an asset the caller
cannot see.
SINCE #1207 A COLLECTION’S IS THE CURATOR’S CHOICE WHEN
THERE IS ONE. The rail resolves it in preference order —
featured_cover_asset_id, then cover_asset_id, then ADR
0027’s derived cover — with every rung carrying the caller’s
own asset predicate, so a chosen cover this caller may not
picture falls through to the next rung instead of leaking.
Before that the rail read neither chosen column and always
derived, which is #1200: the strip showed one picture and
every other collection surface showed another.
Where to centre this tile’s crop horizontally, as a fraction
of the cover picture’s width (#1207) — the curator’s
featured_cover_focal_x, ready to be written straight into
CSS object-position.
Null means centre, which is the CSS default and what every tile did before this existed, so a client that ignores it renders exactly what it rendered before.
ONLY EVER SET WHEN THE TILE IS SHOWING A CHOSEN COVER. A derived cover is a picture the curator never saw in the editor, so positioning chosen for a different picture is not carried onto it; that arm returns null and the tile centres. Null for every non-collection subject.
The vertical half of the tile’s focal point (#1207). See
cover_focal_x.
How far this tile’s crop is tightened, as a multiplier on
the fitting rectangle (#1212) — the curator’s
featured_cover_zoom, ready to be applied alongside
cover_focal_x/_y.
Null means fit, which is what every tile did before this existed. ONLY EVER SET WHEN THE TILE IS SHOWING A CHOSEN COVER, for the reason the focal point is: a derived cover is a picture the curator never framed, so a framing chosen for a different picture is not carried onto it.
Thumbnail hint for the tile’s cover asset; null when there is nothing servable for this caller. Populated for collection subjects too since #559.
True iff a servable col variant exists for the tile’s
cover asset AND the caller passes the content plane (ADR
0064). The rail renders a thumbnail only when true, else a
title-only tile with no byte request (#471). Computed from
live variant existence rather than from a stored flag, which
is what makes it trustworthy — the asset_has_image field
that used to sit beside this one was the projection of a
column nothing wrote, and was removed with it in #579.
Populated for collection subjects too since #559.
True iff every variant in the operator’s CONFIGURED preview ladder exists for the tile’s cover asset AND the caller passes the content plane. Identical contract to Asset.ladder_available — see there for why this is computed from config rather than from the default rung list.
The subject’s owner, as the promo band’s card chip draws it (#1118) — the asset’s owner for an asset subject, the collection’s for a collection.
POPULATED ONLY BY GET /featured/promo, and the absence
elsewhere is a statement rather than an oversight. The
featured rail’s card prints a subtitle, not an author
(#1110), and a value nothing consumes is a value nothing
tests; the admin curation list identifies a row by its
subject id, which is what an operator needs to prune it.
The name inside is resolved by users.ResolveDisplayName
through users.LookupAuthors — the one home of the
display-name ladder, with its authenticated rung and its
anonymous arm. It is NOT re-derived per surface: #1023
exists because that ladder had been transcribed four times
and three copies were wrong.
ABSENT — not redacted — for an author who took ADR 0024’s opt-out and for a hard-deleted account, exactly as on a post card. “No author object” means “no name to show”, never “look it up another way”.
object
The handle. Also the profile link target
(/users/by-username/{username}).
Resolved display string ready to render — never empty.
RESOLUTION IS CALLER-DEPENDENT and matches UserPublic’s
exactly, because it is literally the same expression
(users.ResolveDisplayName):
authenticated: profile.display_name → user.fullname → username anonymous: profile.display_name → username
The anonymous rung skips fullname on purpose. Real name
is authenticated-only (ADR 0070 §3), and a display-name
fallback that reaches it would be that rule defeated by a
COALESCE.
Null for users with no uploaded avatar; the card renders a generated initials disc in that case.
Malformed request
object
Human-readable error summary
Example
the request could not be completedAuthentication required, missing, or invalid
object
Human-readable error summary
Example
the request could not be completedExample
{ "error": "authentication required: sign in and retry with a valid session or API token"}Authenticated but missing required capabilities
object
Human-readable error summary
Example
the request could not be completed