You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 9d939c5
Browse filesBrowse the repository at this point in the historyBrowse files
feat(objectql,spec): purge and empty the recycle bin, and delete a record for good (ADR-0148) (#30990)
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(#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`(#7809
词表 pin 的翻转)。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/sandbox/body-runner.test.ts`,预期 42 个测试全过(其中 6 个是本次新增:job 脚本体传
`permanent: true` 被拒且什么都没删、`false`/不传照常进回收站、宿主代码的彻底删除不受影响)。
- 需要你做的:无。spec 改动已获 Elon 同意(#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** (#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 #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:
- capture not skipped for `permanent` → 1 red (the permanent delete
writes an entry);
- the verb always `delete` → 4 red (purge suite + #7809 pin);
- `permanent` refused only on the `reject` dispatch → 3 red;
- the platform-object refusal dropped → 1 red;
- `purge`'s entry read without the caller's driver options → 1 red
(another organization's entry is purged);
- `empty` run outside the middleware chain → 2 red;
- the fallback stops after one page → 1 red (1,100 entries);
- the stuck-page guard dropped → the suite hangs on that case (no result
in 120 s).
- `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
0a33b23 → 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.**
- On the merged head, `src/recycle-bin/`, the #7809 vocabulary pin,
`write-epoch`, `engine-unknown-option` and
`engine-hook-event-dispatch-gap`: 7 files, 134/134 pass. Full
`@objectstack/objectql` suite: 561 files, 11,231/11,231 pass.
- On the pre-merge head, the full suite passed 11,187 of 11,190. The 3
failures were `protocol-destructive-currency-mode.test.ts` reading a
stale `@objectstack/metadata-protocol` dist, and the file passes 7/7
after that package is rebuilt.
- `node scripts/check-commit-card-trailers.mjs --range
origin/main..HEAD`: exit 0.
<!-- signed: domain:server#2 · os-tesla ·
session_01L9qibyvxbrLP4jm4PargoH -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(objectql,spec,runtime): purge and empty the recycle bin, and delete a record for good (ADR-0148): `ObjectQL.purge(entryId)`, `ObjectQL.empty(object)` and `delete(object, { where: { id }, permanent: true })`, all authorized as the `purge` operation; hooks and app-authored bodies cannot pass `permanent`
8
+
9
+
Clause-②: yes (widening)
10
+
Metadata-protocol: added
11
+
Skills-docs: follow-up #30847 — `skills/objectstack-api/SKILL.md` :268, :417 teach that there is no recycle bin; the skills lane owns that file.
12
+
13
+
The recycle bin's removal side. All three acts are dispatched through the middleware chain as one new operation, `purge`, on the business object the rows belong to, so one grant will govern all three: `manageRecycleBin`, the recycle-bin manager's (ADR-0148 D5). **Until `@objectstack/plugin-security` maps `purge` to that grant, it refuses `purge` for every caller that is not the system**, the fail-closed rule it already applies to the destructive verbs. So in this release only the system, or a composition without the security plugin, can purge, empty or delete for good. Restoring, the two grants and the REST doors are not in this release.
14
+
15
+
-**`@objectstack/spec`:**`EngineDeleteOptions` gains `permanent?: boolean`: 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.
16
+
-**Hooks and app bodies cannot pass `permanent`:** app-authored code cannot erase a record past the recycle bin; its deletes always enter the bin, and the permanent delete stays with a principal holding `manageRecycleBin`. `HookDeleteOptions` (the bag `ctx.api.object(n).delete()` takes) omits `permanent`, and `@objectstack/runtime`'s sandbox refuses a hook, action or job body's `delete` carrying `permanent` (any value but `false`) with `400 VALIDATION_ERROR` (`param: 'permanent'`) before anything is removed, whatever envelope the body runs under, a job's system envelope included. `permanent: false` and an absent key are the ordinary delete.
17
+
-**`@objectstack/objectql`, the permanent delete:**`delete(object, { where: { id }, permanent: true })` runs as the by-id delete does (its `beforeDelete` / `afterDelete` hooks, which read `permanent` off `input.options`, its cascade, its events), writes no recycle-bin entry, and is dispatched as `purge` with the row's id. These are refused `400 VALIDATION_ERROR` (`param: 'permanent'`) before any middleware or hook runs:
18
+
-`permanent: true` on a delete that is not by id, or that asks `multi: true`;
19
+
-`permanent: true` on a platform object (`sys_*`, `cloud_*`, `ai_*`), whose delete never enters the bin and is already permanent;
20
+
- a `permanent` that is not a boolean.
21
+
`permanent: false` is the ordinary delete.
22
+
-**`@objectstack/objectql`, `purge(entryId, { context })`:** removes one recycle-bin entry and answers `true`. 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 first, scoped to the caller's organization, for the object its rows belong to; the removal is then dispatched as `purge` on that object, with no id.
23
+
-**`@objectstack/objectql`, `empty(object, { context })`:** removes every entry of one object that the caller's organization holds, and answers how many. It is one `purge` dispatch on `object`, so one authorization covers it. The object need not be registered any more, so an uninstalled package's entries can still be emptied. A platform object, or an empty name, is refused `400 VALIDATION_ERROR` (`param: 'object'`). It answers `0` when the composition registers no bin.
24
+
-**Middleware authors:**`OperationContext.operation` gains `'purge'`, which never carries an `ast`. A middleware that switches on the operation exhaustively, or keys write handling on `'delete'`, sees the permanent delete as `'purge'`. `purge` advances the engine's write epoch, as `insert`, `update` and `delete` do (`WRITE_EPOCH_OPERATIONS`). `beforePurge` / `afterPurge` are not engine hook events: registering one warns that it never fires, as before.
|**permanent**|`boolean`| optional | Delete for good: the rows skip the recycle bin and cannot be restored. Authorized as the `purge` operation, not `delete`. By-id deletes only. |
0 commit comments