Observation-class finding, split out of #6966 (PR #7101) rather than folded into it — it is older and wider than that card's dispatch-contract subject, and it belongs to the service-storage surface.
Filed unassigned; this seat is only recording it.
The invariant
file-reference-lifecycle.ts states exclusive ownership in its module header:
at most ONE (object, record, field) slot owns a file. Writing an already-owned id into a second slot COPIES the bytes into a fresh sys_file instead of sharing the row.
and gives the reason it matters:
Copying a private record's file id into a world-readable record — the sort of thing generated code does without any visible mistake — cannot widen who can read the bytes, because the second record gets its own copy.
What a predicate update actually does
engine.update(obj, { avatar: 'fileX' }, { multi: true, where: … }) matching rows R1..R3 on an object with a file-class field:
End state: three records reference fileX', one owns it. Read authorisation for those bytes derives from R1 alone, so two records reference a file whose readability is decided by a third record — precisely the widening the copy-on-claim design exists to prevent. Two warnings per bulk update are the only signal.
This is not a regression from #5574 or #5038. Before them the same write produced a different broken end state (three records referencing a copy that nobody owned). Both predate the dispatch change; bulk update was never really supported on this surface, and the guard's own comment said so — "only a single-record update can own one. Copy-on-claim needs a definite owner, so skip".
Why #7101 did not fix it
#7101 corrects the dispatch question (beforeUpdate copy-on-claim now runs once per write instead of once per row, which is behaviour-identical here and removes N−1 sys_file reads plus an out-of-contract row-conditioned rewrite of a shared SET clause). It deliberately leaves afterUpdate claiming per row, because the alternative is worse: restoring the "skip on bulk" intent would leave fileX' owned by nobody while three records reference it, and an unowned file is what the release/tombstone path may eventually collect. Turning a broken ownership record into potentially-deleted bytes is not an improvement, and choosing between them is a service-storage decision, not a dispatch-contract one.
The reasoning is recorded in place at the afterUpdate guard in packages/services/service-storage/src/file-reference-lifecycle.ts.
The shape of an answer (not a recommendation)
Roughly three, and picking among them needs the owning seat:
- Refuse — reject a predicate update that writes a file-class field, the way ADR-0104's other declared-vs-enforced gaps were closed. Honest, and a visible behaviour change for anyone doing it today.
- Copy per row — give each matched record its own copy. Honours the invariant exactly; costs N copies per bulk write and needs a per-row payload, which the batch
SET clause does not have (ADR-0058 Addendum II D3 names a row-conditioned rewrite as out of contract, so this cannot be done from the before* hook as things stand).
- Record it as a documented boundary — say in the module header that field-reference ownership is single-record, name the bulk outcome, and leave the behaviour. Cheapest, and at least stops the header claiming an invariant the code does not hold.
Repro
No test pins the current outcome. packages/services/service-storage/src/file-reference-lifecycle.test.ts gained a per-row multi-delete driver in #7101 (driveDelete); a per-row multi-update driver of the same shape is what this needs, and does not exist yet.
Refs: #6966, PR #7101, #5038, #5574, ADR-0104 D3 wave 2, ADR-0058 Addendum II, ADR-0049.
Observation-class finding, split out of #6966 (PR #7101) rather than folded into it — it is older and wider than that card's dispatch-contract subject, and it belongs to the
service-storagesurface.Filed unassigned; this seat is only recording it.
The invariant
file-reference-lifecycle.tsstates exclusive ownership in its module header:and gives the reason it matters:
What a predicate update actually does
engine.update(obj, { avatar: 'fileX' }, { multi: true, where: … })matching rows R1..R3 on an object with a file-class field:beforeUpdatecopy-on-claim resolves ONE copy (fileX') — there is one batch payload, anddriver.updateManytakes oneSETclause for N rows;avatar = fileX'to all three rows;afterUpdate(per row since [17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038) claimsfileX'for R1;claimFilenever steals, so R2 and R3 logalready owned by …/R1.avatar — not transferringand move on.End state: three records reference
fileX', one owns it. Read authorisation for those bytes derives from R1 alone, so two records reference a file whose readability is decided by a third record — precisely the widening the copy-on-claim design exists to prevent. Two warnings per bulk update are the only signal.This is not a regression from #5574 or #5038. Before them the same write produced a different broken end state (three records referencing a copy that nobody owned). Both predate the dispatch change; bulk update was never really supported on this surface, and the guard's own comment said so — "only a single-record update can own one. Copy-on-claim needs a definite owner, so skip".
Why #7101 did not fix it
#7101 corrects the dispatch question (
beforeUpdatecopy-on-claim now runs once per write instead of once per row, which is behaviour-identical here and removes N−1sys_filereads plus an out-of-contract row-conditioned rewrite of a sharedSETclause). It deliberately leavesafterUpdateclaiming per row, because the alternative is worse: restoring the "skip on bulk" intent would leavefileX'owned by nobody while three records reference it, and an unowned file is what the release/tombstone path may eventually collect. Turning a broken ownership record into potentially-deleted bytes is not an improvement, and choosing between them is aservice-storagedecision, not a dispatch-contract one.The reasoning is recorded in place at the
afterUpdateguard inpackages/services/service-storage/src/file-reference-lifecycle.ts.The shape of an answer (not a recommendation)
Roughly three, and picking among them needs the owning seat:
SETclause does not have (ADR-0058 Addendum II D3 names a row-conditioned rewrite as out of contract, so this cannot be done from thebefore*hook as things stand).Repro
No test pins the current outcome.
packages/services/service-storage/src/file-reference-lifecycle.test.tsgained a per-row multi-delete driver in #7101 (driveDelete); a per-row multi-update driver of the same shape is what this needs, and does not exist yet.Refs: #6966, PR #7101, #5038, #5574, ADR-0104 D3 wave 2, ADR-0058 Addendum II, ADR-0049.