Skip to content

feat(objectql)!: retire delete()'s by-id beforeDelete REPOINT, aligning with update() (#6752) - #7482

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6752-retire-delete-byid-repoint
Aug 11, 2026
Merged

feat(objectql)!: retire delete()'s by-id beforeDelete REPOINT, aligning with update() (#6752)#7482
hotlong merged 1 commit into
mainfrom
claude/issue-6752-retire-delete-byid-repoint

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

⛔ MAINTAINER-MERGED ONLY — do not queue, do not arm auto-merge

This diff touches docs/adr/** (ADR-0058 gains Amendment II.2), so Prime
Directive #14
applies: ⛔ no AI seat merges it, adds it to the merge queue, or
calls enable_pr_auto_merge. Reviewed + approved + fully green does not
override this — an accepted ADR is the decision, so merging one is the act of
adopting a governance position. Left as a draft on purpose; the PM flips it
ready after verification, and the maintainer merges by hand.

Closes #6752.

What this is

Route 2 of the 2026-08-09 maintainer ruling on #6752: a beforeDelete
handler on a by-id delete() may no longer move the delete onto another row
by assigning ctx.input.id. The rebind is refused with HookTargetRebindError /
ERR_HOOK_TARGET_REBIND, path: 'by-id' — exactly what the update() twin
(ADR-0058 Amendment II.1, PR #6697) and both per-row paths (D4) already raised.
Nothing is deleted; afterDelete and the roll-up recompute never run.

The rule across both verbs is now one line: a by-id target is immutable in a
before* handler.

CLEARED id REBOUND to another id
update() by-id refused refused
delete() by-id refused refused (was honoured)
either, per-row refused (D4) refused (D4)

Writing the same id back stays legal — the check is input.id !== id, the
update() check verbatim.

⛔ Route 3 (building update() the same re-resolution) stays excluded by #5574's
recorded ruling; this PR does not reopen it.

This removes a capability that WORKED

Worth stating plainly, because the diff reads like a bug fix and is not one.
#5272's re-resolution was internally correct: it re-read the new target's
pre-image and rebound previous, so afterDelete and the summary recompute saw
the row actually deleted and nothing stale ever leaked. The case that retires a
rebind on update() — the write landing on a row whose pre-image, readonlyWhen
locks and validation rules were never evaluated — genuinely did not apply to it.
That is why PR #6697 left the asymmetry standing rather than folding a behaviour
removal into an ordering change.

The ruling retires it anyway, on three measured axes: compatibility cost zero,
one rule beats two individually-correct rules an author must memorize, and "a
hook silently redirects which row gets deleted" is a top-grade footgun for
authored — especially AI-authored — handlers however correctly the redirect is
implemented.

The compatibility grep, re-run on this branch's base (not inherited)

grep -rnE "(input|ctx\.input|rowCtx\.input)[^;]{0,20}\)?\.id\s*=[^=]" \
  --include=*.ts --include=*.tsx --include=*.md --include=*.mdx .

Six sites, all of them tests in packages/objectql/src/. No consumer anywhere
repoints
— not in the framework, plugins, examples or docs.

site what it is
bulk-write-per-row-hooks.test.ts:675 per-row rebind refusal pin (control)
bulk-write-per-row-hooks.test.ts:695 update() by-id CLEARED refusal pin (control)
bulk-write-per-row-hooks.test.ts:715 delete() by-id CLEARED refusal pin (control)
bulk-write-per-row-hooks.test.ts:746 the repoint pin flipped by this PR
bulk-write-per-row-hooks.test.ts:767 same-id negative control (control)
engine.test.ts:1107 clears the id to force the #2982 fail-closed assertion (control)

⚠️ One correction to the card: its grep was input\.id\s*= with input.id ===
excluded, which misses the (ctx.input as any).id = cast form every pin in
bulk-write-per-row-hooks.test.ts uses — the card reported one hit where there
are six. The conclusion is unchanged and now rests on a grep that can see the
pins
: every additional hit is a test of this very family, so the measured
consumer count is still zero.

The four landing points, all moved together

  1. ADR-0058 Amendment II.1 scope table — the delete()-by-id REBOUND cell
    moves from "honoured (单记录 delete 从不绑定 hookContext.previous —— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272's re-read, unchanged)" to refused, and the
    carve-out paragraph now points forward instead of deferring. New Amendment
    II.2
    records the ruling, its three axes, the replacement routes, and that
    route 3 stays excluded.
  2. hook-target-rebind-errors.ts's NOT-covered section — deleted, because
    there is no longer an exception to remember. Replaced by the full four-cell
    coverage table plus the record of why the last cell arrived late (the
    mechanism it removed was not broken).
    Not on the card, found by reading: the REBOUND branch of the error
    message was written for update() only — it claimed honouring a rebind "would
    write a row that none of those checks ever saw", which was never true on the
    delete path. Left alone it would have shipped a false explanation to the first
    author who hit the new refusal. Restated for both verbs.
  3. engine.ts's re-read blockmeasured dead, so removed, not just
    re-commented.
    Its guard was wantsPreImage && input.id !== id && input.id,
    which is precisely the case the refusal now throws on, so it is unreachable
    the moment the refusal lands. The single pre-dispatch pre-image read that
    binds previous for beforeDelete (单 id update 把同一行前置状态读了 3 次(engine 前置行门 + sys_fetch_previous_update + plugin-audit captureBefore),且后两次不受任何按对象需求门约束 #5846 (a) / feat(objectql): dispatch before* hooks per matched row on a predicate bulk write (#5574, #5846) #6697) is a different read
    and is untouched — readPreImage / bindPreImage survive with one call site
    each.
  4. The D4 §7 pin (bulk-write-per-row-hooks.test.ts) — flipped, never
    deleted.

#5272's pins, each disposition named

pin disposition
bulk-write-per-row-hooks.test.ts §7 still HONOURS a by-id beforeDelete REPOINT FLIPPED to REFUSES a by-id beforeDelete REPOINT — the last cell, retired by #6752. Same handler, same fixtures; now asserts the full envelope (code, name, object, event, path, expectedId, observedId, message), that neither row was deleted, and that afterDelete never ran. Its comment records the flip as a ruling, not a fix, and warns against "restoring symmetry" via route 3.
(new) leaves a by-id beforeDelete that rewrites the SAME id alone ADDED — the negative control the input.id !== id check rests on. Same-id rewrite still deletes the addressed row and afterDelete still describes it. Measured legal before this PR and after.
hook-condition-previous-scope.test.ts §[#5272] a single-record delete binds previous UNTOUCHED — pins previous binding, not the repoint. Passes unchanged.
engine-delete-prior-read-scope.test.ts (per-object read gate, #5272/#6697) UNTOUCHED — the surviving pre-dispatch read is unchanged. Passes unchanged.
plugin-audit/audit-bound-previous.test.ts:300 single-id delete() pays exactly ONE findOne UNTOUCHED, and now strictly easier to hold — the second read is gone. Passes unchanged.
engine.test.ts:1107 (#2982 fail-closed CLEARED) UNTOUCHED (control).
update() twin + CLEARED column + per-row D4 UNTOUCHED (controls), per the card.

⚠️ packages/spec IS touched — declaring it loudly

Three files, all ADR-0087 ledger machinery, no schema and no export surface
changed
(gen:api-surface reports zero drift):

  • packages/spec/src/migrations/entries/semantic/17.delete-by-id-before-hook-repoint-retired.ts — new entry
  • packages/spec/src/migrations/registry.ts — generated region, gen:migration-registry
  • packages/spec/spec-changes.json — generated, gen:spec-changes
  • (plus docs/protocol-upgrade-guide.md, generated by gen:upgrade-guide)

Flagging for the #6017 declaration the PM files.

Changeset + gate reading

.changeset/retire-delete-by-id-before-hook-repoint.md, @objectstack/objectql: major +
@objectstack/spec: patch, carrying <!-- adr-0087: registered delete-by-id-before-hook-repoint-retired -->.

⚠️ Correction to the dispatch brief on check-changeset-no-major.mjs. The
brief warned the fixed group ships breaking as minor. Read against the gate:
that rule is currently suspended — Changesets is in RC pre-release mode
(tag: rc), where a major only ever produces X.0.0-rc.N, so the guard stands
aside by design and says so. major is therefore both correct and green here,
matching the immediate precedent (708431313, registerhook-empty-target-refusal).
If the RC window exits before this lands, the bump must drop to minor.

The ADR-0087 disposition is registered rather than not-required because the
other three are mechanically false: @objectstack/objectql is published (not
unpublished), no prior id covers this surface (not already-registered), and
the changeset ships rewrite instructions, so no-migration-prescription is a
self-contradiction the gate refuses. The entry is semantic (D3), not a D2
conversion
, for the reason hook-context-session-roles-retired and
hook-register-empty-object-target-refused are at the same step: a HookContext
is never persisted, so there is no sys_metadata row to rewrite, and the intent
behind a repoint is not recoverable — only the author knows whether it meant
"delete that row instead" or "delete that row too".

Gates run locally — all completed: success

gate result
@objectstack/objectql suite 178 files / 3149 tests passed
@objectstack/spec suite 373 files / 9789 tests passed
@objectstack/plugin-audit suite 12 files / 201 tests passed
@objectstack/objectql typecheck ✅ clean (tsc --noEmit)
check:adr-anchors ✅ 47 anchored files, 21996 citations resolve
check:durability-log-level ✅ 24 seams, 63 read seams
check:engine-double-contract ✅ 142 pinned, 133 debt, 2 exempt
check:stack-collection-maps ✅ 7 enumerations reconciled
check-engine-split-ratio.mjs ✅ reports (ADR-0076 D7 metric, informational)
check-changeset-no-major.mjs ✅ RC pre-mode — guard stands aside, major noted
check-adr-0087-registration.mjs ✅ 1 declared-breaking changeset, disposition carried
check-empty-changeset.mjs ✅ not empty
spec check:migration-registry ✅ current (60 semantic / 16 retired-key / 45 retired-def)
spec check:spec-changes ✅ up to date
spec check:upgrade-guide ✅ up to date
spec gen:api-surface ✅ zero drift

⚠️ First run of plugin-audit showed 7 failed suites — traced to a stale
@objectstack/objectql build in the worktree (Failed to resolve entry for package), not to this diff. Green after rebuilding. Recorded so nobody re-derives it.

One thing left deliberately alone

The pending .changeset/bulk-write-before-hooks-per-row.md (PR #6697's) says "a
beforeDelete handler that repoints the target is unaffected" — true of that
PR, false of the release both ship in. I did not edit another card's pending
changeset: it is their artifact and a needless conflict magnet. My changeset
states the supersession instead. Flagging it so the release compiler can reconcile
the two entries.

Refs #6752, #5272, #5574, PR #6697, ADR-0058, ADR-0087.


Generated by Claude Code

…ng with update() (#6752)

A `beforeDelete` handler on a by-id `delete()` may no longer move the delete
onto another row by assigning `ctx.input.id`. It is refused with
`HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND` (`path: 'by-id'`) — the
same refusal the `update()` twin and both per-row paths (D4) already raised.
Nothing is deleted; `afterDelete` and the roll-up recompute never run. The
rule across both verbs is now one line: a by-id target is immutable in a
`before*` handler.

This removes a capability that WORKED. #5272 RE-RESOLVED a repointed target,
re-reading its pre-image and rebinding `previous`, so nothing stale ever
reached a consumer — which is why #5574's engine half (PR #6697) deliberately
left the asymmetry standing rather than folding a behaviour removal into an
ordering change, and filed it as #6752. The 2026-08-09 maintainer ruling on
that card retires it on three measured axes: compatibility cost zero (the
repo-wide grep for assignments into a hook's `input.id`, re-run on this
branch's base, finds six sites and all six are this family's own pins), one
rule beats two individually-correct rules an author must memorize, and "a
hook silently redirects which row gets deleted" is a top-grade footgun for
authored handlers however correctly the redirect is implemented. Aligning the
other way — building `update()` the same re-resolution — stays excluded by
#5574's recorded ruling.

The four recorded landing points move together:

  * ADR-0058 Amendment II.1's scope table now shows the REBOUND column
    uniform, with the carve-out pointing forward to the new Amendment II.2,
    which records the ruling and its three axes.
  * `hook-target-rebind-errors.ts`'s "what this error does NOT cover" section
    is gone — there is no exception left to remember — replaced by the full
    four-cell coverage table and the record of why the last cell arrived late.
    The REBOUND message text was written for `update()` only and would have
    misdescribed the delete path; it now states the rule for both verbs.
  * `engine.ts`'s re-read block is DELETED, not merely bypassed: its guard was
    `input.id !== id && input.id`, exactly the case the refusal now throws on,
    so it became unreachable code. The single pre-dispatch pre-image read that
    binds `previous` for `beforeDelete` is a different read and is untouched.
  * The D4 §7 pin is FLIPPED to assert the refusal with its full envelope
    (code, name, object, event, path, expectedId, observedId, message), plus
    that neither row was deleted and `afterDelete` never ran. Flipped, not
    deleted — the case is the record of a ruling. A new negative control pins
    that a same-id rewrite stays legal, which is the measurement the
    `input.id !== id` check rests on.

Registers the ADR-0087 step-17 semantic entry
`delete-by-id-before-hook-repoint-retired` (a runtime hook surface: no
`sys_metadata` row for the D2 chain to rewrite, and the intent behind a
repoint is not recoverable, so it is a D3 semantic TODO). Artifacts
regenerated (`gen:migration-registry`, `gen:spec-changes`,
`gen:upgrade-guide`).

Refs #6752, #5272, #5574, PR #6697, ADR-0058, ADR-0087

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxyoeX26ZjnnwzbegYvTHW
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 6:47pm

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Aug 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec.

109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

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.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

delete() by-id 仍允许 beforeDelete 重定向 input.id —— 与 update() 侧的 ADR-0058 Amendment II.1 不对称,需定夺是否一并退休

3 participants