Skip to content

docs(objectql): historicize the "delete only" example on HookEntry.dispatchUnscopedMultiWrite (#10172) - #10455

Merged
os-elon merged 1 commit into
mainfrom
claude/issue-10172-hookentry-delete-only-example
Aug 21, 2026
Merged

docs(objectql): historicize the "delete only" example on HookEntry.dispatchUnscopedMultiWrite (#10172)#10455
os-elon merged 1 commit into
mainfrom
claude/issue-10172-hookentry-delete-only-example

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#10172

The HookEntry.dispatchUnscopedMultiWrite doc block used sys_attachment as its
worked example of how to express "delete only". Both halves of that sentence stopped
being true when #10091 landed (PR #10169, squash da891e0ef): on main,
packages/services/service-storage/src/attachment-access-hooks.ts declares the flag on
the beforeUpdate registration (:441, registration opens at :347) as well as on the
beforeDelete one (:473). So the guard does declare an update refusal, and the flag
is declared on an update registration.

The defect class is reference text pinned to a moving referent. This passage is not
mentioning sys_attachment in passing — it is the file's worked example for the idiom,
so an author or agent reading it to learn how to say "delete only" is pointed at an
object that now declares both verbs.

Before — packages/objectql/src/engine.ts:1358-1362

 * whose failure is LESS recoverable. One flag now covers both, rather than a
* `dispatchUnscopedMultiUpdate` sibling: the flag is already per-REGISTRATION
* and per-EVENT, so "delete only" (#4757's `sys_attachment` guard, which
* declares no update refusal) is still said exactly — by declaring it on the
* `beforeDelete` registration and not on an update one.

After

 * whose failure is LESS recoverable. One flag now covers both, rather than a
* `dispatchUnscopedMultiUpdate` sibling: the flag is already per-REGISTRATION
* and per-EVENT, so "delete only" is still said exactly — declare the flag on
* the `beforeDelete` registration and omit it from the update one, and the
* whole-operation call is then dispatched for the declared verb alone (the
* dispatcher reads the entries of ONE event, then filters those on the flag).
*
* ⚠️ That idiom is deliberately stated WITHOUT naming a live registration.
* It used to name one: `sys_attachment` declared #4757's delete refusal on
* `beforeDelete` and on no update registration — true when this paragraph was
* written (#9974), false since #10091 gave that guard's update verb a refusal
* of its own. A replacement exemplar would only be the next referent free to
* move, so none is named: the past-tense sentence cannot be falsified by the
* tree moving on, because it is a claim about what those two cards did rather
* than about today's registrations.

Why the replacement cannot go stale the same way

Historicize (shape 1), with the idiom itself restated hypothetically (shape 2) so the
paragraph still teaches what it exists to teach. Deliberately not a swap to a
different live object, which would reproduce the defect with a fresh expiry date.

  • The only claim the passage now makes about a named object is past tense and bracketed
    by two cards
    : sys_attachment said "delete only" this way, Decision: should an unscoped multi: true UPDATE get the whole-operation dispatch that #9719 gave DELETE? (the update half split out of #9798) #9974 wrote that down,
    sys_attachment has no beforeUpdate authorization guard at all — insert and delete are gated, update is not (the comment kit it was derived from gates all three) #10091 ended it. That is a claim about what two merged cards did. Nothing a future PR
    lands can make it false — the tree moving on is exactly the event it already describes.
  • The live half — "declare the flag on the beforeDelete registration and omit it from
    the update one" — names no object at all. It is a statement about the mechanism,
    and it is true by construction of the dispatcher: dispatchUnscopedMultiWriteHooks
    reads this.hooks.get(event) and then filters that one event's entries on the flag
    (engine.ts:2759-2763), so a registration declaring the flag on one verb gets the
    whole-operation dispatch on that verb only. It goes stale only if the mechanism itself
    changes, at which point the mechanism prose above it is being rewritten anyway.
  • ⛔ The corollary I deliberately did not take: no census sentence such as "every
    object that declares the flag today declares it on both verbs" appears in the file. That
    reads as supporting evidence but is itself a present-tense assertion about the live tree
    — the very shape this card is about — and a third object declaring delete-only tomorrow
    would falsify it.

Measurement on origin/main (merge base 46cfa5b4f)

packages/services/service-storage/src/attachment-access-hooks.ts:441 beforeUpdate dispatchUnscopedMultiWrite: true
packages/services/service-storage/src/attachment-access-hooks.ts:473 beforeDelete dispatchUnscopedMultiWrite: true
packages/plugins/plugin-audit/src/comment-access-hooks.ts:481 beforeUpdate dispatchUnscopedMultiWrite: true
packages/plugins/plugin-audit/src/comment-access-hooks.ts:501 beforeDelete dispatchUnscopedMultiWrite: true

Every live declaration in the tree is on both verbs — there is no delete-only exemplar
left to point at, which is the second reason a live referent was not an option here.
Positive control on the grep: the flag appears 7 times in attachment-access-hooks.ts,
so it discriminates rather than matching everything.

Deliberately untouched

Gates — all run at b54f1a44d (the commit in this PR)

Gate set re-derived from the change set with node scripts/pm/dispatch-gates.mjs
(no path arguments — the script takes the merge-base change set itself).

gateexitverdict line it printed
pnpm check:durability-log-level0✓ durability-degradation log levels: 30 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller (5 propagating, declared).
pnpm check:slot-lookup0✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
pnpm check:stack-collection-maps0✓ check-stack-collection-maps: 7 enumerations reconciled against 32 declared collections (16 waiver rows, each with a reason).
node scripts/docs-audit/check-affected-docs.mjs0✓ affected-docs self-test: 262 cases pass.
pnpm check:nul-bytes0check-nul-bytes: OK (scanned 6136 text file(s) ...; no raw ASCII control bytes).
pnpm --filter @objectstack/objectql typecheck0tsc --noEmit (script name echoed, so not a zero-match filter)
eslint packages/objectql/src/engine.ts --no-inline-config0clean, no output

node scripts/check-engine-split-ratio.mjs is report-only (exit 0; the ADR-0076 OQ#5
threshold is deliberately unset), and this commit moves its number, so both readings:

before (46cfa5b4f): engine-core commits 29 · also cross-package 28 · ratio 96.6%
after (b54f1a44d): engine-core commits 30 · also cross-package 28 · ratio 93.3%

The drop is arithmetic, not a signal: this commit touches engine.ts and nothing outside
packages/objectql/, so it is the 30th engine-core commit in the 90-day window and the
2nd that is not cross-package.

Tests: the change is comment-only, so the run was scoped (declared narrowing) to every
test whose input is engine.ts text or the documented mechanism —
engine-unscoped-multi-write-dispatch.test.ts, core-boundary.ratchet.test.ts,
engine-middleware-operation-vocabulary.test.ts: 3 passed (3) files, 34 passed (34)
tests. CI runs the full farm.

Changeset — skip-changeset, on the repo's own rule

No changeset. AGENTS.md Post-Task Checklist §3 asks for one "when the change is a feature
or functional improvement" and states that pure bug fixes do not need one; this is a TSDoc
comment correction with no behaviour change, no API change and no test change, so it
releases nothing of its own. That is route 2 of the changeset gate's own prescription
("it releases nothing → apply the skip-changeset label"). Two precedents on main for
exactly this shape — a source-comment-only correction inside a published package landing
with zero changesets: ce300c8d6 (docs(spec), comment-only in object.zod.ts) and
97d2a03de (docs(plugin-auth), comment-only across 17 files).


Generated by Claude Code

…ispatchUnscopedMultiWrite` (#10172)
The `HookEntry.dispatchUnscopedMultiWrite` doc block used `sys_attachment` as its
worked example of how to express "delete only", asserting in the present tense that
the guard "declares no update refusal" and says so "by declaring it on the
`beforeDelete` registration and not on an update one". Both halves stopped being
true when #10091 gave that guard's update verb a refusal of its own: on `main`,
`attachment-access-hooks.ts` declares the flag on the `beforeUpdate` registration
(:441) as well as the `beforeDelete` one (:473).
The mechanism documentation is unchanged and was already correct — only the
example's referent moved. The idiom is now stated without naming any live
registration, and the `sys_attachment` reading is kept in the PAST tense against
the two cards that bracket it (#9974 wrote it, #10091 falsified it). No live
object replaces it: every registration that declares the flag today declares it on
both verbs, so a fresh exemplar would only be the next referent free to move, and a
past-tense claim about what two cards did cannot be falsified by the tree moving on.
Comment only — no behaviour change, no test change. The `:2741` provenance passage
("#9719 commissions the `sys_attachment` restoration, #9974 the `sys_comment`
update half") is deliberately left alone per triage: it is history, not a
present-tense assertion about the live tree.
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

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see

Coarse fallback — 14 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 a03ac2808a9726f777daf813cbce006e96fbbc0epackageMentionDocs.

Which tree this was computed on

This run read content/docs from e91e44d960deec2ee888a1d31b81453e76602186 — the merge of head b54f1a44d3a8a6b24d6d2c9689ba1300eb1817d5 into base a03ac2808a9726f777daf813cbce006e96fbbc0e, 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 e91e44d960deec2ee888a1d31b81453e76602186 && git checkout e91e44d960deec2ee888a1d31b81453e76602186
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a03ac2808a9726f777daf813cbce006e96fbbc0e b54f1a44d3a8a6b24d6d2c9689ba1300eb1817d5 && git checkout -B drift-repro a03ac2808a9726f777daf813cbce006e96fbbc0e && git merge --no-ff b54f1a44d3a8a6b24d6d2c9689ba1300eb1817d5
node scripts/docs-audit/affected-docs.mjs --json a03ac2808a9726f777daf813cbce006e96fbbc0e

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

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

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-elon@claude