Where this asset of mine appears
GET /assets/{id}/posts
The OWNER’s answer to “where has my file ended up” (ADR 0091 decision 5). An asset is personal storage; it reaches a shared surface only as a member of a post, and this is how its owner sees which posts those are — INCLUDING posts by other people.
Not the same question as GET /posts/by-asset/{id}
That operation is the reader’s: it lists the posts featuring an asset that the CALLER may read, and a post they may not read is simply absent. Absent is the right answer to a reader and the wrong one to an owner, who would conclude their file was nowhere.
This operation is the owner’s, and it is the ONE place in the product where somebody learns something about a post they may not read. So it is deliberately the most conservative shape the decision allows:
items— the posts the ordinary read rule ALREADY grants this caller, in full, exactly as/posts/by-assetreturns them. Nothing new is disclosed here.withheld_count— how many FURTHER posts contain this asset. An integer and nothing else. No id, no title, no author, no tier, no timestamp — nothing that could be turned back into the post. The count is the whole disclosure, and it is what makes “my file is being used somewhere I cannot see” sayable at all.
A caller cannot walk the count into anything: there is no cursor over the withheld set, no filter that shrinks it selectively, and no per-post handle to probe with. Asking twice gives the same integer.
ADR 0091’s first amendment flags this as the part with NO PRIOR ART — on art platforms a post’s files are always the author’s own, so this case never arises there. It is ours, which is the reason to under-build it rather than over-build it. Widening it later is a decision somebody can make on purpose; narrowing it after people have relied on it is not.
Who may ask
The asset’s OWNER, plus assets.admin / system.admin. Anyone
else gets the same 404 a nonexistent asset gets, because
“this asset is used in 3 posts you cannot see” is the owner’s
information about their own file, and answering it for a
stranger would make the endpoint a usage oracle over the whole
library.
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Path Parameters
Section titled “Path Parameters ”Responses
Section titled “ Responses ”The posts this asset appears in
The answer to GET /assets/{id}/posts (ADR 0091 decision 5).
Two fields, and the split between them IS the disclosure rule:
everything the caller is entitled to arrives as a whole post in
items, and everything they are not is compressed to a single
integer that carries no handle back to the post it counted.
object
The posts containing this asset that the ordinary post read
rule grants this caller — the same rule, the same shape and
the same per-caller enrichment GET /posts applies.
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.
How many FURTHER live posts contain this asset — posts the caller may not read.
Existence and count, never identity. There is no companion
array of ids, no cursor, and no filter that moves this
number selectively, because any of those would turn the
count back into the posts it counted. 0 means the caller
can already see every post this asset is in.
Soft-deleted posts are not counted: a deleted post is not somewhere the asset appears.
⚠️ INDEPENDENT OF HOW MANY items CAME BACK. items is a
bounded page; this counts what the caller may NOT read, so
an asset in more readable posts than one page holds still
reports 0 here. The two are answers to different
questions and a client must not derive either from the
other — in particular withheld_count is not
“total − items.length”, which is how #1237 found the server
reporting readable-but-truncated posts as withheld.
Authentication 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"}Resource not found
object
Human-readable error summary
Example
the request could not be completedUnexpected server error
object
Human-readable error summary
Example
the request could not be completed