Found while pinning attachments-storage.attach-requires-parent-edit clause C3 for #9483. Reported, not fixed — #9483 is a tests-only card, and this is a product change.
What the guard promises
packages/services/service-storage/src/attachment-access-hooks.ts carries an explicit #4757 refusal in its beforeDelete handler: when a delete carries no id AND no where, the engine hands deleteMany an AST of { object } — the whole table — so the handler refuses outright rather than authorizing it by resolving zero rows.
'Refusing an unscoped multi-delete of attachments — scope the delete to the rows you mean (an id or a where predicate)'
The reasoning in the comment is the point of the rule: "Nothing to authorize" and "nothing was ever queried" are not the same verdict; reading the second as the first is fail-open.
What actually happens
That branch is unreachable through ObjectQL.delete. The predicate path dispatches beforeDeleteper row via dispatchPerRowBeforeHooks, which builds each row context as input: { id: rowId, options }. The attachment handler opens with asIdList(ctx?.input?.id), so with input.id bound it always takes the by-id branch and never reaches the where === undefined check. There is exactly one triggerHooks('beforeDelete', …) call in engine.ts and it sits in the by-id branch; the multi branch only does the per-row dispatch.
Measured
Real dogfood stack (storage + audit + sharing), a member holding the sys_attachment delete bit, driving ql.delete('sys_attachment', { multi: true, context: memberCtx }) — no id, no where:
| case | rows before | result | rows after |
|---|
| member is uploader of one row, not entitled to the other | 2 | throws ATTACHMENT_DELETE_DENIED — but with the per-row message Cannot delete attachment …: only the uploader or a user who can edit the parent record (att_readonly/…) may delete it | 2 |
| member is the uploader of EVERY matched row | 2 | resolves, deletes both | 0 |
The first row shows the refusal that does fire is the per-row gate, not #4757 — different rule, different message. The second row is the gap: a predicate-less delete wipes every row the caller happens to be entitled to, which is exactly the blast-radius shape the guard was written to refuse outright, regardless of entitlement.
Not a privilege escalation: every deleted row was one the caller could have deleted individually by id. It is a deliberate safety guard that no longer fires.
Why this was not visible
attachment-access-hooks.test.ts pins the refusal by calling the handler directly with a whole-operation context shape, including a case named "refuses even the uploader of every matched row — the AST is unscoped, not row-scoped". That test is green and the wired engine does the opposite — the per-row dispatch (#5038 / #5574) landed after the guard and changed the context the handler receives on this path. A unit suite that constructs its own context cannot see it.
The REST surface is unaffected: POST /data/:object/deleteMany validates against DeleteManyDataRequestSchema, which strips options.where and requires an ids list (#3897), so an unscoped delete cannot be expressed there. The reachable callers are server-side ones that hold the engine — flows, actions, scripts, MCP tools.
Options
- Restore the check where it can see the operation shape — have the engine surface the predicate-less multi intent to per-row handlers (e.g. keep
options carrying an explicit "unscoped" marker the handler can read), or refuse the unscoped AST in the engine itself for objects that declare a delete guard. - Move the refusal into
ObjectQL.delete's multi branch as a general rule, so it protects every guarded object rather than sys_attachment alone. - Accept the per-row gate as sufficient and retire both the guard and the unit case that claims it — the honest version of today's behaviour.
Whichever is chosen, attachment-access-hooks.test.ts's unscoped block should stop asserting a shape the engine no longer produces, or it stays a false green.
Checklist impact
docs/qa/platform-checklist/areas/attachments-storage.json → attachments-storage.attach-requires-parent-edit clause 3 is recorded as NOT pinned by #9483, with this issue named, rather than pinned against current behaviour — pinning today's outcome would turn the fix red.
Found while pinning
attachments-storage.attach-requires-parent-editclause C3 for #9483. Reported, not fixed — #9483 is a tests-only card, and this is a product change.What the guard promises
packages/services/service-storage/src/attachment-access-hooks.tscarries an explicit #4757 refusal in itsbeforeDeletehandler: when a delete carries no id AND nowhere, the engine handsdeleteManyan AST of{ object }— the whole table — so the handler refuses outright rather than authorizing it by resolving zero rows.The reasoning in the comment is the point of the rule: "Nothing to authorize" and "nothing was ever queried" are not the same verdict; reading the second as the first is fail-open.
What actually happens
That branch is unreachable through
ObjectQL.delete. The predicate path dispatchesbeforeDeleteper row viadispatchPerRowBeforeHooks, which builds each row context asinput: { id: rowId, options }. The attachment handler opens withasIdList(ctx?.input?.id), so withinput.idbound it always takes the by-id branch and never reaches thewhere === undefinedcheck. There is exactly onetriggerHooks('beforeDelete', …)call inengine.tsand it sits in the by-id branch; the multi branch only does the per-row dispatch.Measured
Real dogfood stack (storage + audit + sharing), a member holding the
sys_attachmentdelete bit, drivingql.delete('sys_attachment', { multi: true, context: memberCtx })— no id, nowhere:ATTACHMENT_DELETE_DENIED— but with the per-row messageCannot delete attachment …: only the uploader or a user who can edit the parent record (att_readonly/…) may delete itThe first row shows the refusal that does fire is the per-row gate, not #4757 — different rule, different message. The second row is the gap: a predicate-less delete wipes every row the caller happens to be entitled to, which is exactly the blast-radius shape the guard was written to refuse outright, regardless of entitlement.
Not a privilege escalation: every deleted row was one the caller could have deleted individually by id. It is a deliberate safety guard that no longer fires.
Why this was not visible
attachment-access-hooks.test.tspins the refusal by calling the handler directly with a whole-operation context shape, including a case named "refuses even the uploader of every matched row — the AST is unscoped, not row-scoped". That test is green and the wired engine does the opposite — the per-row dispatch (#5038 / #5574) landed after the guard and changed the context the handler receives on this path. A unit suite that constructs its own context cannot see it.The REST surface is unaffected:
POST /data/:object/deleteManyvalidates againstDeleteManyDataRequestSchema, which stripsoptions.whereand requires anidslist (#3897), so an unscoped delete cannot be expressed there. The reachable callers are server-side ones that hold the engine — flows, actions, scripts, MCP tools.Options
optionscarrying an explicit "unscoped" marker the handler can read), or refuse the unscoped AST in the engine itself for objects that declare a delete guard.ObjectQL.delete's multi branch as a general rule, so it protects every guarded object rather than sys_attachment alone.Whichever is chosen,
attachment-access-hooks.test.ts's unscoped block should stop asserting a shape the engine no longer produces, or it stays a false green.Checklist impact
docs/qa/platform-checklist/areas/attachments-storage.json→attachments-storage.attach-requires-parent-editclause 3 is recorded as NOT pinned by #9483, with this issue named, rather than pinned against current behaviour — pinning today's outcome would turn the fix red.