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.
Authorizations
Section titled “Authorizations ”Parameters
Section titled “ Parameters ”Path Parameters
Section titled “Path Parameters ”Request Body required
Section titled “Request Body required ”object
The slug being folded away. Must be a term of this field. Becomes a tombstone; it is never deleted.
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.
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.
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.
Responses
Section titled “ Responses ”The merge as performed, or — with dry_run — as it would
have been performed.
object
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.
The same count for collection_field_value.
Whether source is now archived with replaced_by set
to target. False only on a dry run.
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 completedThe 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
-
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_option—select/multi_select/treeonly: the slug is not a term in the field’s controlled vocabulary. Fortreethe 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_reference—referenceonly:value_refnames 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_referencesimply 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 holdfields.vocabulary.extend. Distinct fromunknown_optionon 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 declaresread_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 aregexp_filterand 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 declaresrequired, 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; amulti_selectwith no members; a NULLvalue_num,value_dateorvalue_ref; and forrich_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_missingrefusal. Human writes only — upload defaults, extraction and the mirrored-column filler are unaffected, exactly as withfield_read_only.
The code of the field that refused the value.
The offending slug, for the two vocabulary reasons. Null otherwise.
Unexpected server error
object
Human-readable error summary
Example
the request could not be completed