Skip to content

Decision: should an unscoped multi: true UPDATE get the whole-operation dispatch that #9719 gave DELETE? (the update half split out of #9798) #9974

Description

@os-warren

Split out of #9798 as its triage note and the unblock comment both instructed — "extending whole-operation dispatch to beforeUpdate's predicate path is flagged by the engine's own design as a decision, not drift; split it out and escalate that half rather than implementing it as a rider". #9798's PR implements the DELETE half only; this card carries the UPDATE half.

This is a decision card, not a ready-to-implement bug: the engine deliberately refuses the mechanism on this event, so wiring it is a product-behaviour choice.

What is declared

resolveTargetRows in packages/plugins/plugin-audit/src/comment-access-hooks.ts (declared at #4630) refuses a multi-write carrying no id and no where on BOTH verbs:

Refusing an unscoped multi-{update|delete} of comments — scope the write to the rows you mean

The identical shape exists on sys_attachment for delete only (#4757).

What was measured (wired engine, on #9798's branch)

Measured through a real ObjectQL + in-memory driver + the production installer — the new wired-path pins in comment-access-hooks.test.ts, describe block unscoped multi-UPDATE (no id, no where) — the still-unreachable half:

Case ql.update('sys_comment', data, { multi: true }) — no where Result
Caller authored every row not refused whole table rewritten
Empty table (zero match) not refused resolves; nothing ran at all
A row the caller may not touch is swept refused per-row message (Cannot update comment c2: …), not the unscoped one

So the update half is a partial, row-dependent guard, not a total fail-open: the unscoped shape is caught only when it happens to sweep a row the caller lacks rights to. The declared refusal — which is about the SHAPE, regardless of what the rows say — never fires. Note the third row is why this is narrower than the delete hole #9719 fixed, and the first two are why it is still a hole.

The corresponding DELETE limbs were all restored in #9798's PR via dispatchUnscopedMultiDelete.

Why it cannot just be wired

dispatchUnscopedMultiDelete is valid on beforeDelete registrations only. assertValidUnscopedMultiDeleteFlag in packages/objectql/src/engine.ts throws at registration time on any other event, and its own comment names this card's question as the reason:

(Extending the whole-operation dispatch to beforeUpdate's predicate path would be a product-behaviour decision of its own, not a widening to make this assert quieter.)

The decision

A. Extend the whole-operation dispatch to beforeUpdate's predicate path (a dispatchUnscopedMultiUpdate sibling, or generalizing the existing flag to both events).

  • Real business need: the declared refusal exists because an unscoped multi: true write is a mistake shape, not a use case. An unscoped multi-update is data corruption (silent, no tombstone, no pre-image to restore from) where delete is data loss — arguably the worse outcome to leave open. Against that: no measured caller is asking for it; the pull is a declared guard that does not hold, not a feature request.
  • Long-term soundness: symmetric with delete, and keeps declared = enforced. The mechanism already exists and is tested; the delta is one dispatch site plus the event validity rule.
  • AI-authored metadata safety: this is the axis that most favours A. An unscoped multi: true update is exactly what generated code emits when a where is forgotten, and today it half-works — which is the worst teaching signal. A loud refusal at the door is structurally hard to get wrong.
  • Startup scope discipline: modest — reuses a landed mechanism on one more event rather than adding surface. But it IS engine-core behaviour change on the update path, which carries more blast radius than delete did (updates are far more common), so it needs the ruling it was split out for.

B. Leave update as-is and narrow the declaration to match — i.e. accept that the refusal is delete-only, and change resolveTargetRows so the update path no longer claims a refusal it cannot deliver.

  • Honest (ADR-0049 enforce-or-remove: a declared-but-unenforceable branch is the failure mode the declaration exists to prevent), and zero engine risk. But it removes a guard on the corruption verb while keeping it on the loss verb, which is hard to justify on the AI-safety axis, and it leaves the first two measured rows above as accepted behaviour.

C. Do nothing. Not recommended: the current state is a declared refusal that is silently partial, which is the exact "declared ≠ enforced" shape #9798 was filed about. If C is chosen it should still be C-plus-comment, so the next reader is not misled by the branch.

Recommendation: A, primarily on the AI-authored-metadata axis — a forgotten where on an update is the most common generated-code mistake this guard could catch, and it currently catches it only by accident. The engine work is a near-copy of #9719's, and the pins that must go red when it lands already exist and say so. If A is judged too much engine-core churn for the current stage, B is the acceptable second — but C is not, because it keeps a guard that reads as enforcement and is not.

What lands with whichever option is chosen

The three MEASURED GAP / partial-guard pins added in #9798's PR document today's behaviour deliberately and are annotated to go RED when this card lands. Closing this card means replacing them with refusal assertions (option A) or with the narrowed declaration's pins (option B) — not relaxing them.

Backlink: #9798 (the delete half, implemented), #9719 / PR #9797 (the mechanism), #4630 (the guard's declaration), #5038 / #5574 (the per-row dispatch contract that makes the shape invisible).

Activity

  1. added a commit that references this issue on Aug 19, 2026
  2. added theissue type on Aug 19, 2026
  3. os-warren commented on Aug 19, 2026

    @os-warren
    CollaboratorAuthor

    Triage: routed to the decision inbox (needs-user-decision · domain:engine · type Bug), assigned os-zhuang per the 2026-08-19 notification-handover ruling (assignment = inbox routing, not a claim). The engine's own registration assert names this exact question as "a product-behaviour decision of its own", and it changes engine-core accept/refuse behaviour on the update path — human floor. Domain follows the recommended landing point (option A lands in packages/objectql/src/engine.ts); if B is ruled, re-route to domain:services (plugin-audit). Not blocked: the decision is independent of in-flight #9798 (the delete half), whose PR carries the three MEASURED-GAP pins annotated to go RED when this card lands.

    <!-- os-decision-facets -->
    ① platform long-term coherence: A restores declared = enforced symmetrically with the delete half (#9719/#9798); B narrows the declaration to match reality but leaves the corruption verb (update) less guarded than the loss verb (delete); C leaves a declared refusal that silently does not fire — the exact shape #9798 was filed about.
    ② measured business pull: no caller asks for unscoped multi-update — the pull is a declared guard that does not hold (measured on the wired engine: whole-table rewrite when the caller owns every row; zero-match resolves silently; refusal only fires per-row by accident).
    ③ AI-agent error-resistance: strongest axis for A — a forgotten where on an update is exactly what generated code emits, and today it half-works, the worst teaching signal; a loud shape-refusal at the door is structurally hard to get wrong.
    ④ startup scope discipline: A reuses a landed, tested mechanism on one more event — modest surface, but it IS engine-core behaviour change on the much hotter update path, which is why it is a card and not a rider.
    Recommendation: A (extend whole-operation dispatch to beforeUpdate), B acceptable second, C only as C-plus-comment.
    Confidence gap: this analysis cannot see downstream hosts relying on today's partial behaviour — an unscoped multi-update that currently succeeds for an all-rows-owned caller would start refusing loudly under A.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions