Set or replace a single field value
PUT /assets/{id}/fields/{field_id}
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Path Parameters
Section titled “Path Parameters ”Request Body required
Section titled “Request Body required ”Pass whichever value_* field matches the target field’s type. The handler validates against field_definition.type and rejects mismatches with 400.
The mapping is: text / longtext / rich_text / select / tree →
value_text; number / boolean → value_num; date / datetime →
value_date; multi_select → value_options; reference →
value_ref.
A tree value is ONE option slug in value_text — the node
being selected — not a path string and not an array. Slugs are
unique across the whole option tree, so the node’s own slug
addresses it; the server reassembles the ancestor labels into
resolved_options[slug].path on read. See the 2026-07-31
tree-storage amendment to ADR 0012.
A boolean value is the number 1 or 0 in value_num; any
other number is rejected. Not the strings “true” / “false” in
value_text — that shape was what the collection path and
every display surface used until #791, and it was never what
this endpoint accepted.
object
PER-FIELD optimistic concurrency (#1119). The token is THIS
VALUE ROW’S OWN set_at, taken from the last
AssetFieldValue the caller read or was returned. The write
is applied only if the stored row still carries exactly that
set_at; otherwise the operation answers 409 with
AssetFieldValueConflict and stores nothing.
The guard is FIELD-LOCAL on purpose. It is never the asset’s
updated_at: two people editing two different fields of one
asset are not in conflict, and a subject-level token would
make them so. Every write to a value row advances its
set_at, on INSERT and on UPDATE alike, so the token the
caller holds is exactly the version it read.
Sending this where NO row exists is a 409, not an
insert. A timestamp is a claim that a particular version is
still there; if the row was cleared meanwhile, resurrecting
it silently is the update that was refused wearing a
disguise. Use if_absent for a genuine first write.
Mutually exclusive with if_absent; sending both is a 400.
Sending NEITHER is the unguarded last-write-wins behaviour
this endpoint has always had, which the upload flush depends
on and which is unchanged.
Refused with 400 on a MIRRORED field (mirrors_column):
those values are the asset’s own columns, so their
concurrency lives on the asset plane’s
AssetUpdate.if_unchanged_since and not here.
The first-write half of the guard: apply this write only if
NO value row exists for this field on this subject. If one
does, the operation answers 409 with
AssetFieldValueConflict (present: true) and stores
nothing.
This is what an editor sends for a field it loaded with no value. It is never a timestamp: inventing one for a row that does not exist would be a guard against a version nobody wrote.
Mutually exclusive with if_unchanged_since; sending both
is a 400. Refused with 400 on a mirrored field.
Responses
Section titled “ Responses ”Value set
One field value as seen on an asset.
object
Where this value came from. default means an upload
default put it here and nothing has improved on it since —
extraction is free to overwrite it, and a human editing the
field replaces it with manual. It is the one provenance a
client cannot claim: AssetFieldValueWrite.set_by has no
default member, because a value a caller chose to send is
by definition not one nobody chose.
mirror means the field declares mirrors_column and this
value IS the asset column — nothing was stored under the
field. It is not a claim about who last edited it, because
the column carries no such record; set_at is the asset’s
updated_at and set_by_user_ref is always null. Like
default it is server-only: a caller cannot send it.
Display data for the vocabulary slugs this value holds, keyed by slug. Present only for select / multi_select / tree, and only for slugs that resolve against the field’s options document — a slug with no entry (the vocabulary dropped it, or the value predates it) is simply absent, and callers must fall back to rendering the slug itself.
asset_field_value stores the slug and never the label (ADR 0012) precisely so relabelling a term rewrites nothing. The cost of that indirection is that a reader has to resolve; this map is where the server pays it, once, for every consumer, so no display surface has to hold the field definition just to print a value.
object
One controlled-vocabulary term as a reader needs to see it.
Note there is no value — the map key is the slug.
object
Display text. Equal to the slug when the term carries no explicit label, which is the case for every option written in the bare-string form (see ADR 0012’s 2026-07-30 correction) — so a caller can always render this directly.
The term’s lifecycle state. A reader should mark anything other than active, so a value stops looking current the moment an operator retires the term behind it.
Ancestor labels from the root of the field’s vocabulary down
to and including this term — what a tree value needs in
order to display as “Europe / United Kingdom / London” when
the stored value is nothing but london.
Absent for any term that sits at the top level, which is
every term in a flat select / multi_select vocabulary: a
one-element path would say nothing label does not. So a
caller renders path.join(...) when it is present and
label when it is not.
The asset a reference value points at, as a reader needs to
see it. Present only for reference values whose value_ref
resolves to an asset this caller may see.
Absent is meaningful and load-bearing: it means the target
did not resolve — soft-deleted, or a dangling ref to a row that
no longer exists. A client MUST fall back to rendering the bare
value_ref UUID and MUST NOT treat the absence as an error.
Degrading to the id keeps the panel intact and leaks nothing:
the id was already on the record the caller is reading.
This mirrors resolved_options exactly, and for the same
reason (#775): the row carrying the value already joins the row
carrying the display text, so the server pays the resolution
once for every consumer rather than letting each display
surface print a raw UUID.
object
The target asset’s id. Equal to the enclosing value’s
value_ref — repeated here so a client holding a
resolved_reference never has to reach back out of it to
build the /assets/{id} link.
The target asset’s title. May be the empty string —
assets.title defaults to '' and nothing requires an
uploader to set one — so a client renders the id when this
is blank rather than an empty link.
Malformed request
object
Human-readable error summary
Example
the request could not be completedAuthentication required, missing, or invalid
object
Human-readable error summary
Example
the request could not be completedExample
{ "error": "authentication required: sign in and retry with a valid session or API token"}Authenticated but missing required capabilities
object
Human-readable error summary
Example
the request could not be completedResource not found
object
Human-readable error summary
Example
the request could not be completedThe guarded write was refused: the stored value is not the version the caller guarded against, and nothing was stored.
A guarded field-value write was refused because the stored row is not the version the caller guarded against (#1119).
Nothing was stored. The body carries what the caller needs to
reconcile without a second round trip: whether a value is there
NOW, and if so what it is, including the set_at a retry would
guard against.
current is REQUIRED and NULLABLE, so the key is ALWAYS
present. present: false with current: null is the cleared
case, and a client re-baselines it to if_absent: true; an
omitted key would be indistinguishable from a server that
forgot to send one. There is deliberately no fabricated
set_at for a row that does not exist.
object
The field whose value was not written.
Whether a value row exists for this field on this subject right now. True means somebody else’s write is there; false means the value was removed.
The value as stored now, or JSON null when present is
false. Its set_at is the token a follow-up guarded write
should carry.
object
Where this value came from. default means an upload
default put it here and nothing has improved on it since —
extraction is free to overwrite it, and a human editing the
field replaces it with manual. It is the one provenance a
client cannot claim: AssetFieldValueWrite.set_by has no
default member, because a value a caller chose to send is
by definition not one nobody chose.
mirror means the field declares mirrors_column and this
value IS the asset column — nothing was stored under the
field. It is not a claim about who last edited it, because
the column carries no such record; set_at is the asset’s
updated_at and set_by_user_ref is always null. Like
default it is server-only: a caller cannot send it.
Display data for the vocabulary slugs this value holds, keyed by slug. Present only for select / multi_select / tree, and only for slugs that resolve against the field’s options document — a slug with no entry (the vocabulary dropped it, or the value predates it) is simply absent, and callers must fall back to rendering the slug itself.
asset_field_value stores the slug and never the label (ADR 0012) precisely so relabelling a term rewrites nothing. The cost of that indirection is that a reader has to resolve; this map is where the server pays it, once, for every consumer, so no display surface has to hold the field definition just to print a value.
object
One controlled-vocabulary term as a reader needs to see it.
Note there is no value — the map key is the slug.
object
Display text. Equal to the slug when the term carries no explicit label, which is the case for every option written in the bare-string form (see ADR 0012’s 2026-07-30 correction) — so a caller can always render this directly.
The term’s lifecycle state. A reader should mark anything other than active, so a value stops looking current the moment an operator retires the term behind it.
Ancestor labels from the root of the field’s vocabulary down
to and including this term — what a tree value needs in
order to display as “Europe / United Kingdom / London” when
the stored value is nothing but london.
Absent for any term that sits at the top level, which is
every term in a flat select / multi_select vocabulary: a
one-element path would say nothing label does not. So a
caller renders path.join(...) when it is present and
label when it is not.
The asset a reference value points at, as a reader needs to
see it. Present only for reference values whose value_ref
resolves to an asset this caller may see.
Absent is meaningful and load-bearing: it means the target
did not resolve — soft-deleted, or a dangling ref to a row that
no longer exists. A client MUST fall back to rendering the bare
value_ref UUID and MUST NOT treat the absence as an error.
Degrading to the id keeps the panel intact and leaks nothing:
the id was already on the record the caller is reading.
This mirrors resolved_options exactly, and for the same
reason (#775): the row carrying the value already joins the row
carrying the display text, so the server pays the resolution
once for every consumer rather than letting each display
surface print a raw UUID.
object
The target asset’s id. Equal to the enclosing value’s
value_ref — repeated here so a client holding a
resolved_reference never has to reach back out of it to
build the /assets/{id} link.
The target asset’s title. May be the empty string —
assets.title defaults to '' and nothing requires an
uploader to set one — so a client renders the id when this
is blank rather than an empty link.
The request is well-formed but the value cannot be stored.
ONE response shared by the asset and the collection field-value
writers, so the two cannot drift into describing the same
refusal differently. field is the field’s code and option
the slug that was refused, so a client can point at the
offending term without parsing error.
field_not_for_collection— the field is asset-scoped.value_type_mismatch— the value is in the wrong member for the field’s declared type.unknown_option—select/multi_select/treeonly: the slug is not in the field’s controlled vocabulary.option_not_offerable— the slug names a deprecated or archived term the record did not already hold. Terms are never hard-deleted, so a value already carrying a retired slug keeps saving; only changing to one is refused.dangling_reference—referenceonly:value_refnames an asset that does not resolve. Refused on write; a value whose target is deleted after a valid write still reads (bare id).vocabulary_extension_forbidden— the field is open and the term would have been created, but the caller does not holdfields.vocabulary.extend.field_read_only— the field declaresread_only, so a person may not set or clear its values.pattern_mismatch— the value does not match the field’sregexp_filteracross its whole length.field_required— the field declaresrequired, so a person may neither write an empty value into it nor remove the value it holds (#1389).
Why a well-formed field-value write could not be stored.
ONE body shared by the asset and the collection writers (both
return it via the FieldValueUnprocessable response), so the two
cannot drift into describing the same refusal differently.
field is the field’s code and option the slug that was
refused, so a client can point at the offending term without
parsing error.
object
-
field_not_for_collection— the field is asset-scoped and cannot be written on a collection. -
value_type_mismatch— the value arrived in the wrong value_* member for the field’s declared type. -
unknown_option—select/multi_select/treeonly: the slug is not a term in the field’s controlled vocabulary. Fortreethe whole option tree is searched, not just the top level, and a branch slug is as valid as a leaf. -
option_not_offerable— the slug names a deprecated or archived term that the record did not already hold. Options are never hard-deleted, so a record already carrying a since-retired term keeps saving; only changing to one is refused. -
dangling_reference—referenceonly:value_refnames an asset that does not resolve (never existed, or has been deleted). Refused on WRITE so a value cannot be pointed at a nonexistent asset. Note the asymmetry with READ: a value that resolved when written and whose target is deleted later still reads fine, degrading to the bare id (resolved_referencesimply absent) — the write gate does not retroactively invalidate it. -
vocabulary_extension_forbidden— the field IS open, and the term would have been created, but this caller does not holdfields.vocabulary.extend. Distinct fromunknown_optionon purpose: the term is absent in both cases, but here the fix is a grant rather than a correction, and a client that cannot tell them apart cannot say which. Reported per TERM rather than as a bare 403 because a write may carry five terms of which four already exist — naming the one that was refused is strictly more useful than refusing the principal. -
field_read_only— the field declaresread_only, so a person may not set or clear its values (#1173). NOT a 403: no capability grants this and no grant would lift it, so reporting it as a permission problem would send the operator hunting for a role that does not exist. The field’s configuration is the thing to change. -
pattern_mismatch— the field declares aregexp_filterand the supplied value does not match it across its whole length. Applies to human writes only; defaults and extraction are not checked, so a stored value may legitimately fail the pattern a new one is held to. -
field_required— the field declaresrequired, so a person may neither write an EMPTY value into it nor remove the value it holds (#1389). What “empty” means is per type: NULL or whitespace-only text; amulti_selectwith no members; a NULLvalue_num,value_dateorvalue_ref; and forrich_text, markup carrying no visible words, since the sanitiser strips no empty elements and<p><br></p>would otherwise pass. FALSE is a real boolean value and is never empty.The rule is the LATER-write rule and applies to the ordinary asset and collection field-value paths. It is not a completeness gate: asset creation still requires nothing, and collection create keeps its own separate
required_collection_field_missingrefusal. Human writes only — upload defaults, extraction and the mirrored-column filler are unaffected, exactly as withfield_read_only.
The code of the field that refused the value.
The offending slug, for the two vocabulary reasons. Null otherwise.