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 f9e16d8
Browse filesBrowse the repository at this point in the historyBrowse files
fix(security,metadata-protocol): a packaged permission set's DELETE stops reporting a deletion it did not perform (#19411)
Fixes#19306
Clause-②: no
## What was wrong
`DELETE /api/v1/data/sys_permission_set/{id}` on a **package-declared**
set answered
`200 {"object":…,"id":…,"success":true}` and changed nothing. The
identical call on an
environment-authored set really deletes. Both shipped a **byte-identical
envelope**, so no
caller could act differently on the two cases: a UI fired a success
toast and showed the row
again on refresh, on a *security-configuration* write.
The mechanical source, in
`packages/plugins/plugin-security/src/permission-set-projection.ts`:
the write-through's delete leg opened with `let lastOutcome: any = true`
and the loop body
could only ever assign `true` again, so no path could report anything
else. The difference
was already named one line up — `logger.info('[security] permission set
reset to its declared
baseline …')` — but it went to the log, never to the caller.
## What this does NOT change
The record surviving is the **design**, not the defect: an
artifact-backed definition
tombstones its overlay (an ADR-0005 RESET) and the record re-projects to
the declared body
instead of vanishing. The restore leg's own argument depends on that
guarantee. This PR makes
the **answer** truthful; it does not make a packaged set deletable.
## The fix
1. `permission-set-projection.ts` — the delete leg reports how many of
the addressed
**records** actually went, read off the record itself rather than off
the re-projection's
counter. (That counter is not the fact: on a kernel wired to the
ADR-0094 *awaited*
projector — the production shape — `deleteMetaItem` has already retired
the record, so the
idempotent re-projection legitimately reports `deleted: 0` for a delete
that really
happened. Reading it would have answered `success: false` for **every**
delete. The first
draft did exactly that and the new pin's control leg caught it.)
2. `metadata-protocol/src/protocol.ts` — `deleteData` maps that onto the
already declared
`success` key instead of hard-coding `true`.
⛔ **No spec change.** `DeleteDataResponseSchema` keeps its three keys;
`success` is declared
"Whether deletion succeeded" and a record that is still there was not
deleted. The `/meta`
door's `reset` discriminator was deliberately **not** moved onto this
door — that is a
`packages/spec` widening and belongs to the spec lane, not to this PR.
⛔ **Not `false` from the middleware either.** The boolean arm's `false`
is the driver
contract's "no row matched", which `deleteData` turns into `404
RECORD_NOT_FOUND` — about a
record the same caller can still GET. Zero rows removed is the engine
delete result's other
declared arm and the only value that positively says "it is still
there".
## On the wire
| case | before | after |
|:---|:---|:---|
| packaged set | `200 {"success":true}`, row unchanged | `200
{"success":false}`, row unchanged |
| environment-authored set | `200 {"success":true}`, row gone | `200
{"success":true}`, row gone |
| unknown id | `404 RECORD_NOT_FOUND` | `404 RECORD_NOT_FOUND` |
| ordinary object | `200 {"success":true}`, row gone | `200
{"success":true}`, row gone |
## Evidence
**Live REST, `examples/app-showcase` booted `--fresh`, seeded platform
admin over a cookie
session — an ordinary non-system caller.** The card's reading was taken
against
`f93beea0a6`; this branch's base is `fade3da23`, so both legs were
re-taken on today's tree.
BEFORE (base `fade3da23`, port 38913) — the defect reproduced:
```
DELETE /api/v1/data/sys_permission_set/ps_mua1tdbwu14gimg2 (showcase_auditor, managed_by=package)
-> 200 {"object":"sys_permission_set","id":"ps_mua1tdbwu14gimg2","success":true}
GET same id -> 200, same id, updated_at 2026-09-20T16:46:31.868Z (unchanged), row count 17 -> 17
```
BEFORE, negative control — an environment-authored set really deletes:
```
POST /api/v1/data/sys_permission_set {"name":"probe_env_set_19306"} -> 201, rows 17 -> 18
DELETE /api/v1/data/sys_permission_set/ps_mua1uc5xypituh2j -> 200 {"success":true}
rows 18 -> 17 ; GET the id -> 404 RECORD_NOT_FOUND
```
AFTER (this branch, port 38914) — same two legs:
```
DELETE /api/v1/data/sys_permission_set/ps_mua26j2717zrru57 (showcase_member_default, managed_by=package)
-> 200 {"object":"sys_permission_set","id":"ps_mua26j2717zrru57","success":false}
GET same id -> 200, same id, updated_at 2026-09-20T16:56:45.823Z (unchanged), row count 17 -> 17
```
```
POST /api/v1/data/sys_permission_set {"name":"probe_env_set_19306_after"} -> 201, rows 17 -> 18
DELETE /api/v1/data/sys_permission_set/ps_mua276okvsajceez -> 200 {"success":true}
rows 18 -> 17 ; GET the id -> 404 RECORD_NOT_FOUND
DELETE /api/v1/data/sys_permission_set/ps_definitely_not_a_row -> 404 RECORD_NOT_FOUND
DELETE /api/v1/data/showcase_task/dckUaD8NSPulx3wv -> 200 {"success":true}, GET -> 404
```
⇒ the packaged leg is the only answer that moved; record survival, ids,
`updated_at` and row
counts are identical before and after, and no delete became a no-op.
**Unit.** `pnpm --filter @objectstack/plugin-security --filter
@objectstack/metadata-protocol test`
— plugin-security 115 files / 2234 tests passed, metadata-protocol 182
files (3 skipped) /
2601 tests passed (19 skipped). `typecheck` on both: exit 0.
**Reverse verification** (both on committed state, mutation proven on
disk by
`scripts/ablation-replace.mjs`'s anchor and blob counts, restored
byte-identical — the
restored blob equals the HEAD blob in both cases):
| mutation | pin | result |
|:---|:---|:---|
| `success: deleted !== 0` back to `success: true` (anchor x1 to x0,
blob `01c0b69e23ad` to `2387a4ca325b`) |
`protocol.record-not-found.test.ts` | 1 failed / 13 passed |
| the delete-leg outcome back to `opCtx.result = true` (anchor x1 to x0,
blob `d55d579b1a48` to `51005a746830`) |
`permission-set-projection.test.ts` | 1 failed / 66 passed |
Both restored: `git hash-object` equals `git rev-parse HEAD:path`, `git
diff HEAD` empty.
**Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`
derived 65 families from this diff; all 65 ran, all exit 0, reconciled
with `--ran`
(65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN). Repo-wide `pnpm lint` ran
unnarrowed: exit 0.
All readings at `9f1c8b100`.
## Acceptance notes
Noticed while measuring, deliberately **not** fixed here:
- `os data delete` in its default (non-JSON) format prints `Record
deleted: {id}`
unconditionally, so the human-readable CLI surface still reports a
deletion for a packaged
permission set even though its own `--format json` arm now forwards
`deleted: false`
correctly. Same defect family, different door — reported for filing, not
carried here.
- `deleteManyData`'s per-row `success` was not audited on this card; the
batch door is out of
this card's scope.
- #19307 (the duplicate-name 409 in this same file carrying no `code`)
is not addressed here
and remains open — it is hard-serialised after this card and none of its
lines were touched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_
---
## 第二轮 —— 隔离契约复核打回一条阻塞项,已修(本节由认领席 `domain:services` 补写)
⚠️ 正文首轮由 dev 一次写成(dev ⛔ 不 PATCH 正文),因此以上描述的是**第一轮**的探针 ——
而那个探针正是复核判为阻塞的东西。本节是第二轮的记录,以 head `9de7b7b97` 为准。
### 被打回的缺陷:存活探针 fail-open
第一轮的探针走 `tryFind`,其尾为 `catch { return []; }` ⇒ 读**抛异常**时返回空数组,与「行不在」无法区分
⇒ 该行被计为已删除 ⇒ 门口答 `200 {"success":true}`,**而记录还在原地**。
即:本 PR 要消灭的那个谎被原样保留,只是触发条件从「打包声明集」换成了「这一次读失败」。
### 修法
新增模块内 `probeRecordSurvival`(`permission-set-projection.ts:144`),返回三态
`'gone' | 'present' | 'unknown'`,`catch { return 'unknown'; }`。调用点
`:1385`:
```ts
const survival = await probeRecordSurvival(ql, 'sys_permission_set', row.id);
if (survival === 'unknown') { logger?.warn?.( /* 耐久通道:说明调用方被告知了什么、怎么落定 */ ); }
if (survival === 'gone') removed += 1;
```
⇒ **只有真正答复了的读才能产生 `gone`**;`unknown`
计为幸存(fail-closed),并在耐久通道报告一次。方向与先例写进了代码:同插件
`per-organization-catalog.ts` 的 `listSeedOrganizationIds`
早已写明同一条规则(零个组织是「没什么要种」,读不出来是「我们不知道」)。
探针的 context 与读法与 `tryFind` 逐字节同形(`seedCtx()`,不带 `transaction`
键),故复核已核准的事务语义不变 —— ambient 事务句柄照常承载。
### 本轮新增的钉子
- **单元 pin**:`a read-back that cannot ANSWER reports the record as NOT
deleted (fail-closed)`。它只让**读回那一次**失败(目标解析与探针都按 id 读,靠顺序区分),并**先断言
`byIdReads >= 2`** —— 注入若没打中,测试变**红**而不是空绿。
- **端到端 pin ×2**:`showcase-permission-projection.dogfood.test.ts` 与
`two-doors-permission.dogfood.test.ts` 此前只断言 `status <
300`,信封回归它们照样全绿;现各加一行 `expect((await res.json())?.success, 'a reset does
not report a deletion').toBe(false)`。
### 门禁面的变化
派生族集由 **65 → 69**(`packages/qa/dogfood` 进入改动集,新增 spec 的
`check:empty-state`、`check:liveness`、`check:strictness-ledger`、`check:variant-docs`),69
族全跑、全 exit 0;repo-wide `pnpm lint` 未收窄,exit 0。
### ⛔ 未改动、经复核核准后逐条现验
- `packages/spec/**`:**零改动**(`git diff --name-only origin/main...HEAD --
packages/spec` 为空)。`Clause-②: no` 维持不变 —— 依 #16349 的方向裁决,「拉回已声明契约」不触条款②。
- `protocol.ts` 的另外两张未修面(`origin/main` 的 `:12518` batch 臂、`:13070`
`deleteManyData`)**一字未动**,已另立 **#19412**(`domain:engine` 面)。
- `success: deleted !== 0` 与其上的 `deleted === false` → 404
分支与第一轮核准版本逐字节相同。
第一轮的实时 REST 前后读数仍然成立且未重取:本轮只改**读失败**这条路径,线上两条腿的行为未变,该路径由单元 pin 覆盖。
<sub>本节由 `domain:services` 席(`session_01AhQASwqJr2Z7XfGWUdvnbF`)补写,依
`.claude/agents/os-dev.md`「PR 正文 dev 只写一次…事后要改的报告点名改法,席位代写」。复核记录见卡上
`5751509468`。</sub>
---
_Generated by [Claude Code](https://claude.ai/code)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
0 commit comments