Skip to content

Fold one vocabulary term into another (curation)

POST
/fields/{id}/values/merge

The second half of ADR 0092 §4’s alias-then-merge. An alias is the cheap, non-retroactive, reversible fix — it redirects FUTURE writes and touches no stored row. This is the expensive one: it rewrites every stored value naming source to name target instead, on assets and collections alike.

What survives

source is not deleted. It becomes a TOMBSTONE: archived with replaced_by pointing at target, permanently. That is the difference between a term that was merged away and a term that never existed — and it is the difference a federated peer needs, because a peer that saw uk before the merge has no other way to learn where the value went. A merge that deletes the row makes the two indistinguishable forever, which is why ADR 0092 rejects it.

A tombstoned slug keeps RESOLVING: a later write naming source lands on target rather than being refused, so a value arriving late from a peer or a queued client is not lost.

Friction, on purpose

Every production system surveyed for #789 puts obstacles in front of this operation, because rewriting rows before the redirect has proven correct is the failure mode they all record. Ours are: a capability of its own (fields.vocabulary.merge, which fields.admin does NOT imply), a required human-written reason that lands in the audit trail, and dry_run, which reports exactly what would change and changes nothing.

id
required
string format: uuid
object
source
required

The slug being folded away. Must be a term of this field. Becomes a tombstone; it is never deleted.

string
target
required

The slug it becomes. Must be a term of this field, must not equal source, and must not itself be archived — merging into a retired term would move every value onto something no picker offers.

string
reason
required

Why this merge is correct, in a human’s words. Required and recorded: a merge is the one vocabulary operation that edits records their owners did not touch, and an audit trail that cannot say why is not one.

string
>= 3 characters <= 500 characters
dry_run

Report what would change and change nothing. The counts are computed by the same queries the real run uses, inside a transaction that is rolled back.

boolean

The merge as performed, or — with dry_run — as it would have been performed.

object
source
required
string
target
required
string
dry_run
required
boolean
asset_values_rewritten
required

asset_field_value rows that named source and now name target. Counts rows, not assets — a multi_select row is one row however many terms it holds.

integer
collection_values_rewritten
required

The same count for collection_field_value.

integer
tombstoned
required

Whether source is now archived with replaced_by set to target. False only on a dry run.

boolean

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 merge names a term the field does not have, or asks for something the vocabulary cannot express.

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

Unexpected server error

object
error
required

Human-readable error summary

string
Example
the request could not be completed