Skip to content

A predicate update writing a file field gives N records one file id under an exclusive-ownership model #7102

Description

@os-zhuang

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:

  1. 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.
  2. 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).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions