Preview one field's change across many assets
POST /batch/asset-fields/preview
DRY RUN. Resolves a typed selection to a distinct, ordered set
of assets, partitions every one of them, and mints a SINGLE-USE
token that applyBatchAssetFieldEdit spends. Writes no field
value, creates no vocabulary term and records no audit event
(ADR 0019, #1173, #1119).
Expansion
The selection is a list of typed {kind, id} entries, and the
SERVER expands post entries through post membership. A client
never sends an expanded asset list, so a post that gained or
lost a member between the operator’s last read and this call is
expanded against the membership that exists NOW. Duplicates are
collapsed to distinct assets — an asset reachable through two
selected posts is one target, written once — and the resulting
set is ordered BY ASSET ID, never by selection order, so a
preview and its apply derive the identical ordered set. A
selected post with no members contributes no targets and is
reported in empty_posts.
Collections are not a selection kind here.
The two ceilings
At most 500 SELECTION ENTRIES, checked BEFORE any membership
query runs, and at most 1000 DISTINCT EXPANDED TARGETS. Both
refuse with 422 (selection_entry_ceiling /
expanded_target_ceiling) rather than trimming: a partial
expansion would silently write a different set than the operator
selected. A single post whose membership alone exceeds the
expanded ceiling is refused on the same terms.
The six partitions
Every target of a successful preview lands in EXACTLY ONE of
would_change, no_op, refused, inapplicable, unreadable
and unauthorized, and
expanded = would_change + no_op + refused + inapplicable
+ unreadable + unauthorized
eligible = would_change + no_op
refused is a TARGET-LEVEL refusal carrying a machine reason
(a required field that the operation would empty, a retired
vocabulary term chosen fresh on that target); it is never
written at apply. unreadable is the read side of the
anti-oracle rule: a caller who may write a field but may not
READ it on this subject learns nothing about the stored value,
not its emptiness, not its membership, not its set_at.
What the token binds
The ordered target set with each target’s partition; the field’s
identity and its configuration fingerprint including the
vocabulary options document; the mode; the exact canonical
value; and the CALLER. Apply writes only the would_change
subset and never re-expands. The token is never authority: the
apply re-evaluates the caller’s CURRENT effective permissions,
the field’s CURRENT configuration and the reference target’s
CURRENT liveness, and refuses when any of them moved.
Authorizations
Section titled “Authorizations ”Request Body required
Section titled “Request Body required ”Takes the mode, the field, the proposed value and the typed selection. It takes NO operator reason and NO confirmation count: both belong to the apply, which is the call that actually changes records.
object
What the batch does to each target’s value.
overwrite— replace the value on every eligible target. Reportsno_opas ZERO even against a target already holding the proposed value: a set advancesset_atand writes a history row, so it is a real change to the record.fill_empties— write only where the value is semantically EMPTY. A target holding any non-empty value isno_op. FALSE is a real boolean value and is never empty.append— add the proposed terms to the existing set.remove— take the proposed terms out of the existing set.
append and remove are multi_select ONLY. Every other type
refuses them batch-wide with 422 mode_not_supported_for_type
rather than being given an invented set semantics.
At least one entry, at most 500. The entry ceiling is checked BEFORE any membership query runs.
ONE typed selection entry. Typed because the shipped selection store is a bare list of ids with no kind discriminator, and a server that guessed would expand a post id as an asset id and silently write nothing.
object
What a selection entry names. post entries are expanded
server-side through post membership; asset entries are
already targets. Collections are not a selection kind.
The proposed value, in whichever typed member matches the
field’s declared type — the same mapping
AssetFieldValueWrite uses: text / longtext / rich_text /
select / tree → value_text; number / boolean → value_num;
date / datetime → value_date; multi_select →
value_options; reference → value_ref.
The five types that CANNOT express a semantically empty value
(number, boolean, date, datetime, reference) must carry their
typed member; omitting it is 400 value_type_mismatch. A
multi_select with an empty option set is likewise 400
value_type_mismatch, mirroring the single-target writer.
The three text-shaped types and the two single-slug vocabulary
types CAN express emptiness, and what happens then is a
property of the field rather than of the batch: a REQUIRED
field refuses it (422 required_value_empty), and an OPTIONAL
one stores it. Nothing trims: a text field given ” ” stores
” ”. select and tree do NOT treat whitespace lexically —
"" never enters the vocabulary pipeline and stores as ”, while
” ” enters it as a slug, matches nothing, and is 422
unknown_slug.
object
Responses
Section titled “ Responses ”Preview computed. Nothing was written.
A computed preview and the single-use token that applies it. Nothing has been written: no field value, no vocabulary term, no audit event.
object
OPAQUE. Bound to the caller, single-use, and the only thing the apply needs. Its structure is not a contract and a client must not parse it.
Ties this preview to the audit envelope its apply writes.
ECHOED so an operator surface can show what it is about to do without holding its own copy. The apply reads the mode from the token and never from the request.
The CANONICAL value the apply will store, after vocabulary canonicalisation and rich-text sanitising — aliases and merge tombstones already followed to their targets, casing and whitespace variants already collapsed onto the existing slug, duplicates already deduped on the canonical slug with order preserved. Preview and apply store the same bytes.
object
Terms that DO NOT YET EXIST in an open vocabulary and would be created by the apply. Listing them is not creating them: a preview mutates no options document. A term is minted at apply ONLY IF at least one target successfully stores it.
Selected posts that hold no members, and so contributed no targets.
The six partition counts plus the two derived totals, so a client can assert the reconciliation rather than recompute it:
expanded = would_change + no_op + refused + inapplicable
+ unreadable + unauthorized
eligible = would_change + no_opobject
Every expanded target, ordered by asset id.
One expanded target and its partition. if_unchanged_since
appears ONLY on a would_change target and is that value row’s
own set_at — absent where no row exists yet. It is never
emitted for an unreadable target, which would disclose that
the value exists and when it was last written.
object
Which of the six partitions a previewed target belongs to. Every target of a successful preview belongs to exactly one.
would_change— the apply will write this target.no_op— eligible, and the mode leaves it unchanged.refused— refused ON ITS MERITS for this target, with a machine reason. Never written at apply. Preview-only: a value that MOVES trips the concurrency guard and reportsconflict, and a definition or vocabulary change is a batch-wide 409, so nothing can turn into arefusedbetween preview and apply.inapplicable— the field does not apply to this asset’s type. Not an error.unreadable— the caller may not READ this field on this subject. Nothing about the stored value is disclosed.unauthorized— the caller may not write this target: no bulk instrument in its scope, or the ordinary subject authority rule refuses them.
The request could not have been valid for any state of the system.
ONE refusal body for both batch operations, so the two cannot describe the same refusal differently.
Every apply-time refusal — the request-shape checks and steps 1 through 6 alike — writes zero field values, mints no vocabulary term, commits no audit envelope, and LEAVES THE TOKEN UNSPENT.
expected and actual are populated where a number is the
whole story: the count a confirm_count_mismatch wanted, and
the ceiling and the measured size on the two ceiling refusals.
field is the field’s code where one is known.
object
Why a batch preview or apply was refused. The status code carries the class and this carries the cause.
400 — the request could not have been valid for ANY state:
empty_selection, unknown_mode, unknown_selection_kind,
value_type_mismatch, token_required,
reason_payload_too_large, reason_required,
reason_too_long, confirm_count_invalid,
confirm_count_required, confirm_count_not_applicable,
confirm_count_mismatch.
403 — authorization, INCLUDING anything not provably this
caller’s token: preview_token_invalid,
bulk_capability_required, field_write_capability_required,
vocabulary_extend_required.
422 — well formed, understood, and refused on its merits:
field_archived, field_read_only, field_mirrored,
mode_not_supported_for_type, pattern_mismatch,
required_value_empty, fill_empties_value_empty,
unknown_slug, archived_slug, dangling_reference,
selection_entry_ceiling, expanded_target_ceiling.
409, apply only — the token is provably this caller’s own but
is spent or stale, or the world moved; the remedy is to
re-preview: preview_consumed, preview_expired,
definition_drift, vocabulary_drift, reference_invalidated.
dangling_reference (preview) and reference_invalidated
(apply) are deliberately NOT the same code. The first says the
proposed target never resolved; the second says it resolved
when the operator looked and has since stopped. They call for
different actions and a client that cannot tell them apart
cannot say which.
On expanded_target_ceiling, the number of SELECTION
ENTRIES that expanded to the over-ceiling target count — so
an operator can see that a handful of posts reached
thousands of assets.
Authentication 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"}The caller may not reach for this instrument, or may not write this field.
ONE refusal body for both batch operations, so the two cannot describe the same refusal differently.
Every apply-time refusal — the request-shape checks and steps 1 through 6 alike — writes zero field values, mints no vocabulary term, commits no audit envelope, and LEAVES THE TOKEN UNSPENT.
expected and actual are populated where a number is the
whole story: the count a confirm_count_mismatch wanted, and
the ceiling and the measured size on the two ceiling refusals.
field is the field’s code where one is known.
object
Why a batch preview or apply was refused. The status code carries the class and this carries the cause.
400 — the request could not have been valid for ANY state:
empty_selection, unknown_mode, unknown_selection_kind,
value_type_mismatch, token_required,
reason_payload_too_large, reason_required,
reason_too_long, confirm_count_invalid,
confirm_count_required, confirm_count_not_applicable,
confirm_count_mismatch.
403 — authorization, INCLUDING anything not provably this
caller’s token: preview_token_invalid,
bulk_capability_required, field_write_capability_required,
vocabulary_extend_required.
422 — well formed, understood, and refused on its merits:
field_archived, field_read_only, field_mirrored,
mode_not_supported_for_type, pattern_mismatch,
required_value_empty, fill_empties_value_empty,
unknown_slug, archived_slug, dangling_reference,
selection_entry_ceiling, expanded_target_ceiling.
409, apply only — the token is provably this caller’s own but
is spent or stale, or the world moved; the remedy is to
re-preview: preview_consumed, preview_expired,
definition_drift, vocabulary_drift, reference_invalidated.
dangling_reference (preview) and reference_invalidated
(apply) are deliberately NOT the same code. The first says the
proposed target never resolved; the second says it resolved
when the operator looked and has since stopped. They call for
different actions and a client that cannot tell them apart
cannot say which.
On expanded_target_ceiling, the number of SELECTION
ENTRIES that expanded to the over-ceiling target count — so
an operator can see that a handful of posts reached
thousands of assets.
Resource not found
object
Human-readable error summary
Example
the request could not be completedWell formed and understood, and this definition, value or scale refuses it.
ONE refusal body for both batch operations, so the two cannot describe the same refusal differently.
Every apply-time refusal — the request-shape checks and steps 1 through 6 alike — writes zero field values, mints no vocabulary term, commits no audit envelope, and LEAVES THE TOKEN UNSPENT.
expected and actual are populated where a number is the
whole story: the count a confirm_count_mismatch wanted, and
the ceiling and the measured size on the two ceiling refusals.
field is the field’s code where one is known.
object
Why a batch preview or apply was refused. The status code carries the class and this carries the cause.
400 — the request could not have been valid for ANY state:
empty_selection, unknown_mode, unknown_selection_kind,
value_type_mismatch, token_required,
reason_payload_too_large, reason_required,
reason_too_long, confirm_count_invalid,
confirm_count_required, confirm_count_not_applicable,
confirm_count_mismatch.
403 — authorization, INCLUDING anything not provably this
caller’s token: preview_token_invalid,
bulk_capability_required, field_write_capability_required,
vocabulary_extend_required.
422 — well formed, understood, and refused on its merits:
field_archived, field_read_only, field_mirrored,
mode_not_supported_for_type, pattern_mismatch,
required_value_empty, fill_empties_value_empty,
unknown_slug, archived_slug, dangling_reference,
selection_entry_ceiling, expanded_target_ceiling.
409, apply only — the token is provably this caller’s own but
is spent or stale, or the world moved; the remedy is to
re-preview: preview_consumed, preview_expired,
definition_drift, vocabulary_drift, reference_invalidated.
dangling_reference (preview) and reference_invalidated
(apply) are deliberately NOT the same code. The first says the
proposed target never resolved; the second says it resolved
when the operator looked and has since stopped. They call for
different actions and a client that cannot tell them apart
cannot say which.
On expanded_target_ceiling, the number of SELECTION
ENTRIES that expanded to the over-ceiling target count — so
an operator can see that a handful of posts reached
thousands of assets.