Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-audit): ask enable.files/enable.feeds on the update verb too - #10332
Conversation
Both capability gates registered on beforeInsert only, so a re-point via UPDATE landed rows on a parent object whose declaration refuses them: a caller who could not create a sys_attachment on a files:false object could move an existing one onto it, and a sys_comment could be re-threaded into a feeds-disabled object's thread. The access kits authorize the re-point (comment-access-hooks since #4630, attachment-access-hooks since #10091) — those are access checks; the capability half was never asked. enable.files/enable.feeds are properties of the TARGET object, so the verb that made a row target it does not change the answer. Register the same two enforcement functions on beforeUpdate. Both already read only input.data, so an absent parent_object / thread_id means "not a re-point" and returns on the first line — a rename or body edit on a grandfathered row is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…#6656 no-global-pre-image assertion The #6656 case asserted "no beforeUpdate/beforeDelete registration" by filtering on EVENT NAME. Its own comment recorded that as a proxy: the property it pins is the retirement of captureBefore, an UNSCOPED pre-image reader that made hasHooksFor true system-wide and bought a prior-row read on every update in the stack. It carved the capability gates out by noting they are "on a single named object each" and "read no prior row" — true of the new beforeUpdate registrations too, which the event-name filter could not express. Filter on GLOBAL registrations instead, and add the affirmative half: each gate is declared on beforeInsert AND beforeUpdate, scoped to one object. The demand gate costs nothing new on these two objects — comment-access-hooks (#4630) and attachment-access-hooks (#10091) already declare beforeUpdate scoped to sys_comment / sys_attachment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
…rasing them check:query-options-erasure counts erased option bags in test code against a shrink-only ceiling; the new file pushed the test surface 240 -> 246. These inputs are all ON-contract (`where`, `multi`), so the gate's own remedy is to type them, not to cast — the `as unknown as` escape names a contract being bypassed deliberately, which is not what these do. Back to 240, at the ceiling, none new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
📓 Docs Drift CheckThis PR changes 1 package(s): 18 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 — 7 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 7096d599ebaa9ff74e6425bd85b5c3e800db92d9 && git checkout 7096d599ebaa9ff74e6425bd85b5c3e800db92d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 75e9301372987d99b458d168d88d926bf09b9003 c226824d043aca5cdc46c90a14cd5a792231ec24 && git checkout -B drift-repro 75e9301372987d99b458d168d88d926bf09b9003 && git merge --no-ff c226824d043aca5cdc46c90a14cd5a792231ec24
node scripts/docs-audit/affected-docs.mjs --json 75e9301372987d99b458d168d88d926bf09b9003
|
Uh oh!
There was an error while loading. Please reload this page.
…e.feeds describe strings (#10336) Both `.describe()` strings said the flag rejects *creation*. Since #10170 (PR #10332) `plugin-audit` registers each capability gate on `beforeUpdate` as well as `beforeInsert` (audit-writers.ts), so both refuse any write that makes a row TARGET the walled object — a create and an update that re-points a `sys_attachment` / re-threads a `sys_comment` alike, 403 FILES_DISABLED / FEEDS_DISABLED. The strings now say that, which is what the docblocks directly above them already said. These strings are not inert prose: `.describe()` reaches the generated schema output and the authoring surfaces built on it, so an author (or an LLM authoring metadata) reading "otherwise creation is rejected" would conclude a re-point is outside the flag's reach. It is not. `enable.activities` is genuinely a mirror-on-write switch and is untouched. Text only: no default, type, or key changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw
Fixes#10170
Accept → reject flip: an update of
sys_attachment/sys_commentthat re-points the row onto a capability-disabled target object goes from accepted today to 403. This PR was developed atclaude-opus-5under the maintainer's 2026-08-20 authorization (Clause-② cards may be developed one tier below the gate while fable is exhausted), and the compensating control is that triage reviews before merge. The PR is draft; auto-merge is not armed and must not be armed until triage has reviewed.The measurement establishing the flip is below — it is a behavioural reproduction on this branch, not an assertion.
Premise, re-established behaviourally before anything changed
New real-engine harness (
capability-gate-update-verb.test.ts) run against unmodifiedaudit-writers.ts. The file carries a control whose only job is to prove the harness is wired before any conclusion is read from it — the control passed, soenablereally round-trips through the registry and both gates are live:With the harness proven, the update verb on the same engine:
Tests 4 failed | 7 passed (11)— exactly the four update-verb re-points, on both dispatch shapes, for both capabilities. The row landed on the walled parent; this is not a missing error, it is a completed write.Verifying triage's reading of the declaration — it holds, with one nuance worth seeing
Triage read
enable.files/enable.feedsas properties of the target object. Checked againstpackages/spec/src/data/object.zod.tsrather than inherited:files(:269-281) — "permitssys_attachmentrows to target this object"; the flag lives in the target object'senableblock, not on the attachment.feeds(:283-290) — "rejects newsys_commentrows targeting this object".The reading holds. The nuance: both
describestrings then narrow to the verb — "otherwise creation is rejected", "rejects new attachments". So the declaration says the state property in its contract sentence and the verb in its short description. That tension is precisely the card: the contract sentence is the one the block-level docs and the gate's own doc comment state, and it is a property of the object. Nothing in the spec says a row may become a target of a walled object as long as no creation happened.What changed
Both existing enforcement functions are registered on
beforeUpdatein addition tobeforeInsert, scoped to one object each. No new error code —FILES_DISABLED/FEEDS_DISABLEDare existing standard-catalog members already inerror-code-ledger.zod.tsand already mapped to 403 inrest/src/error-response.ts;packages/specis untouched.Both functions already read only
ctx.input.data, so:parent_object/thread_idreturns on the gate's first line — not a re-point, not re-checked;before*stash not surviving toafter*on the predicate path) does not apply here — there is no stash.dispatchUnscopedMultiWriteis deliberately NOT declared (reasoning in the source, pinned in a test). That flag serves guards refusing an operation shape; these read the payload, which the per-row fan-out delivers to every matched row — so an unscopedmulti: truere-point is already refused on its first matched row. The only case the flag would add is a zero-match unscoped write, where nothing is written and nothing ever comes to target the walled object; refusing that is #4757/#9974's operation-shape policy, not this capability opt-in.A landed pin had to be re-pointed — please look at this
audit-hook-object-scope.test.tsasserted "plugin-audit declares NObeforeUpdate/beforeDeletehook (#6656)" and this change turns it red. Triaged rather than edited to green:The property #6656 pins is the retirement of
captureBefore— an unscoped pre-image reader that madehasHooksForanswer true for EVERY object onbeforeUpdateand bought a prior-row read on every update in the stack. The assertion expressed that by filtering on event name, and the case's own comment already recorded that as a proxy: it carved out the capability gates as "unrelated … on a single named object each" that "read no prior row", noting an assertion catching them "would fail on them while measuring nothing about this card". That carve-out is true of the new registrations too — the event-name filter just could not express it once the gates stopped being insert-only.So the filter now selects global registrations (
options.object === undefined), which is the invariant, and a second case adds the affirmative half: each gate is declared onbeforeInsertandbeforeUpdate, each scoped to one object.Cost check, measured not assumed: these registrations add zero new prior-row reads on any object.
comment-access-hooks.ts(#4630,:447) and service-storage'sattachment-access-hooks.ts(#10091,:347) already declarebeforeUpdatescoped tosys_comment/sys_attachment, sohasHooksForis already true for both wherever the access kits install.Who is affected — measured, not asserted
A structural sweep of the 4 660 in-tree source files (
git ls-fileson ts/tsx/js/mjs, excludingdist/), each search validated against a known-present control term first:parent_objectwhereclausethread_idon an updateobjectui@12841b6)sys_attachmentwrite is a create; the onlysys_commentupdate writesreactionsonlywhereclause, or a fixtureenable.files: truein-treeproject, todotask) + dogfood fixturesenable.feeds: falsein-treeNo existing row is newly refused. The narrowing is keyed on the payload, not on row state: a row already sitting on a walled parent is untouched, and renames/body edits/reaction writes on it keep working. Only a write that makes a row newly target a walled object is refused. This is pinned as its own test case, in both dispatch shapes, precisely so the narrowing cannot silently widen to grandfathered rows.
Tests
Real
ObjectQLwith a stub driver, not the hand-rolled fake inaudit-writers.test.ts— the fake'sregisterHookignores the{ object }option, so a registration change is unobservable there, and only the real engine fans a predicate write out per row.Two-directional on both dispatch shapes, every rejection asserting the ADR-0112 envelope (
codeANDstatus):dispatch.moderecord)per-row)files, walled parentFILES_DISABLED+ row unchangedFILES_DISABLED+ both rows unchangedfiles, enabled parentfeeds, walled objectFEEDS_DISABLED+ row unchangedFEEDS_DISABLEDfeeds, enabled objectPlus: unscoped
multi: truerefused on its first matched row; non-re-point updates unaffected; free-form/absentthread_idstill allowed.Ablation — predicted first, then measured
Predicted before running: removing the two
beforeUpdateregistrations turns exactly 5 of 12 red — the four re-point cases plus the unscoped-multi pin — while the insert control and every "enabled parent still succeeds" case stays green.Observed:
Tests 5 failed | 7 passed (12), and the five were exactly the predicted five.Rebuild statement, argued from the files. The ablation subject is
audit-writers.ts, imported by the test as./audit-writers.js— a relative specifier resolving to the sibling source file, so neutering it takes effect with no rebuild. TheObjectQLunder it is a different matter:packages/plugins/plugin-audit/vitest.config.tsaliases only@objectstack/core,platform-objects/audit,spec*andtypes—@objectstack/objectqlmatches none of those patterns (verified by evaluating the five regexes against the specifier), so the engine resolves through packageexportstopackages/objectql/dist/, which was built at the top of this run.Restore proved byte-identical, both legs:
Gate union — derived, not recalled
node scripts/pm/dispatch-gates.mjs(no paths passed; the script derives the change set itself from the merge base) re-run after the final commit on a clean worktree, and the gate set was identical to the pre-commit derivation. Exit codes captured before any pipe; verdicts quoted from each gate's own line. Run atc226824d0:check:query-options-erasurefirst came back red — the new test file grew the test surface 240 → 246. Repaired the way the gate names (these inputs are on-contract, so they are typed withsatisfies EngineQueryOptions/EngineUpdateOptions), not by raising the ceiling.One gate declared unrun, with the reason:
check:type-check-debt(the--re-measureratchet half) refuses on this worktree —@objectstack/service-knowledgehas no built type entry point, and the gate is explicit that measuring from here would silently measure a different world. It re-measures ledgered packages only, and@objectstack/plugin-auditis in neitherDEBTnorTEST_DEBT(checked by parsing both ledger blocks out ofscripts/check-type-check-coverage.mjs), so no number it records can move for a diff confined to this package. The structural half is green above and the package's owntypecheckexits 0. CI runs it on the built closure regardless.Generated by Claude Code