Create a new field definition (admin)
POST /fields
Authorizations
Section titled “Authorizations ”Request Body required
Section titled “Request Body required ”object
What the field describes. Defaults to asset for backwards compatibility.
object
Let values create terms this field does not have yet. See
FieldDefinition.open_vocabulary — honoured for
multi_select only, inert elsewhere.
Upload default. Validated against type and options on write.
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.
Responses
Section titled “ Responses ”Field created
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 completedUnexpected server error
object
Human-readable error summary
Example
the request could not be completed