Skip to content

Commit f20de62

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19716-skill-md-principle-only
2 parents 8df1e7e + de4ed33 commit f20de62

10 files changed

Lines changed: 327 additions & 107 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `composeStacks` refuses a stack whose value for a concatenated collection (`permissions`, `data`, `views`, …) is not an array, with the ADR-0112 envelope
6+
7+
**BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to compose with that stack's content silently missing.
8+
9+
Step 3 of `composeStacks` concatenates every collection key the composer declares `concat` (`permissions`, `data`, `apps`, `views`, `flows`, `agents`, `packages`, … — every `'concat'` row of `COMPOSE_KEY_DISPOSITIONS`). It kept only the array values and announced the rest with a one-time `console.warn`. The strict `defineStack` parse already rejects a non-array value for any of these keys, so the reachable population is an input that bypassed it — a hand-built stack object, or `defineStack(config, { strict: false })`. Measured before this change, per key, composing a well-formed stack with one whose value for the key is a map:
10+
11+
| the second stack's value | before | after |
12+
| :--- | :--- | :--- |
13+
| a map, a number, a string, `null`, `false`, a `Set` | composed; the composed collection lacks every entry of that stack (e.g. its permission-set grants, its seed rows), one `console.warn` | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
14+
| the same, under `manifest: 'preserve'` | composed; the top-level collection lacks the entries, while that stack's package body still carries the malformed value — the artifact disagrees with itself | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
15+
16+
A composed artifact is complete or it is refused, so no non-array value is skipped. An absent key (`undefined`) is not malformed and composes as before. The refusal is the one `composeStacks` already raises for a non-array `objects`: the code the strict parse raises for the same authored mistake, the zod issue on `issues` (`path` rooted at the key, `expected: 'array'`), and a message naming the stack by manifest id and position and the key. For a key `defineStack` accepts in the map form, the message says so. A non-object entry inside an array is still concatenated as-is — the entry is carried, not lost.
17+
18+
The one-line fix: author the key as an array, or pass the stack through strict `defineStack` (which normalizes the map form and rejects every other shape where it is written).
19+
20+
No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`, and the error class stays module-local.
21+
22+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, Zod schema, export, config field or stored metadata shape is added, removed, renamed or re-spelled. The strict defineStack parse already refused every input this refuses, so an authored stack that passed its schema composes exactly as before; what narrows is the runtime behaviour of composeStacks on inputs that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->
23+
24+
Clause-②: no (narrowing)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
172172
- 表未覆盖的包首次分诊时归类并走 PR 更新本表;新增或退役 `domain:*` 必须同批改本表。
173173
- `domain:*` 只由分诊席产出,例外仅在飞卡衍生 sub-issue 承父卡域;无标签的卡 ⛔ 不得认领。
174174
- 跨域例外路径:拆不动的跨域单 PR 由分诊席指定一个车道 PM 认领,认领评论申报文件面。
175-
- 派发前在飞检查:读本车道 `pm:dispatched` 卡认领评论申报的文件面,本地求交、不另发查询。
175+
- 派发前在飞检查:读 `pm:dispatched` 卡认领评论申报的文件面,本地求交、不另发查询。
176176
- 跨域例外路径的范围加上该单文件面触及各域的在飞单;相交 ⇒ 排该 PR 之后串行或让行。
177177
- 跨域相交只经三条越界通道:跨域例外路径、认领席跟到底的越界面、接手无主阻塞项。
178178
- 合并队列是共享串行资源:车道买到的是并行编写,不是并行落地。

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@
145145
- 派第二个 dev 上别人的分支前先读三处:远程分支头(`git ls-remote`)、该分支最后推送时刻。
146146
- 第三处是卡上最新 `os-dev-report` 的时刻;任一落在云端收集边界内 ⇒ ⛔ 不派第二个 dev。
147147
- 改在该 PR 上留言唤醒它自己的 dev(云端 dev 订阅自己的 PR),满一个边界的静默才走接手。
148-
- PM 会话已死 ⛔ 不推导其 dev 已死;跨账号活性只认 GitHub 产出读数。
149-
- 死后恢复查三态:远程(`git ls-remote`)、仅容器磁盘、真没了。
148+
- PM 会话已死 ⛔ 不推导其 dev 已死;死后恢复查三态:远程 `ls-remote`、仅容器磁盘、真没了。
149+
- 账号封禁即死会话:接手者重认领,消失的记录(认领/复核/裁决)按正文行在当前 head 重出。
150150
- 中间那态最值钱:worktree 比 agent 活得久、同容器共享,未提交改动通常还在。
151151
- 只查远程会令下一个 dev 从零开始;捡到的工作一律标 UNVERIFIED 交接,不丢弃也不继承。
152152
- worktree 已存在 ⇒ ⛔ 不要新建,`cd` 进去接着做,第二个 worktree 会劈开工作。

‎.claude/skills/pm-dispatch/references/platform-readings.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
- 两次实测:分诊席 2026-09-15 `claude[bot]`→`os-sam`;技能席 2026-09-15→16 `claude[bot]`→`os-zhuang`。
133133
- 类只认每次写回读的 `user.login`/`user.type`,⛔ 不从开轮标记沿用:标记读数带日期、不站住。
134134
- `performed_via_github_app`、`GET /user` 与 core 限流头对两类同答 ⇒ 三者都不是判别式。
135-
- 用户类写绑作者:封号则其评论与卡按作者 404,标签/状态/标题/正文存活。
135+
- 封号隐藏其全部评论、无 timeline 事件;assignee、PR、正文存活;分诊没了挂 `pm:retriage`。
136136
- ⇒ 耐久算按令牌类重算,⛔ 不假定署名恒 `claude[bot]`;用户类下请 PR 作者复审回 422。
137137
- GraphQL 池 5000/时,只留给没有 REST 对应物的几件。
138138
- 那几件 = `rest-channel.md`〈不可迁移〉那三件;draft 翻转与 auto-merge 挂载有 ccr 路、不在内。

‎.claude/skills/pm-dispatch/references/seat-post-protocol.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
- 班次叙事 ⛔ 不进贴正文:逐卡状态在卡与 PR 上必填,轮次合计走轮次报告。
1717
- 接管压缩:接任 PM 把交接的有效内容按四段模板重述为新正文,编辑历史即存档。
1818
- 旧账留在上一版 body revision,审计评论带版本指针(哪一版、何时)。
19-
- 超大贴由半状态巡查的 report-only 项浮出,⛔ 不等读取失败。
2019
- 热文件串行队是跨轮跨班次的常设事实:文件 → 有序卡片清单 → 每张卡认领的区域。
2120
- 新增座位贴(拆域或加席)先查 `pm:seat` 索引再立贴,席号取本车道现有最大席号加一。
2221

@@ -28,6 +27,7 @@
2827
- 可跑读法:本车道 `pm:dispatched` 卡按 `updated_at` 降序一搜,或读最近一条认领评论。
2928
- 写侧刷新三时刻:派发、收班、暂停或交接前;时间戳恒取自 `scripts/pm/post-stamped.mjs`。
3029
- 刷新前回执未读敲门;`post-stamped.mjs --body` 拦截:评论晚于正文末次戳 ⇒ `--ack-through=ID`。
30+
- 耐久状态只住正文或以 App 写入;正文守中继上限 60,000 字节,超大贴由半状态巡查浮出。
3131
- 协议升级迁移状态、或巡检发现正文不符时,当场改正文 + 审计评论。
3232
- Routine 座位的收尾简报也落自己的座位贴,它是下一轮自退守卫的读数。
3333
- 安置默认离任留守;留不了才热移交:在飞卡落收单注记 + 点名看护者。
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `composeStacks` step 3 (the `concat` pass) refuses a stack whose value for a
5+
* concatenated collection key is not an array (#19784 — ruling C, the
6+
* follow-up the #18239 ruling routed here).
7+
*
8+
* ## What was wrong
9+
*
10+
* Step 3 kept only the array values it found for each `concat` key and
11+
* announced the rest with a one-time `console.warn`. So a stack reaching
12+
* composition with `permissions: { … }` or `data: 42` (a hand-built stack
13+
* object, or `defineStack(config, { strict: false })`) composed "successfully"
14+
* into an artifact that simply lacked that stack's grants or seed rows — and
15+
* the same for every other concatenated key. Under `manifest: 'preserve'` the
16+
* dropped value survived only inside that stack's package body, so the
17+
* artifact's top level and its package list disagreed.
18+
*
19+
* ## What is pinned
20+
*
21+
* The ruling: 「A composed artifact is complete or it is refused」. Every key the
22+
* composer concatenates (derived from `COMPOSE_KEY_DISPOSITIONS`, never
23+
* transcribed — a key added to the table is covered the day it lands) refuses a
24+
* non-array value with step 2's envelope for the same defect on `objects`:
25+
* `STACK_SCHEMA_INVALID`, `status: 422`, one zod issue rooted at the key.
26+
*
27+
* Every refusal has its CONTROL: the same composition with the key authored as
28+
* an array is accepted with both stacks' content, so no refusal can satisfy
29+
* the assertions for the wrong reason.
30+
*/
31+
import { describe, it, expect, vi, afterEach } from 'vitest';
32+
import { composeStacks, defineStack, COMPOSE_KEY_DISPOSITIONS, type ObjectStackDefinition } from './stack.zod';
33+
34+
type Envelope = Error & {
35+
code?: string;
36+
status?: number;
37+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>;
38+
};
39+
40+
/** The thrown value, or `null` when the composition is accepted. */
41+
function refusal(fn: () => unknown): Envelope | null {
42+
try {
43+
fn();
44+
return null;
45+
} catch (e) {
46+
return e as Envelope;
47+
}
48+
}
49+
50+
const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const });
51+
52+
/** A stack object nobody parsed. */
53+
const handBuilt = (overrides: Record<string, unknown>) => overrides as unknown as ObjectStackDefinition;
54+
55+
/** A stack through `defineStack`'s `strict: false` door, which skips the parse. */
56+
const unparsed = (overrides: Record<string, unknown>) => defineStack(overrides as never, { strict: false });
57+
58+
const CONCAT_KEYS = Object.entries(COMPOSE_KEY_DISPOSITIONS)
59+
.filter(([, rule]) => rule === 'concat')
60+
.map(([key]) => key);
61+
62+
const B1 = "'com.example.b' (stack #1)";
63+
64+
afterEach(() => {
65+
vi.restoreAllMocks();
66+
});
67+
68+
describe('#19784 — composeStacks refuses a non-array concatenated collection', () => {
69+
it('covers every concat key the composer declares (the table is the census, not a transcription)', () => {
70+
expect(CONCAT_KEYS).toContain('permissions');
71+
expect(CONCAT_KEYS).toContain('data');
72+
expect(CONCAT_KEYS.length).toBeGreaterThanOrEqual(30);
73+
});
74+
75+
for (const key of CONCAT_KEYS) {
76+
describe(`'${key}'`, () => {
77+
const a = () => handBuilt({ manifest: mf('com.example.a'), [key]: [{ name: 'a_item' }] });
78+
const b = (value: unknown) => handBuilt({ manifest: mf('com.example.b'), [key]: value });
79+
80+
it('a map-shaped value is refused with STACK_SCHEMA_INVALID / 422 — never skipped', () => {
81+
const refused = refusal(() => composeStacks([a(), b({ b_item: { name: 'b_item' } })]));
82+
expect(refused).toBeInstanceOf(Error);
83+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
84+
expect(refused?.status).toBe(422);
85+
expect(refused?.issues).toHaveLength(1);
86+
expect(refused?.issues?.[0]?.path).toEqual([key]);
87+
expect(refused?.issues?.[0]?.code).toBe('invalid_type');
88+
expect(refused?.issues?.[0]?.expected).toBe('array');
89+
expect(refused?.message.startsWith(`composeStacks validation failed: ${B1} declares '${key}'`)).toBe(true);
90+
});
91+
92+
it("is refused under `manifest: 'preserve'` too", () => {
93+
const refused = refusal(() =>
94+
composeStacks([a(), b({ b_item: { name: 'b_item' } })], { manifest: 'preserve' }),
95+
);
96+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
97+
expect(refused?.status).toBe(422);
98+
});
99+
100+
it('the control — the same key authored as an array composes the entries of BOTH stacks', () => {
101+
const composed = composeStacks([a(), b([{ name: 'b_item' }])]) as Record<string, unknown>;
102+
expect(composed[key]).toEqual([{ name: 'a_item' }, { name: 'b_item' }]);
103+
});
104+
});
105+
}
106+
107+
/** The value shapes, on the two keys the ruling's measurement named. */
108+
// A Set rides a hand-built stack only: `defineStack`'s map-form normalizer
109+
// reads any object through `Object.entries`, so a Set entering through the
110+
// `strict: false` door arrives here already flattened to `[]` — upstream of
111+
// composition, and not this pass's finding.
112+
const shapes: Array<{ label: string; value: () => unknown; door: typeof handBuilt }> = [
113+
{ label: 'a number, through `strict: false`', value: () => 42, door: unparsed },
114+
{ label: 'a string, through `strict: false`', value: () => 'not-an-array', door: unparsed },
115+
{ label: 'null, through `strict: false`', value: () => null, door: unparsed },
116+
{ label: 'false, through `strict: false`', value: () => false, door: unparsed },
117+
{ label: 'a Set of entries, on a hand-built stack', value: () => new Set([{ name: 'b_item' }]), door: handBuilt },
118+
];
119+
for (const key of ['permissions', 'data']) {
120+
for (const shape of shapes) {
121+
it(`'${key}' as ${shape.label} is refused, whichever position it holds`, () => {
122+
const good = () => handBuilt({ manifest: mf('com.example.a'), [key]: [{ name: 'a_item' }] });
123+
const bad = () => shape.door({ manifest: mf('com.example.b'), [key]: shape.value() });
124+
for (const run of [() => composeStacks([good(), bad()]), () => composeStacks([bad(), good()])]) {
125+
const refused = refusal(run);
126+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
127+
expect(refused?.status).toBe(422);
128+
expect(refused?.issues?.[0]?.path).toEqual([key]);
129+
}
130+
});
131+
}
132+
}
133+
134+
it('an ABSENT key is not a malformed one — composed from the stacks that declare it, silently', () => {
135+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
136+
const composed = composeStacks([
137+
handBuilt({ manifest: mf('com.example.a'), permissions: [{ name: 'a_item' }] }),
138+
handBuilt({ manifest: mf('com.example.b'), permissions: undefined }),
139+
]);
140+
expect(composed.permissions).toEqual([{ name: 'a_item' }]);
141+
expect(warn).not.toHaveBeenCalled();
142+
});
143+
144+
it('a non-object ENTRY inside an array is carried as-is — the composed content is unchanged', () => {
145+
const composed = composeStacks([
146+
handBuilt({ manifest: mf('com.example.a'), permissions: [{ name: 'a_item' }] }),
147+
handBuilt({ manifest: mf('com.example.b'), permissions: [null, { name: 'b_item' }] }),
148+
]);
149+
expect(composed.permissions).toEqual([{ name: 'a_item' }, null, { name: 'b_item' }]);
150+
});
151+
152+
it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => {
153+
const refused = refusal(() => defineStack({ manifest: mf('com.example.b'), permissions: 42 } as never));
154+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
155+
expect(refused?.status).toBe(422);
156+
});
157+
});

‎packages/spec/src/compose-stacks-key-loss.test.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,17 +188,22 @@ describe('#5005 rule 3 — a key with no declared rule warns', () => {
188188
expect(warnSpy.mock.calls.map((c) => String(c[0])).some((w) => w.includes("'futureList'"))).toBe(true);
189189
});
190190

191-
it('warns rather than skipping a collection key that holds a non-array value', () => {
191+
it('refuses — never skips — a collection key that holds a non-array value (#19784)', () => {
192+
// Skipping it (the #5005 warn-and-drop) composed an artifact that silently
193+
// lacked stack B's views; the full per-key census lives in
194+
// `compose-stacks-concat-shape-refusal.test.ts`.
192195
const a = raw({ manifest: manifestA, views: [{ name: 'v1' }] });
193196
const b = raw({ manifest: manifestB, views: 'not-an-array' });
194197

195-
const composed = composeStacks([a, b]);
196-
expect(composed.views).toHaveLength(1);
197-
expect(
198-
warnSpy.mock.calls
199-
.map((c) => String(c[0]))
200-
.some((w) => w.includes('composeStacks') && w.includes("'views'") && w.includes('cannot be composed')),
201-
).toBe(true);
198+
let thrown: (Error & { code?: string; status?: number }) | undefined;
199+
try {
200+
composeStacks([a, b]);
201+
} catch (e) {
202+
thrown = e as Error & { code?: string; status?: number };
203+
}
204+
expect(thrown?.code).toBe('STACK_SCHEMA_INVALID');
205+
expect(thrown?.status).toBe(422);
206+
expect(thrown?.message).toContain("'views'");
202207
});
203208

204209
it('does NOT warn for keys the composer has a declared rule for', () => {

0 commit comments

Comments
 (0)