Skip to content

fix(objectql): restore the #4757 unscoped multi-delete refusal on sys_attachment through the wired engine - #9797

Merged
os-elon merged 4 commits into
mainfrom
claude/issue-9719-unscoped-multi-delete-refusal
Aug 19, 2026
Merged

fix(objectql): restore the #4757 unscoped multi-delete refusal on sys_attachment through the wired engine#9797
os-elon merged 4 commits into
mainfrom
claude/issue-9719-unscoped-multi-delete-refusal

Conversation

@os-elon

@os-elonos-elon commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9719

What was broken

sys_attachment's declared #4757 refusal — a multi: true delete carrying no id and no where must be refused outright — could not fire through ObjectQL.delete. Re-measured on this tree before writing code, all four premises from the claim comment held, plus the fourth limb:

  1. the refusal exists in attachment-access-hooks.ts with its declared envelope (ATTACHMENT_DELETE_DENIED, 403);
  2. the handler's by-id branch shadows it — per-row dispatch ([17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038/beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574) binds input.id on every predicate dispatch;
  3. the only two beforeDelete dispatch sites are the by-id branch and the per-row fan-out;
  4. the zero-match limb: the per-row dispatch is gated on doomed.length being positive, so an unscoped delete matching nothing dispatches nothing — a caller probing against an empty table sees success and ships the unscoped delete.

Limb 4 is why the refusal cannot live solely in the handler, and it is measured in this PR's ablation (below), not assumed.

Ruling and route

Triage's adjudication (issue comment 5331638200): restore the declared refusal for sys_attachment (option 1), re-point the false-green unit block in the same PR; option 3 not available, option 2 (generalize to every guarded object) not commissioned.

Both PM-suggested mechanisms were measured; the shipped shape is the second one made literal — declared, not inferred:

  • Marker in per-row options only — rejected by measurement: it rides the per-row fan-out, so the zero-match limb still never dispatches (the ablation leg reproduces exactly this: the empty-table pin stays a resolve, with no per-row dispatch).
  • Engine-thrown refusal keyed on a declaration — rejected as policy duplication: the bypass rules (system context, no identity envelope) are the engine's buildSession "session-worthy" judgment plus the handler's own gates, and the error vocabulary is the consumer's; a second engine-side copy of either can drift from the one the handler already declares and unit-tests.
  • Consumer-only middleware — rejected: registerMiddleware is an optional member of the lifecycle-engine surface (silent fail-open where absent), it would re-implement the session-worthy judgment from raw context, and it leaves the handler's declared branch permanently dead — the exact declared-vs-wired gap this card exists to kill.

Shipped: registerHook gains an opt-in declaration, dispatchUnscopedMultiDelete (valid on beforeDelete only; refused loudly on any other event — ADR-0078, no silently inert declaration). ObjectQL.delete's predicate branch dispatches the whole-operation context ONCE — before the matched-row read, zero-match included — to declaring registrations, when the caller's raw predicate is absent or null (read from input.options.where, the upper-bound slot hook.zod.ts blesses for before* handlers; never from the AST, which middleware may have narrowed). The dispatched context is derived, dispatch: { mode: 'record', index: 0 } with the batch scope identity-shared, so every hook.zod.ts sentence about the batch-level context stays true. Binding input.id on it is refused (HookTargetRebindError, new path 'unscoped-multi') — same rule as D4/#6752, never a silent no-op.

The engine stays neutral: undeclared registrations, scoped deletes (including the match-all where: {}), by-id deletes, and every other object see zero new dispatches — pinned. The product behaviour change is enacted solely by the sys_attachment registration declaring the flag, which makes the handler's own #4757 branch reachable again with its declared envelope. Option 2 is not taken; generalizing remains a separate decision card.

Test re-point (the false green, killed)

The unscoped multi-delete (no id, no where) — #4757 block in attachment-access-hooks.test.ts called the handler directly with a hand-rolled whole-operation context and stayed green while the wired engine wiped the table. It is replaced by a wired block driving a real ObjectQL + in-memory driver + this module's installer (ql.delete('sys_attachment', …) end to end), pinning:

  • the measured gap verbatim: { multi: true } from the uploader of EVERY row rejects with code: 'ATTACHMENT_DELETE_DENIED', status: 403, message first-sentence Refusing an unscoped multi-delete of attachments… — and the rows survive;
  • where: null the same way;
  • the zero-match limb: refusal on an EMPTY table, with a positive control (where: {} on the same empty table resolves — the refusal measures the shape, not the emptiness);
  • the per-row gate as a distinct refusal (its Cannot delete attachment… message) still firing through the wire;
  • passing controls: by-id, real where, and match-all where: {} deletes still resolve; system-context and context-less programmatic unscoped deletes still bypass.

Engine-side pins (new engine-unscoped-multi-delete-dispatch.test.ts, 13 cases against the real engine): dispatch condition, whole-operation shape, ordering (refusal before any driver call — zero reads), zero-match limb with its no-flag positive control, neutrality for scoped/by-id/undeclared, scope identity, rebind refusal, and the registration-time flag validation.

Note the wired service-storage block consumes @objectstack/objectql through dist (deliberately un-aliased, per the KNOWN_UNALIASED_TEST_IMPORTS ledger) — rebuild objectql before trusting a local verdict from it; the test file says so in place.

Verification

Union run at 8f430db0 (post-merge of origin/main, workspace rebuilt):

  • @objectstack/objectql: 219 files / 3870 tests green; typecheck green.
  • @objectstack/service-storage: 24 files / 360 tests green.
  • Gates, all green at that sha: check:durability-log-level · check:stack-collection-maps · check:test-source-alias · check:type-source-resolution · check:nul-bytes · check:i18n · check:query-options-erasure · check:type-check-coverage · check:engine-double-contract · check:where-matcher (both new stub matchers conform by refusing combinators) · check:changeset-gate-self-tests · check:objectui-changeset · check-adr-0087-registration · check-changeset-no-major · check-empty-changeset · check-engine-split-ratio (report-only) · check-affected-docs. The changeset five were named by re-running dispatch-gates.mjs against the real diff (additions beyond the dispatch list, as ordered).
  • check:type-check-debt (re-measure): green at the final HEAD as well — 33 ledger entries re-measured, none above its recorded number, surplus none; @objectstack/objectql sits exactly at its frozen 355 after the excursion fix below. (At first report time this re-run was still queued on the shared verify lock and was honestly recorded in_progress; this line records its completed reading.)
  • Ablation, both legs rebuilt and dist-preflighted (ablation-dist-preflight.mjs, marker dispatchUnscopedMultiDeleteHooks): with engine.ts + hook-target-rebind-errors.ts restored to origin/main and objectql rebuilt (marker proven ABSENT from all built files), the three wired unscoped pins go red by resolvingpromise resolved "2" on the uploader-of-every-row case is the issue's measured wipe, reproduced — while the other 26 stay green; fix restored and rebuilt (marker proven PRESENT), 29/29 green.
  • check:type-check-debt excursion: the first re-measure read @objectstack/objectql at +3 over its frozen 355 — all three in the new test file (two 1-arg registerObject calls, one unused loop variable). Fixed at the source; re-measured back to exactly 355. No ledger entry was raised.

Read-couplings observed (declared in the claim): check-durability-degradation-log-level.mjs changed on main mid-run (#9657's lane) and was re-run green at the final HEAD under its new detection. check:engine-double-contract (#9680's lane) showed no shift across my runs.

Declared-surface deviations (claim comment 5334565259)

The claim declared engine.ts to region level plus the two attachment files. The commissioned mechanism mechanically entails four adjacencies, none in contact with any concurrent card (#9476 is in src/validation/record-validator.ts; the devx cards touch scripts only):

  • engine.tsHookEntry + registerHook (~:1310, ~:2095) — the declaration has to live on the registration;
  • engine.ts new private dispatchUnscopedMultiDeleteHooks beside dispatchPerRowBeforeHooks;
  • hook-target-rebind-errors.ts — one union member + one message branch, so the retired-lever refusal does not lie about the seam it fired on (the claim's own "if the context-shape contract must move" clause);
  • attachment-lifecycle.ts — the AttachmentLifecycleEngine.registerHook options type carries the flag so the registration type-checks without a cast.

Changeset

@objectstack/objectql minor (new opt-in registration declaration), @objectstack/service-storage patch (guard restoration). Not declared-breaking: the accept set does not expand — this is a declared = enforced restoration (triage's boundary test), so no ADR-0087 disposition marker is owed.


Generated by Claude Code

…e refusal through the wired engine
The predicate path's per-row dispatch (#5038/#5574) binds input.id on every
beforeDelete dispatch, so sys_attachment's declared #4757 refusal of a
predicate-less multi: true delete always took the handler's by-id branch —
and a zero-match predicate dispatched nothing at all. The refusal could not
fire through ObjectQL.delete on exactly the shape it refuses.
Engine: a new opt-in registration declaration, dispatchUnscopedMultiDelete
(beforeDelete only, refused elsewhere), makes ObjectQL.delete's predicate
branch dispatch the whole-operation context ONCE — before the matched-row
read, zero-match included — to registrations that declared for it. Binding
input.id on that context is refused (HookTargetRebindError, path
'unscoped-multi'), mirroring D4/#6752. Undeclared objects see no new
dispatch.
service-storage: the sys_attachment access-hook registration declares the
flag, making the handler's own #4757 branch reachable again with its
declared envelope (ATTACHMENT_DELETE_DENIED, 403). The unit block that
pinned the whole-operation shape by direct handler call — green while the
wired engine did the opposite — is re-pointed at a real ObjectQL engine.
Fixes#9719
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
check:type-check-debt re-measured @objectstack/objectql at +3 over its frozen
355: two 1-arg registerObject calls and one unused loop variable, all in the
new dispatch test. Fixed at the source (registerObject gets its packageId; the
seed loop no longer declares an unused row) — the ledger number is back to
exactly 355. The service-storage harness carried the same two idioms latently
(its test layer is not ledger-measured today) — fixed the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/service-storage, touching 12 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx(via beforeDelete (literal), input.id (literal))
  • content/docs/automation/hook-bodies.mdx(via input.id (literal))
  • content/docs/data-modeling/formulas.mdx(via beforeDelete (literal))
  • content/docs/data-modeling/validation.mdx(via beforeDelete (literal))
  • content/docs/kernel/events.mdx(via registerHook (symbol), beforeDelete (literal))
  • content/docs/permissions/attachments-access.mdx(via sys_attachment (literal))
  • content/docs/plugins/development.mdx(via registerHook (symbol))
  • content/docs/protocol/objectql/schema.mdx(via beforeDelete (literal))

4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via sys_attachment (literal))
  • content/docs/releases/v15.mdx(via sys_attachment (literal))
  • content/docs/releases/v16.mdx(via registerHook (symbol), beforeDelete (literal))
  • content/docs/releases/v17.mdx(via registerHook (symbol), sys_attachment (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 60 pages)

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 185c7bd51ac92d934ba1ad813eb01ebb6e2ca55bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b9d8338c8e39faa4e98625b117bf7cfdeb3f50c3 — the merge of head 8f430db06fc41346607fa6c3913730bbf442efed into base 185c7bd51ac92d934ba1ad813eb01ebb6e2ca55b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b9d8338c8e39faa4e98625b117bf7cfdeb3f50c3 && git checkout b9d8338c8e39faa4e98625b117bf7cfdeb3f50c3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 185c7bd51ac92d934ba1ad813eb01ebb6e2ca55b 8f430db06fc41346607fa6c3913730bbf442efed && git checkout -B drift-repro 185c7bd51ac92d934ba1ad813eb01ebb6e2ca55b && git merge --no-ff 8f430db06fc41346607fa6c3913730bbf442efed
node scripts/docs-audit/affected-docs.mjs --json 185c7bd51ac92d934ba1ad813eb01ebb6e2ca55b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 185c7bd51ac92d934ba1ad813eb01ebb6e2ca55b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@os-elon
os-elon marked this pull request as ready for review August 19, 2026 00:19
@os-elon
os-elon added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 1258dcaAug 19, 2026
29 checks passed
@os-elon
os-elon deleted the claude/issue-9719-unscoped-multi-delete-refusal branch August 19, 2026 00:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The #4757 unscoped multi-delete refusal on sys_attachment never fires through ObjectQL.delete — per-row hook dispatch bypasses it

2 participants

@os-elon@claude