Update a field definition (admin)
PATCH /fields/{id}
Partial update. Optimistic concurrency: pass the field’s
last-known updated_at as if_unchanged_since; 409 if the
row has been edited since. Required in spirit for the options
editor per ADR 0012 — editing one term rewrites the whole
options document, so an unguarded write silently discards a
concurrent editor’s curation. Omit to opt out (legacy
last-write-wins).
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Path Parameters
Section titled “Path Parameters ”Request Body required
Section titled “Request Body required ”Partial update; only present fields are touched.
object
Optimistic-concurrency guard (ADR 0012). When set, the
server returns 409 EditConflict and writes nothing if the
field’s updated_at has moved past this value. Omit to
opt out of the check (legacy behaviour).
Type-dependent constraints. For select / multi_select /
tree this is {"values": [...]} where each entry is
either a bare slug string or an object
{value, label?, status?, replaced_by?, aliases?, children?}.
status is one of active | deprecated | archived and
defaults to active when absent; replaced_by must name
another slug in the same field. aliases are extra
write-time match keys — see FieldDefinition.options. Entries carrying nothing
beyond their slug serialise back as bare strings, so
documents written before options had a lifecycle
round-trip unchanged. Options are never hard-deleted —
retire them with deprecated or archived.
object
Let values create terms this field does not have yet. See
FieldDefinition.open_vocabulary — honoured for
multi_select only, inert elsewhere.
Show this field at a glance on an asset card. See
FieldDefinition.show_on_card. Refused with 400 on a field
carrying a read_capability, and refused in the same way
when this request would give a carded field one.
Offer this field as a filter control on the advanced search
page. See FieldDefinition.show_in_advanced_search. Governs
the control only — it does not touch searchable and
changes no query result.
Offer this field on the upload / create surface. See FieldDefinition.show_on_upload.
Assign this field to a tab of the edit surface. Omit to
leave it alone. Use clear_edit_tab: true to unassign it —
a partial update cannot express “unassign” by sending null,
because null is already “leave alone” for every other
property here. Blank or whitespace-only is refused with 400.
Unassign this field’s edit tab. Mutually exclusive with edit_tab.
Say when this field should be offered (#1173, #1119,
ADR 0099). Omit to leave it alone. Use
clear_display_condition: true to remove one; a partial
update cannot express “remove” by sending null, because null
is already “leave alone” for every other property here, and it
cannot express it by sending [] either, because the storage
CHECK refuses the empty array as a second spelling of unset.
REPLACES THE WHOLE ARRAY. There is no merge and no way to add a single term: a condition is one predicate, not a bag of independent settings, and an operator editing one is editing a sentence.
See FieldDefinition.display_condition for the term grammar,
the operator and type matrix, and the runtime semantics.
Refused with 400, each with a sentence naming what is wrong:
- a term that does not parse, or that names a facet dimension other than a field
- an operator the controller’s type does not accept, and
>=or<=in any pairing - a controller code this server does not have
- a controller describing the other subject kind
- a condition on a MIRRORED definition, or a term naming one
- a term naming the field itself, or any cycle in the
subject-kind graph. The walk covers the WHOLE graph, so
A -> BthenB -> CthenC -> Ais refused on the third write and not before - an EMPTY total applies_to intersection across the dependent
and EVERY controller, with empty treated as universal. This
is an N-way intersection rather than a pairwise check:
dependent
{t1,t2}with controllers{t1}and{t2}is acceptable one controller at a time and refused with both - two
=terms with different literals on one SINGLE-VALUEDtext/longtext/select/treecontroller, which can never both be true. Duplicate identical terms are allowed, and so are distinct=membership terms on onemulti_select, which can genuinely hold both. There is no general contradiction solver and there is not meant to be - a controller that is ALREADY ARCHIVED.
deprecatedis accepted, because edit surfaces deliberately render active and deprecated definitions together. Archiving a controller LATER never rewrites or clears a stored condition: the dependent fails open, and restoring the controller resumes ordinary evaluation
Remove this field’s display condition, restoring “always
offered”. Mutually exclusive with display_condition; sending
both is a 400.
Deliberately unguarded by the refusal list above, the way
clear_regexp_filter is: those rules restrict a CONFIGURED
condition, and taking one back off is legal on every field, so
a setting can always be removed even where it could no longer
be applied.
Refuse human writes to this field’s values. See
FieldDefinition.read_only for what that does and does not
stop. Refused with 400 on a field carrying
mirrors_column.
Set the pattern a human-supplied value must match. See FieldDefinition.regexp_filter for the syntax and the anchoring. Omit to leave the current setting alone.
Four refusals, all 400:
- a blank string. Use
clear_regexp_filter: trueto remove a pattern — a partial update cannot express “remove” by sending null, because null is already “leave alone” for every other property here, and""is precisely the state that would make “no pattern” ambiguous if it were stored. Note that BLANK here means genuinely empty: a whitespace-only pattern is a valid pattern and is accepted, unlikeedit_tab, which is a label rather than a pattern and is trimmed before its own blank check. - a pattern that does not compile as Go RE2. The server’s message names the syntax error.
- a non-empty pattern on a type that does not honour one —
anything other than
textorlongtext. - a non-empty pattern on a field carrying
mirrors_column.
The last two restrict the CONFIGURED PATTERN only.
clear_regexp_filter: true is accepted on every field,
including mirrored ones and unsupported types, so a setting
can always be taken back off.
Remove this field’s pattern, restoring “no constraint”.
Mutually exclusive with regexp_filter; sending both is a
400. Always permitted, on every field and every type.
Fold this field’s text into the full-text search index. See
FieldDefinition.searchable — it governs the INDEX only, does
not disable an explicit filter=field:… predicate, and is
independent of show_in_advanced_search. Changing it
re-derives the affected documents synchronously as part of
this request.
Sets this field’s upload default. Omit to leave it alone.
Use clear_default: true to remove it — a partial update
cannot express “remove” by sending null, because null is
already “leave alone” for every other property here.
object
Required when kind is context, forbidden otherwise.
uploading_user— the uploader’s display name. Text types only.uploading_team— the name of the team the upload belongs to. Text types only. Not applied when the uploader belongs to no team, or to more than one.current_date— creation time. Date types only; adatefield gets the day, adatetimethe instant.
The set is closed. It carries no “target collection”
because POST /assets has no collection in scope — see the
2026-07-31 defaults amendment to ADR 0081.
Remove this field’s upload default. Mutually exclusive with default_value.
Responses
Section titled “ Responses ”Updated
object
Federation-stable slug. Globally unique.
What the field describes. asset definitions apply to
individual assets; collection definitions apply to whole
collections. One row is one or the other — operators who
want a field on both kinds create two rows.
Type-dependent constraints / values. Shape per ADR 0012.
For select / multi_select / tree: {"values": [...]}
where each entry is a bare slug string, or an object
{value, label?, status?, replaced_by?, aliases?, children?}.
An entry with no status is active. Entries that are
deprecated or archived must not be offered for new
values; deprecated ones still resolve and display on
assets that already carry them.
aliases are extra match keys (ADR 0092 §4): a value
naming an alias stores the term’s own slug instead. The
redirect applies to FUTURE writes only and is undone by
removing the alias — nothing stored is rewritten. Aliases
are normalised to lowercase and must not collide with any
slug, label or other alias in the same field, since an
ambiguous key has no correct resolution. A real slug or
label always wins over an alias.
An archived entry carrying replaced_by is a MERGE
TOMBSTONE rather than a plain retire, and behaves
differently on write: naming it resolves to the successor
instead of being refused, so a value that predates the
merge still lands somewhere real.
NOTE: a client must not treat this document as the way to
OFFER values at production size — see
GET /fields/{id}/values, which is the contract. This stays
here because a read surface resolving a handful of stored
slugs needs the labels, and because a small vocabulary may
legitimately be shipped whole.
children nests the vocabulary and is what makes a field
of type tree hierarchical. Slugs must be unique across
the WHOLE tree, not merely within a level — the server
rejects a duplicate at any depth — which is what lets a
stored value be a single leaf slug rather than a path.
object
When true, a value naming a term this field does not have CREATES the term instead of being refused: the text becomes the label and its slugified form becomes the stored value. When false (the default) an unknown term is a 422.
Matching before creating is on slug OR label,
case-insensitive and whitespace-trimmed, so Character,
character and character all name one term rather than
three.
Honoured for multi_select only. The flag is legal on any
type and inert on the rest — opening select or tree is a
later decision, and tree additionally has to say WHERE in
the hierarchy a new term lands.
Fold this field’s text into the FULL-TEXT SEARCH INDEX.
true by default.
That is the whole of what it governs.
rebuild_asset_search_text() is its only consumer: with it
off, the field’s values stop contributing to
assets.search_text, so a bare word typed into the search
box no longer finds a record because of THIS field.
It does NOT make the field unfilterable. An explicit
structured predicate — filter=field:<code><op><value> —
is independent of this flag and keeps working on any
ACTIVE field the caller may read. Requiring both was a
conflation, and it made a well-formed filter on a
non-indexed field return an empty result rather than a
refusal (#1173, sprint 18d).
Independent again from show_in_advanced_search, which
decides whether the advanced page draws a CONTROL for the
field and touches no index and no query result.
Changing it re-derives the affected documents synchronously, field-scoped, as part of the update: an operator who unticks it does not have to reindex anything for search to start obeying them.
Resource type refs this field applies to. Empty = all. Ignored when subject_kind = ‘collection’.
CanonicalField the metadata-extraction pipeline writes into this field. Empty = no extraction (the field stays operator-managed). Edited via the dedicated PUT …/extraction endpoint.
Write behaviour when extraction has a value. Empty = default (skip_if_set).
Render this field at a glance on an asset card (#552).
A DISPLAY HINT, in the same class as display_order and
display_group: nothing may gate access, filtering or
correctness on it, and a client that ignores it entirely
must still be correct, merely plainer. With no field
marked, a card falls back to its own default.
It FEDERATES with the definition. ADR 0083 leaves a property out of a schema envelope when it “names something that exists only on the sender”; this names the field, not the server, so a peer’s fields render the way that peer meant them to.
Refused on a field carrying a read_capability. A card is
rendered on browse, for a page of assets, where the server
has evaluated no per-field capability — so the combination
is a 400 rather than a setting that silently does nothing.
The refusal holds in both directions: a field already on
the card cannot acquire a read capability either.
Offer this field as a filter control on the advanced search page (ADR 0092 §3, #1173).
A PARTICIPATION flag: it answers “does the operator want
this field on that page”, which is a different question
from searchable (“does this field’s text feed the search
index”) and from type (“what does a value look like”).
Before this flag existed every surface guessed from those
two, which is why an install with 200 fields got 200
filters and nobody could say otherwise.
true by default, so a field that has never been
configured appears exactly where it appears today. Turning
it off removes the CONTROL and nothing else: the field’s
values still feed the index, still match a text search, and
still filter when a caller composes
filter=field:<code>=<value> directly.
searchable: false is a separate setting with a NARROWER
meaning than “unfindable”: it takes the field’s text out of
the FULL-TEXT SEARCH INDEX, which is the only thing that
flag governs. rebuild_asset_search_text() is its sole
consumer. It does NOT disable an explicit
filter=field:<code><op><value> predicate. Any ACTIVE
field the caller may read can be named by a structured
field: filter whether or not its values participate in
the text index (#1173, sprint 18d). Requiring both was a
conflation, and it made a well-formed filter on a
non-indexed field return an empty result rather than a
refusal.
The read-capability gate composes on top of both flags and
wins: a field marked for the page is still withheld from a
caller who may not read it, on the page and in
facet.Selection.Authorize, which is also where a
structured field: term is refused.
It FEDERATES with the definition — it names the field, not the server (ADR 0083’s exclusion criterion).
Offer this field on the upload / create surface (ADR 0092
§3, #1173). true by default, because the upload composer
rendered every active field for the asset type before this
flag existed.
OBEYED, as of #1119, by the full-page create surface: that
page asks for the asset type the server actually assigned to
the uploaded file and renders only the fields whose flag is
true. The older upload MODAL still renders every field —
both surfaces coexist, the modal being the quick path — so
false is honoured on the create page and not yet in the
modal.
It remains a form-composition hint and never an access
control: nothing about field VALUES changes either way, and
a hidden field can still be written through
PUT /assets/{id}/fields/{field_id}.
Not mutually exclusive with required, because required-ness
is enforced on the value-write path and not at asset
creation. FEDERATES with the definition.
Which tab of the edit surface this field sits in (ADR 0092
§3, #1173). null (the default) = unassigned, which is
today’s behaviour: no surface has tabs yet and fields group
by display_group.
A COARSER grouping than display_group, not a replacement
for it — a tab holds groups. The empty string is refused on
write, so “no tab” has exactly one representation and a
form posting a blank input cannot invent a third state.
Consumed by #1119. FEDERATES with the definition.
Refuse HUMAN writes to this field’s values (#1173).
false by default, which is today’s behaviour. When true,
an authenticated caller cannot set or clear a value of this
field — on an asset or on a collection — whatever
capabilities they hold. Existing values are left exactly as
they are; this is a rule about who may WRITE, not a freeze
on what is stored.
It stops PEOPLE and nothing else. Upload defaults, the
metadata-extraction pipeline and the mirrored-column filler
keep writing, because a field an operator marks read-only is
normally one they mean the system to own. Those writers are
distinct server-side call sites with no operation of their
own in this document, so the exemption cannot be claimed by
a client: there is no “this is a system write” flag to send,
and set_by records provenance after the fact rather than
deciding anything.
ASSET fields refuse immediately, including where the field
holds no value yet. POST /assets writes no field values
at all — AssetCreate.metadata is a free-form document on
the asset row — so there is no human first-write to protect.
COLLECTION fields have one: field_values on
CollectionCreate seeds initial values inside the create
transaction, and that seed is ALLOWED. Every later
PUT / DELETE on /collections/{id}/fields/{field_id}
is refused.
Refused at configuration time on a field carrying
mirrors_column: those are views onto an assets column
that POST /assets and PATCH /assets/{id} also write, and
only one of the two planes would obey the flag.
Does NOT federate. It is an access rule, the same class ADR
0083 keeps out of a field-schema envelope alongside
read_capability / write_capability.
Pattern a HUMAN-supplied value of this field must match
(#1173). null (the default) means no constraint, and it is
the ONLY representation of that state — see
FieldDefinitionUpdate.regexp_filter for how a pattern is
set, and clear_regexp_filter for how one is removed.
Go RE2 syntax. The server anchors it: a value matches when
it matches \A(?:<pattern>)\z, so the pattern always
describes the WHOLE value. Do not write ^…$ — those are
LINE anchors as soon as a pattern turns on (?m), and they
would bind to only the outer branches of a top-level
alternation. a|b therefore means “the whole value is a, or
the whole value is b”, which is what an operator writing it
expects.
Stored verbatim. Nothing trims or rewrites it, because
whitespace inside a pattern is meaningful: \A(?: )\z
legitimately matches exactly three spaces.
Honoured for text and longtext. Configuring a non-empty
pattern on any other type is refused, rich_text included:
that column holds server-sanitised HTML, so a pattern would
be matched against markup rather than against anything the
operator can see.
It validates INPUT, not the stored row. The same system
writers read_only exempts are not checked, so a value the
extraction pipeline produced may legitimately fail a pattern
a person would be held to. Nothing rewrites values already
stored when a pattern is configured or changed.
Refused at configuration time on a field carrying
mirrors_column, for the reason given under read_only.
FEDERATES with the definition: it describes the field, not the server.
When set, this field is a VIEW onto that column of the
assets row rather than storage of its own (#822): reading
it projects the column, writing it updates the column, and
it can hold no value of its own — so the field and the
column cannot disagree. title and description ship this
way; null (the default) is an ordinary field.
Two consequences for a client:
- A write to a mirrored field is a write to the ASSET and
is gated as one — the caller needs the same authority a
PATCH /assets/{id}would demand, not merely a session. - A surface that already renders the column with a first-class control (the upload form’s title box, an asset header) should skip the mirrored field rather than offer a second editor for the same value.
Read-only over the API, deliberately: which columns are mirrorable is a schema decision enforced by a CHECK constraint, not something an operator can retarget at a column that may not exist. It is also LOCAL — it names a column of this server’s schema — so per ADR 0083’s exclusion criterion it does not travel in a federated field-schema envelope.
When this field should be OFFERED at all (#1173, #1119,
ADR 0099). null (the default) means always, and it is the
ONLY representation of that state: the storage CHECK refuses
[], {}, "" and JSON null alike, so no reader has to
know a second spelling of unset.
Each entry is a bare <code><op><value> term with NO field:
prefix, and the entries are combined with AND. Terms use
the EXISTING search term grammar (facet.SplitFieldTerm);
the search grammar is not extended for this feature, and
only field:-class terms are admissible.
A FORM HINT and never authorization. It decides whether a
CONTROL is drawn, and nothing about access, filtering,
indexing or write validity. A hidden field keeps its values,
keeps its read_capability and write_capability, and can
still be written through
PUT /assets/{id}/fields/{field_id}. A client that ignores
this property entirely is still correct, merely plainer.
Parsing, and the one asymmetry that will surprise an
operator. The parser lowercases and trims the CODE, matches
operators longest-first, splits on the first of =~<> and
keeps later operator characters in the value, and TRIMS the
parsed value. Nothing ever trims or case-folds the STORED
value. So work_type= Commission compares the literal
Commission against the stored value exactly and
case-sensitively, and a stored " Commission " does NOT
match. ~ is a case-insensitive substring test instead.
Operators by type. text and longtext accept = and
~. select and tree accept = against the stored slug.
multi_select accepts = as MEMBERSHIP. rich_text,
boolean, number, date, datetime and reference accept
neither. >= and <= are refused in every pairing: they are
range bounds, which is a filtering question rather than a
composition one.
boolean stays excluded even though sprint 20a gave it a
three-state control. That changed the CONTROL, not the
REPRESENTATION: a boolean is still 1 or 0 in value_num, and
admitting it here would require a search-engine change. Do
not widen this table by widening search semantics.
Evaluation is conjunctive with a whole-condition fail-open. All terms true shows the field; any term false hides it. If ANY term is unevaluable, because the controller’s definition is missing or unresolvable or because the caller may not read it, the WHOLE condition has no verdict and the field is SHOWN. This is NOT “unknown counts as true inside the AND”: with one term false and another unevaluable, an AND would still hide, and that is the bug. A readable controller with genuinely no value is a real FALSE and still hides.
Hiding destroys nothing. No Set, no Clear and no empty row
is generated, the persisted value is untouched, an unsaved
draft survives and reappears on reveal, and a hidden
required field creates no new completeness or save gate
anywhere.
Set through FieldDefinitionUpdate.display_condition and
removed through clear_display_condition; it is deliberately
absent from FieldDefinitionCreate, because a create body
cannot reference a graph that does not exist yet.
FEDERATES with the definition (ADR 0083, amendment 2026-09-03): it names the field rather than the server. It is the first such property to reference a SECOND field, so a term whose referent has not arrived is preserved verbatim and fails open at runtime.
Applied when an asset is created and this field would
otherwise be empty. null = no default. Federates with the
field definition; per-team overrides live at
/fields/{id}/default-overrides and do not.
object
Required when kind is context, forbidden otherwise.
uploading_user— the uploader’s display name. Text types only.uploading_team— the name of the team the upload belongs to. Text types only. Not applied when the uploader belongs to no team, or to more than one.current_date— creation time. Date types only; adatefield gets the day, adatetimethe instant.
The set is closed. It carries no “target collection”
because POST /assets has no collection in scope — see the
2026-07-31 defaults amendment to ADR 0081.
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 completedEdited by someone else after if_unchanged_since.
Returned with HTTP 409 when an optimistic-concurrency check
fails. Carries the row’s current updated_at so the
client can decide whether to reload + retry or surface a
diff UI.
object
Human-readable summary (e.g. “asset was edited by someone else”).
The current updated_at on the row (server-authoritative).