Uh oh!
There was an error while loading. Please reload this page.
feat(objectql,plugin-audit): refuse an unscoped multi-UPDATE on the shape, not by accident (#9974) - #10093
Conversation
…hape (#9974) Extend #9719's whole-operation dispatch to `beforeUpdate`'s predicate path, per the maintainer's option-A ruling of 2026-08-19, and generalize the flag (`dispatchUnscopedMultiDelete` -> `dispatchUnscopedMultiWrite`) instead of adding a sibling: it is already per-registration and per-event, so a delete-only guard still says "delete only". The #4630 refusal in `resolveTargetRows` now fires on the SHAPE — no id and no `where` — on both write verbs. Previously an unscoped multi-update was refused only when it happened to sweep a row the caller lacked rights to, with the per-row message, and resolved silently when the caller owned every row. The three MEASURED-GAP pins are REPLACED with refusal assertions, not relaxed. Objects whose guards do not declare the flag are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
…ur change A caller entitled to every row loses a call that works today. Stated as a narrowing of the accept set with the call-site fix named, not as "restoring" a guard, so the release notes read honestly for whoever is affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift CheckThis PR changes 3 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 21 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4c320d6f76aebe9069c246281c03ec352bd75b9c && git checkout 4c320d6f76aebe9069c246281c03ec352bd75b9c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e61ee68320da9136fedbfe2609b0d21f97b587b4 eb1ea4bed1c4a6c42e18c8cb401f88d275dbf95c && git checkout -B drift-repro e61ee68320da9136fedbfe2609b0d21f97b587b4 && git merge --no-ff eb1ea4bed1c4a6c42e18c8cb401f88d275dbf95c
node scripts/docs-audit/affected-docs.mjs --json e61ee68320da9136fedbfe2609b0d21f97b587b4
|
os-elon
commented
Aug 20, 2026
CI note — |
| suite | dispatch entries recorded |
|---|---|
plugin-auditcomment-access-hooks.test.ts (positive control) | 9 — all object=sys_comment flagged=1 |
example-showcase, entire suite (337 tests) | 0 |
Supporting evidence: the showcase suite is green locally 10/10 (standalone ×3, under CI's TZ=UTC + NODE_OPTIONS, serial --maxWorkers=1, and ×5 repeat); the CI shard-3 turbo invocation reproduces green locally (74/74 tasks), as do shards 1+2 (85/85), so the whole local farm is green; the showcase references none of this PR's symbols (0 hits for sys_comment, sys_attachment, dispatchUnscoped*, HookTargetRebind), and only sys_comment declares the flag on beforeUpdate; and main between this branch's merge base and the base CI merged with added only .claude/skills/** and one qa-checklist JSON — no code, so CI's merge commit is code-identical to what ran green here.
The exact failing assertion is unrecoverable: GitHub truncates the attempt-1 job log to a tail that ends before the showcase task's own output, and attempt 2 replaced the job. CI's own completeness guard was green on attempt 1 (13 package(s), 4308 test(s) declared and all 4308 accounted for), which by that step's own documentation means a real assertion failure rather than a dead worker — i.e. a non-deterministic test, not lost cases.
One candidate mechanism, offered as a lead rather than a diagnosis: examples/app-showcase/test/approval-resume-relation-expand.test.ts polls the async inbox on a 5-second wall-clock deadline (deliveredInbox) and returns short on timeout, after which the caller asserts toHaveLength(1) — so on a loaded runner "slow" is reported as "wrong". I could not reproduce a failure there even at 3× CPU oversubscription, so it is not filed as a defect; it is named so the next occurrence has somewhere to start.
Method note
The local gate union for this PR came from dispatch-gates.mjs, which derives families from changed paths — and examples/app-showcase is not a changed path, so no derivation could have named it, while Test Core runs the whole monorepo regardless. For a PR that changes a published symbol other packages consume, the local check set has to include the consumers, not just the changed packages. That gap is closed for this PR by the three shard runs above.
⛔ Nothing in this round touched the three replaced pins; they remain refusal assertions.
Generated by Claude Code
Generated by Claude Code
os-elon
commented
Aug 20, 2026
PM review — ACCEPT, enqueueing
The red was sent back, and came back with a cause rather than a disappearanceI returned this PR red and told it that "flake" is not a root cause. What came back is the strongest possible form of the answer, and it is worth naming because it is not the usual one: ⭐ The dev instrumented the built ⭐ And the control caught a broken probe. The first instrumentation attempt logged 0 hits everywhere — because it patched an entry point vitest does not load (it misses the My lead was disproved, correctly. I suggested a duck-typed seam still spelling Verified independently
Also confirmed: the earlier rename is pre-release, not breaking. Both #9719/#9798 changesets are still unconsumed on And the ablation stands: reverting the update-side dispatch reproduces the card's table verbatim — limb 1 resolving instead of rejecting (whole table rewritten), limb 3 falling back to the per-row On not filing a flaky-test cardThe dev declined to file one because it could not reproduce the failure and filing would assert a cause it did not have. That was the right judgement and I am not overriding it. But the candidate it named is worth a card under a different claim, and I have filed one — see below. The distinction matters: "this test is flaky" is unproven, while "this helper reports a timeout as a wrong value" is a structural fact readable from the source, needing no reproduction at all. One method note carried forwardThe dev's local check set came from Enqueueing. Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32324427332 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32324797163 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#9974
Implements the maintainer's option A ruling of 2026-08-19: extend #9719's whole-operation dispatch to
beforeUpdate's predicate path, so thesys_commentrefusal declared at #4630 fires on the shape — no id and nowhere— regardless of what the rows say.Implementation shape: generalized the existing flag, did not add a sibling
The card allowed either a
dispatchUnscopedMultiUpdatesibling or generalizing the flag. I generalized:dispatchUnscopedMultiDelete→dispatchUnscopedMultiWrite, valid onbeforeUpdateandbeforeDelete, with one dispatch helper takingeventas a parameter.Why this one leaves less duplicated logic:
HookEntry, two inregisterHook's options, two in each consumer's duck-typed engine seam, and two registration asserts — for one mechanism whose only difference is the event name.sys_attachmentdeclares it on itsbeforeDeleteregistration and nowhere else, and its accept set is unchanged. There is a dedicated pin for that (a beforeDelete-only declaration does not leak the dispatch onto update).engine.ts: "one rule across both verbs beats two individually-correct rules an author has to memorize."The rename is compiler-enforced and mechanical;
@objectstack/service-storageis a rename-only follow.The three pins were REPLACED, not relaxed
comment-access-hooks.test.ts, describe block nowunscoped multi-UPDATE (no id, no where) — #4630 through the wired engine (#9974):rejectswithRECORD_NOT_ACCESSIBLE/403 +Refusing an unscoped multi-update of comments; bodies unchangedCannot update comment c2: …Limb 3's message mattered as much as its verdict, so it is pinned in both directions: the new sentence must be present and
Cannot update comment c2must be absent — asserting only the former would pass on a message that still led with the row.The block keeps its neighbours as controls: the per-row refusal still fires on a scoped update,
where: {}still resolves, system context still bypasses, and an empty table withwhere: {}still resolves (so the empty-table refusal measures the shape, not emptiness).Reverse verification
Direction predicted up front: RED on the three limbs. Ablation = delete the
update()dispatch site, rebuild@objectstack/objectql, prove it leftdist/(plugin-audit consumes objectql's built artifact — it is inKNOWN_UNALIASED_TEST_IMPORTS), then run.Ablated run —
Tests 4 failed | 39 passed, and the failures reproduce the card's measured table verbatim:AssertionError: promise resolved "2" instead of rejecting(two rows rewritten)expected Error: Cannot update comment c2: only its… to deeply equal ObjectContaining{…}(the per-row message)The fourth was the
where: nullspelling, which is the same shape check. Every control stayed green, so the failures are the dispatch and not the harness.Restore leg rebuilt too, and proven present (
✓ marker present in 4 built files) before re-reading green.Caller sweep — behaviour change, so the blast radius was surveyed
Structural, not a line grep: brace-matched the enclosing object literal around every
multi: trueand resolved the enclosing call name, so a comment describing a call is not counted as one. Shorthand{ where, multi: true }and spread options are treated as scoped.multi: trueupdate call sites — 21 in test files, 1 is prose inside a string literal (packages/spec/scripts/liveness/proof-registry.mts:287).examples/, 0 in the dogfood apps. The showcase's only bulk write is adelete_recordflow node carryingfilter: { status: 'closed' }, and the app already pins "never declaresmulti: truewithout a bounding filter".packages/non-test source.Positive controls, so the zero is evidence rather than a failed search: the same regex returns 22 hits against the file I authored, and the same run classified 150scoped multi-update call sites — a non-empty complement proving the classifier discriminates instead of returning empty for everything.
Decisive narrowing: the dispatch reaches only registrations that declare the flag, and exactly one object declares it on
beforeUpdate(sys_comment). There is no programmaticsys_commentbulk write anywhere in repo source. Nothing in this repo newly refuses, so nothing needed scoping in this PR.Checks run locally, with their real results
Gate set derived from the actual change set via
node scripts/pm/dispatch-gates.mjs(no paths passed — the script reads the merge base itself), then run at81c380d51:@objectstack/objectqltest@objectstack/plugin-audittest@objectstack/service-storagetestobjectql+plugin-audittypecheckservice-storagedefines notypecheckscript — its DTS build is the equivalent, and passes)check:engine-double-contractcheck:where-matchercheck:type-check-debt(--re-measure)check:type-check-coverage,check:query-options-erasurecheck:slot-lookup,check:durability-log-level,check:stack-collection-mapscheck:test-source-alias,check:type-source-resolution,check:cross-package-test-inputscheck:changeset-gate-self-tests,check:objectui-changeset,check-empty-changeset,check-changeset-no-major,check-adr-0087-registrationcheck:engine-split-ratio,check:nul-bytescheck:i18n@objectstack/cli, re-ran: OK, 9 packages in syncThe
check:i18nline is recorded as it happened rather than as a green, because a prerequisite failure is NOT MEASURED, not a pass.Out-of-scope finding
Filed as #10091, unassigned, not fixed here:
sys_attachmenthas nobeforeUpdateguard at all — insert and delete are gated, update is not, while thesys_commentkit derived from it gates all three. Unrelated to this card's mechanism (that object declares the flag on delete only, deliberately), so it gets its own card rather than widening this PR.Scope
No new configuration surface, no opt-out flag, no public API beyond the refusal itself. Objects whose guards do not declare the flag are untouched — pinned by
an object whose registration does NOT declare the flag keeps exactly today's dispatches — and today's ACCEPT.Draft on purpose: not merging, not arming auto-merge.
Generated by Claude Code