Skip to content

Commit 720d4a7

Browse files
committed
Merge origin/main into claude/issue-17564-h19-judge-against-the-body
2 parents e55182b + 310760d commit 720d4a7

9 files changed

Lines changed: 548 additions & 42 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
`ActionEngineFacade.delete` refuses a nullish id instead of silently skipping it
6+
7+
**Who this is for: untyped hosts.** A JS host, or a `registerAction` handler
8+
whose context slot is still `(ctx: any)`, can hand `ctx.engine.delete()` a
9+
nullish id — `delete('todo_task', null)`, or an array with a hole in it. Until
10+
now the arm dropped that element on the floor: nothing refused it, nothing
11+
warned, and the call **resolved as though the row had been deleted**. A silent
12+
no-op on a destructive verb is the one failure an untyped caller has no way to
13+
detect, which is why it is worth a line in your changelog rather than a shrug.
14+
15+
**What changes.** Every id now reaches the engine as written, and the engine's
16+
own delete-dispatch predicate refuses a `where.id` that is not a truthy scalar:
17+
the call rejects with `Delete requires an ID or options.multi=true` where it
18+
used to resolve in silence. In the array form the refusal stops the loop where
19+
the declared member doc already said a failure stops it — ids before the
20+
nullish element are deleted, ids after it are untouched.
21+
22+
**If a host was leaning on the old behaviour**, filter before you call:
23+
24+
```js
25+
const ids = candidates.filter((id) => id != null);
26+
if (ids.length > 0) await ctx.engine.delete('todo_task', ids);
27+
// `delete nothing` is the EMPTY ARRAY (it resolves, deleting nothing) —
28+
// never a null id. An empty array is contract; a nullish id never was.
29+
```
30+
31+
**No declaration moves, and this is not a correction of the `string | string[]`
32+
widening that shipped just before it.** That declaration is accurate: it takes a
33+
single id or an array of them, and under it **no typed caller could ever reach
34+
the skipped branch** — the accept set it publishes has never admitted nullish.
35+
The array form, its per-row semantics, its ordering and its empty-array case are
36+
all unchanged and pinned as controls. What moves is only the runtime's
37+
undeclared tolerance for a value three separate statements already excluded: the
38+
published type, the member's own doc comment, and the spec-side pin that reads
39+
«"delete nothing" is the EMPTY ARRAY, never a null id».

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -748,18 +748,18 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
748748
用户也很少,短期不考虑渐进。」):废弃别名/拼写与能力退役默认**立即退休**,
749749
不设分阶段窗口、不留双拼写宽限;staged 选项仅凭具名外部用户证据才可呈报为推荐。
750750

751+
**基本裁决原则**(维护者 2026-09-10 指示,逐字:「我们的项目以objectstack 协议为准,
752+
文档应该以实际实现为准。协议不正确的应该先修改协议。」):spec 声明 > 实现 > 文档面;
753+
声明而未兑现是实现缺口,补实现或退役,⛔ 不在消费端收窄;未声明键随实现,文档随之。
751754
推荐意见必须基于这四条轴给出理由;四轴冲突时如实呈现权衡,
752755
交维护者拍板。
753756
- 分歧推荐序按拉动定向:有实测拉动 ⇒ 荐长远形态一次付清;零拉动 ⇒ 荐不扩散。
754757
- 防错轴破余下平局向响亮/结构性;安全/权限边界与破坏性难逆动作恒在人工地板。
755758
- ⛔ 此序只排推荐:分歧块照旧升级,四棱同向置信门与代裁面不变。
756759
- 长远合理性权重恒 ≥50%:推荐以长远的读数领起,四轴冲突时其余各轴合起来投不翻它。
757-
- 权重按缩小而非扩大特例与契约增生读,⛔ 不据它为投机扩张背书。
758-
- 权重是推荐规则不是授权规则:50% 不把人工地板的事变成可派发的事。
759-
- 收件箱由维护者定期消化,⛔ 不 assign 推送。
760-
- 四维分析从业务的角度写;写法六项与四棱块固定形状见 `references/decision-analysis.md`
761-
- 四棱是四轴的卡面序列化,一一对应,也是分诊代裁置信门的输入。
762-
- 决策通道优先序,卡先于弹窗:凡需维护者裁决,第一动作是落 `needs-user-decision` 卡。
760+
- 权重只排推荐不授权:读缩小特例与契约增生,⛔ 不背书投机扩张、不放行人工地板事。
761+
- 四维分析从业务角度写;四棱是四轴的卡面序列化,一一对应,也是分诊代裁置信门的输入。
762+
- 卡先于弹窗:需裁决先落 `needs-user-decision` 卡;收件箱由维护者定期消化,⛔ 不 assign 推送。
763763
- 决策卡带选项、推荐、证据与四棱块;被阻塞的执行卡同笔挂 `pm:blocked` + `Blocked-by:`
764764
- `AskUserQuestion` 只是在场加速器:仅当维护者在本会话 ~30 分钟内有过人类输入才可发。
765765
- 每问必带推荐项,被 Skip 或长挂即转卡通道,⛔ 不重弹。

.claude/skills/pm-dispatch/references/core-rules.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@
140140
- 落卡先刷新前提,每条前提行自带一条 re-check 命令;决策默认锚在所属 issue。
141141
- 裁决记录四件同笔:鲜度门、状态转换、依赖行活性现验、条件已知即就地判掉。
142142
- 出决策箱须引一个同趟取回过的裁决 id,⛔ 时间戳加回忆不算引用。
143-
- 落卡与呈报必带 `Governing text:`;spec 与代码不一致默认改代码,改协议单独立卡非选项
143+
- 落卡与呈报带 `Governing text:`;基本裁决原则:协议为准,文档以实现为准,协议错另立卡先改
144144
- 方案逐轴过升级与决策段的四轴:业务需求、长远合理性、防 AI 写错、创业阶段聚焦。
145145
- 推荐须逐轴给理由、长远权重不低于一半;有拉动荐长远终态一次付清,零拉动荐不扩散。
146146
- 每张决策卡落卡即带标准四棱块;卡先于弹窗,被阻塞的执行卡同笔转 `pm:blocked`

.claude/skills/pm-dispatch/references/dispatch-runbook.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@
219219
- 该行原文:基于合并后的代码工作,issue 引用的片段可能已变,先核对当前 main。
220220
- 在飞重叠每轮拦截:main 新落 PR 与在飞申报文件面求交,相交即发四句警告。
221221
- 四句:合 main 重跑测试矩阵、读对方 diff 重划边界、只补它没覆盖的、被完全覆盖就停下。
222-
- ⛔ 不硬造 diff。
222+
- ⛔ 不硬造 diff。中途收窄标准条款须点名替换承接人与时点,缺一 dev 报 blocked 不半执行。
223223
- 全仓 pin 清扫两句原文:① grep 错误码与错误消息全仓扫同语义 pin,一轮翻完,不只改本包。
224224
- ② 翻转后的 pin 必须承重:断言新语义的实质(行数、译文、状态码),不是旧断言已删。
225225
- 真正非法形状的拒收断言逐字保留,被守护的面永不缩水。

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,8 +438,8 @@ recognised and the whole block lands under it, leaving two; the session-URL form
438438
verbatim and a bare one lands under it, leaving two. ⛔ A tail bare footer on a comment is the
439439
platform's, not your form downgraded. Which layer does this is unknown; don't go establishing
440440
it. **Commit message:** an agent commit ends with the model-free trailer pair
441-
`Claude-Session: https://claude.ai/code/session_<id>` and
442-
`Co-authored-by: Claude <noreply@anthropic.com>`; no model identifier lands in a PR title or body,
441+
`Claude-Session: https://claude.ai/code/session_<id>` and `Co-authored-by: Claude <noreply@anthropic.com>`,
442+
and the pre-push hook refuses a model identifier in that pair; no model identifier lands in a PR title or body,
443443
a comment, a changeset, a doc or a code comment. The one exemption is a REPORTING one: a harness-written
444444
`Co-Authored-By` trailer is not declared a deviation; the pair stays model-free; landed history is not rewritten.
445445

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#17620] `ActionEngineFacade.delete` no longer swallows a NULLISH element.
5+
*
6+
* ## What was here, and who could reach it
7+
*
8+
* The `delete` arm of {@link buildActionEngineFacade} normalises its argument
9+
* to a list and issues one `ql.delete` per id. It used to open that loop with
10+
* `if (id != null)`, so a nullish element was **silently skipped**: nothing
11+
* refused it, nothing warned, and the call resolved as though the deletion had
12+
* happened — a silent no-op on a destructive verb, which is the one failure a
13+
* caller cannot detect.
14+
*
15+
* The declared type is `string | string[]` (#15117), so **no typed caller ever
16+
* reached the guard** — the population is UNTYPED hosts: a JS host, or a
17+
* `registerAction` handler whose slot is still `(ctx: any)`. That is also why
18+
* this file is not a correction of #15117 / PR #17608: that card's contract
19+
* sentence is true, and this arm's declared behaviour is unchanged by the
20+
* removal.
21+
*
22+
* ## Why removing the guard is enough to make it loud
23+
*
24+
* Every id now reaches `ql.delete(object, { where: { id }, context })` as
25+
* written, and the engine's own dispatch predicate answers that call: a
26+
* `where.id` that is not a TRUTHY SCALAR is neither `by-id` nor (absent
27+
* `multi`) a bulk intent, so `ObjectQL.delete` throws
28+
* {@link ENGINE_DELETE_REJECT_MESSAGE}. The refusal is the producer's, not a
29+
* second copy of it — which is why the double below opens with
30+
* {@link assertEngineDeleteDispatch} rather than a hand-rolled id check: a
31+
* double looser than the engine would keep this file green against a facade
32+
* that still swallowed the value.
33+
*
34+
* ⚠️ The pin is the exported MESSAGE CONSTANT, compared exactly. This refusal
35+
* is a plain `Error` — it carries no ADR-0112 `code`/`status` — so a bare
36+
* `toThrow()` here would stay green against any unnamed `Error` at all, which
37+
* is precisely what an unfixed arm would have to produce to be believed.
38+
*
39+
* @see packages/runtime/src/action-execution.ts — `buildActionEngineFacade`.
40+
* @see packages/spec/src/ui/action-params.test.ts — the declaration's own pin,
41+
* whose `@ts-expect-error` reads «"delete nothing" is the EMPTY ARRAY,
42+
* never a null id».
43+
*/
44+
45+
import { describe, it, expect } from 'vitest';
46+
import { ENGINE_DELETE_REJECT_MESSAGE, assertEngineDeleteDispatch } from '@objectstack/metadata-core';
47+
import { buildActionEngineFacade } from './action-execution.js';
48+
49+
const deps: any = { resolveService: () => undefined, getObjectQL: async () => undefined };
50+
51+
/**
52+
* An engine double whose `delete` is bound to the REAL engine's dispatch
53+
* contract: one call to the producer's own predicate, never a mirrored `if`.
54+
* Everything it accepts, a running server accepts; everything it refuses, a
55+
* running server refuses (`scripts/check-engine-double-contract.mjs`).
56+
*/
57+
function makeEngine() {
58+
const deleted: Array<{ object: string; id: unknown; context: unknown }> = [];
59+
const ql: any = {
60+
deleted,
61+
async insert(_object: string, data: Record<string, unknown>) {
62+
return { id: (data as Record<string, unknown>)?.id ?? 'rec_new' };
63+
},
64+
async find(_object: string, _options?: Record<string, unknown>) {
65+
return [];
66+
},
67+
async count(_object: string, _options?: Record<string, unknown>) {
68+
return 0;
69+
},
70+
async delete(object: string, options?: Record<string, unknown>) {
71+
assertEngineDeleteDispatch(options);
72+
const where = (options as { where?: Record<string, unknown> } | undefined)?.where;
73+
deleted.push({ object, id: where?.id, context: (options as { context?: unknown } | undefined)?.context });
74+
return { ok: true };
75+
},
76+
};
77+
return ql;
78+
}
79+
80+
/** Drive the arm and hand back whatever it rejected with, or `undefined`. */
81+
async function rejection(run: Promise<unknown>): Promise<unknown> {
82+
return run.then(() => undefined, (e: unknown) => e);
83+
}
84+
85+
describe('#17620 — ActionEngineFacade.delete refuses a nullish id', () => {
86+
it('refuses a nullish ELEMENT of the array form instead of skipping it', async () => {
87+
const ql = makeEngine();
88+
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' });
89+
90+
const err = await rejection(engine.delete('crm_case', [null]));
91+
92+
expect(err).toBeInstanceOf(Error);
93+
expect((err as Error).message).toBe(ENGINE_DELETE_REJECT_MESSAGE);
94+
// …and it is loud INSTEAD of deleting, not as well as: nothing landed.
95+
expect(ql.deleted).toEqual([]);
96+
});
97+
98+
it('refuses a nullish SINGLE id (the non-array spelling) the same way', async () => {
99+
const ql = makeEngine();
100+
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' });
101+
102+
for (const nullish of [null, undefined]) {
103+
const err = await rejection(engine.delete('crm_case', nullish));
104+
expect(err).toBeInstanceOf(Error);
105+
expect((err as Error).message).toBe(ENGINE_DELETE_REJECT_MESSAGE);
106+
}
107+
expect(ql.deleted).toEqual([]);
108+
});
109+
110+
it('stops AT the nullish element — ids before it are deleted, ids after it untouched', async () => {
111+
const ql = makeEngine();
112+
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' });
113+
114+
const err = await rejection(engine.delete('crm_case', ['case_1', null, 'case_3']));
115+
116+
expect((err as Error).message).toBe(ENGINE_DELETE_REJECT_MESSAGE);
117+
// The declared partial-progress shape, unchanged: "a failure part-way
118+
// through leaves the ids before it deleted and the ids after it
119+
// untouched" (`ActionEngineFacade.delete`'s member doc).
120+
expect(ql.deleted.map((d: { id: unknown }) => d.id)).toEqual(['case_1']);
121+
});
122+
});
123+
124+
describe('#17620 — controls: the declared contract is untouched', () => {
125+
it('a well-formed single id still deletes', async () => {
126+
const ql = makeEngine();
127+
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1', tenantId: 'org_acme' });
128+
129+
await expect(engine.delete('crm_case', 'case_1')).resolves.toBeUndefined();
130+
131+
expect(ql.deleted).toHaveLength(1);
132+
expect(ql.deleted[0]).toMatchObject({ object: 'crm_case', id: 'case_1' });
133+
// the elevated caller envelope still rides every call (#3914)
134+
expect(ql.deleted[0].context).toMatchObject({ isSystem: true, userId: 'u1', tenantId: 'org_acme' });
135+
});
136+
137+
it('the declared ARRAY form still deletes every id, in order, one call each', async () => {
138+
const ql = makeEngine();
139+
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' });
140+
141+
await expect(engine.delete('crm_case', ['case_1', 'case_2', 'case_3'])).resolves.toBeUndefined();
142+
143+
expect(ql.deleted.map((d: { id: unknown }) => d.id)).toEqual(['case_1', 'case_2', 'case_3']);
144+
});
145+
146+
it('an empty array still deletes nothing and resolves', async () => {
147+
const ql = makeEngine();
148+
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' });
149+
150+
await expect(engine.delete('crm_case', [])).resolves.toBeUndefined();
151+
152+
expect(ql.deleted).toEqual([]);
153+
});
154+
});

packages/runtime/src/action-execution.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1468,10 +1468,25 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec?
14681468
},
14691469
// Both spellings are DECLARED contract (#15117), not a tolerance: the
14701470
// spec's `ActionEngineFacade.delete` takes `string | string[]`.
1471+
//
1472+
// [#17620] And there is no third, undeclared one. This loop used to
1473+
// open with `if (id != null)`, so a NULLISH element was silently
1474+
// skipped and the call resolved as though the deletion had happened —
1475+
// a silent no-op on a destructive verb. The declared type excludes
1476+
// nullish, so no typed caller ever reached it; the population was
1477+
// UNTYPED hosts (a JS host, a `registerAction` handler whose slot is
1478+
// still `(ctx: any)`), which is exactly the population that cannot see
1479+
// the loss. Every id now goes to `ql.delete` as written, where the
1480+
// engine's own dispatch predicate refuses a `where.id` that is not a
1481+
// truthy scalar (`ENGINE_DELETE_REJECT_MESSAGE`) — the loud answer the
1482+
// declaration already implied. Removing the guard declares nothing new:
1483+
// it pulls the runtime back onto the contract that is already on the
1484+
// record, here, in the spec member doc, and in the `never a null id`
1485+
// pin at `packages/spec/src/ui/action-params.test.ts`.
14711486
async delete(object: string, idOrIds: string | string[]): Promise<void> {
14721487
const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
14731488
for (const id of ids) {
1474-
if (id != null) await ql.delete(object, { where: { id }, context });
1489+
await ql.delete(object, { where: { id }, context });
14751490
}
14761491
},
14771492
async find(object: string, query: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {

scripts/engine-double-contract.pinned.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3356,6 +3356,11 @@
33563356
"verb": "update",
33573357
"pinned": 1
33583358
},
3359+
{
3360+
"file": "packages/runtime/src/action-engine-facade-nullish-id.test.ts",
3361+
"verb": "delete",
3362+
"pinned": 1
3363+
},
33593364
{
33603365
"file": "packages/runtime/src/action-execution-calldata-batch-retired.test.ts",
33613366
"verb": "findOne",

0 commit comments

Comments
 (0)