Repository navigation
feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148) - #30990
Conversation
…recycle bin A by-id delete that sets `permanent: true` keeps nothing in the recycle bin and is authorized as the `purge` operation rather than `delete`: the privacy-erasure door ADR-0148 D3 keeps, and a recycle-bin manager's act. A delete by predicate never enters the bin, so the key is for by-id deletes only. The authorable surface and the reference page are regenerated. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…lete, as the purge operation ObjectQL.purge(entryId) removes one recycle-bin entry and ObjectQL.empty(object) every entry of one object, scoped to the caller's organization. A by-id delete with permanent: true writes no entry. All three are dispatched as the new `purge` operation on the business object, never with an AST, so the recycle-bin manager's grant governs them; plugin-security refuses the unmapped verb for every non-system caller until it is mapped. The permanent delete is refused 400 off the by-id path, on a platform object and for a non-boolean value. `purge` advances the write epoch and names no hook pair, so beforePurge/afterPurge keep warning as before. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
…-jf62r6 The generated authorable surface is regenerated from the merged tree (gen:schema): main's rows and EngineDeleteOptions:permanent together. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4czFGLnXKQd6cQjv3Pr
📓 Docs Drift CheckThis PR changes 3 package(s): 45 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 175 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 4143e31d3a64f0f231ced9268b49880deb6c159a && git checkout 4143e31d3a64f0f231ced9268b49880deb6c159a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 41dc302496e48ed23d3dcd23f61d6365448237eb 9cfc766723c1e79bd24fb924e14a57e3f227470b && git checkout -B drift-repro 41dc302496e48ed23d3dcd23f61d6365448237eb && git merge --no-ff 9cfc766723c1e79bd24fb924e14a57e3f227470b
node scripts/docs-audit/affected-docs.mjs --json 41dc302496e48ed23d3dcd23f61d6365448237eb
|
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW Contract reviewServed-tier: Inputs read: PR #30990 body and file list (13 files, +852/-48); commits 2302a5f (spec, 3 files), aa8b43f (engine), 16a1d37 (merge of origin/main 1aeeafd); card #30237 body and all 12 comments (one page); the head's check-runs. The read-only shape held: nothing was built, run or re-run. ① Derived judgmentsScope against the slice and the claim.
The merge commit 16a1d37.
Security.
Refusals (code, status, message).
Tests and ablations.
Census.
Check-runs on the head (last read 2026-11-06 16:13Z): 62 check-runs; 28 success, 8 skipped, 26 in progress (Test Core 1-5, 7, 8; Test UI 1-16; Type Check UI; Type Check workspace; Build Core). None red at either read (16:10Z, 16:13Z). The commit status API reports no statuses. The PR is a draft. Landing waits for all green (contract-review.md ②). ② Semver levelConfirmed. ③ Boundary flags
④ Design judgment
The spec change holds against the ruling, its consumer and the acceptance scenarios. No must-fix. Implemented-by: VERDICT: PASS |
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW 席位核对 · ACCEPT · 2026-11-06T16:17Z · #30237 (recycle bin PR 2 of 5)Head-sha: 16a1d37
|
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW Hold · 2026-11-06T16:52Z · Test Core (2/8) red on
|
…ete; the hook face omits it HookDeleteOptions omits permanent, and the drift pin lists it under omitted. The sandbox body API refuses a body's delete carrying permanent (any value but false) with 400 VALIDATION_ERROR, param permanent, before the delete runs, so a job body under its system envelope cannot purge: its deletes enter the recycle bin (ADR-0148 D3, maintainer ruling A). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxhbtrhtRZ958iyyJp
…te is refused and removes nothing; false or absent deletes into the bin Changeset: @objectstack/runtime minor, and one sentence that hooks and app bodies cannot pass permanent. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxhbtrhtRZ958iyyJp
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH Contract review · deltaServed-tier:
1. The fix against the ruling and the order
2. The spec change3. Tests and ablation4. Merge and trailers5. Check-runs on 9f76546must-fixnone should-fixnitbody-delta accuracy (
|
…; changeset headline names runtime Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibxhbtrhtRZ958iyyJp
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH 维护者速读
席位意见:可以落地。 席位核对ACCEPT · seat
|
Part of #30237: the second of five PRs in the seat's split (claim amendment 6673429400; PR 1 #30780 merged as 25e1c1a). This one adds purge, empty and the permanent delete (Q3 ruled A, 6676408264). Still on the card: restore (PR 3), the permission reader for
allowRestoreOwnDeleted/manageRecycleBin(PR 4) and the REST doors (PR 5).Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up #30847 — skills/objectstack-api/SKILL.md :268, :417 (they teach that there is no recycle bin)
维护者速读
purge(entryId)(删掉一条回收站条目)和empty(object)(清空某个对象在本组织的全部条目),以及delete(object, { where: { id }, permanent: true })(彻底删除,不进回收站)。三者都以新的purge操作经过中间件链,作用在业务对象上,且都不带 AST。另按 Tesla 裁决 A(engine: snapshot deletes into the recycle bin, restore under original ids, purge, and the TTL reap (#3146 slice 3) #30237 评论 6691358077):应用编写的代码(hook、action、job 的脚本体)不能彻底删除,HookDeleteOptions去掉了permanent,沙箱对带permanent的删除直接拒绝(400 VALIDATION_ERROR),删除一律进回收站。purge这个动词。plugin-security还没把purge映射到manageRecycleBin(PR 4 做),所以在它映射之前,非系统调用者的这三种操作一律被拒(fail-closed),这是预期行为。OperationContext.operation多了'purge':按'delete'处理写操作的中间件看到的彻底删除是'purge'(已列出 PR 4 要处理的 plugin-security 位置)。回滚就是 revert 本 PR。packages/objectql/src/recycle-bin/purge.ts(三种操作和拒绝规则)、packages/objectql/src/engine.ts的delete()(readPermanentDelete接入点和runByIdDelete跳过快照)、packages/objectql/src/engine-middleware-operation-vocabulary.test.ts([finding] The bulk AST write path passesopCtx.operationraw, sopurge/transfer/restorederive no row scope whileupdate/deletenow do (#7665 asymmetry) #7809 词表 pin 的翻转)。pnpm --filter @objectstack/runtime exec vitest run src/sandbox/body-runner.test.ts,预期 42 个测试全过(其中 6 个是本次新增:job 脚本体传permanent: true被拒且什么都没删、false/不传照常进回收站、宿主代码的彻底删除不受影响)。What changes
@objectstack/spec(its own commit, 2302a5f; the maintainer's spec consent is recorded on #30237 in 6688058790):EngineDeleteOptionsSchemagainspermanent: z.boolean().optional(): delete for good, so the rows skip the recycle bin and cannot be restored. It is authorized as thepurgeoperation, notdelete, and is honoured on a by-id delete only.packages/spec/authorable-surface/data.json(one row) andcontent/docs/references/data/data-engine.mdx(one row).Hooks and app-authored bodies cannot pass
permanent(maintainer ruling A, recorded on #30237 in 6691358077; the hook face narrows from what this PR first offered, which never shipped):@objectstack/spec:HookDeleteOptionsis nowOmit<EngineDeleteOptions, 'context' | 'permanent'>, and the drift pin inhook-api.test.tslistspermanentunder OMITTED.hook.zod.tsneeds no mirror: itsapiis typedIScopedContext, and itsEngineDeleteOptionsmention is the firing delete's own options, which abeforeDeletehook still readspermanentoff.@objectstack/runtime:buildSandboxApi(sandbox/body-runner.ts), the one place every hook, action and job body gets itsctx.api, gains an outer layer,refuseBodyPermanentDeletes. A repository'sdelete(options)whoseoptions.permanentis anything butfalse/ absent is refused before the verb runs:400 VALIDATION_ERROR,param: 'permanent', message "A job body cannot delete a record permanently: app-authored code cannot purge; its deletes enter the recycle bin. Remove 'permanent' from the delete of 'OBJECT'." (the face label varies: hook body / action body / job body). It walks every derived context (object,sudo,withRunAs, transactions) through the sharedderiveThroughSeam. It holds under a job's{ isSystem: true, tenantId }envelope, which is the point: the system is never refusedpurge. The engine's owndelete(…, { permanent: true })(Q3 = A) is unchanged for every other caller.'@objectstack/runtime': minoradded, and one bullet saying hooks and app bodies cannot passpermanent.@objectstack/objectql: the removal side, in the newrecycle-bin/purge.ts. The two new engine members are host-literal delegations, per the facade gate.OperationContext.operationgains'purge'. All three acts are dispatched through the middleware chain aspurge, on the business object the rows belong to, so one grant (manageRecycleBin, ADR-0148 D5) will govern all three once PR 4 maps it. Until then,plugin-securityrefuses the unmapped destructive verb for every caller that is not the system (DESTRUCTIVE_OPERATIONS), which is the Q3 = A posture.purgenever carries an AST ([finding] The bulk AST write path passesopCtx.operationraw, sopurge/transfer/restorederive no row scope whileupdate/deletenow do (#7665 asymmetry) #7809). The security plugin's step 3 handsopCtx.operationto the RLS compiler raw, and the compiler knowsselect/insert/update/deleteonly. So:purgeandemptydispatch with noastand no id. The security plugin's 2.7 pre-image check needs a live-row id, so it skips them; they are judged at the object level only.where.idis the live row's, so step 2.7 judges that row as it judges a delete (rlsOperationForVerb('purge')isdelete).delete(),readPermanentDeleteruns right after the dispatch is resolved, before the operation context is built. It sets the verb topurge, andrunByIdDeleteopens no capture, so no entry is written. Everything else runs as for a by-id delete: the hooks (which readpermanentoffinput.options), the cascade, the events and the secret release. Accepted deviation (seat, 11-06 07:31Z): the plan did not name these refusals. They are refused400 VALIDATION_ERROR(param: 'permanent') before any middleware or hook runs:permanent: trueon a delete that is not by id, or that asksmulti: true. Only a by-id delete enters the bin, so a predicate delete has nothing to skip, and itswhererides an AST;permanent: trueon a platform object (sys_,cloud_,ai_), whose delete never enters the bin and is already permanent. This also keepspurgeoff every object the grants cache watches (GRANTS_CACHE_WATCHED_OBJECTSin@objectstack/core, allsys_*), whose watched-write set isinsert/update/delete;permanentthat is not a boolean.purge(entryId, { context })answerstruewhen the entry was removed. It answersfalsewhen no entry with that id is visible to the caller's organization, or when the composition registers no bin. The entry is read raw first, with the caller's driver options (so the caller's organization scopes it), for itsobject_name. The removal is then dispatched aspurgeon that object, and the bin row is removed raw inside the executor. Nothing is removed if the chain refuses.empty(object, { context })removes every entry ofobjectthat the caller's organization holds, and answers how many. It is onepurgedispatch onobject, so one authorization covers the act.deleteManyon the bin with{ object_name }. On a driver withoutdeleteMany, it removes the entries by id, one bounded page (500) at a time, and stops on a page that removes nothing.400(param: 'object'). It answers0when there is no bin.empty(object?)(seat, 11-06 07:31Z): the object is required. Emptying every object at once would be onepurgeauthorization per object, and a caller with the grant on some objects only would get a partial empty. PR 5's Console door can loop over the objects the admin page lists.purgejoinsWRITE_EPOCH_OPERATIONS, so the permanent delete, a purge and an empty each advance the engine's write epoch, asdeletedoes.ENGINE_OPERATION_VERBSbecameRecord<Verb, boolean>, where the value says whether the verb names abefore/afterhook pair.purgeisfalse, sobeforePurge/afterPurgestay outside the engine's lifecycle namespace and keep warning at registration, as before. Inside the namespace they would be refused, which is a narrowing ofregisterHookthis PR does not make. The#17713dispatch-gap pins are unchanged.openRecycleBinCapture's last parameter is renamedpredicate→skip, andrunByIdDeletepassesmulti === true || permanent.RecycleBinRemovalOptionsis exported from the package barrel, besideRecycleBinRetentionSettings.For PR 4 (sites in
@objectstack/plugin-securitythat key on'delete'and skip apurgedispatch; out of this PR's surface, not changed here):security-plugin-authz-middleware.ts:399: the self-owned epoch bump. It is inert while the engine owns the seam, which it does.security-plugin-authz-middleware.ts:2302: the bulk-write guard target. It is AST-only, sopurgenever reaches it.security-plugin-authz-middleware.ts:2314: the step-3 comment says the vocabulary has seven members and none is destructive. It should say thatpurgeis a member and never carries an AST.security-plugin-addressed-writes.ts:110/:129,security-plugin-authored-row-write.ts:108,security-plugin-cbp-write-gate.ts:364,security-plugin-rls-filter.ts:172/:371,platform-ownership-policies.ts:358,rls-compiler.ts:1349: the permanent delete reaches these aspurge, so each should readrlsOperationForVerbor listpurgebesidedeleteonce the verb is mapped.packages/verify/src/handle.ts:598(observeWrites) recordsinsert/update/deleteonly, so a permanent delete passes it unrecorded.Tests
packages/objectql/src/recycle-bin/purge.test.ts(25 cases). It runs a realObjectQLon a driver that keeps rows and honoursoptions.tenantIdthe way the SQL driver does, under theisolatedposture.purgedispatch with the row's id and no AST;beforeDelete/afterDelete, which seepermanent: true.permanent: falsewrites an entry, as the ordinary delete does.purge(plugin-security's current posture). The ordinary delete still passes.400refusals, each with no dispatch and no write:multi: true;sys_object;permanent.purge:falsefor another organization's entry, an unknown id, and a composition with no bin;400.empty:deleteMany, reads 1,100 entries in three bounded pages and stops on a full page it cannot remove;0with no bin;'', asys_object and acloud_object with400.packages/objectql/src/engine-middleware-operation-vocabulary.test.ts: the [finding] The bulk AST write path passesopCtx.operationraw, sopurge/transfer/restorederive no row scope whileupdate/deletenow do (#7665 asymmetry) #7809 pin flips forpurge.purgeandempty.purgeand no other destructive verb.purgedispatches are exactly the three driven, none with an AST, and only the permanent delete with an id.purge.permanent: trueoff the by-id path is refused before any middleware runs.packages/objectql/src/write-epoch.test.ts: the counted operations aredelete,insert,purgeandupdate.packages/objectql/src/engine-unknown-option.test.ts(unchanged): its drift pin derives the delete key set fromEngineDeleteOptionsSchema, and it is green only becausepermanentjoinedENGINE_DELETE_OPTION_KEYS.Ablations, each run against the tests above and restored:
permanent→ 1 red (the permanent delete writes an entry);delete→ 4 red (purge suite + [finding] The bulk AST write path passesopCtx.operationraw, sopurge/transfer/restorederive no row scope whileupdate/deletenow do (#7665 asymmetry) #7809 pin);permanentrefused only on therejectdispatch → 3 red;purge's entry read without the caller's driver options → 1 red (another organization's entry is purged);emptyrun outside the middleware chain → 2 red;packages/runtime/src/sandbox/body-runner.test.ts, new top-level describe (6 cases, realObjectQLover a row-keeping driver, one entry already in the bin):permanent: trueis refusedVALIDATION_ERROR/400with the rule in the message; no delete is dispatched, the record stays, the bin still holds its one entry;permanent: falseand an absent key are the ordinary delete: onedeletedispatch, the record enters the bin;engine.delete(…, { permanent: true })still purges (onepurgedispatch, no entry).Ablation (via
scripts/ablation-replace.mjs, anchor x1 → x0, blob 0a33b238 → d1c7f8ab): the refusal's condition replaced byif (false)→Tests 3 failed | 39 passed (42)(the three refusal cases); restored, blob == HEAD,git diff HEADempty. The test imports./body-runner.jsrelatively, so no dist leg.Acceptance notes
purgetomanageRecycleBin. That is the fail-closed posture ADR-0148 D2 keeps.afterDelete. If a manager's purge must be ledgered, that needs an event-specific writer, which is a later item.Verification
Ruling-A fix (head
9f765466: merge ofmainbcf8a37e, fixd8e95344, pins + changeset9f765466): the gate readings are in the os-dev report 6692900377 on #30237 (109/109 derived families exit 0; runtime 551 files / 8,832 tests; ablation 3 red). The seat's delta review is 6693177811 on this PR. The readings below are for16a1d37e.Head
16a1d37e: commits2302a5f0(spec) andaa8b43fa(engine), then a merge ofmainat1aeeafd9. The merge regeneratedpackages/spec/authorable-surface/data.jsonwithpnpm --filter @objectstack/spec gen:schema, as the generated-file merge driver asks.pnpm --filter @objectstack/spec --filter @objectstack/objectql --filter @objectstack/metadata-protocol --filter @objectstack/client run build, exit 0. Then every family fromnode scripts/pm/dispatch-gates.mjs --commands: all 109 exited 0.--ranreports "109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN".pnpm lintexited 0.aa8b43fa. The same 109 families. Two failed first on stale builds of packages this PR does not touch, and passed after rebuilding those packages:check:skill-examplesexited 3 (@objectstack/client);check:dts-closureexited 1 (@object-ui/i18n).pnpm --filter <pkg> run typecheckexited 0 for@objectstack/objectql(on both heads), and on the pre-merge head forcore,plugin-audit,plugin-security,plugin-sharing,service-storageandverify: every package that readsOperationContext.src/recycle-bin/, the [finding] The bulk AST write path passesopCtx.operationraw, sopurge/transfer/restorederive no row scope whileupdate/deletenow do (#7665 asymmetry) #7809 vocabulary pin,write-epoch,engine-unknown-optionandengine-hook-event-dispatch-gap: 7 files, 134/134 pass. Full@objectstack/objectqlsuite: 561 files, 11,231/11,231 pass.protocol-destructive-currency-mode.test.tsreading a stale@objectstack/metadata-protocoldist, and the file passes 7/7 after that package is rebuilt.node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD: exit 0.