Skip to content

feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148) - #30990

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-jf62r6
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-jf62r6

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

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),删除一律进回收站。
  • 为什么改:ADR-0148 回收站的移除侧。Q3 裁定为 A:彻底删除只有回收站管理员能做,所以它和清除、清空共用 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 passes opCtx.operation raw, so purge / transfer / restore derive no row scope while update / delete now do (#7665 asymmetry) #7809 词表 pin 的翻转)。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/sandbox/body-runner.test.ts,预期 42 个测试全过(其中 6 个是本次新增:job 脚本体传 permanent: true 被拒且什么都没删、false/不传照常进回收站、宿主代码的彻底删除不受影响)。
  • 需要你做的:无。spec 改动已获 Elon 同意(engine: snapshot deletes into the recycle bin, restore under original ids, purge, and the TTL reap (#3146 slice 3) #30237 评论 6688058790),契约复核和落地由席位负责。

What changes

@objectstack/spec (its own commit, 2302a5f; the maintainer's spec consent is recorded on #30237 in 6688058790):

  • EngineDeleteOptionsSchema gains permanent: z.boolean().optional(): delete for good, so the rows skip the recycle bin and cannot be restored. It is authorized as the purge operation, not delete, and is honoured on a by-id delete only.
  • Regenerated: packages/spec/authorable-surface/data.json (one row) and content/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: HookDeleteOptions is now Omit<EngineDeleteOptions, 'context' | 'permanent'>, and the drift pin in hook-api.test.ts lists permanent under OMITTED. hook.zod.ts needs no mirror: its api is typed IScopedContext, and its EngineDeleteOptions mention is the firing delete's own options, which a beforeDelete hook still reads permanent off.
  • @objectstack/runtime: buildSandboxApi (sandbox/body-runner.ts), the one place every hook, action and job body gets its ctx.api, gains an outer layer, refuseBodyPermanentDeletes. A repository's delete(options) whose options.permanent is anything but false / 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 shared deriveThroughSeam. It holds under a job's { isSystem: true, tenantId } envelope, which is the point: the system is never refused purge. The engine's own delete(…, { permanent: true }) (Q3 = A) is unchanged for every other caller.
  • Changeset: '@objectstack/runtime': minor added, and one bullet saying hooks and app bodies cannot pass permanent.

@objectstack/objectql: the removal side, in the new recycle-bin/purge.ts. The two new engine members are host-literal delegations, per the facade gate.

  • One verb. OperationContext.operation gains 'purge'. All three acts are dispatched through the middleware chain as purge, 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-security refuses the unmapped destructive verb for every caller that is not the system (DESTRUCTIVE_OPERATIONS), which is the Q3 = A posture.
  • purge never carries an AST ([finding] The bulk AST write path passes opCtx.operation raw, so purge / transfer / restore derive no row scope while update / delete now do (#7665 asymmetry) #7809). The security plugin's step 3 hands opCtx.operation to the RLS compiler raw, and the compiler knows select / insert / update / delete only. So:
    • purge and empty dispatch with no ast and 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.
    • The permanent delete is a by-id delete. Its where.id is the live row's, so step 2.7 judges that row as it judges a delete (rlsOperationForVerb('purge') is delete).
  • The permanent delete. In delete(), readPermanentDelete runs right after the dispatch is resolved, before the operation context is built. It sets the verb to purge, and runByIdDelete opens no capture, so no entry is written. Everything else runs as for a by-id delete: the hooks (which read permanent off input.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 refused 400 VALIDATION_ERROR (param: 'permanent') before any middleware or hook runs:
    • permanent: true on a delete that is not by id, or that asks multi: true. Only a by-id delete enters the bin, so a predicate delete has nothing to skip, and its where rides an AST;
    • permanent: true on a platform object (sys_, cloud_, ai_), whose delete never enters the bin and is already permanent. This also keeps purge off every object the grants cache watches (GRANTS_CACHE_WATCHED_OBJECTS in @objectstack/core, all sys_*), whose watched-write set is insert / update / delete;
    • a permanent that is not a boolean.
  • purge(entryId, { context }) answers true when the entry was removed. It answers false when 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 its object_name. The removal is then dispatched as purge on 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 of object that the caller's organization holds, and answers how many. It is one purge dispatch on object, so one authorization covers the act.
    • It uses deleteMany on the bin with { object_name }. On a driver without deleteMany, it removes the entries by id, one bounded page (500) at a time, and stops on a page that removes nothing.
    • An object that is no longer registered can still be emptied.
    • A platform object, or an empty name, is refused 400 (param: 'object'). It answers 0 when there is no bin.
    • Accepted deviation from the plan's empty(object?) (seat, 11-06 07:31Z): the object is required. Emptying every object at once would be one purge authorization 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.
  • Write epoch. purge joins WRITE_EPOCH_OPERATIONS, so the permanent delete, a purge and an empty each advance the engine's write epoch, as delete does.
  • Hook namespace unchanged. ENGINE_OPERATION_VERBS became Record<Verb, boolean>, where the value says whether the verb names a before / after hook pair. purge is false, so beforePurge / afterPurge stay outside the engine's lifecycle namespace and keep warning at registration, as before. Inside the namespace they would be refused, which is a narrowing of registerHook this PR does not make. The #17713 dispatch-gap pins are unchanged.
  • openRecycleBinCapture's last parameter is renamed predicate → skip, and runByIdDelete passes multi === true || permanent.
  • RecycleBinRemovalOptions is exported from the package barrel, beside RecycleBinRetentionSettings.

For PR 4 (sites in @objectstack/plugin-security that key on 'delete' and skip a purge dispatch; 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, so purge never 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 that purge is 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 as purge, so each should read rlsOperationForVerb or list purge beside delete once the verb is mapped.
  • packages/verify/src/handle.ts:598 (observeWrites) records insert / update / delete only, so a permanent delete passes it unrecorded.

Tests

  • packages/objectql/src/recycle-bin/purge.test.ts (25 cases). It runs a real ObjectQL on a driver that keeps rows and honours options.tenantId the way the SQL driver does, under the isolated posture.

    • The permanent delete:
      • removes the record and its master-detail child;
      • writes no entry;
      • is one purge dispatch with the row's id and no AST;
      • fires beforeDelete / afterDelete, which see permanent: true.
    • permanent: false writes an entry, as the ordinary delete does.
    • The permanent delete is refused and changes nothing under a middleware that refuses non-system purge (plugin-security's current posture). The ordinary delete still passes.
    • 400 refusals, each with no dispatch and no write:
      • a delete by filter;
      • a by-id delete with multi: true;
      • a sys_ object;
      • a non-boolean permanent.
    • purge:
      • removes the one entry, as one dispatch on its object with no AST and no id;
      • answers false for another organization's entry, an unknown id, and a composition with no bin;
      • is refused, removing nothing, for a non-system caller under that middleware, and the system purges;
      • refuses a bad id with 400.
    • empty:
      • removes the caller's organization's entries of one object only, in one dispatch, and counts them;
      • removes every organization's entries for the system;
      • on a driver without deleteMany, reads 1,100 entries in three bounded pages and stops on a full page it cannot remove;
      • empties an object that is no longer registered;
      • is refused, removing nothing, under that middleware;
      • answers 0 with no bin;
      • refuses '', a sys_ object and a cloud_ object with 400.
  • packages/objectql/src/engine-middleware-operation-vocabulary.test.ts: the [finding] The bulk AST write path passes opCtx.operation raw, so purge / transfer / restore derive no row scope while update / delete now do (#7665 asymmetry) #7809 pin flips for purge.

    • The weld and the probe now hold 8 verbs, and the probe also drives a permanent delete, purge and empty.
    • The union declares purge and no other destructive verb.
    • The purge dispatches are exactly the three driven, none with an AST, and only the permanent delete with an id.
    • The AST-carrying set is unchanged. The no-AST list gains the three purge.
    • permanent: true off the by-id path is refused before any middleware runs.
  • packages/objectql/src/write-epoch.test.ts: the counted operations are delete, insert, purge and update.

  • packages/objectql/src/engine-unknown-option.test.ts (unchanged): its drift pin derives the delete key set from EngineDeleteOptionsSchema, and it is green only because permanent joined ENGINE_DELETE_OPTION_KEYS.

  • Ablations, each run against the tests above and restored:

  • packages/runtime/src/sandbox/body-runner.test.ts, new top-level describe (6 cases, real ObjectQL over a row-keeping driver, one entry already in the bin):

    • a job body passing permanent: true is refused VALIDATION_ERROR / 400 with the rule in the message; no delete is dispatched, the record stays, the bin still holds its one entry;
    • uncaught, the same refusal fails the job run with that envelope;
    • a hook body (the engine's api) and an action body (the executionContext fallback) are refused the same way;
    • permanent: false and an absent key are the ordinary delete: one delete dispatch, the record enters the bin;
    • host code's engine.delete(…, { permanent: true }) still purges (one purge dispatch, no entry).
  • Ablation (via scripts/ablation-replace.mjs, anchor x1 → x0, blob 0a33b238 → d1c7f8ab): the refusal's condition replaced by if (false) → Tests 3 failed | 39 passed (42) (the three refusal cases); restored, blob == HEAD, git diff HEAD empty. The test imports ./body-runner.js relatively, so no dist leg.

Acceptance notes

  • The card's Wanted items in this slice: purge, empty, and the permanent delete as the manager's act (Q3 = A).
  • No non-system caller can use any of the three until PR 4 maps purge to manageRecycleBin. That is the fail-closed posture ADR-0148 D2 keeps.
  • A purge or an empty writes no audit row: plugin-audit skips the bin, and the verb fires no hook. The permanent delete is audited as a delete, through its 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 of main bcf8a37e, fix d8e95344, pins + changeset 9f765466): 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 for 16a1d37e.

Head 16a1d37e: commits 2302a5f0 (spec) and aa8b43fa (engine), then a merge of main at 1aeeafd9. The merge regenerated packages/spec/authorable-surface/data.json with pnpm --filter @objectstack/spec gen:schema, as the generated-file merge driver asks.

  • Gates on the merged head. First the build: pnpm --filter @objectstack/spec --filter @objectstack/objectql --filter @objectstack/metadata-protocol --filter @objectstack/client run build, exit 0. Then every family from node scripts/pm/dispatch-gates.mjs --commands: all 109 exited 0. --ran reports "109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN". pnpm lint exited 0.
  • Gates on the pre-merge head 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-examples exited 3 (@objectstack/client);
    • check:dts-closure exited 1 (@object-ui/i18n).
  • Typecheck. pnpm --filter <pkg> run typecheck exited 0 for @objectstack/objectql (on both heads), and on the pre-merge head for core, plugin-audit, plugin-security, plugin-sharing, service-storage and verify: every package that reads OperationContext.
  • Tests.
  • node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD: exit 0.

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/runtime, @objectstack/spec, touching 32 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/spec/authorable-surface/data.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

45 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 41dc302496e48ed23d3dcd23f61d6365448237eb.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/objectql/src/index.ts, packages/spec/authorable-surface/data.json) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 80 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 175 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 41dc302496e48ed23d3dcd23f61d6365448237eb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4143e31d3a64f0f231ced9268b49880deb6c159a — the merge of head 9cfc766723c1e79bd24fb924e14a57e3f227470b into base 41dc302496e48ed23d3dcd23f61d6365448237eb, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 41dc302496e48ed23d3dcd23f61d6365448237eb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 16a1d37e1df7cb62b01096281a9e5fbf87c0c1ae
Local-runs: none
Scope: packages/spec/src/data/data-engine.zod.ts + packages/spec/authorable-surface/data.json, content/docs/references/data/data-engine.mdx, .changeset/30237-recycle-bin-purge-empty-permanent.md, packages/objectql/src/recycle-bin/purge.ts, packages/objectql/src/recycle-bin/purge.test.ts, packages/objectql/src/recycle-bin/capture.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/engine-driver-options.ts, packages/objectql/src/engine-middleware-and-context.ts, packages/objectql/src/write-epoch.ts, packages/objectql/src/engine-middleware-operation-vocabulary.test.ts, packages/objectql/src/write-epoch.test.ts, packages/objectql/src/index.ts, packages/metadata-core/src/engine-delete-dispatch.ts, packages/core/src/security/tenant-layer0-verdict.ts, packages/plugins/plugin-security/src/permission-evaluator.ts, packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts, packages/plugins/plugin-security/src/lifecycle-verb-rls-operation.ts, packages/plugins/plugin-audit/src/audit-writers.ts, packages/verify/src/handle.ts, packages/runtime/src/sandbox/body-runner.ts, packages/services/service-automation/src/builtin/crud-nodes.ts, packages/spec/src/automation/builtin-node-config.zod.ts, scripts/tenant-audit-census.mjs, docs/adr/0148-recycle-bin-separate-store-restore-under-original-ids-retention-in-days.md

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. git merge-tree was used as a read of git objects to reproduce the merge; scripts/pm/check-governed-merges.mjs --pr 30990 and record-recognisers.mjs --paths were used as read-only tooling.

① Derived judgments

Scope against the slice and the claim.

  • Confirmed. Every hunk is in PR 2's slice (purge, empty, permanent delete) or is the test and vocabulary work that slice forces. Nothing of PR 3 (restore), PR 4 (permission.zod.ts, OPERATION_TO_PERMISSION, GRANT_BITS) or PR 5 (REST doors) rides along: packages/plugins/plugin-security/**, packages/core/**, packages/rest/** are untouched (file list).
  • Confirmed. The spec change is exactly the consented key: packages/spec/src/data/data-engine.zod.ts:447-457 adds permanent: z.boolean().optional() with a describe, nothing else. Consent 6688058790 covers this key in data-engine.zod.ts only, and permission.zod.ts is not touched.
  • Confirmed. The regenerated shard is exactly what the key implies: packages/spec/authorable-surface/data.json gains one row, "data/EngineDeleteOptions:permanent", between :multi and :where; the mdx gains one table row at content/docs/references/data/data-engine.mdx:659. The other spec artefacts (api-surface/data.json, declaration-map, json-schema.manifest, export-origins, authorable-defaults) list export names or defaulted keys, not every key, so they owe no row; authorable-surface.base.json is the frozen baseline the ratchet compares against, not a regenerated file.
  • Confirmed. All hunks sit inside the claim's file surface (6673233448: packages/objectql/src/**; packages/spec/** as consented). But the card carries no PR 2 amendment: 6673429400 admits PR 1 only and says "each later PR gets its own amendment"; 6675653517 widens PR 1; 6678640327 lists PR 2 as pending. See ③.

The merge commit 16a1d37.

  • Confirmed. git merge-tree --write-tree aa8b43fa 1aeeafd9 reproduces the merge with one unmerged path, packages/spec/authorable-surface/data.json, which the generated-file merge driver refuses to text-merge. The merge commit's tree differs from that auto-merge tree in that one file only (2 lines). Against the main parent, the merge commit's data.json differs by exactly the one permanent row, so the regeneration is consistent with main plus the key. No hand edit beyond that resolution.

Security.

  • Confirmed: permanent: true is authorized as purge. packages/objectql/src/engine.ts:9153 reads it before the operation context is built; engine.ts:9160 sets operation: permanent ? 'purge' : 'delete'. The check is the chain's: plugin-security's object gate hands the raw verb to checkObjectPermission (security-plugin-authz-middleware.ts:844-849); permission-evaluator.ts:305-311 finds no mapping for purge and the DESTRUCTIVE_OPERATIONS set (:93) answers deny. So every non-system principal is refused until PR 4 maps the verb to manageRecycleBin. Fail-closed, as the body says.
  • Confirmed: a caller cannot reach a permanent delete through a batch or by-filter path. readPermanentDelete (purge.ts:115-124) refuses permanent: true unless the dispatch kind is by-id and multi is not true; resolveEngineDeleteDispatch (engine-delete-dispatch.ts:189-219) is the same ladder the engine routes on. A by-filter delete never dispatches as purge.
  • Confirmed: no snapshot. engine.ts:9572 passes options?.multi === true || permanent as skip, and capture.ts:332 returns no capture when skip is true.
  • Confirmed: platform objects are refused for all three acts. purge.ts:125 and :192 refuse through recycleBinTakesObject (capture.ts:72-73, hasPlatformObjectPrefix over sys_, cloud_, ai_, platform-object-names.ts:199). purge() takes its object from the entry's object_name, which the bin only ever holds for business objects (Q7, 6675653517).
  • Confirmed: the system bypass. security-plugin-authz-middleware.ts:418 lets isSystem through for every verb; that is the existing boundary and the PR names it. The flow-run and app-automation exception (:404-415) covers the four identity objects only; a purge on a sys_* object is refused by the engine first.
  • Confirmed: tenant wall for the normal case. purge() reads the entry with buildDriverOptions(RECYCLE_BIN_OBJECT, context) (purge.ts:155-159), so an organization-scoped caller cannot see another organization's entry (test purge.test.ts:289-298). empty() deletes with the same scope (purge.ts:198-201; test :330-340). The system is unscoped by design (:342-348).
  • Should-fix (security, rule level), owed before PR 4 maps the verb: the two object-level purge dispatches do not carry the chain's recorded tenant verdict, and the engine's own fallback falls open for a caller with no active organization. purge.ts:158 and :198 build the bin's driver options without recordedVerdict: opCtx.tenantLayer0Verdict, unlike every engine executor (engine.ts:4611, :9519, :7623). For a deny verdict with no active organization, driverReadScope answers "no scope" (engine-driver-options.ts:440-441), and under isolated a caller with no active organization is deny (tenant-layer0-verdict.ts:181). The chain enforces deny through the composed RLS filter on the AST path and the by-id pre-image (security-plugin-authz-middleware.ts:1058-1066, :2354-2411); purge() and empty() carry neither an AST nor an id, so neither applies. Today the DESTRUCTIVE_OPERATIONS backstop refuses the dispatch before the executor runs, so nothing is removed, and purge()'s pre-dispatch read discloses only whether an entry id exists (403 against false). Once PR 4 maps purge, an organization-less principal that still clears the object gate would empty every organization's entries of the object. Close it in PR 4's slice or a delta here: refuse (or answer false / 0) when the engine's own Layer-0 verdict is deny, and pass the recorded verdict inside the executor.
  • Should-fix (boundary), for the seat's judgment: the sandbox body runner forwards a body's own opts to ql.delete (packages/runtime/src/sandbox/body-runner.ts:1133), and a job body runs under a fresh { isSystem: true, tenantId } envelope (:1424). So app-authored job code can now pass permanent: true and erase a record with no bin entry, as the system, with no manageRecycleBin check. Before this PR the same body's delete entered the bin. The flow delete node cannot (crud-nodes.ts:1002 builds { where, multi, context }; DeleteRecordConfigSchema is a strict object, builtin-node-config.zod.ts). The REST and action paths build fixed options (batch-routes.ts:430, action-execution.ts:790, :3504). Whether app-automation's system envelope should stop short of purge the way it stops short of identity writes (Security: a runAs system flow writes identity-assignment rows (sys_user_position, sys_invitation) past the delegated-administration gate #25251, Security: a runAs system hook or an action script body reaches the four identity-assignment objects past the delegated-administration gate (the #25251 refusal requires flowRunId) #25653) is a maintainer question; it is not a defect of this slice.
  • Confirmed, knowingly deferred: a purge or an empty writes no audit row. plugin-audit skips sys_recycle_bin (audit-writers.ts:256+, the PR 1 must-fix A entry) and purge names no hook pair (engine-top-level.ts:165). The permanent delete is audited through its afterDelete. ADR-0148 D3 puts "delete and restore audit reports" on a cloud card, so no open-source ledger row is owed here; the PR body's acceptance note says so.

Refusals (code, status, message).

  • Confirmed. Every new refusal is 400 with code: 'VALIDATION_ERROR' and a param (purge.ts:65-74): permanent not a boolean (:107-114); permanent: true off the by-id path or with multi: true (:115-124); permanent: true on a platform object (:125, param: 'permanent'); a bad entryId (:144-151, param: 'entryId'); empty('') or a platform object (:184-192, param: 'object'). Messages name the rule and the fix; developerMessage names the object. VALIDATION_ERROR is a standard ledger code; no new code is introduced, so error-code-ledger.zod.ts owes nothing.
  • Confirmed. These are the "permanent-delete refusals" the body calls an accepted deviation. The code matches the body's list exactly: by-filter, multi: true, platform prefix, non-boolean. The acceptance itself has no record on the card or the PR. See ③.

Tests and ablations.

  • Confirmed. purge.test.ts runs a real ObjectQL under isolated with a driver that keeps rows and honours tenantId (:21-91, :125-148). It covers the permanent delete (record and cascade gone, no entry, one purge dispatch with id and no AST, hooks see permanent: true, permanent: false still captures, refusal under a middleware that refuses non-system purge, the four 400s), purge() (one dispatch with no id, another organization's entry answers false, unknown id, no bin, refusal versus system, bad ids) and empty() (own organization only, system takes all, pages of 500 on a driver with no deleteMany, 1,100 entries in 3 pages, a stuck page ends the loop, unregistered object, refusal, no bin, three 400s).
  • Confirmed. The eight ablations in the body would go red as stated, read against the assertions: capture not skipped → :194; verb always delete → :196, :233 plus the vocabulary pins (engine-middleware-operation-vocabulary.test.ts:348, :362-366, :414-421); refusal only on reject → :243-254 and :372-384 of the vocabulary file; platform refusal dropped → :256-264; entry read unscoped → :289-298; empty outside the chain → :339, :395; fallback stops after one page → :370; stuck-page guard dropped → :374-379 never returns. The exact red counts are the dev's; the direction holds.
  • Confirmed. The [finding] The bulk AST write path passes opCtx.operation raw, so purge / transfer / restore derive no row scope while update / delete now do (#7665 asymmetry) #7809 pin is re-pinned, not loosened: the union is read from the engine source (:329), purge is the only destructive member (:340), every purge dispatch is asserted AST-free (:362-366), and the AST-carrying set is unchanged (:410). The registerHook accepts event names the engine never dispatches — read filters on beforeFindOne/beforeCount silently never run #17713 lifecycle namespace stays at 14 names (engine-hook-event-dispatch-gap.test.ts:122) because ENGINE_LIFECYCLE_HOOK_EVENTS filters on the verb's boolean (engine-top-level.ts:174).

Census.

  • Confirmed. The tenant-audit census enumerates packages/services and packages/plugins only (scripts/tenant-audit-census.mjs:248, SURFACE_ROOTS). The raw bin reads and removals in packages/objectql/src/recycle-bin/purge.ts are outside its population, as capture.ts's raw insert was in PR 1. No census row is owed.

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 level

Confirmed. .changeset/30237-recycle-bin-purge-empty-permanent.md declares '@objectstack/objectql': minor and '@objectstack/spec': minor, with Clause-②: yes (widening) and Metadata-protocol: added line-initial, matching the PR body. Metadata-protocol: added takes at least minor on a package the diff moves; both packages that move are listed. The spec adds an optional key with no default and changes no existing behaviour, so it is a widening. In objectql, OperationContext.operation gains a member, WRITE_EPOCH_OPERATIONS gains purge, two engine methods and one type export are added, and ENGINE_OPERATION_VERBS changes its value type from true to boolean (a module-private constant, not exported). No narrowing is hidden: a delete that succeeded before still succeeds, since permanent was unreadable before this PR (engine-unknown-option refused it as an unknown key), and beforePurge/afterPurge registration keeps warning rather than refusing (engine-top-level.ts:160-165). The changeset body's middleware-author paragraph states the 'purge' verb and its no-AST rule.

③ Boundary flags

  • Author: PR user.login is objectstack-fleet[bot]; no outside login to name.
  • The dev's PR body and os-dev report (6689274673) say the seat accepted two deviations at 07:31Z: empty requires the object, and the permanent-delete refusals. No card comment, PR comment or claim amendment records that acceptance. In the card thread the dev's 07:30Z message asked the seat to look at two trade-offs: empty must name the object, and beforePurge/afterPurge stay warnings. The refusals were in the attached body draft. The seat should record its acceptance of all three (the object parameter, the refusals, the hook namespace) in the PR 2 claim amendment, and confirm it meant the refusals.
  • Owed by the seat before landing: the PR 2 claim amendment that 6673429400 promised ("each later PR gets its own amendment"), naming PR 2's file surface (the 13 paths above) and the deviations. The diff is inside the claim's broad surface and the spec consent, so this is a record the seat writes, not a change the dev makes.
  • The two should-fix items in ① are flagged for PR 4: the tenant-verdict gap in the object-level purge dispatches must be closed before purge is mapped to a grant, and the job-body permanent door under the system envelope wants a maintainer's word. Neither is reachable by a non-system principal on this head.
  • open_questions in the os-dev report: none. Out-of-scope finding carried by the seat: the plugin-security sites keyed on 'delete' (listed in the body under "For PR 4") and verify's observeWrites (packages/verify/src/handle.ts:598, which records a permanent delete as nothing). Both are correctly left to PR 4.
  • Skills-docs: skills/objectstack-api/SKILL.md:268, :417 still teach that there is no recycle bin; the follow-up is skills: objectstack-api still teaches that DELETE is unrecoverable and that there is no recycle bin (#30237, ADR-0148) #30847 and the changeset carries the Skills-docs: line.
  • Governed paths: node scripts/pm/check-governed-merges.mjs --pr 30990 reports 0 of 13 paths governed; 900 changed lines, under the 3000-line human-merge threshold. Ordinary queue landing applies once the record is in and CI is green.

④ Design judgment

packages/spec/src/data/data-engine.zod.ts (the one hits file), against the ruling it implements and its first consumer.

  • The key implements Q3 = A (6676408264) as ruled: permanent: true is the purge door, authorized as purge, with no snapshot. The describe and docblock say exactly that and name the by-id-only rule, which the engine enforces (purge.ts:115-124). The docblock cites ADR-0148 D3, where the sentence lives ("Permanent delete stays available for privacy erasure and never enters the bin", ADR-0148 :233-234, :252).
  • The first consumer is the engine's delete() (engine.ts:9153), which reads the key through readPermanentDelete and through ENGINE_DELETE_OPTION_KEYS (engine-top-level.ts:331-332), so the unknown-option drift pin derives the accepted key set from the schema and stays green only because the engine admits the key. The hook input type (spec/src/data/hook-api.ts, hook.zod.ts) carries it to beforeDelete/afterDelete, which the test shows reading it.
  • The key is optional with no default, so every existing author and caller is unchanged; a false is the ordinary delete. A non-boolean is refused rather than coerced, which is the right reading of z.boolean() at the engine door.
  • One wording gap, not a defect: the describe says "By-id deletes only" but not that multi: true beside an id is also refused. The engine says so in its refusal message, and the docblock says it. No change asked.

The spec change holds against the ruling, its consumer and the acceptance scenarios. No must-fix.

Implemented-by: claude/project-thread-jf62r6
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

席位核对 · ACCEPT · 2026-11-06T16:17Z · #30237 (recycle bin PR 2 of 5)

Head-sha: 16a1d37

  • Contract review: CONTRACT_REVIEW_TIER record 6689562965 on this head, PASS, no must-fix. The only reviewed face is the consented permanent key in data-engine.zod.ts; the regenerated authorable-surface/data.json differs by that one row.
  • Inside claim 6673233448 with amendment 6689578503 (PR 2's 13-path surface; empty names its object; the permanent refusals; the purge hook pair warns). Implements the maintainer's Q3 = A (6676408264): permanent: true is the purge verb, needs manageRecycleBin, writes no snapshot. Spec consent 6688058790.
  • Read by the seat:
    • The purge verb is set before the operation context is built and is checked by plugin-security's object gate fail-closed for every non-system caller. Off the by-id path, with multi: true, or on a sys_ / cloud_ / ai_ object it is refused 400 before any write.
    • Organization-scoped callers cannot see or purge another organization's entries (pinned in purge.test.ts).
    • The merge 16a1d37e reproduces with git merge-tree; the only difference is the generated shard, regenerated by gen:schema.
  • Should-fix, carried, not blocking:
    • (1) The object-level purge dispatches do not pass the recorded Layer-0 tenant verdict. Nothing can reach that on this head, because the destructive-operation backstop refuses first. It is carried to PR 4 in the amendment.
    • (2) A maintainer question, not a defect of this slice: app job bodies run as system and can pass permanent: true. Whether app automation's system envelope should stop short of purge goes to the maintainer with the card's handoff.
    • Purge and empty write no audit row. ADR-0148 D3 defers that, and the body says so.
  • Gates: on this head 33 success, 8 skipped, 21 still running, 0 failure at the time of this record. Local: dev report 6689274673 (109/109 families, objectql 11,231/11,231). Auto-merge waits for the rest.
  • Changesets: @objectstack/spec minor, @objectstack/objectql minor, Metadata-protocol: added, Clause-②: yes (widening): right. permanent was an unknown key before, so nothing that succeeded before is refused now.
  • Landing: 900 changed lines, no governed path. Ready, then auto-merge, through the relay. The card stays open: PR 3 to PR 5 go back to the queue with a handoff, because the lane is winding down.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 16:18
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 16:18
@objectstack-fleet
objectstack-fleet Bot disabled auto-merge November 6, 2026 16:52
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Hold · 2026-11-06T16:52Z · Test Core (2/8) red on 16a1d37e: a real failure in this PR

packages/spec/src/data/hook-api.test.ts:232, drift pin "HookDeleteOptions / EngineDeleteOptionsSchema": EngineDeleteOptionsSchema now has permanent, and the pin's KEPT ∪ OMITTED (where, multi / context) does not list it. As typed today, HookDeleteOptions = Omit<EngineDeleteOptions, 'context'>, so a hook's ctx.api.object(n).delete() can pass permanent: true.

The fix needs a decision on the published hook face. Either keep permanent (a hook's purge is still gated by the target object's manageRecycleBin) and add it to kept, or omit it from HookDeleteOptions and add it to omitted. This belongs with the maintainer question already carried: system-context permanent: true from app job bodies (record 6689562965, should-fix 2).

Auto-merge is now off. The ACCEPT 6689623157 no longer stands for landing. The lane is signing off, so the fix, its delta review and the landing pass to the next seat. The card handoff is 6690307585.

…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
@github-actions github-actions Bot added size/xl and removed size/l labels Nov 6, 2026
…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

Contract review · delta

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9f765466ea1fe5a6705aa0d2fea04c55899f4e78
Local-runs: probe — the narrowing is a spec + sandbox refusal, so the reviewer reproduced the dev's ablation and the two touched suites on the head rather than read them from the report
Scope: delta 16a1d37..9f76546 (7c83475 merge of main bcf8a37, d8e9534 fix, 9f76546 pins + changeset); files packages/spec/src/data/hook-api.ts, hook-api.test.ts, packages/runtime/src/sandbox/body-runner.ts, body-runner.test.ts, .changeset/30237-recycle-bin-purge-empty-permanent.md; read for context (unchanged on the branch): packages/runtime/src/stored-metadata-reader-seam.ts (deriveThroughSeam), packages/runtime/src/sandbox/quickjs-runner.ts (VM api bridge, SANDBOX_ERROR_PASSTHROUGH), packages/objectql/src/engine.ts (ObjectRepository, buildHookApi, purge/empty), engine-object-repository.ts, hook-binder.ts, hook-run-as.ts, packages/spec/src/data/hook.zod.ts, api-surface/data.json, export-origins/data.json; comments 6691358077 (ruling A, 2026-11-06T17:40Z), 6692900377 (dev report, 2026-11-06T18:57Z) on #30237; PR #30990 body; the fix order and the proposed body delta in the seat's project files; check-runs on 9f76546.

  • Local probe. worktree at 9f76546 (pnpm install + turbo build --filter='@objectstack/runtime^...'); packages/spec: vitest run src/data/hook-api.test.ts → 13 passed (13); packages/runtime: vitest run src/sandbox/body-runner.test.ts → Test Files 1 passed (1), Tests 42 passed (42); ablation (anchor if (permanent !== undefined && permanent !== false) { ×1 → if (false) {, blob 0a33b238 → d1c7f8ab, both hashes reproduced from source) → Tests 3 failed | 39 passed (42), exactly the three refusal cases (job refused, job uncaught, hook+action refused); restored, blob == HEAD 0a33b238, git status clean. git merge-tree --write-tree 16a1d37e bcf8a37e → tree e658bb7f == tree of merge commit 7c83475.

1. The fix against the ruling and the order

  • Where the refusal sits. buildSandboxApi (packages/runtime/src/sandbox/body-runner.ts:1170-1180) now wraps the whole layered API in refuseBodyPermanentDeletes (:1248-1252), outermost, above the stored-metadata read/write refusals. All three body faces take their ctx.api from it: hook :1380 ('hook body'), action :1477 ('action body'), job :1508 ('job body', envelope { isSystem: true, tenantId? } built at :1505). The dev's reason for not sitting at the ~L1133 ql.delete forward is correct: buildSandboxApiSource (:1254-1275) returns the engine's own api for a hook, and ql.createContext(execCtx).object(name) for a job/action, so the buildEngineRepoFacade forward (:1133) is only the last-resort fallback; a refusal there would have missed the job face entirely.
  • Derived contexts. deriveThroughSeam (stored-metadata-reader-seam.ts:410-447) re-wraps object(name) (repository wrapper), sudo() and withRunAs() (recursive wrap), transaction(cb) (wraps the trx handed to the callback) and beginTransaction() (wraps begun.ctx). The VM's transaction path reads txState.api = r.ctx from ctx.api.beginTransaction() (quickjs-runner.ts:770) and resolves every later object() from txState.api, so a delete inside a body-opened transaction goes through the wrapped ctx. Marker Symbol.for('objectstack.runtime.bodyPermanentDeletesRefused') keeps it idempotent.
  • What the VM can reach. The bridge installs exactly object(name) with READ find/findOne/count/aggregate and WRITE insert/update/delete/updateMany/deleteMany/upsert (quickjs-runner.ts:705-706), plus transaction(fn). No sudo, withRunAs, raw engine handle, ql, purge or empty is installed on the VM's ctx.api; a body has no path to the engine other than ctx.api.object(name).<verb>. deleteMany/updateMany resolve to proxy[method] on the repository (installApiMethod, quickjs-runner.ts:1324, :1368 "not implemented" SandboxError when absent); ObjectRepository has delete, deleteById, update, updateById and no deleteMany (engine-object-repository.ts; class ObjectRepository at engine.ts:10402-10578, whose only other member is execute(actionName), not installed on the VM), so deleteMany is not a second delete door, and deleteById(id) takes no options bag. Args cross via vm.dump (quickjs-runner.ts:1353, JSON), so a non-plain options object cannot reach the host; the check options !== null && typeof options === 'object' is the right shape for what arrives.
  • Refused vs allowed. refuseRepositoryPermanentDelete (body-runner.ts:1215-1233): refused at :1226 when options.permanent !== undefined && options.permanent !== false, so true, 'true', 1, null, {} are all refused; false and an absent key pass through unchanged. The error is code: 'VALIDATION_ERROR', status: 400, httpStatus: 400, param: 'permanent', object: <name>, message "A body cannot delete a record permanently: app-authored code cannot purge; its deletes enter the recycle bin. Remove 'permanent' from the delete of ''." (bodyPermanentDeleteRefusal, :1190-1207, message at :1192-1193). Thrown before value.apply, so nothing is dispatched. In the VM the body sees code, status, message (and fields/userMessage when set; SANDBOX_ERROR_PASSTHROUGH, quickjs-runner.ts:1470); param stays host-side, as the test title says.
  • Host engine unchanged. engine.delete(…, { permanent: true }) is untouched by the delta (no objectql file in 7c83475..9f76546) and pinned in the new last test ("host code keeps the engine's permanent delete": one purge dispatch, no bin entry).
  • Residual route, outside the order's scope (dev listed it under out_of_scope_findings). A metadata hook bound through the binder with a legacy in-process handler (a package-registered function, hook-binder.ts:216, :257, deprecation warning :290; DEPRECATED per hook.zod.ts:485) is app-authored by the engine's own definition (APP_AUTHORED_HOOK_API, hook-run-as.ts:270) and gets a real ScopedContext from buildHookApi (engine.ts:10633; ScopedContext.object() :10600), not the sandbox API. Such a handler can cast past HookDeleteOptions and, under runAs: 'system' or inherited elevation, the engine purges. The identity-write refusal the ruling cites as precedent (Security: a runAs system flow writes identity-assignment rows (sys_user_position, sys_invitation) past the delegated-administration gate #25251/Security: a runAs system hook or an action script body reaches the four identity-assignment objects past the delegated-administration gate (the #25251 refusal requires flowRunId) #25653) is enforced engine-side off the app-automation elevation mark and therefore covers that route; this fix covers the sandbox only, which is exactly what the order asked for. Recorded below as a follow-up, not a blocker.
  • 2. The spec change

    • packages/spec/src/data/hook-api.ts:219: HookDeleteOptions = Omit<EngineDeleteOptions, 'context' | 'permanent'> & HookDriverPassthroughOptions; the exclusions docblock gains a permanent entry (:81-87) citing ADR-0148 D3 and the ruling comment. Narrow: one key, no other face touched.
    • hook-api.test.ts:223-229: drift pin kept: ['where','multi'], omitted: ['context','permanent'] with the ruling cited. The pin checks shapeKeys(EngineDeleteOptionsSchema) == kept ∪ omitted, i.e. it forces the decision for every schema key; it does not itself assert that the TS type omits permanent (see should-fix 1).
    • hook.zod.ts needs no mirror: HookContextSchema.api is z.unknown() typed IScopedContext (:1609), and the EngineDeleteOptions mentions (:757, :882) describe the firing delete's own input.options, which a beforeDelete hook still reads permanent off — that is the engine's bag, not the hook's delete face. Correct.
    • Published-key / metadata-protocol: api-surface/data.json:380 and export-origins/data.json:370 list HookDeleteOptions (type) by name only; its shape is not a published key set, so no regenerated surface is owed. Net against main: EngineDeleteOptions widens (as before), HookDeleteOptions' key set is identical to main's (bcf8a37e:packages/spec/src/data/hook-api.ts:206 minus nothing new), so Clause-②: yes (widening) and Metadata-protocol: added in the PR body stay correct. No doc or skill on the branch teaches permanent on a body's delete (grep of content/**, skills/**, authorable-surface/**).
    • Changeset: '@objectstack/runtime': minor added and one bullet "Hooks and app bodies cannot pass permanent" (.changeset/30237-recycle-bin-purge-empty-permanent.md:4, :16), consistent with the code (any value but false; every envelope). The changeset's headline still reads feat(objectql,spec) while three packages now bump (nit).

    3. Tests and ablation

    • body-runner.test.ts:588-740, new describe, real ObjectQL over a row-keeping driver, sys_recycle_bin registered with RECYCLE_BIN_ENTRY_FIELDS, one entry t0 seeded into the bin, middleware records every delete/purge dispatch on pdb_ticket:
      • job body permanent: true → outcome: 'degraded', reason ^VALIDATION_ERROR 400 and the rule text; deletes empty, t1 still in rows, bin still ['t0'] (:667-683);
      • uncaught → run rejects { code: 'VALIDATION_ERROR', status: 400 }, rows and bin intact (:685-692);
      • hook body (engine api, ScopedContext({ isSystem: true })) and action body (executionContext: { isSystem: true } fallback) refused alike; no dispatch (:694-715);
      • permanent: false and absent key → one delete dispatch, rows empty, bin ['t0','t1'] (:717-731);
      • host engine.delete(…, { permanent: true }) → one purge dispatch, bin ['t0'] (:733-739).
        So the pins read rows and the bin, not a stub's call log: nothing removed, bin intact. The job pin's envelope is { isSystem: true } (no organization passed, so no tenantId); the tenantId variant is not separately pinned (nit).
    • Ablation: anchor occurs once; the blob hashes the dev reported reproduce from source (head 0a33b238c2…, ablated d1c7f8abfd…). Local re-run: 3 failed | 39 passed (42), the same three refusal cases red, the false/absent and host-purge pins still green (so the ablation isolates the refusal, not the delete path).

    4. Merge and trailers

    • 7c83475 is a plain merge: git merge-tree --write-tree 16a1d37e bcf8a37e yields tree e658bb7f…, identical to the merge commit's tree, so no hand resolution was made; the merge touched none of the delta's five files.
    • d8e9534 and 9f76546 carry the standard assistant Co-authored-by line and a Claude-Session: link only; no model name, no Fixes. The Fixes #… lines in git log 16a1d37e..9f765466 all belong to main's commits brought in by the merge.

    5. Check-runs on 9f76546

    • 65 check-runs: 57 success, 8 skipped (opt-in / scheduled jobs: Lint UI, UI Published Artifacts, Dogfood Verify CLI, Temporal Conformance, Dogfood Regression Gate matrix row, Schedule gate, 18.x harness, Packed-tarball smoke), 0 failure, 0 in progress. Test Core (2/8), the previously red shard, is success. mergeable_state: clean.

    must-fix

    none

    should-fix

    1. packages/spec/src/data/hook-api.test.ts:174-179: the compile-time pin for the delete face has no // @ts-expect-error for permanent on the wrong literal (the pattern upsert has at :163-168), and its comment still says "the engine's delete set is { context, where, multi }", which is stale since EngineDeleteOptionsSchema gained permanent. The drift pin alone would stay green if someone reverted the Omit to 'context' only. One line plus a comment fix.
    2. Follow-up (seat's judgment, not blocking this delta): the legacy in-process handler route for binder-bound hooks (see §1, last bullet) is app-authored by the engine's own APP_AUTHORED_HOOK_API definition and is not refused permanent; the precedent the ruling cites (Security: a runAs system hook or an action script body reaches the four identity-assignment objects past the delegated-administration gate (the #25251 refusal requires flowRunId) #25653) holds its refusal engine-side off the elevation mark. If the maintainer's "app-authored code" is meant to include that deprecated route, it needs an engine-side refusal keyed on the same mark; worth a line in the PR body's acceptance notes or a card.

    nit

    • Changeset headline feat(objectql,spec) now under-names the bumped packages (runtime was added).
    • The job pin runs without organization, so the tenantId-carrying envelope the docblock names is not separately exercised.
    • Dev report says the VM sees "code, status and message only"; the allowlist is code, fields, status, userMessage (+ message). Immaterial to the refusal (param indeed stays host-side).

    body-delta accuracy (out/30990-fix-body-delta.md)

    • §1 速读: the appended 改了什么 sentence and the replaced 两分钟验证 command/count match the code (42 tests confirmed locally; 6 new = the five it blocks at :667-739, one of them a two-case loop). It says "replace three lines" but then marks 需要你做的 as unchanged; two lines change, one stands (wording nit).
    • §2 What-changes block: accurate. HookDeleteOptions Omit ✓ (hook-api.ts:219); drift pin OMITTED ✓; hook.zod.ts reasoning ✓ (:1609, :757, :882); refuseBodyPermanentDeletes as the outer layer of buildSandboxApi ✓ (:1170-1180); "anything but false/absent" ✓ (:1226); the quoted message ✓ verbatim (:1191-1193) with the face label varying ✓; deriveThroughSeam over object/sudo/withRunAs/transactions ✓; holds under the job envelope ✓; engine unchanged ✓; changeset runtime: minor + bullet ✓.
    • §3 Tests: the six cases listed match :667-739; ablation line (anchor ×1→×0, blob 0a33b238→d1c7f8ab, 3 failed | 39 passed (42)) — hashes reproduced here, counts reproduced locally (3 failed | 39 passed); "imports ./body-runner.js relatively, so no dist leg" ✓ (body-runner.test.ts:6).
    • §4 ✓.
    • Nothing in the delta contradicts the existing Clause-② / Metadata-protocol lines, and the delta does not propose changing them, which is right.

    Implemented-by: claude/project-thread-jf62r6
    Reviewed-by: session_01L9qibyvxbrLP4jm4PargoH

    VERDICT: PASS (delta) — landing waits only on the seat applying the PR-body delta (accurate as drafted); should-fix 1 (the @ts-expect-error permanent compile pin and its stale comment) can ride the landing or a follow-up, and should-fix 2 is a seat judgment on the deprecated in-process handler route, not a blocker.

…; changeset headline names runtime

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxhbtrhtRZ958iyyJp
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

维护者速读

  • 改了什么:回收站的移除侧。引擎新增 purge(entryId)(删一条回收站条目)、empty(object)(清空某对象在本组织的全部条目)和 delete(…, { permanent: true })(彻底删除,不进回收站),三者共用新的 purge 操作。按你的裁决 A(6691358077):应用编写的代码(hook、action、job 的脚本体)不能彻底删除,沙箱对带 permanent 的删除直接拒绝(400),删除一律进回收站。
  • 为什么改:ADR-0148 回收站(engine: snapshot deletes into the recycle bin, restore under original ids, purge, and the TTL reap (#3146 slice 3) #30237)的第 2 步;Q3 = A,彻底删除只属于回收站管理员。
  • 风险与回滚:plugin-security 要到 PR 4 才把 purge 映射到 manageRecycleBin,在那之前非系统调用者的这三种操作一律被拒(fail-closed,预期如此)。回滚=撤销本 PR。
  • 先看:packages/objectql/src/recycle-bin/purge.ts;packages/runtime/src/sandbox/body-runner.ts 的 refuseBodyPermanentDeletes。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/sandbox/body-runner.test.ts,预期 42 个测试全过。
  • 需要你做的:无,席位落地。

席位意见:可以落地。

席位核对

ACCEPT · seat domain:server#2 · head 9cfc7667 · 2026-11-06T19:39Z

  1. Shape: Part of #30237 (PR 2 of 5), base main, 17 files, +1130/−58 (1,188 changed lines; check-governed-merges --pr 30990: 0 of 17 paths governed). Clause-②: yes (widening), Metadata-protocol: added; changesets @objectstack/spec + @objectstack/objectql + @objectstack/runtime minor, with the one sentence that hooks and app-authored bodies cannot pass permanent. Take-over claim 6691497009. Spec consent 6688058790; ruling A 6691358077.
  2. Premise: the PR was red on the hook-api drift pin because EngineDeleteOptions gained permanent; the maintainer ruled that app-authored code never purges, so the hook face omits it and the body runner refuses it.
  3. Review: domain:server#1's review 6689562965 (its should-fix 2 became ruling A). CONTRACT_REVIEW_TIER delta 6693177811 (PASS on 9f765466, no must-fix): the refusal is the outermost layer of buildSandboxApi, which all three body faces use, and covers every derived context (object, sudo, withRunAs, transactions); the VM reaches the engine only through ctx.api.object(name), so there is no other route; anything but false or absent is refused; host engine.delete(…, { permanent: true }) is unchanged. The reviewer reproduced the ablation (3 red). Its should-fix 1 is in 9cfc7667, read by the seat: a hook delete literal carrying permanent fails to compile (@ts-expect-error), the stale comment corrected, the changeset headline names runtime; dev ablation of the Omit → check-test-typecheck 1 problem in hook-api.test.ts, restored to HEAD.
  4. Gates / check-runs: on head 9cfc7667: 59 success, 10 skipped, 0 failed. Dev report 6692900377: 109/109 derived families exit 0, runtime 551 files / 8,832 tests.
  5. Accepted deviations / out of scope: the deprecated in-process handler route for binder-bound hooks is app-authored but gets a real ScopedContext and could cast past HookDeleteOptions under runAs: 'system' (review should-fix 2). The fix order scoped ruling A to the sandbox; the seat files this as its own card. The PR-body delta was applied by the seat.
  6. Landing: not governed: the seat arms auto-merge on the maintainer's word in the seat thread 「你应该使用 pm-dispatch 技能审核和落地pr」(2026-11-06T16:49Z). PR 3 follows serially.

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit 9d939c5 Nov 6, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-jf62r6 branch November 6, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant