Repository navigation
Commit 6a70821
domain:server#3 · os-project-manager · session_01FEkQ5pXkgNXVpCn2GZDjyy
Body: seat refresh (round 2) · 2026-11-08T12:15Z
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9p73F432cc8kwWQBkFm1pQ)_
Part of #30237
Clause-②: yes (widening)
Metadata-protocol: none
Skills-docs: follow-up #30847
Spec consent: 6718336940 (`permission.zod.ts`
`ObjectPermissionVerbTarget` + `objectPermissionGrants` Q5 cells +
describes/docblock; `liveness/permission.json` two rows planned→live)
and 6720657486 (the `allowRestore` / `allowPurge` tombstone
prescriptions and the `restore` / `purge` guidance strings name the
recycle bin's grants). Reviews: 6719122724 (round 1), 6721290796 (delta,
head 95d2a17). PR 4 of 5 (hand-over 6690307585): the recycle-bin
permission reader. PR 5 (REST doors) follows after this merges.
## 维护者速读
- 改了什么:回收站的两个权限键开始生效:`restore` 读
`allowRestoreOwnDeleted`,`purge`(清除单条、清空、永久删除)读
`manageRecycleBin`;普通成员只能恢复自己删除的记录,恢复别人删的需要
`manageRecycleBin`;被组织墙拒绝的调用者看不到任何回收站条目。共享层与 explain 按写入类别判定生命周期动词:按 id
的永久删除(`purge`)问 `canDelete`,无删除权限的调用者得 403(仅有编辑共享不够),`security/explain`
也按删除门报告 `purge`。
- 为什么改:PR 1–3 已上线回收站,但两个键无人读取,非系统调用者的 restore/purge 一律被拒,回收站对用户不可用;同时关闭
PR 2 评审 should-fix 1(无活动组织的调用者 purge/empty 不受组织范围约束)。键一旦被读取,`purge`
便能到达共享层,故共享门与 explain 必须按删除类判定(评审 6719122724 SF1/SF2)。
- 风险与回滚:放宽(Clause-② widening):持有两键或 `modifyAllRecords` 的人获得
restore/purge;`allowDelete` 不蕴含任何一项(Q5 = A)。无解析器或解析器抛错时,恢复他人条目一律拒绝(fail
closed)。共享层对按 id 的 `purge` 是收紧方向(与普通 delete 同门)。回滚即 revert,无数据迁移。
-
先看:`packages/objectql/src/recycle-bin/restore.ts`(`assertCallerMayRestoreEntry`,自有条目检查);`packages/objectql/src/recycle-bin/purge.ts`(`recycleBinWallDenies`
与
`recordedVerdict`);`packages/plugins/plugin-sharing/src/sharing-plugin.ts`(按写入类别选门);`packages/core/src/security/lifecycle-verb-rls-operation.ts`(`rlsOperationForVerb`
/ `LIFECYCLE_VERB_RLS_OPERATION` 移入 core 并导出,plugin-security 与
plugin-sharing
共用);`packages/spec/src/security/permission.zod.ts`(`objectPermissionGrants`
两个新单元与墓碑文案)。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/recycle-bin/restore.test.ts src/recycle-bin/purge.test.ts` 与 `pnpm
--filter @objectstack/plugin-sharing exec vitest run
src/lifecycle-verb-write-gate.test.ts`,预期全部通过,含「another member's entry …
403」「tenant wall」与「refuses FORBIDDEN when the row is not the caller's to
delete」。
- 需要你做的:无,席位落地(契约复核由席位执行)。
## What changes
**The mapping (plugin-security).** `OPERATION_TO_PERMISSION` gains
`restore → allowRestoreOwnDeleted` and `purge → manageRecycleBin`,
spelled from `RECYCLE_BIN_GRANT_KEYS`. The evaluator answers both
through the one fold (`objectPermissionGrants`, #18785);
`DESTRUCTIVE_OPERATIONS` stays as the backstop for any future unmapped
destructive verb. `MODIFY_ALL_WRITE_KEYS` now derives both keys, and
`crudBucketForOperation` folds restore → `update`, purge → `delete`. The
system context is unchanged.
**The fold (spec, under consent 6718336940).** `objectPermissionGrants`
answers the two keys as Q5 = A: each bounded by read; `modifyAllRecords`
implies both; `manageRecycleBin` implies `allowRestoreOwnDeleted`;
`allowDelete` implies neither. `OBJECT_PERMISSION_VERBS` and `can()` are
unchanged. Core's `GRANT_BITS` adds the two keys, so `/me/permissions`
reports them.
**The write-class map moves to core.** `LIFECYCLE_VERB_RLS_OPERATION` /
`rlsOperationForVerb` (transfer / restore → update, purge → delete) are
now declared in
`packages/core/src/security/lifecycle-verb-rls-operation.ts` and
exported from `@objectstack/core`; plugin-security's module re-exports
them, so its importers are unchanged and plugin-sharing reads the same
map.
**Every write-class site reads `rlsOperationForVerb`**:
`rls-compiler.ts` `mapOperationToRLS`, `security-plugin-rls-filter.ts`,
`security-plugin-addressed-writes.ts`,
`security-plugin-cbp-write-gate.ts`, `platform-ownership-policies.ts`.
The authz middleware's epoch bump lists `purge` and `restore` beside the
other writes.
**The sharing write gate judges the write class (review SF1,
plugin-sharing).** `buildSharingMiddleware` picks its gate by
`rlsOperationForVerb(op)`: a by-id `purge` (the permanent delete) is
asked `admitsByIdWrite(object, id, 'delete', …)`, exactly as an ordinary
delete is, so a caller without delete authority on the row is refused
`403 FORBIDDEN`; an edit share no longer suffices. A bulk `purge` is
scoped by `buildWriteFilter(object, ctx, 'delete')`. The recycle bin's
own doors (`restore(entryId)`, `purge(entryId)`, `empty(object)`)
dispatch with no id and no AST: they address no live row, so the gate
passes them on untouched.
**`security/explain` reports by the write class (review SF2,
plugin-security).** `explain-engine.ts` derives `writeClass =
rlsOperationForVerb(engineOp)` and uses it for the record's sharing
gate, the authored-deferral verb, the queue guard and the fault
sentence: `purge` is explained against `canDeleteRecord`, never
`canEditRecord`, so explain no longer reports an edit share as admitting
a purge the door refuses.
**The engine's own-entry check (`restore.ts`).** After the `restore`
dispatch clears the evaluator, `assertCallerMayRestoreEntry` refuses
`403 PERMISSION_DENIED` when the entry's `deleted_by` is not the
caller's `userId`, unless the caller's effective permissions (the
registered `_effectiveObjectPermissionsResolver`) grant
`manageRecycleBin` on the entry's object. No resolver, or a resolver
that throws → refused. `attributedUserId` is not consulted (attribution
only). No new engine member: the `restore()` / `purge()` / `empty()`
host literals gain closures over existing state (amendment 6718183894).
**The tenant wall on the bin (should-fix 1, `purge.ts`).**
`recycleBinWallDenies` answers `deny` for a caller the Layer-0 verdict
denies: `restore` → `null`, `purge` → `false`, `empty` → `0`, before
anything is read. The `purge` and `empty` executors re-check against
`opCtx.tenantLayer0Verdict` and pass `{ recordedVerdict }` to the
driver, so the removal is scoped by the verdict the security layer
recorded. `restore`'s executor makes the same check.
**The retired keys' prose (spec + lint, under consent 6720657486).** The
`allowRestore` / `allowPurge` tombstone prescriptions, and the `restore`
/ `purge` guidance strings, now say the key granted nothing when it was
retired and name the recycle bin's grants (`allowRestoreOwnDeleted`,
`manageRecycleBin`, ADR-0148) instead of promising the bit back with M2;
"Delete the key." stays its own sentence, so the lint's
`retiredKeyRemedy` hint is unchanged.
`packages/lint/src/rule-explanations.ts` says the same. References
regenerated.
**verify / core.** `observeWrites` (`packages/verify/src/handle.ts`) now
sees `purge` and `restore`. `resolve-user-grants-cache.ts`
`WRITE_OPERATIONS` stays insert/update/delete: the bin never takes a
platform object, and every object that cache watches is a `sys_` object
(comment added).
**Docs.** `content/docs/permissions/system-context.mdx` rows 29d/29e own
the two new elevation reads (`check:system-context-census`).
`permissions/permissions-matrix.mdx` (lifecycle callout) and
`protocol/objectql/security.mdx` (the two flag rows and the retired
note) now describe the live keys. References and liveness counts
regenerated by `check:generated --fix`.
## Tests
- `permission-evaluator.test.ts`: named Q5 cells; exhaustive 3-state
sweep over the six bits the bin keys read (729 × 2); a present/absent
sweep over all ten bits × eight verbs (no bit widens another verb);
cross-set read half.
- `security-plugin.test.ts`: the old fail-closed pins of the unmapped
verbs are rewritten: the object gate answers `restore` / `purge` from
their keys and reads no row.
- `restore.test.ts`: another member's entry refused with no resolver,
with `allowRestoreOwnDeleted` only, with `allowDelete` only, with
manage-without-read, with manage on another object, and with a throwing
resolver; allowed with `manageRecycleBin` or `modifyAllRecords`; own
entry asks no resolver; wall deny → `null`; recorded deny → `null`.
- `purge.test.ts`: wall deny → `false` / `0` with no dispatch; recorded
deny → `false` / `0`; recorded `organization` verdict scopes the removal
(cross-tenant purge through `recordedVerdict`); system exempt.
- `plugin-sharing/src/lifecycle-verb-write-gate.test.ts` (new): a by-id
`purge` is asked `delete` and refused `FORBIDDEN` when the row is not
the caller's to delete, passed when admitted; a bulk `purge` is scoped
by the delete filter; the bin's own `restore` / `purge` dispatches pass
untouched with nothing asked; the ordinary by-id delete stays on the
same gate.
- `plugin-security/src/explain-lifecycle-verb-sharing-gate.test.ts`
(new): `purge` and `delete` ask `canDeleteRecord`, not `canEditRecord`,
and report the row not writable; `update` asks `canEditRecord`
(control).
- `permission.test.ts` (spec): the fold cells; the retirement pins name
ADR-0148 and `manageRecycleBin`, not M2.
`validate-retired-permission-residue.test.ts` (lint): the new
explanation. `effective-object-permissions.test.ts` (core): the
super-user wildcard shape.
<details><summary>Ablations (21, each red, each restored to HEAD by
scripts/ablation-replace.mjs)</summary>
Mutations by `scripts/ablation-replace.mjs` (anchor must hit once;
restore proven blob == HEAD). A* run `restore.test.ts` +
`purge.test.ts`, B* `permission-evaluator.test.ts` +
`security-plugin.test.ts`, C* spec `permission.test.ts`, D1–D2
plugin-sharing `lifecycle-verb-write-gate.test.ts`, D3 plugin-security
`explain-lifecycle-verb-sharing-gate.test.ts`.
- `A1-not-manager-refusal`: Tests 4 failed | 84 passed; restored=1; e.g.
"refuses another member's entry for allowRestoreOwnDeleted only, 403"
- `A2-no-resolver-refusal`: Tests 1 failed | 87 passed; restored=1; e.g.
"refuses another member's entry, 403, when no permission resolver
answers: it fails closed"
- `A3b`: Tests 1 failed | 41 passed; restored=1; e.g. "refuses, 403,
when the resolver throws: it fails closed"
- `A4-own-entry-check-call`: Tests 6 failed | 82 passed; restored=1;
e.g. "refuses another member's entry, 403, when no permission resolver
answers: it fails closed"
- `A5-engine-wall-deny`: Tests 3 failed | 85 passed; restored=1; e.g.
"purge answers false for a caller the wall denies, before reading or
dispatching anything"
- `A6-recorded-deny`: Tests 3 failed | 85 passed; restored=1; e.g.
"purge answers false, and removes nothing, when the security layer
recorded a deny verdict on the dispatch"
- `A7-purge-recorded-scope`: Tests 1 failed | 87 passed; restored=1;
e.g. "purge's removal is scoped by the recorded verdict: an organization
it does not name keeps its entry"
- `A8-empty-recorded-scope`: Tests 1 failed | 87 passed; restored=1;
e.g. "empty's removal is scoped by the recorded verdict, not by the
caller's own organization"
- `A9-restore-executor-wall`: Tests 1 failed | 87 passed; restored=1;
e.g. "answers null when the security layer recorded a deny verdict on
the dispatch"
- `A10-purge-pre-read-wall`: Tests 1 failed | 87 passed; restored=1;
e.g. "purge answers false for a caller the wall denies, before reading
or dispatching anything"
- `A11-empty-pre-wall`: Tests 1 failed | 87 passed; restored=1; e.g.
"empty answers 0 for a caller the wall denies, and removes no
organization's entries"
- `A12-restore-pre-wall`: Tests 1 failed | 87 passed; restored=1; e.g.
"answers null for a caller the tenant wall denies (no active
organization), and leaves the entry"
- `B1-restore-map-row`: Tests 9 failed | 260 passed; restored=1; e.g.
"the Modify-All write-bypass CLASS is exactly what this file derives
from its own dispatch map"
- `B2-purge-map-row`: Tests 9 failed | 260 passed; restored=1; e.g. "the
Modify-All write-bypass CLASS is exactly what this file derives from its
own dispatch map"
- `B3-purge-maps-to-restore-key`: Tests 5 failed | 264 passed;
restored=1; e.g. "the Modify-All write-bypass CLASS is exactly what this
file derives from its own dispatch map"
- `C1-spec-manage-reads-delete`: Tests 1 failed | 121 passed;
restored=1; e.g. "[#30237 Q5 = A] folds the recycle-bin keys: bounded by
read; manage implies restoring your own; modifyAll impl"
- `C2-spec-restore-unbounded`: Tests 1 failed | 121 passed; restored=1;
e.g. "[#30237 Q5 = A] folds the recycle-bin keys: bounded by read;
manage implies restoring your own; modifyAll impl"
- `C3-spec-manage-no-own-restore`: Tests 1 failed | 121 passed;
restored=1; e.g. "[#30237 Q5 = A] folds the recycle-bin keys: bounded by
read; manage implies restoring your own; modifyAll impl"
- `D1-sharing-write-class`: Tests 3 failed | 3 passed; restored=1; e.g.
"asks `canDelete` (verb `delete`) of a by-id `purge`, and refuses
FORBIDDEN when the row is not the c"
- `D2-sharing-bin-door-skip`: Tests 2 failed | 4 passed; restored=1;
e.g. "passes the recycle bin's own restore(entryId) dispatch on
untouched: no id, no AST, nothing asked"
- `D3-explain-write-class`: Tests 1 failed | 2 passed; restored=1; e.g.
"purge asks canDeleteRecord, not canEditRecord, and reports the row not
writable"
</details>
## Acceptance notes
- The `purge(entryId)` pre-dispatch entry read cannot carry the recorded
verdict: it runs before the dispatch, so it is scoped by the engine's
own verdict (which grants no exemption). The removal itself carries
`recordedVerdict`.
- plugin-security records `opCtx.tenantLayer0Verdict` only on AST
operations, so for the AST-less `restore` / `purge` dispatches it is
normally absent today and the engine verdict decides. The
recorded-verdict path is exercised by a test middleware.
- The CBP master gate and the delegated-admin gate do not judge id-less
`restore` / `purge` (no predicate path; RBAC `sys_` tables only), as
before.
- Accepted deviation (review 6719122724 (d)): the authz middleware's
bulk-guard site still compares the operation literal rather than reading
`rlsOperationForVerb`. It is AST-only, and #7809 keeps `purge` /
`restore` AST-free, so no lifecycle verb reaches it.
- The sharing gate's pass-through for the bin's id-less, AST-less
`restore` / `purge` dispatches keeps `restore` (write class `update`)
out of the bulk path; the delta review (6721290796) judged it
unreachable by a request a caller can shape. An ordinary `update` /
`delete` without id or AST is not passed through.
- A super-user wildcard's `/me/permissions` map now shows the two bin
keys on `sys_` objects too, where the engine refuses to bin them; the
map reports the grant, not the bin's reach.
- The remaining "returns with M2" texts outside this PR's surface
(conversion and semantic migration entry 18 and its registry row, the ui
permission slice, the metadata `plugin.ts` comment, the dead liveness
rows, the lint `retiredKeyRemedy` docblock) are tracked in #31508.
## Verification
Head `95d2a17f` (origin/main `db502314` merged). 39 files changed, +1088
/ −325.
- `node scripts/pm/dispatch-gates.mjs --commands`: 124 families; every
one run with its exit captured before any pipe; `--ran` reconciliation:
`124 derived, 124 run, 0 NOT-MEASURED, 0 UNRUN`; 124/124 exit 0 on the
final tree (after `git fetch --unshallow` and a full `pnpm
--workspace-concurrency=2 build`).
- `check:system-context-census` (two new `isSystem` reads) answered by
rows 29d/29e + `--write`.
- `pnpm --filter
{objectql,plugin-security,plugin-sharing,core,verify,spec,lint}
typecheck`: exit 0. eslint over the touched source: exit 0.
- Tests:
| Suite | Files | Tests |
|---|---|---|
| plugin-security (full) | 250 | 5873 |
| plugin-sharing (full) | 71 | 1470 |
| objectql (full) | 570 | 11570 |
| core (full) | 143 | 3242 |
| lint (full) | 197 | 8363 |
| spec security / conversions / migrations | 39 | 1348 |
| verify `handle.observe-writes.test.ts` | 1 | 11 |
| repo projects | — | core 96, objectql 5, spec 1556 |
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01FEkQ5nYzXijPZzrM68FB5J
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4b1f9e commit 6a70821
39 files changed
Lines changed: 1088 additions & 325 deletions
File tree
- .changeset
- content/docs
- permissions
- protocol/objectql
- references
- api
- security
- packages
- core/src/security
- lint/src
- objectql/src
- recycle-bin
- plugins
- plugin-security/src
- plugin-sharing/src
- spec
- liveness
- state-counts
- src/security
- verify/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
78 | | - | |
| 78 | + | |
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
150 | 150 | | |
151 | 151 | | |
152 | 152 | | |
| 153 | + | |
| 154 | + | |
153 | 155 | | |
154 | 156 | | |
155 | 157 | | |
| |||
551 | 553 | | |
552 | 554 | | |
553 | 555 | | |
| 556 | + | |
| 557 | + | |
554 | 558 | | |
555 | 559 | | |
556 | 560 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| 104 | + | |
| 105 | + | |
104 | 106 | | |
105 | 107 | | |
106 | 108 | | |
107 | 109 | | |
108 | | - | |
109 | | - | |
110 | | - | |
111 | | - | |
112 | | - | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
113 | 115 | | |
114 | 116 | | |
115 | 117 | | |
| |||
0 commit comments