ADR 192 · Accepted
ADR-192: The eighth writer describes an asset, and stays out of the seventh's field
Context
set_file_alternative_text (ADR-135) writes one field of sys_file_metadata and refuses every other argument by name. That refusal is the right behaviour for a tool whose name states the field it writes, and it left an assistant asked to caption an asset able to set the alternative text and nothing else — which it reported accurately, and uselessly. title and description had no writer at all, and read_fal_asset_meta does not even read description back.
The obvious move is to widen set_file_alternative_text into a general metadata writer. This record says why that is the wrong one, and what the eighth writing tool does instead.
Decision
update_fal_asset_meta is the eighth writing tool, on exactly the terms of the previous seven: disabled by default, in the editing group, an explicit ToolEffect (IDEMPOTENT_WRITE — setting named scalar fields to given values converges on repeat), a human approval before every call (ADR-134), a preview at suspend (ADR-136), a write through the DataHandler under the acting user's permissions (ADR-083), a read-after-write verification, and a refusal vocabulary that never confirms a uid exists. It uses the ADR-146 plan() shape, as ADR-180 asked the writers after it to.
It writes title and description, either or both, on one file's live default-language metadata record. It does not write alternative.
Two tools, and no field with two writers
Widening set_file_alternative_text was rejected on three grounds, and only the third is decisive.
The first two are ordinary: it is shipped, tested code, and its name states the one field it writes — widening it would change what it does while keeping a name describing the old behaviour.
The third is the one that would have cost somebody something. A field with two writers gives an approver two cards that can both claim it, and the refusal vocabulary would need a second meaning for "this tool does not write that": at present it means another tool does, which is actionable. So the two tools are field-disjoint by construction, and each says so on the wire — this one's description names set_file_alternative_text rather than only declining, so a model told "not here" is not left to guess where.
The cost is named rather than hidden: an assistant setting all three fields makes two calls and costs two approvals. That is the same trade ADR-180 made for "page plus first element", for the same reason — one card, one thing.
The first plan() writer that updates
The four plan() writers before it all create a record, and PlansOneEditorialWriteTrait::createRecord() was extracted for them (ADR-180). This one updates, so that helper does not apply and the datamap and the read-back are its own.
Nothing is extracted for that. One implementation is not a shape — the same test ADR-146 applied when it declined to grow WritesThroughDataHandlerTrait. A second updating plan() writer is the trigger to look again.
What WAS extracted, because it was measured
This record first said the same about the FAL lookup, and the duplication detector disagreed with a number: 93 lines of the new tool against set_file_alternative_text, 5.9 % new duplicated lines on a 3 % gate. That is the same instrument, and the same answer, ADR-146 reached for the three writers it added — two copies made in one sitting are copy-paste, not two decisions — so ResolvesOneFalAssetTrait now carries the resolution both metadata writers perform: the sys_file row, the storage gate, the default-language access check, and the live default-language sys_file_metadata row with the three pins that decide which row a write lands on.
set_file_alternative_text is retrofitted to it, which ADR-135 and ADR-146 both declined to do, and that is not a reversal of those records. They declined to route working code through a trait answering a DIFFERENT question, for a commonality that was argued rather than measured. Here the two files held the same query verbatim and one of the copies was days old. Its own functional test — the file mounts, the workspace-draft row, the neutral refusals — passes unchanged, which is what makes the retrofit a refactor rather than a rewrite.
What stays per tool is everything the tools differ in: the refusal vocabulary, the fields, the read-back and the preview.
Two behaviours that came out of reading core rather than assuming it
An omitted field is not an empty one. A field the call leaves out is absent from the datamap; an empty string clears the field it names. Collapsing the two would make "set the title" also erase a description an editor wrote by hand, which is the one way a metadata writer destroys work nobody asked it to touch. Three tests fail when that distinction is removed.
The field-level grant is asked BEFORE the write. Core ships sys_file_metadata.title with 'exclude' => true and description without one. The DataHandler skips a field the acting user holds no non_exclude_fields grant for — silently, with an empty errorLog — and applies the rest of the datamap. For the one-field writer before it that is a failure to detect after the fact; here it would be a half-described asset, because a user granted description and not title would get the description written and a failure reported. The tool therefore asks the same question the DataHandler asks, through the same method (BackendUserAuthentication::check('non_exclude_fields', …)), before writing anything, and refuses the whole call. The per-field read-back stays as the backstop for everything that check does not model.
Worth recording about that method: it tests isset($groupData[$type]) before isAdmin(), so a user object assembled without group data fails the grant check whatever its admin flag says. The tool inherits that faithfully because it calls the same method; a reimplementation would not have.
The title is bounded in bytes. The column is tinytext — 255 bytes, not 255 characters. A German title of 200 characters is 400 bytes and would be truncated by the database rather than refused by the tool. Where an installation declares a TCA max that is narrower, it wins: it is what the backend form enforces, and a tool accepting more would write what an editor could not.
Consequences
✓ Eight editorial writes are available where seven were, and the editorial metadata of an asset is writable at all.
✓ No field of sys_file_metadata has two writers, so every approval card answers for exactly one tool.
✕ Describing an asset fully costs two approvals, because the alternative text is another tool's.
✕ read_fal_asset_meta still does not return description, so an admin using the agent cannot read back a field this tool can write. The approval card shows the before value, which is the channel that matters for a non-admin — the reader is admin-only and in the structure group — but the asymmetry is real and is left rather than widened here.
✕ The updating datamap and read-back still exist twice on sys_file_metadata, once in each of the two writers that touch it. Only the lookup is shared; the duplication detector did not object to the rest, and neither does this record until a third writer makes it a shape.
Revisit when
A second updating plan() writer is proposed — then the question ADR-146 asked about the creating ones applies to these, and the answer may be an extraction.
Also revisit if a field ever genuinely needs two writers. The disjointness above is a rule this record chose, not one the runtime enforces.