Skip to content

Set or replace a single field value

PUT
/assets/{id}/fields/{field_id}
id
required
string format: uuid
field_id
required
string format: uuid

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
value_text
string
nullable
value_num
number
nullable
value_date
string format: date-time
nullable
value_options
Array<string>
nullable
value_ref
string format: uuid
nullable
set_by
string
default: manual
Allowed values: manual exif iptc xmp api import computed
if_unchanged_since

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.

string format: date-time
if_absent

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.

boolean

Value set

One field value as seen on an asset.

object
field_id
required
string format: uuid
field_code
required
string
field_label
string
type
required
string
Allowed values: text longtext rich_text number boolean date datetime select multi_select tree reference
value_text
string
nullable
value_num
number
nullable
value_date
string format: date-time
nullable
value_options
Array<string>
nullable
value_ref
string format: uuid
nullable
set_by
required

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.

string
Allowed values: manual exif iptc xmp api import computed default mirror
set_at
required
string format: date-time
set_by_user_ref
integer format: int64
nullable
resolved_options

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
key
additional properties

One controlled-vocabulary term as a reader needs to see it. Note there is no value — the map key is the slug.

object
label
required

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.

string
status
required

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.

string
Allowed values: active deprecated archived
path

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.

Array<string>
nullable
resolved_reference

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

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.

string format: uuid
title
required

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.

string

Malformed request

object
error
required

Human-readable error summary

string
Example
the request could not be completed

Authentication required, missing, or invalid

object
error
required

Human-readable error summary

string
Example
the request could not be completed
Example
{
"error": "authentication required: sign in and retry with a valid session or API token"
}

Authenticated but missing required capabilities

object
error
required

Human-readable error summary

string
Example
the request could not be completed

Resource not found

object
error
required

Human-readable error summary

string
Example
the request could not be completed

The 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
error
required
string
field_id
required

The field whose value was not written.

string format: uuid
present
required

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.

boolean
current
required

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
field_id
required
string format: uuid
field_code
required
string
field_label
string
type
required
string
Allowed values: text longtext rich_text number boolean date datetime select multi_select tree reference
value_text
string
nullable
value_num
number
nullable
value_date
string format: date-time
nullable
value_options
Array<string>
nullable
value_ref
string format: uuid
nullable
set_by
required

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.

string
Allowed values: manual exif iptc xmp api import computed default mirror
set_at
required
string format: date-time
set_by_user_ref
integer format: int64
nullable
resolved_options

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
key
additional properties

One controlled-vocabulary term as a reader needs to see it. Note there is no value — the map key is the slug.

object
label
required

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.

string
status
required

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.

string
Allowed values: active deprecated archived
path

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.

Array<string>
nullable
resolved_reference

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

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.

string format: uuid
title
required

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.

string

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_optionselect / multi_select / tree only: 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_referencereference only: value_ref names 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 hold fields.vocabulary.extend.
  • field_read_only — the field declares read_only, so a person may not set or clear its values.
  • pattern_mismatch — the value does not match the field’s regexp_filter across its whole length.
  • field_required — the field declares required, 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
error
required
string
reason
required
  • 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_optionselect / multi_select / tree only: the slug is not a term in the field’s controlled vocabulary. For tree the 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_referencereference only: value_ref names 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_reference simply 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 hold fields.vocabulary.extend. Distinct from unknown_option on 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 declares read_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 a regexp_filter and 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 declares required, 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; a multi_select with no members; a NULL value_num, value_date or value_ref; and for rich_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_missing refusal. Human writes only — upload defaults, extraction and the mirrored-column filler are unaffected, exactly as with field_read_only.

string
Allowed values: field_not_for_collection value_type_mismatch unknown_option option_not_offerable dangling_reference vocabulary_extension_forbidden field_read_only pattern_mismatch field_required
field

The code of the field that refused the value.

string
nullable
option

The offending slug, for the two vocabulary reasons. Null otherwise.

string
nullable