List posts (paginated feed)
GET /posts
The browse-page feed. Authenticated callers see the walled-garden tier, their own posts at any visibility, followed authors’ followers-tier posts and anything shared with them.
Anonymous-visible under public mode (#1181, ADR 0063): with
public mode ON an anonymous caller sees the public tier and
nothing else, and the default visibility filter is public
rather than the signed-in org-only. With public mode OFF the
request is refused with 401 before it reaches the handler. The
same read rule decides rows in both cases — no separate
enforcement plane.
Writes on this path (POST /posts) are unaffected by public
mode and require authentication in both states.
Parameters
Section titled “ Parameters ”Query Parameters
Section titled “Query Parameters ”Restrict the page to posts this user ref has LIKED (#1106) — the profile’s Likes tab.
It is a filter on this feed rather than an endpoint of its own because a likes listing is a DERIVED listing: its rows are chosen by one user’s actions and read by another, so the only question that matters is whose read rule decides what appears. Here the answer is structural — the caller’s own read rule is ANDed onto the result exactly as it is for every other filter, so a post the caller may not read is ABSENT from the page whoever liked it, and the count on the page is the count of what they may read.
NARROWING, never widening. The likes table is written by
third parties, so liking a post can never put it in front of
somebody the author did not admit.
Plain-text search over the post’s TSVECTOR. A future search DSL will replace this with a full query language.
Narrow the feed to posts carrying the named tag(s) (#1123).
REPEATABLE, and the repeat means AND: ?tag=a&tag=b selects
posts carrying EVERY one of them. That is the meaning
tag:a tag:b has in the search DSL, and since #1251 both
surfaces render it from the same predicate rather than each
holding a copy.
Repeated rather than a comma list — unlike kind on this
same operation — because a tag is user text in which a comma
is a legal character, so splitting on one would turn an exact
match into two tags that intersect to nothing. A single
?tag=x is unchanged.
Matching is EXACT and NOT case-folded (migration 00050), and it NARROWS: the read rule is ANDed on afterwards, so a tag can never surface a post the caller could not already read. Blank values are ignored.
Scope the feed to one team’s posts — the team page (#684).
NARROWING ONLY, like every other filter on this operation.
The read rule is ANDed onto the result rather than ORed
into it, and it never consults team_id, so this parameter
can only remove posts from the page the caller would
otherwise have received. Membership of the team confers
nothing here.
Not a team-existence probe: an unknown team, a soft-deleted team and a team with no readable posts are one answer, an empty page.
Restrict the feed to posts CONTAINING an asset of the given kind — the browse footer’s type filter (#1166, widened by #1190).
Comma-joined, spelled the way /search?types= already
spells a multi-value narrowing filter on this wire:
?kind=image,video. The vocabulary is the frontend’s
ViewKind union — image, video, pdf, audio,
sequence, font, sprite, 3d, ebook, doc,
audiobook, archive, placeholder — because the value
this filters ON is the one the card’s kind badge RENDERS,
and a filter that used a different vocabulary from the badge
could not be checked against it by looking at the page.
ANY MEMBER, not the cover. A post matches when ANY of its member assets resolves to a selected kind — “a post containing an ebook matches the ebook filter, cover or not” (#1190). #1166 shipped the cover-only reading and it answered the wrong question: a multi-file art drop was unreachable by every kind it actually contained except whichever one its cover happened to be.
THE KINDS THEREFORE DO NOT PARTITION. One post can be
returned by several kinds — a bundle holding a .glb and an
.mp4 is in ?kind=3d and in ?kind=video — and a card in
?kind=ebook can be drawing a video badge, because the
badge names the COVER while the filter names the
MEMBERSHIP. ?kind=A,B is still exactly the union of
?kind=A and ?kind=B.
⚠️ A MEMBER THIS CALLER MAY NOT READ CONTRIBUTES NO KIND.
The card withholds everything about a restricted member —
it ships with no asset object at all — because the kind of
something you may not see is not yours to know; a filter
that selected a post through such a member would hand the
fact back by elimination (ask for each kind in turn and the
one that returns the post has named it). So the field-plane
readability rule sits INSIDE the per-member test: a post
whose only epub is restricted is absent from ?kind=ebook
for a stranger and present for its owner, and a post whose
members are all unreadable is reachable unfiltered and by no
kind.
NARROWING, like every other filter on this operation. It is a plain conjunct beside the post read rule, never a disjunct with it, so it can only remove posts from the page the caller would otherwise have received — including for an anonymous caller under public mode, who sees public posts containing a visible asset of that kind and nothing else.
An unrecognised name selects NOTHING rather than being
ignored: ?kind=nonsense answers an empty page, because
ignoring it would serve the entire feed under a label
promising one kind. An empty value is no filter at all,
which is what the control sends when every box is ticked.
The AI axis — the browse footer’s “Hide AI-made work” toggle (#1251, ADR 0094 fourth amendment). Two values, and they PARTITION the corpus:
not_pure— everything that is NOT purely AI-generated. This is the value the toggle sends, and the only one any control on this site emits.pure— only the purely-AI work. Offered for symmetry withfilter=ai:on/search; no UI sends it.
⚠️ HIDING EXCLUDES PURELY-AI POSTS ONLY. A post MIXING AI
and human contributors STAYS VISIBLE under not_pure,
because excluding it for one member’s declaration would
punish the honest declaration the whole design rests on:
“AI could be used as part of an ideation phase and the
final project might be pure human made”. So the predicate
reads posts.ai_pure (the FILTERING fact, migration
00061) and never posts.ai_provenance (the LABELLING
fact, which propagates on ANY member and would therefore
exclude exactly the mixed posts the ruling protects).
It FAILS TOWARD SHOWING. An UNDECLARED contributor makes a
post not-pure, so a work nobody was asked about survives
not_pure: wrongly hiding human work is a worse error
than showing one more AI post to someone who asked not to
see them.
⛔ A FILTER, NEVER A GATE (ADR 0094 §4). Nothing is withheld on this axis. A caller who does not send this parameter sees pure-AI work in their page exactly as before, and nothing derived from it — counts, facets, suggest, thumbhash, embeddings — is subtracted for anyone. An operator policy (“no AI on this instance”) is NOT this parameter; that is moderation.
NARROWING, like every other filter on this operation: a plain conjunct beside the post read rule, never a disjunct with it.
⚠️ AN UNKNOWN VALUE IS A 400 HERE, where ?kind=nonsense
and ?visibility=nonsense answer an empty page — and the
divergence is deliberate, not an oversight to tidy up.
Those two are POSITIVE selections, where “show me only X”
for an X nobody has is legibly answered by an empty page.
This one is an EXCLUSION with a closed two-value
vocabulary, so a tolerated ?ai=generated or ?ai=false
would render a predicate matching nothing and a viewer who
asked to hide AI work would get an EMPTY WALL instead of an
unfiltered one — which reads as the site being broken.
All three fail CLOSED and none of them can widen; this one
additionally says so out loud. It is the same answer
filter=ai:junk gets from /search, rendered by the same
validator (facet.FacetType.CanonicalValue).
The VIEW-level mature filter, which is the browse footer’s Mature row (#1292, ADR 0090’s 2026-08-26 amendment). One value:
not_mature: everything that is not flagged mature.
⭐ THE VOCABULARY HAS ONE VALUE ON PURPOSE. Mature is
gated by three conjuncts (ADR 0090 §2) and this parameter is
a fourth thing entirely: a reader who has ALREADY qualified
saying “not in these results, right now”. Layer 3 narrows
and never consents, so there is deliberately no spelling of
this parameter that asks for mature work. “Only the mature
ones” would be a positive selection over a gated axis, and
the property that this filter can never widen is worth more
as a fact about the wire than as a promise in prose. ?ai=
offers both directions because its axis gates nothing; this
one does not, for the same reason in reverse.
⛔ IT IS NOT THE GATE, AND IT DOES NOT REPLACE IT. The three conjuncts still decide which mature rows this caller could have received at all; this subtracts from what survives them. A caller who does not send it gets exactly what they got before this parameter existed, and a caller who does can never receive a row the conjuncts withheld.
⚠️ IT APPLIES TO THE CALLER’S OWN MATURE POSTS TOO, unlike the gate, whose owner exemption exists so that an operator’s switch cannot take an artist’s work away from them. Nothing is taken away here: the reader asked, and one untick gives it straight back. “Not in this feed” means not in this feed.
NARROWING, like every other filter on this operation: a plain conjunct beside the post read rule, never a disjunct with it.
⚠️ AN UNKNOWN VALUE IS A 400, for the reason ?ai=
gives: this is an EXCLUSION over a closed vocabulary, so a
tolerated ?mature=yes would render a predicate matching
nothing and hand the reader an EMPTY WALL where they asked
for a slightly smaller one.
Pre-baked feed selector. When set, applies a viewer-anchored filter on top of the other query params.
following— restrict to what the caller follows (browse-page “Following” tab): posts by an author they follow, OR posts belonging to a team they follow. One filter over both follow graphs, because the page offers one control and its rail lists the teams you follow. Still NARROWING — the read rule is ANDed on after it and consults neither follow table, so following a studio reveals nothing of its work you could not already see. Returns an empty page for callers who follow nothing.latest(default) — no extra filter; same shape as omitting the param.
Sort direction over the feed’s (posted_at, id) key.
desc(default) — newest first.asc— oldest first.
It moves the KEYSET COMPARISON as well as the ORDER BY,
because a cursor is only meaningful relative to the order
that produced it. A desc cursor replayed against an asc
page would ask for “older than X” while the page walks
forward, which skips rows and then repeats them.
A cursor therefore belongs to ONE direction. Flipping dir
mid-scroll means starting a new scan: send no cursor on
the first request after the flip. The browse page already
works this way — its result-set identity key includes the
direction, so changing it resets the accumulated pages.
Admin-only. When true, soft-deleted posts appear
alongside live rows. Non-admin callers ignored.
Restrict the page to DRAFTS — posts whose author has not published them (ADR 0091 decision 7). This is the “my unfinished work” listing, and it is the ONLY listing on this API that returns a draft at all.
Omitted or false is the ordinary feed: published posts
only, for every caller including the author of a draft.
That is not a default anyone can talk their way out of by
combining filters — an unpublished post is absent from
browse, from search, from a collection, from ?feed= following and from its own author’s ?author_ref=me
page, because “on a shared surface” is decided before any
of them.
NARROWING like every other filter here. The read rule is
ANDed on after it and is STRICTER for a draft than for a
published post: a draft is readable by its author and by a
posts.admin holder only, whatever its visibility tier
says. So ?draft=true returns your own drafts, an
instance moderator’s returns everyone’s, and a stranger’s
returns an empty page rather than somebody’s unfinished
public post.
Responses
Section titled “ Responses ”A page of posts
object
Feed entity. Wraps 1+ assets with descriptive metadata. The
members array is the ordered list of assets in the post;
cover_asset_id is the one shown in feed/grid cards.
object
The renderable identity behind author_user_ref (#557).
WHY IT IS ON THE POST. A feed card carries an author
header, and a page is ~20 cards. Resolving each one
through GET /users/{ref} is twenty profile round trips
per page — a permanent tax on the hottest surface in the
app, and there is no batch user endpoint outside admin.
So the identity travels WITH the post, resolved once per
page for the distinct set of authors on it.
ABSENT MEANS “NOT DISCLOSED”, NOT “NO AUTHOR”. Every post
has an author — author_user_ref is required and always
present. This object is omitted when the caller is not
entitled to the identity behind that ref:
- the author set
hide_from_anonymousand the caller is anonymous (ADR 0070 §3 / ADR 0024’s opt-out). The author’s own profile 404s for this caller, so putting their name and face on a card would be that opt-out defeated by a JOIN. - the
userrow is gone (a hard-deleted account).
There is deliberately NO placeholder identity. “Someone who opted out posted this” still discloses that they posted; the card renders with no author header instead.
PER-CALLER, THEREFORE NOT CACHED. Like
members[].preview_available (#471) and restricted
(#883), this is derived per request from the caller’s
identity and never written into the cross-caller post
cache. See posts.enrichAuthors.
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.
Pointer to one of members[].asset_id — the chosen cover.
Defaults to members[0].asset_id when not set explicitly.
Optional standalone thumbnail asset, NOT a member of the
post. Used when the uploader wants a different image as
the feed thumbnail than any of the post’s assets (e.g. a
cropped detail or an entirely separate cover image). When
set, the collection cover mosaic prefers this asset over
cover_asset_id (cover_thumbnail_asset_id ?? cover_asset_id,
collections.ComposeCovers).
⚠️ NO FEED OR GRID CARD READS IT. This description used to
say the feed card preferred it, and that was never true of
any shipped card: PostCard, PostListTable and cardAsset all
resolve cover_asset_id, falling back to the first member,
and none of the three mentions this column (#1210).
cover_focal_* below therefore attaches to the resolved
card cover and not to this.
Where the post cover’s SQUARE crop is centred horizontally, as a fraction of the picture’s width (#1210). Null means centre, which is what every card rendered before this existed.
THE DESTINATION SHAPE IS A SQUARE because the browse GRID
tile is the only post surface that crops. PostCard turns on
CardThumb’s fill in grid alone, and fill is
object-fit: cover on an aspect-square frame. Masonry
takes the picture’s own shape, and feed, thumbnail, band and
list letterbox it whole onto the matte, so none of them can
act on this and all of them are byte-identical with it set
and unset.
ONE PAIR, unlike Collection, which carries two: the
featured rail’s 890:500 card and the collection tile are
different shapes and one fraction cannot be right for both,
while a post has a single cropping destination.
⚠️ IT IS CHOSEN AGAINST THE ORIGINAL PICTURE. A consumer
honours it by rendering a contain rung with
object-position; applying it to col crops a crop, since
col’s square was already taken at the centre before this
value could act. A cover with no contain rung available
falls back to col centred, which is the behaviour every
card had before focal points existed.
Always null or set TOGETHER with cover_focal_y; a half
focal point is unanswerable rather than weaker, and the
column CHECK refuses it.
The vertical half of the post cover’s focal point (#1210).
See cover_focal_x.
The post is a DRAFT — its author has not put it in front of people yet (ADR 0091 decision 7).
A draft appears on NO shared surface: not browse, not
search, not a collection, not its own author’s feed, and
nothing about it is federated to a peer. It is readable by
its author (and by a posts.admin holder) through
GET /posts/{id} and listable by them through
GET /posts?draft=true, and by nobody else — a public
draft is no more readable by a stranger than a private
one, because publication and visibility are different
questions. visibility says who may read it ONCE
published; this says whether it is published at all.
The two lifecycles are separate in the other direction too: a draft’s member assets are ordinary assets in their owners’ storage, unaffected by whether the post that shows them is published.
Derived from state_id — true when the post’s workflow
state is the post domain’s wip state, false when it is
published. It is on the wire so a client never has to
resolve a state UUID against GET /workflow/states to
answer the one question the UI actually asks.
Whether this post accepts NEW ordinary comments and replies (#1119 sprint 21d).
A SETTING OF THE POST, held independently by every post:
not a preference of its author, not a capability of the
reader, and not a workflow state. true for every post
that existed before the setting did, and for every post
created without saying otherwise.
CREATION ONLY. When false, POST /posts/{id}/comments
answers 409 comments_disabled for a top-level comment and
for a reply alike, and that is the whole effect: existing
comments are neither deleted nor hidden, comment_count is
what it was, and listing, deletion and moderation are
unchanged. Whiteboards (POST /posts/{id}/whiteboards) and
annotations are separate surfaces and are not gated by it.
Always present and never null: a client renders the
composer on true and the “comments are off” note on
false, with no third case to invent a meaning for.
Changed through PATCH /posts/{id} by whoever may edit the
post; holding posts.comment confers nothing here.
The post’s workflow state — a row in workflow_states
whose domain = 'post'. READ-ONLY on this API: it is set
by POST /posts (from draft) and moved by
POST /posts/{id}/publish and
POST /posts/{id}/unpublish, which go through the
workflow state machine so the move is validated and
audited. No request body accepts it.
Prefer draft for anything a UI renders. This field is the
underlying identity, kept on the wire for admin surfaces
that show workflow history.
DERIVED from the post’s live contributors — its member
assets AND its two cover pictures (#1167, ADR 0094). Never
accepted on a request body; maintained by a database
trigger, as mature is, so it cannot be forgotten by a
write path nobody has written yet.
The rule is ASYMMETRIC:
- a POSITIVE claim propagates on ANY — one
generatedcontributor makes the postgenerated, failing that oneassistedcontributor makes itassisted; - the NEGATIVE claim requires ALL — the post reads
noneonly when it has at least one live contributor and every one of them declaresnone.
So ONE UNDECLARED CONTRIBUTOR MAKES THE POST UNDECLARED.
Deriving none over a contributor nobody asked would
fabricate that maker’s disclaimer at the post level, which
is the same error the nullable asset column exists to
prevent. A post with no live contributors is undeclared too.
The covers arm is deliberate: posts.mature shipped
members-only and #1147 was the bill — a card shows its
cover first, so a label that ignores the cover is not true
of what the post presents.
Whether THIS caller has liked this post. Always false for an anonymous caller, who has no identity to have liked with.
Shipped with the feed card (#557) for the same reason
author is, and it is the same round-trip arithmetic:
GET /posts/{id}/like answers this for one post, and a
feed card needs it for twenty. Without it the heart on
every card is drawn unfilled until clicked — a control
that lies about the reader’s own state — or the page
issues one request per card to find out.
PER-CALLER, therefore derived after the post cache and never written into it. See posts.enrichLiked.
The head of this post’s thread — the newest N TOP-LEVEL
comments, newest first, where N is small and fixed
(posts.TopCommentsPerPost). The feed card prints them under
the description and links to the post for the rest (#1047).
THE HEAD OF THE THREAD, not a summary of it. The ordering
and the predicates are the ones GET /posts/{id}/comments
already applies to its thread roots — newest first, no
replies, no soft-deleted rows — so the preview is literally
the top of what opening the post shows, and there is no
second notion of “which comments matter” to keep in step.
Absent or empty when the post has no comments. A client that ignores it renders the card it rendered before, with the comment COUNT it always had.
VISIBILITY. This rides a post the caller has already been
granted by postReadableExpr — the list query’s own gate —
so the preview inherits it and adds nothing. It is
deliberately NOT built by calling the comments endpoint per
post: that would be an N+1, and (see #1047’s handoff) that
endpoint’s own gate is weaker than the feed’s.
The commenter’s identity is resolved by
users.LookupAuthors, the same expression the post’s own
author uses, so the ADR 0024 opt-out holds here too: a
commenter who hid from anonymous readers has NO author
object on their preview entry for an anonymous caller. Their
words still appear, exactly as an opted-out author’s POST
still appears — the opt-out is about the identity, not the
content, and treating it otherwise would silently delete
people’s comments from the feed.
One comment as a feed card shows it (#1047) — a name, a line of text, and when.
ALLOW-LIST, NOT A TRIMMED Comment. The server BUILDS this
object from four named columns rather than building the full
comment and deleting fields from it, so a column added to
comments later — a moderation note, an annotation payload, an
actor URI — is withheld from the feed because it was never
fetched. Same discipline as PostAuthor and PostMember’s
placeholder.
Notably ABSENT, and deliberately: body_html, like_count,
parent_id / root_id / depth, edited_at,
annotation_type / annotation_data. A preview is two lines
under a picture; threading, reactions and review annotations are
what opening the post is for.
body and not body_html: the card clamps this to a couple of
lines of plain text, and shipping server-rendered markup to be
truncated by CSS would mean either rendering HTML in a card that
has no business rendering it, or clipping mid-tag.
object
The comment’s plain text, as written. Untruncated — the card clamps it, because how many lines fit is a question about the card and not about the comment.
The commenter, when they are disclosed to this caller.
ABSENT MEANS “NOT DISCLOSED”, NOT “NO COMMENTER”, and the
three ways it happens are all correct as an absence: the
commenter set hide_from_anonymous and the reader is
anonymous (ADR 0024 / ADR 0070 §3), the account is gone, or
the comment came from a FEDERATION PEER and has no local
user at all — see remote_display_name.
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.
For a comment that arrived from a paired peer: the remote
actor’s cached display name, resolved from
federation_remote_actors exactly as the thread endpoint
resolves it. Absent on locally-authored comments, which
carry author instead.
May be empty when the peer has shipped no display hints
yet — the same caveat Comment.display_name carries — in
which case the card prints no name rather than inventing
one from the actor URI.
An asset attached to a post, with its sort_order in the carousel and the asset payload joined-in to avoid an N+1.
RESTRICTED MEMBERS (#883). A post’s author can attach an asset
that the reader is not entitled to — their own restricted
work, or (once #882 lands) someone else’s. Membership must never
WIDEN an item, so when the reader fails
visibility.FieldsReadable this object degrades to a
placeholder: restricted flips to true, asset is ABSENT, and
the only thing carried across from the asset row is
owner_display_name.
The placeholder is deliberately PRESENT rather than dropped from the array. A reader is meant to be able to tell that something is there they cannot see — that is what makes “request access” (#881) meaningful, and silently omitting the member would hide the restriction instead of stating it.
ALLOW-LIST, not deny-list. The permitted key set on a restricted
member is exactly asset_id, sort_order, restricted,
owner_display_name — the post_assets row’s own columns plus a
marker plus the one asset-derived value the owner permitted. NO
column of assets crosses this boundary. A field added to
Asset later is therefore withheld by construction rather than
by remembering to add it to a list of known-sensitive names.
object
True when the caller may not see this member. asset is then
absent and the client renders a placeholder tile.
Set only when restricted is true: the display name of the
asset’s owner, so the placeholder can say whose work it is
and #881 can address the request. Absent — not empty — when
the owner has no resolvable name or the asset is unowned, so
a client cannot tell “withheld” from “genuinely empty”.
ABSENT when restricted is true. Present, and complete, in
every other case.
object
True when the caller may not see this asset’s columns. Every
other key except id and owner_display_name is then
absent, and the client renders a placeholder tile.
REQUIRED, so it is always present to branch on: an optional
marker would read as undefined on a withheld payload from
an older server and fall through to “not restricted”, which
is the wrong default for a security marker.
Set only when restricted is true: the display name of the
asset’s owner, so the placeholder can say whose work it is
and #881 can address the request. Absent — not empty — when
the owner has no resolvable name or the asset is unowned, so
a client cannot tell “withheld” from “genuinely empty”.
Not carried on a readable asset, which has owner_user_ref
and can resolve the profile itself. owner_user_ref is NOT
carried on a withheld one: the placeholder’s allow-list is
the owner’s NAME, and a ref is a second way to ask.
This asset carries a MATURE rating (#1115, epic #1114, ADR 0090).
⚠️ A RATING IS NOT A CLEARANCE. sensitivity answers who is
ALLOWED to see this; mature answers who has OPTED IN. They
are orthogonal — a public artwork can be mature, and a
restricted one need not be — and a client that treats one
as a level of the other will get both wrong.
Whether a disqualified viewer receives this row at all, and whether its preview is blurred, are the server’s decisions (ADR 0090 §3). The flag is here so a client can LABEL what it was given, never so it can decide what to hide.
The MAKER’S DECLARATION about generative-AI involvement in this work (#1167, ADR 0094).
⚠️ ABSENT/NULL MEANS UNDECLARED — NOBODY WAS ASKED. It does
NOT mean none. Every asset that predates the feature is
undeclared and was deliberately not backfilled: writing
none onto those rows would assert “the maker declares no
generative AI” on the maker’s behalf, which is a fabricated
disclosure on the one topic where a false disclaimer is the
worst available error. A client that renders a missing
value as “no AI” is lying for the artist. Render it as
“not stated”, or render nothing.
⚠️ A DECLARATION IS NOT A PERMISSION (ADR 0094 §4). This is
orthogonal to sensitivity and to mature. It is a FILTER
a viewer may apply to their own feed and NEVER a gate that
withholds the work from anyone: the work stays public,
findable and countable, and nothing derived from it —
search text, facets, suggest, thumbhash, embeddings, counts,
covers — is withheld on account of it. That is what keeps
this field cheap, and it holds only while nothing gates on
it.
Does not federate yet: the v1 envelope rejects unknown
top-level fields. ADR 0094 §6 pre-decides the wire mapping
for the next @context bump.
Sha256 of the linked storage object
Flat tag value list — backwards-compatible projection
that pre-1.14.B consumers depend on. New consumers
prefer tag_details below which carries per-tag source
- confidence + provenance.
Per-tag source/confidence/provenance.
Same set of tags as tags, ordered the same way; this
field is the typed projection the frontend
AssetTagBadge renders.
One tag with full source/confidence/provenance metadata. Backs the per-source UI badge.
object
The tag value itself (e.g. “kittens”).
Where the tag came from. manual = operator typed it
in the UI; ai = generated by an AI tag job;
import = bulk CSV / EXIF / upstream system. AI runs
never overwrite manual or import rows.
Provider-supplied confidence in [0, 1] when source=‘ai’; null for manual and import tags. UI filter threshold lives in ai.tag.confidence_threshold (default 0.5).
Provider slug (openai / claude / ollama) for source=‘ai’.
Model identifier (gpt-4o / claude-3-5-sonnet) for source=‘ai’.
Free-form JSONB metadata blob (EXIF, custom fields, etc.).
object
Optional workflow state (domain = ‘asset’).
The team this asset is scoped to, or absent when it has
none — which is the common case. Set at create time via
AssetCreate.team_id (#953) and not changeable afterwards.
It is on the FIELD plane, so a withheld asset does not carry it: which studio holds a file you may not open is a fact about that file.
Post-upload processing pipeline state. pending means
background jobs (variant generation, EXIF extraction,
etc.) are still in flight — the original file at /file
is always available, but /variants/{key} URLs may
404 until processing completes. ready is the steady
state. failed surfaces in the UI as a retry affordance.
True iff a servable col thumbnail variant exists for this
asset AND the caller passes the content plane
(visibility.CanReadContent, ADR 0064). The client renders the
thumbnail only when this is true and otherwise shows a
placeholder WITHOUT requesting bytes — so a content-gated or
preview-less asset never fires a /variants/col (or /file)
request that would 404 and spam the console (#471). A
restricted asset the caller may not read reads identically to
“no preview” (false), so this never confirms restricted
(0064-safe).
True iff EVERY variant in the operator’s CONFIGURED preview
ladder exists for this asset AND the caller passes the
content plane. Same ADR 0064 contract as
preview_available: a restricted asset reports false rather
than 403, so the flag never confirms restricted.
This is the signal a responsive srcset needs.
preview_available promises only col — a 320x320 COVER
crop — so a client that inferred the wider preview /
screen / hires rungs from it would request bytes that may
not exist, which is exactly the 404 class #471 removed. With
no way to ask, the frontend disabled responsive images
entirely and every card serves a 320px square regardless of
viewport. This flag is how the client stops guessing.
THE LADDER IS OPERATOR-CONFIGURABLE, so this is NOT “the four
default rungs exist”. It is computed against
sysconfig.GetPreviews, whose defaults (col/preview/screen/
hires) are a default and not the contract. An install that
drops or adds a rung changes what this flag means, which is
the intent — clients should read the ladder from
GET /previews rather than assuming the default keys.
Deliberately NOT an alias for preview_available. On a
healthy install the raster pass writes the whole ladder or
none of it, so today the two are nearly co-extensive; they
diverge exactly where it matters — assets whose raster pass
partly failed, and any future partial-ladder state.
True iff a servable sprites.vtt cue file exists for this
asset AND the caller passes the content plane. Same ADR 0064
contract as preview_available — a restricted asset reports
false rather than 403.
This is the hover-scrub gate (#835). The card’s scrub used to
be licensed by FILE EXTENSION — “this is an mp4, therefore it
has a sheet” — which is a guess about storage made from a
filename. It is wrong in both directions: a video whose
expensive preview.video job has not drained yet has a col
(the cheap poster job, #818) and no sheet, and the card
requested one anyway and 404’d; and a format that DOES have a
sheet but was not on the hardcoded list — an animated GIF
since #832 — could never scrub no matter what was in storage.
sprites.vtt and not sprites.jpg because the cue file is
what the client actually reads: it declares how many cells
the sheet really contains, which is not cols×rows for a clip
too short to fill the grid. Both are written together by
every producer (preview.video, preview.3d,
preview.gif), so either would answer “is there a sheet”,
but only one answers “can I drive from it”.
True iff an ANONYMOUS visitor’s read rule would return this
asset (#1209). Derived from the anonymous arm of
visibility.Predicate for EntityAsset, which is
soft-delete plus status = 'active' plus
sensitivity = 'public' plus processing_status = 'ready'
(ADR 0063; ADR 0020 governs the tiers).
NOT A STATEMENT ABOUT THIS CALLER, unlike every other flag
beside it. preview_available, ladder_available and
scrub_available answer “may YOU render this”; this answers
“would a STRANGER be shown it”, and it has one answer per
row regardless of who asks. A surface that reached for it as
a readability decision would be asking the wrong question,
and would be wrong for every signed-in caller.
WHAT IT IS FOR. A curator choosing a cover for a public
collection or post is choosing a picture strangers will be
shown, and a pick that fails this rule falls back on every
anonymous surface with no explanation. The picker could
previously check only status, because this schema
deliberately carries no sensitivity, so a team-tier pick
fell back unwarned. It said so rather than guessing, since a
warning that guesses in the reassuring direction is worse
than none.
IT DISCLOSES NOTHING NEW. The fact is derived from the GATE
rather than from content, and it is exactly what any visitor
can establish by opening the same URL with no session. It
rides only on payloads that already passed the field plane:
the withheld-asset placeholder is a complete literal
carrying id, restricted and owner_display_name, and
does not carry this, so a withheld row states nothing about
its own tier.
Absent on a withheld row, present everywhere else.
Base64-encoded thumbhash (~30 bytes decoded) for instant blurred placeholder rendering on feed cards. Computed synchronously at upload for image assets. NULL for non-image resources.
Recorded pixel width of the SOURCE image, or null when the install has never measured one.
REQUIRED-but-nullable for the same reason as thumbhash
above: it is a field the card frame reads to decide what a
tile IS. Masonry sizes each tile from this ratio so the
space is reserved before the image loads and the wall does
not reflow as 72 images arrive (#640).
Not a column on assets — the value lives in
asset_field_value under the pixel_width field definition
seeded for #618, and is projected here.
The quantity is the shape of the image the preview ladder’s
contain rungs are built from, NOT the source file’s pixels
(ADR 0071 §6). For a raster the two coincide with one
deliberate refinement: the ladder source is the
EXIF-ROTATED image, so an orientation=6 phone photo reports
the portrait shape a viewer sees rather than the landscape
grid stored on disk. For everything with no source pixels at
all — a 3D model, a font, an audio file, a plain-text
document — it is the shape of the one image that kind
produces on its way through the pipeline: a turntable frame,
a glyph specimen, a waveform, a rendered plate. A 2048x384
waveform is a 5.33:1 tile and nothing in the .ogg says so.
Null therefore means only “no preview render has recorded a
shape for this asset yet” — a draft (the render selects
status = 'active'), a kind no preview handler can render
at all, or an asset whose preview predates #757 and has not
been re-rendered. The client falls back to measuring the
image once it loads.
Recorded pixel height of the source image, or null. See
pixel_width — the two are projected as a PAIR, so a
consumer never has to handle one without the other.
Soft-delete timestamp. Non-null only on rows surfaced by
the admin include_deleted=true listing (the trash view);
null on live rows.
Optional reason captured at soft-delete time.
WebVTT subtitle / caption tracks attached to this asset. Empty array when none, or when the asset’s renderable kind doesn’t support subtitles (image, 3D, document, etc.). Tracks ride along with the asset; they do NOT appear in asset-count queries and do NOT federate as standalone activities.
One WebVTT subtitle track attached to an asset. NOT a first-class asset; rides along with its parent in the Asset.subtitle_tracks array. Tracks are uniquely identified by (asset_id, lang) — re-uploading the same lang replaces the existing row rather than creating a duplicate.
object
RFC 5646 / BCP 47 language tag (e.g. “en”, “en-US”, “ja”, “fr-CA”). The sentinel value “und” is reserved for sidecar files that matched by basename but had no inferrable language segment.
Optional human-readable label (“English (US)”, “Forced”, “Director’s commentary”). Empty string when not provided; never null.
CAS hash of the stored WebVTT file.
The format the track was uploaded in. The conversion worker stores WebVTT regardless; tracking the source lets operators re-convert if a converter bug surfaces.
1.0 for text-based sources (deterministic conversion). <1.0 for OCR’d bitmap sources like IDX (DVD subtitles). UI MAY surface a warning banner below 0.8.
The values of the fields an operator marked
show_on_card, already resolved to display strings and in
the order the field definitions declare (#552).
Absent or empty when nothing is marked, when this asset
carries no value for any of them, or on a restricted
placeholder — a card MUST fall back to its own default
rather than render an empty strip. That fallback is what
makes the flag a hint: a client that never reads this key
is plainer, not wrong.
Only ungated fields can appear, structurally: the schema
refuses show_on_card on a field carrying a
read_capability, so this array cannot become a side door
around a per-field gate.
One at-a-glance field value on a card (#552). Deliberately a DISPLAY shape and not a second AssetFieldValue: the card shows one line per field, so the server resolves the vocabulary slug to its label here rather than shipping the slug plus a resolution map to every tile on a browse page.
object
The field definition’s federation-stable slug.
The operator-facing name, for the caption.
The value as one line. Never empty — a field with nothing on this asset is omitted from the array rather than sent blank.
The peer this asset came from, or absent/null when it is ours. Present so a surface can answer “whose is this?” without making remote work look lesser — federated content uses the same card, the same viewer and the same display hints; what it must not do is arrive unattributed.
object
The renderable identity behind owner_user_ref — the
artist block on an asset card (#1047).
THE SAME SHAPE AND THE SAME RESOLUTION AS A POST’S AUTHOR,
because it is literally the same code: users.LookupAuthors,
batched once per page. An asset card that resolved the
owner client-side would be an N+1 per tile, and worse, it
would re-derive the display-name ladder in the browser —
which is how the anonymous rung gets skipped (#1023).
ABSENT MEANS “NOT DISCLOSED”, NOT “NO OWNER”. A ref is
omitted from the lookup when the owner set
hide_from_anonymous and the reader is anonymous (ADR 0024
/ ADR 0070 §3), or when the account is gone. There is no
redacted entry and no placeholder identity: the card draws
no artist block at all. Deliberately not a sentinel —
“someone who opted out owns this” still discloses that they
own it.
Absent on a restricted placeholder too, which carries
owner_display_name instead: that surface has its own
allow-list and its own name rule
(users.PlaceholderOwnerName), and a second, wider
identity object riding the same row would defeat it.
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.
The peer this post came from, or absent when it is ours —
the post-level twin of Asset.origin (#552), added with
the feed card (#557).
origin_server_id has always been on this schema and is
not enough on its own: it is a bare UUID, and answering
“whose is this?” with an identifier answers nothing. A
feed card attributes remote work by the name the operator
gave the peer at handshake, in the same card and the same
layout local work uses. Federated content is not lesser;
what it must not be is unattributed.
object
Optional team scope (ADR 0010 Layer 5). When set, team-
scoped capability checks apply: callers who hold
posts.read scoped to this team or any ancestor team
can see the post regardless of visibility. NULL = no
team scope; only visibility / role / ACL gates apply.
Soft-delete timestamp. Non-null only on rows surfaced by
the admin include_deleted=true listing (the trash view);
null on live rows.
Optional reason captured at soft-delete time.
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"}Unexpected server error
object
Human-readable error summary
Example
the request could not be completed