Repository navigation
Commit 5cb7d7e
feat(mcp): delete_records deletes many records of one object in one confirmed call (#31024)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao9VoosHMokHL8ds8NeDWBdp)_
Clause-②: yes (widening)
Metadata-protocol: none
Fixes #30891
## 维护者速读
- **改了什么**:MCP 新增 `delete_records` 工具,按筛选条件或 id 列表,一次删除同一对象的多条记录。
- **为什么改**:AI 清理一批记录时只能逐条调 `delete_record`、逐条确认;现在先看一次预览(共几条、样例
id),人点头后一次确认删除。
- **风险与回滚**:只新增一个工具,现有工具不变。每条记录走 `delete_record`
的同一删除路径(权限、行级安全、包的锁定规则),管理员也绕不过;每次最多 200
条(部署的读取页上限更小时按它)。遇到故障(驱动错误、代码钩子崩溃)立即停止并报告已删条数。OAuth 令牌需同时有 `data:read` 和
`data:write`。回滚即 revert 本 PR。
- **先看**:`packages/mcp/src/mcp-http-tools.ts` 里 `delete_records` 的注册段和
`deleteEach`。
- **两分钟验证**:`pnpm --filter @objectstack/mcp exec vitest run
src/mcp-delete-records.test.ts`(27 条);真实栈上的锁定规则见
`packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts`。
-
**需要你做的**:无需决定。注意一点:预览无法预先说出哪些记录会被锁定规则拒绝(引擎没有删除试运行),确认后的回答按原因分组列出被拒的记录和
id。
## What changes
One new tool on both MCP transports (HTTP `/api/v1/mcp` and the stdio
server, which share `registerObjectTools`): `delete_records`. Over OAuth
it is offered only to a token that carries both `data:read` and
`data:write`.
**Request**
```jsonc
{
"objectName": "crm_task",
// exactly one of:
"where": { "status": "closed" }, // a filter, as query_records takes it; {} = every record you can see
"recordIds": ["t1", "t2"], // 1..200 ids
"confirm": true // optional boolean; only true deletes
}
```
**Unconfirmed call = the preview.** It reads the selection as the caller
and deletes nothing. It answers `delete_record`'s refusal from #30861
(same member, code, status, envelope keys and `details` keys;
`details.actionName` is `delete_records`) with a `preview` beside
`error`:
```jsonc
{
"error": { "code": "ACTION_CONFIRMATION_REQUIRED", "message": "delete_records on 'crm_task' carries no confirmation — nothing was deleted. The selection matches 4 record(s); …", "status": 428,
"details": { "actionName": "delete_records", "objectName": "crm_task", "confirmationMember": "confirm" } },
"preview": { "object": "crm_task", "matched": 4, "wouldAttempt": 4, "cap": 200,
"sampleRecordIds": ["…"], "notFound": ["…"] /* id list only */, "note": "Nothing was deleted. …" }
}
```
**Confirmed call.** Each selected record is deleted through the bridge's
`remove`, the member `delete_record` deletes with, one at a time, under
the caller's resolved context. Refused records stay and are grouped by
code, status and reason:
```jsonc
{
"object": "crm_task", "matched": 4, "attempted": 4, "deleted": 2, "refused": 2,
"refusals": [ { "code": "RECORD_LOCKED", "status": 409, "reason": "<the rule's own sentence>", "count": 2, "recordIds": ["…", "…"] } ],
"remaining": 2, "cap": 200,
"next": "…" // only when more than 200 matched
}
```
- **The cap is 200 per call.** That is the bound the platform's other
many-row delete door already uses: REST `POST /data/:object/deleteMany`
refuses more ids than `batch.maxBatchSize`, default 200. Like that
route, this tool deletes one row at a time, so the cap also bounds how
long one call runs. A filter is read in id order at the cap, never one
row past it, and the bridge's `aggregate` count says whether more match.
When more than 200 match, `remaining` and `next` say how many are left
and that the same call continues. When refused rows sit inside the first
200, `next` names `{"id": {"$nin": [...]}}` to move past them. A bridge
without `aggregate` keeps a cap+1 probe and answers `matchedMoreThan:
200`, not a guess.
- **The cap follows the deployment's read window down.** The data door
refuses (never clamps) a read over `OS_DATA_MAX_PAGE_SIZE` or past
`OS_DATA_QUERY_ROW_BUDGET`, and the caller has no `limit` to lower. So
on a deployment that sets either under 200, one call runs at that size
(`deleteRecordsCap`), and the answer's `cap` names it. An id list longer
than that cap is refused before anything is read.
- **A fault stops the call; a rule's refusal does not.** Each row's
throw is judged with the REST `/data` door's own classifier
(`mapDataError`, `@objectstack/types`), so the two doors cannot disagree
on a row. A 4xx verdict is a refusal and is grouped: a coded rule
refusal under the code and message `delete_record` answers, a sandboxed
hook body's uncoded throw as `400 HOOK_ABORTED` with the body's
sentence. A 5xx verdict is a fault: a code hook's bare `throw new
Error`, a lost connection, a driver error, and a driver's own string
code (`ECONNREFUSED`, a SQL state, `SQLITE_BUSY`), which the door
answers `500` too. The first fault ends the call: `isError: true`, an
`error` naming the fault, the record it stopped at, how many were
deleted (they stay deleted) and how many were not attempted, and a
`progress` member with the call's answer so far (`stoppedAt`,
`notAttempted`, no `next`).
- **No bypass, for an administrator either** (ruling 6687860173, 「不绕过」).
The tool has no delete path of its own. It calls the same `remove` as
`delete_record`, with the context the principal resolver answered, so
the engine judges each row's `beforeDelete` hooks, restricted relations,
permissions and RLS as it does for a one-row delete.
- **`details` withheld.** A refusal is read with the one-row tool's
reader (`thrownRefusalEnvelope`: code, message, status), so a refusal's
`details` (e.g. a permission refusal's positions) stays off the wire, as
on every other tool.
- **ADR-0090 D10.** A narrowed delegated read is stated on the preview
and on the answer, as `query_records` does.
Also: the SKILL.md tool list and the package README name the tool.
`content/docs/ai/connect-mcp.mdx` gets the table row and a
security-model bullet. In `@objectstack/spec`, only doc comments change:
`AIActionConfirmation`'s docblock names `delete_records` as the third
door, and the error-code ledger's `ACTION_CONFIRMATION_REQUIRED` rows
name both delete tools. There is one changeset, patch level, for
`@objectstack/mcp`, `@objectstack/spec` and `@object-ui/console`.
**Files**
- `packages/mcp/src/mcp-http-tools.ts`: the tool,
`resolveDeleteSelection`, `deleteEach`, `rowRefusalOf`,
`deleteRecordsCap`.
- `packages/mcp/src/skill-md.ts`, `packages/mcp/README.md`,
`content/docs/ai/connect-mcp.mdx`: teaching.
- `packages/spec/src/contracts/ai-service.ts`,
`packages/spec/src/api/error-code-ledger.zod.ts`: doc comments only.
- `ui/apps/console/src/pages/developer/objectstack-skill.ts` and one
line of `objectstack-skill.test.ts`: the console's "Download skill"
text, a byte-for-byte mirror of `skill-md.ts` under #31015's drift pin,
carries the same `delete_records` bullet. This is outside the card's
`ui/**` fence, under seat claim amendments 6692914335 and 6692945782.
- Tests: `mcp-delete-records.test.ts` (new), the dogfood file (new), and
the tool-surface pins listed under Tests.
- `scripts/engine-double-contract.pinned.json`: the new engine double's
ledger row.
- `.changeset/30891-mcp-delete-records.md`.
## Tests
- `packages/mcp/src/mcp-delete-records.test.ts` (27 tests, real stdio
bridge on an engine double whose delete refuses locked rows with
`RECORD_LOCKED`/409, and other users' rows with `PERMISSION_DENIED`/403
carrying `details`):
- advertisement: destructive hint, optional boolean `confirm`,
`recordIds.maxItems` 200;
- preview: 428 refusal plus preview (count, sample), `engine.delete`
never called, all rows intact; `false`, `"true"` and `1` are refused
too;
- the refusal has `delete_record`'s code, status, envelope keys and
`details` keys;
- confirmed: allowed rows deleted, refused rows grouped with ids,
`details` withheld, one engine delete per row;
- every engine read and delete carries the caller's context object
unchanged (no `isSystem`, no copy);
- id list: duplicates collapse, `notFound`;
- cap: 205 rows, the first call deletes 200 with `remaining: 5` and
`next`, the second finishes; over the cap with refused rows, `next`
names `$nin`;
- no `aggregate`: `matchedMoreThan: 200`;
- a stale roll-up counts as deleted; `success: false` lands in
`notRemoved`;
- selection xor; an id list over 200 is refused at the input;
- the read window, through the platform's own `planQueryReadWindow` in
front of the bridge: a page size equal to the cap reads at 200 and
counts 205 (a lit control shows the window refusing 201); exactly 200
matching has no `next`; a decimal-string count is read; a page size or
row budget of 50 lowers the cap to 50; an id list longer than a lowered
cap is refused before any read;
- faults: an uncoded throw, a coded 5xx and a driver's `ECONNREFUSED`
each stop the call at that row (`isError`, the record, the deleted
count, `progress`, later rows never asked); a sandboxed body's uncoded
refusal is grouped as `HOOK_ABORTED`/400 and the call goes on;
- the long-lived stdio server (InMemoryTransport) behaves the same.
- `packages/qa/dogfood/test/mcp-delete-records.dogfood.test.ts`: a real
stack with a package `beforeDelete` lock in HotCRM's shape, called by
the administrator over the live `/api/v1/mcp`. It covers:
- the preview (REST still reads every row);
- the REST `DELETE` control (409 for the same admin);
- the confirmed call: unlocked rows gone, locked rows one
`RECORD_LOCKED`/409 group with the hook's own sentence, and still
readable;
- the cap on 203 real rows with the runtime's real aggregate count, on a
host whose `OS_DATA_MAX_PAGE_SIZE` is 200 (lit control: REST
`?limit=201` answers 400 `MAX_RECORDS_EXCEEDED`);
- a host whose page size is 2: the call runs at 2 and says so, and a
3-id list is refused;
- a sandboxed hook body's refusal grouped as `HOOK_ABORTED`/400 while
the walk goes on, then a code hook's bare `throw new Error` stopping it,
with REST `DELETE` as the control (400 and 500 on the same rows).
- Tool-surface pins learn the thirteenth tool: the scopes,
authoring-scope, HTTP and stdio tool lists, unknown-argument keys, the
coded-refusal sites, the SKILL.md list and the stored-metadata-body
read-site census. The scopes pin now has `delete_records` only under
both data scopes, and a `data:write`-only `tools/call delete_records`
refused with no `query`, `aggregate` or `remove` reaching the bridge.
- The console's SKILL.md drift pin (#31015) and its tool-name list
include `delete_records`.
## Acceptance notes
- **Card premise, partly falsified: the preview cannot name per-record
refusals.** The engine has no delete dry run
(`BatchOptions.validateOnly` was retired for promising one). Running the
delete in a rolled-back transaction would still fire `afterDelete`
hooks, which do not wait for a commit. So the preview gives the count
and a sample and says the rules are judged when the delete runs. The
confirmed call's answer carries the grouped refusals with ids.
- **Groups key on code + status + message, not on a rule name.** The
engine attaches no hook or rule name to a refusal, so two rules that
throw the same code, status and sentence share a group.
- **Both data scopes (seat decision on review round 1).** The preview
reads ids and a count, a list-class read, so a token granted
`data:write` without `data:read` is not offered the tool.
`delete_record` stays under `data:write` alone.
- **The cap follows the read window down: an extension beyond the
review's literal ask.** The review asked to read at the cap instead of
cap+1, which fixes a window of exactly 200. A window under 200 would
still refuse every filter selection, with a message naming a `limit` the
caller never sent, so the cap takes the window's size instead.
- **Fault vs refusal follows the data door, not "coded vs uncoded".**
The review's rule was "an uncoded throw is a fault". Measured on a
booted stack, a sandboxed hook body's deliberate refusal also arrives
uncoded (REST answers it `400 HOOK_ABORTED`), and a driver's errors
often carry string codes (`ECONNREFUSED`, `57P01`, `SQLITE_BUSY`; REST
answers them `500`). So the tool asks the REST door's classifier and
stops on its 5xx verdicts.
- **An object whose `apiMethods` exclude `list` cannot use
`delete_records`.** The selection is a list-class read, so the exposure
gate refuses it. `delete_record` still works there.
- **Out of scope, follow-ups:** a delete dry run in the engine, which
would let the preview name refusals (the seat files it as its own card);
`skills/objectstack-ai/SKILL.md` teaching `delete_records` (`skills/**`
is outside this card).
- **Serial with #30892 / PR #31009 (MCP attachments).** It touches the
same files: three count/prose conflicts (`connect-mcp.mdx`, the
unknown-argument-keys test, the `skill-md.ts` imports), and the merged
tool count is 15. Whichever lands second takes them, and must also
mirror its own `skill-md.ts` lines into the console copy. This branch
does not merge #31009's. origin/main was merged in again at `17a02908`
(#31015's drift pin arrived there).
## Verification
Head `4e5b0f2d` (origin/main merged at `17a02908`):
- `pnpm --filter @objectstack/mcp test`: 58 files, 725 tests passed.
`mcp-delete-records.test.ts` has 27.
- `pnpm --filter @objectstack/mcp run typecheck` (`tsc --noEmit` plus
`check:test-typecheck`, debt ledger held) exited 0.
- Dogfood: `mcp-delete-records.dogfood.test.ts` (7) and
`mcp-delete-record-confirmation.dogfood.test.ts` (4) passed, 11/11,
after a full `./packages/**` + `@objectstack/dogfood^...` build. The
dogfood resolves `@objectstack/mcp` from dist.
- Console: `pnpm --dir ui exec vitest run
apps/console/src/pages/developer/objectstack-skill.test.ts`, 13/13.
- eslint over every touched `.ts` file (packages and the two console
files) exited 0.
<details><summary>Ablations, round 1: four new negative pins, each
mutated once through <code>scripts/ablation-replace.mjs</code> (WRAP
mode), each red, each restored. <code>mcp-http-tools.ts</code> is
byte-identical on <code>4e5b0f2d</code> (blob
<code>d06c686d</code>).</summary>
**B1. A `data:write`-only token is refused.** Mutation: ` if (canRead)
{` becomes ` if (true) {`, so the tool registers under `data:write`
alone.
```
× data:write alone exposes only the write family, without delete_records
× [#30891] delete_records with a data:write-only grant is refused WITHOUT reading or deleting
× a data:write token can neither list nor call an authoring tool, and the registry is never touched
Tests 3 failed | 27 passed (30)
AssertionError: expected [ [ 'query', 'task', { …(4) } ] ] to deeply equal []
```
B1 ran before the round's commit; its restore put the file back to the
older HEAD. The round's edits were replayed and proved byte-identical
(blob `d06c686d`, the blob B1 mutated), then committed before B2–B4.
**B2. A fault stops the call.** Mutation: ` if (door.status >= 500)
return undefined;` becomes ` if (false) return undefined;`, so a 5xx
verdict is grouped like a refusal.
```
unit: × an uncoded throw … / × a coded 5xx … / × a driver's own string code … ends the call at that row
Tests 3 failed | 24 passed (27)
dogfood: × confirmed: the refusal is grouped and the walk goes on; the fault stops it, naming the row and what was deleted
Tests 1 failed | 6 passed (7)
ablation-replace: ok restored: blob == HEAD (d06c686) and `git diff HEAD` is empty
```
**B3. The selection is read at the cap.** Mutation: ` const ids = await
readIds(cap);` becomes ` const ids = (await readIds(cap + 1)).slice(0,
cap);`, the old probe.
```
unit: × a host whose maximum page size is the cap … / × exactly the cap matching … / × (both lowered-cap cases)
Tests 4 failed | 23 passed (27)
dogfood: × the cap, on a host whose maximum page size IS the cap … / × a host whose maximum page size is under the cap …
Tests 2 failed | 5 passed (7)
[data] read refused: code=MAX_RECORDS_EXCEEDED surface=list object=dlr_bulk_row rows=201 limit=200
ablation-replace: ok restored: blob == HEAD (d06c686) and `git diff HEAD` is empty
```
**B4. The cap follows the read window down.** Mutation:
`deleteRecordsCap` returns `DELETE_RECORDS_MAX_PER_CALL`
unconditionally.
```
× a maximum page size under the cap lowers this call's cap to it … / × a query row budget … / × an id list longer than a lowered cap …
Tests 3 failed | 24 passed (27)
AssertionError: expected 200 to be 50 // Object.is equality
```
The dogfood runs for B2 and B3 rebuilt `@objectstack/mcp`'s dist inside
the mutation; it was rebuilt again after the restore.
</details>
<details><summary>Ablations from the first round (A1–A3), on
<code>ef1973df</code>, unchanged by this round</summary>
- **A1.** The preview runs the deletes: 7 red.
- **A2.** The confirmation gate removed: 7 red.
- **A3.** The runtime bridge's `remove` hands the engine `{ ...ec,
skipAutomations: true }` (the admin-bypass shape): the dogfood admin pin
red (`deleted` 4, `refused` 0); the REST control stayed green.
</details>
Gates on `4e5b0f2d`:
- `dispatch-gates.mjs --commands` derived 116 commands.
- 8 artifact-roster families that name a path of this diff were added:
`check-published-list-mirrors.mjs`, spec `check:meta-url-spelling`,
`check:authz-resolver`, `check:changeset-fixed`,
`check:facade-delegation-only`, `check:filter-alias-parity`,
`check:i18n-stale-fill` and `check:published-readme-exports`.
- All 124 ran with stdin from `/dev/null`, and every one exited 0.
- `dispatch-gates --ran` reconciled 116 derived, 116 run, 0
NOT-MEASURED.
- The record is
`server-seat-2/r2026-11-06/out/30891-gates-ran-4e5b0f2d.list` in the
project files.
- The changeset: one added file; `changeset status` and
`check:changeset-fixed` exited 0.
<details><summary>Notes on the gate runs</summary>
- **First sweep, on `e223b854`.** `check:where-matcher` exited 1 on a
real finding: the new test's engine double read a combinator (`$and`,
`$or`) as a field name. It now refuses any `$`-key inside its `every`
callback (`a7989233`).
- **Round 1.** `check:test-typecheck` exited 1 on a real finding: the
new scope pin read two JSON-RPC answers as `unknown`, growing the
package's debt. They are typed (`4e5b0f2d`).
- **Prerequisites, not findings.** Spec dist, `client-react` dist, the
dual-build dists and pinned commit `621a4876` were built or fetched
first, as in round 0.
- **The sweep** ran after a full `./packages/**` +
`@objectstack/dogfood^...` build on the merged head.
</details>
<!-- signed: domain:server#2 · os-tesla ·
session_01L9qibyvxbrLP4jm4PargoH -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent eb96480 commit 5cb7d7e
20 files changed
Lines changed: 1768 additions & 30 deletions
File tree
- .changeset
- content/docs/ai
- packages
- mcp
- src
- qa/dogfood/test
- spec/src
- api
- contracts
- scripts
- ui/apps/console/src/pages/developer
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
| 248 | + | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
| |||
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
| 257 | + | |
257 | 258 | | |
258 | 259 | | |
259 | 260 | | |
| |||
383 | 384 | | |
384 | 385 | | |
385 | 386 | | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
386 | 406 | | |
387 | 407 | | |
388 | 408 | | |
| |||
411 | 431 | | |
412 | 432 | | |
413 | 433 | | |
414 | | - | |
| 434 | + | |
415 | 435 | | |
416 | 436 | | |
417 | 437 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
| 121 | + | |
121 | 122 | | |
122 | 123 | | |
123 | 124 | | |
| |||
131 | 132 | | |
132 | 133 | | |
133 | 134 | | |
134 | | - | |
| 135 | + | |
| 136 | + | |
135 | 137 | | |
136 | 138 | | |
137 | 139 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
105 | 108 | | |
106 | 109 | | |
107 | 110 | | |
| |||
0 commit comments