Repository navigation
fix(objectql,runtime)!: an app-authored hook handler's permanent delete is refused - #31065
Conversation
…e is refused
The in-process handler of a hook bound through the hook binder is
app-authored code (APP_AUTHORED_HOOK_API). Its ctx.api, and every api
derived from it, now refuses delete(..., { permanent }) with the same
400 VALIDATION_ERROR and sentence a sandboxed body's api gives, under
the caller and under runAs: 'system' (ADR-0148 D3, ruling A).
The refusal moves to objectql (refuseAppAuthoredPermanentDelete) and the
runtime's sandbox reuses it, so both faces share one code and sentence.
It judges a shallow copy of the options and forwards that copy.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc
📓 Docs Drift CheckThis PR changes 2 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 38 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 6245e5141392d86f033f691694fb82d5a093a50d && git checkout 6245e5141392d86f033f691694fb82d5a093a50d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cad764d3e2d71210bce48653aa823a3b940071d7 5f8df60f19477bdd337f21eb7dea23f936b8b6d7 && git checkout -B drift-repro cad764d3e2d71210bce48653aa823a3b940071d7 && git merge --no-ff 5f8df60f19477bdd337f21eb7dea23f936b8b6d7
node scripts/docs-audit/affected-docs.mjs --json cad764d3e2d71210bce48653aa823a3b940071d7
|
|
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Contract reviewServed-tier: Reviewed at 2026-11-07T00:55Z, read-only: the PR body, its file list, the three-dot diff against the merge base ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgmentThe narrowing is the ruling made real at the right seam. The one place every app-authored in-process handler gets its api is Attempts to break it, each held: a derived api under every No must-fix. Should-fix: the developer-message sentence in ③; and a pin on Implemented-by: VERDICT: PASS |
|
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc 席位核对ACCEPT · 2026-11-07T01:04Z · head
Generated by Claude Code |
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Clause-②: yes (narrowing) — the in-process
handlerof a hook bound from metadata is app-authored code, and itsctx.apinow refusesdelete(…, { permanent })with any value butfalse, under everyrunAs, on every api derived from it; a handler that passed the key pastHookDeleteOptionswith a cast and purged the record is refused instead.Metadata-protocol: none
Skills-docs: none — no skill or doc teaches
permanenton a hook's delete; the refusal is the one sandboxed bodies already answer.维护者速读
handler运行的 hook 属于应用编写的代码;它的ctx.api(及由它派生的sudo()、withRunAs、事务上下文)上的delete(…, { permanent })现在与沙箱 body 一样被拒:400 VALIDATION_ERROR、param: 'permanent'、同一句话。拒绝逻辑移入@objectstack/objectql,runtime 沙箱改为复用这一份。manageRecycleBin的主体。feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148) #30990 只在沙箱 body 上执行了这条;已弃用的进程内handler路线此前只有类型层面的约束。permanent的应用 handler,仓内没有这种用法;平台代码(engine.delete、代码注册的 hook、createContext、purge/empty)不变。回滚即 revert 本 PR。packages/objectql/src/recycle-bin/app-authored-delete.ts(拒绝本体与选项副本);②packages/objectql/src/engine-scoped-context.ts的object()(按appAuthored包装);③packages/runtime/src/sandbox/body-runner.ts(删掉本地副本,改引共享拒绝)。pnpm --filter @objectstack/objectql exec vitest run src/recycle-bin/purge.test.ts,预期Tests 39 passed (39)。What changes
APP_AUTHORED_HOOK_API,hook-run-as.ts), and its in-processhandlerreceives a realScopedContext.ScopedContext.object()on an app-authored api now returns its repository wrapped byrefuseAppAuthoredPermanentDelete:delete(options)whoseoptions.permanentis anything butfalseor absent answers400 VALIDATION_ERRORwithparam: 'permanent',object: NAME, and the sentence a sandboxed body gets, with the face namedhook handler: "A hook handler cannot delete a record permanently: app-authored code cannot purge; its deletes enter the recycle bin. Remove 'permanent' from the delete of 'NAME'." It is answered before the engine is called, so no middleware, hook or driver call runs.appAuthoredis carried byderive(), the one constructor ofsudo(),withRunAs(...),transaction(cb),beginTransaction()and the user-grant seeding, so the refusal holds under the triggering caller,runAs: 'user'andrunAs: 'system'.packages/runtime/src/sandbox/body-runner.tstopackages/objectql/src/recycle-bin/app-authored-delete.tsand is exported from@objectstack/objectql; the sandbox'srefuseBodyPermanentDeletesapplies that function, so bodies and handlers share one code and one sentence. The developer message names the handler face beside the three body faces.deletereceives. A sandboxed body's options are plain JSON, for which the copy is the same value.engine.delete(…, { permanent: true }),engine.createContext(...), a hook registered in code withengine.registerHook(no metadata binding, so not app-authored),purge/empty, and every ordinary delete.deleteById(id)takes no options.ObjectRepositoryhas no other delete verb.Tests
New top-level describe in the recycle-bin harness,
packages/objectql/src/recycle-bin/purge.test.ts("an app-authored hook handler's permanent delete (ruling A)"): a realObjectQL, a row-keeping driver, the bin registered as@objectstack/platform-objectsdeclares it, and a middleware log of every dispatch. The hook is bound throughbindHooksToEnginewith an inlinehandlerand fired by an insert from an organization user.runAsinherit,userandsystem(6): the envelope (code,status,param,object), the message's first sentence, no dispatch on the target object, the row still present, the bin empty. The bulk legs assert the sentence because the engine already refuses a permanent delete by filter, with another sentence;sudo()derives and on the onetransaction(cb)hands its callback (2);permanentthat answers differently on a second read is read once, and the delete that runs is the ordinary one (1);permanentkey andpermanent: false, underinheritandsystem, are the ordinary delete: onedeletedispatch, the row gone, one bin entry (4);engine.createContext({ isSystem: true })keep the permanent delete: twopurgedispatches, no bin entry (1).The runtime's existing body pins (
packages/runtime/src/sandbox/body-runner.test.ts: job, hook and action bodies refused;falseand absent keys pass; host purge kept) run unchanged against the shared refusal, which the runtime reaches through objectql'sdist/.Ablations (each committed first; restore proven by blob hash and an empty `git diff HEAD`)
packages/objectql/src/engine-scoped-context.ts: anchorreturn this.appAuthored ? refuseAppAuthoredPermanentDelete(name, repo, 'hook handler') : repo;x1 to x0 (return repo;), blob91de02feto969ca039.purge.test.ts:Tests 8 failed | 31 passed (39), exactly the six refused legs and the two derived-api legs; the controls, the platform leg and the 25 earlier cases stay green. The by-id legs fail because the record is purged; the bulk legs because the engine's own filter-delete refusal answers another sentence. Restored, blob91de02feequals HEAD.packages/objectql/src/recycle-bin/app-authored-delete.ts: anchorreturn value.apply(target, [judged, ...rest]);x1 to x0 (forward the originalargs), blob7aa3fa4eto4418631c.Tests 1 failed | 38 passed (39), the read-once leg alone. Restored, blob7aa3fa4eequals HEAD.dist/(the runtime reaches it there): anchorthrow appAuthoredPermanentDeleteRefusal(objectName, face);x1 to x0, blob7aa3fa4etof42cd278; objectql rebuilt;ablation-dist-preflightfound the marker in 4 built files.packages/runtimebody-runner.test.ts:Tests 3 failed | 39 passed (42), exactly the three body-refusal cases (job refused, job uncaught, hook and action body). Restored, objectql rebuilt, preflight--absentover 14 built files, tree clean.Acceptance notes
handler(examples/app-crm/src/hooks/opportunity.hook.ts,examples/app-todo/src/objects/task.hook.ts), plus test fixtures (packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts,packages/cli/test/fixtures/option-b-collection-zoo.ts). None of them passespermanent, and no production code in the repository passespermanent: trueto any delete. Retiring the deprecated route would touch those two examples; this PR leaves the route in place and refuses at run time.ctx.apithe route hands its handler and on every api derived from it.test:repo(the vitestrepoproject) is CI's, inTest Core; not run locally.Verification
Local runs
All readings on the head
5f8df60f19(the merge ofmainat23cf11a4b) unless a line names743fff85e3(the same diff before that merge, which brought in three commits touching neither package).pnpm --filter @objectstack/objectql test(743fff85e3):Test Files 563 passed (563),Tests 11372 passed (11372).pnpm --filter @objectstack/runtime test(743fff85e3):Test Files 553 passed (553),Tests 8869 passed | 19 skipped (8888). LockVERDICT command-exit 0for both.typecheckof@objectstack/objectqland@objectstack/runtime(eachtsc --noEmitpluscheck:test-typecheckOK);src/recycle-bin/purge.test.tsTests 39 passed (39);src/sandbox/body-runner.test.tsTests 42 passed (42); lockVERDICT command-exit 0.pnpm --filter @objectstack/dogfood testafter building its closure:Test Files 431 passed | 3 skipped (434),Tests 3580 passed | 41 skipped (3621), lockVERDICT command-exit 0.node scripts/pm/dispatch-gates.mjs --commands: 62 commands, every one exit 0;--ranreconciliation:62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED. Among themcheck:adr-0087-registration(the changeset'snot-required (no-migration-prescription)disposition accepted),check:empty-changeset,check:changeset-no-major,check:engine-double-contract,check:doc-authoring,check:nul-bytes,check:dual-build-cjs-loads(116 entry points across 71 packages load).test:repoand the full lint farm: CI's.Generated by Claude Code