Skip to content

List the posts pinned in a collection

GET
/collections/{id}/posts

Returns posts currently pinned in this collection (excludes rows where the per-membership expires_at has passed), in sort_order ASC, added_at ASC order so the list matches the curator’s arranged sequence.

TWO gates apply and both are required. The PARENT collection must be visible to the caller, and each POST is filtered by the one post read rule — the same expression GET /posts and GET /posts/{id} use. A collection may contain posts its curator can read and you cannot (that is what #882 makes possible), so membership never widens a post: a post you may not read is simply not in this list.

A post you may not read is ABSENT, not placeheld. The member grid’s asset listing used to answer the other way — a withheld member came back as a visible placeholder so the reader could see a restriction and ask for access (#881/#883) — but a post carries its author’s title and description in the row itself, and the post surface has no request-access flow to attach to. Absence is the honest answer here. (That listing, GET /collections/{id}/resources, retired with #1236; this is the only membership listing a collection has.)

id
required
string format: uuid
limit
integer
default: 50 >= 1 <= 200

A page of posts

object
items
required
Array<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
id
required
string format: uuid
author_user_ref
required
integer format: int64
author

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_anonymous and 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 user row 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
ref
required
integer format: int64
username
required

The handle. Also the profile link target (/users/by-username/{username}).

string
display_name
required

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.

string
avatar_url

Null for users with no uploaded avatar; the card renders a generated initials disc in that case.

string
nullable
title
required
string
description
required
string
visibility
required
string
Allowed values: public private org-only followers explicit-share
cover_asset_id

Pointer to one of members[].asset_id — the chosen cover. Defaults to members[0].asset_id when not set explicitly.

string format: uuid
nullable
cover_thumbnail_asset_id

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.

string format: uuid
nullable
cover_focal_x

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.

number format: double
nullable
cover_focal_y

The vertical half of the post cover’s focal point (#1210). See cover_focal_x.

number format: double
nullable
draft
required

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.

boolean
comments_enabled
required

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.

boolean
state_id

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.

string format: uuid
nullable
ai_provenance

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 generated contributor makes the post generated, failing that one assisted contributor makes it assisted;
  • the NEGATIVE claim requires ALL — the post reads none only when it has at least one live contributor and every one of them declares none.

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.

string
nullable
Allowed values: none assisted generated
posted_at
required
string format: date-time
like_count
required
integer format: int64
liked

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.

boolean
comment_count
required
integer format: int64
comments_preview

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.

Array<object>

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
id
required
string format: uuid
body
required

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.

string
created_at
required
string format: date-time
author

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
ref
required
integer format: int64
username
required

The handle. Also the profile link target (/users/by-username/{username}).

string
display_name
required

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.

string
avatar_url

Null for users with no uploaded avatar; the card renders a generated initials disc in that case.

string
nullable
remote_display_name

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.

string
nullable
tags
required
Array<string>
members
required
Array<object>

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
asset_id
required
string format: uuid
sort_order
required
integer
restricted
required

True when the caller may not see this member. asset is then absent and the client renders a placeholder tile.

boolean
owner_display_name

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”.

string
asset

ABSENT when restricted is true. Present, and complete, in every other case.

object
id
required
string format: uuid
restricted
required

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.

boolean
owner_display_name

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.

string
title
string
description
string
asset_type
integer format: int64
owner_user_ref
integer format: int64
nullable
status
string
Allowed values: draft active archived
mature

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.

boolean
ai_provenance

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.

string
nullable
Allowed values: none assisted generated
file_hash

Sha256 of the linked storage object

string
nullable
file_extension
string
nullable
file_size_bytes
integer format: int64
nullable
tags

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.
Array<string>
tag_details

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.

Array<object>

One tag with full source/confidence/provenance metadata. Backs the per-source UI badge.

object
value
required

The tag value itself (e.g. “kittens”).

string
source
required

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.

string
Allowed values: manual ai import
confidence

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).

number format: double
nullable
created_by_provider

Provider slug (openai / claude / ollama) for source=‘ai’.

string
nullable
created_by_model

Model identifier (gpt-4o / claude-3-5-sonnet) for source=‘ai’.

string
nullable
metadata

Free-form JSONB metadata blob (EXIF, custom fields, etc.).

object
key
additional properties
any
state_id

Optional workflow state (domain = ‘asset’).

string format: uuid
nullable
team_id

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.

string format: uuid
nullable
processing_status

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.

string
Allowed values: pending ready failed
preview_available

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).

boolean
ladder_available

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.

boolean
scrub_available

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”.

boolean
anonymously_visible

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.

boolean
thumbhash

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.

string
nullable
pixel_width

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.

integer format: int32
nullable
pixel_height

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.

integer format: int32
nullable
created_at
string format: date-time
updated_at
string format: date-time
deleted_at

Soft-delete timestamp. Non-null only on rows surfaced by the admin include_deleted=true listing (the trash view); null on live rows.

string format: date-time
nullable
deleted_reason

Optional reason captured at soft-delete time.

string
nullable
subtitle_tracks

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.

Array<object>

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
lang
required

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.

string
label

Optional human-readable label (“English (US)”, “Forced”, “Director’s commentary”). Empty string when not provided; never null.

string
file_hash
required

CAS hash of the stored WebVTT file.

string
source_format
required

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.

string
Allowed values: vtt srt ssa ass sub idx
confidence
required

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.

number format: float
<= 1
created_at
string format: date-time
card_fields

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.

Array<object>

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
code
required

The field definition’s federation-stable slug.

string
label
required

The operator-facing name, for the caption.

string
value
required

The value as one line. Never empty — a field with nothing on this asset is omitted from the array rather than sent blank.

string
origin

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
peer_id
required
string format: uuid
display_name
required
string
instance_url
string
owner

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
ref
required
integer format: int64
username
required

The handle. Also the profile link target (/users/by-username/{username}).

string
display_name
required

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.

string
avatar_url

Null for users with no uploaded avatar; the card renders a generated initials disc in that case.

string
nullable
origin_server_id
string format: uuid
nullable
origin

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
peer_id
required
string format: uuid
display_name
required
string
instance_url
string
team_id

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.

string format: uuid
nullable
created_at
required
string format: date-time
updated_at
required
string format: date-time
deleted_at

Soft-delete timestamp. Non-null only on rows surfaced by the admin include_deleted=true listing (the trash view); null on live rows.

string format: date-time
nullable
deleted_reason

Optional reason captured at soft-delete time.

string
nullable
next_cursor
string
nullable

Resource not found

object
error
required

Human-readable error summary

string
Example
the request could not be completed