Set or replace a single field value on a collection
PUT /collections/{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.
Mismatches return 422 with value_type_mismatch — this
description said 400 until #778, but the operation has only
ever declared (and only ever returned) 422. The asset-side
equivalent does return 400; the two paths differ.
The mapping matches AssetFieldValueWrite’s exactly — every
type, boolean and tree included, stores in the same column
with the same encoding on both sides.
boolean collection values were the strings “true” / “false”
in value_text until #791, where the asset path had always
used 0 / 1 in value_num. This path now takes value_num and
rejects anything but 0 or 1, the same contract the asset side
enforces.
A tree value is ONE option slug in value_text. Until the
2026-07-31 tree-storage amendment to ADR 0012 this path wrote
value_options while the asset path wrote value_text, so the
two sides put the same field’s value in different columns.
object
PER-FIELD optimistic concurrency (#1119). The token is THIS
VALUE ROW’S OWN set_at, taken from the last
CollectionFieldValue 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
CollectionFieldValueConflict 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.
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
CollectionFieldValueConflict (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.
Responses
Section titled “ Responses ”Value set
One field value as seen on a collection. Mirrors
AssetFieldValue but for the collection subject_kind; the
set_by vocabulary is narrower (no exif/iptc/xmp — collections
aren’t files).
object
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 is absent and the caller falls back to the slug itself. Mirrors AssetFieldValue.resolved_options exactly (#840): collection metadata resolves the same way asset metadata does, so a select value renders its label and not its raw slug.
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.
The collection twin of AssetFieldValueConflict, with the same
contract: nothing was stored, current is required and
nullable, and present: false carries current: null with no
fabricated set_at. Two schemas rather than one because
current is a different value shape on each subject kind.
object
The value as stored now, or JSON null when present is
false.
object
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 is absent and the caller falls back to the slug itself. Mirrors AssetFieldValue.resolved_options exactly (#840): collection metadata resolves the same way asset metadata does, so a select value renders its label and not its raw slug.
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.