Skip to content

Configure a field's metadata-extraction source + mode

PUT
/fields/{id}/extraction

Sets which extraction-source canonical field this field-definition receives values from + the apply mode. Empty source clears extraction. Empty mode defaults to skip_if_set. Edits invalidate the metadata.extraction_ config cache so the next extract job sees the new wiring.

Gated on the same admin capability as updateField.

id
required
string format: uuid

Per-field extraction wiring. Empty source clears extraction; the operator can then re-attach it later without losing the field-definition’s other settings.

object
source
required

Canonical field name the extractor produces (e.g. capture_datetime, camera_make, gps_coordinates). Empty disables extraction for this field.

string
mode

Apply behaviour. Empty defaults to skip_if_set — the conservative choice that never clobbers operator work.

string
Allowed values: "" skip_if_set replace append prepend

Updated field definition.

object
id
required
string format: uuid
code
required

Federation-stable slug. Globally unique.

string
label
required
string
description
string
type
required
string
Allowed values: text longtext rich_text number boolean date datetime select multi_select tree reference
subject_kind
required

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.

string
Allowed values: asset collection
options

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

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.

boolean
required
required
boolean
searchable
required

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.

boolean
applies_to
required

Resource type refs this field applies to. Empty = all. Ignored when subject_kind = ‘collection’.

Array<integer>
read_capability
string
nullable
write_capability
string
nullable
display_order
required
integer
display_group
required
string
extraction_source

CanonicalField the metadata-extraction pipeline writes into this field. Empty = no extraction (the field stays operator-managed). Edited via the dedicated PUT …/extraction endpoint.

string
extraction_mode

Write behaviour when extraction has a value. Empty = default (skip_if_set).

string
Allowed values: "" skip_if_set replace append prepend
show_on_card

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.

boolean
show_in_advanced_search

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).

boolean
show_on_upload

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.

boolean
edit_tab

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.

string
nullable
read_only

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.

boolean
regexp_filter

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.

string
nullable
mirrors_column

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.

string
nullable
display_condition

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.

Array<string>
nullable
status
required
string
Allowed values: active deprecated archived
deprecated_replacement_id
string format: uuid
nullable
default_value

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
kind
required
string
Allowed values: literal context
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
context

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; a date field gets the day, a datetime the 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.

string
Allowed values: uploading_user uploading_team current_date
created_at
required
string format: date-time
updated_at
required
string format: date-time

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