Skip to content

Commit 5c94f83

Browse files
os-zhuangclaude
andauthored
feat(spec): SaveMetaItemResponseSchema 声明保存响应的全集字段 version / seq / state / projectionApplied (#5745) (#5861)
* feat(spec): SaveMetaItemResponseSchema 声明保存响应的全集字段 (#5745) `PUT /api/v1/meta/:type/:name` 一直比 schema 承认的多发字段。声明停在 `{ success, message? }`,而路由实际返回 `{ success, version, seq, state, message }`(注册了 projector 时还有 `projectionApplied`)—— 契约描述的是真实 响应体的真子集。 因为这些是普通 `z.object`,这个缺口以最安静的方式失败:`safeParse` 保持绿, 未声明的键被静默 strip。改前在 origin/main 上实测: raw keys : ["success","version","seq","state","message"] after parse : ["success","message"] STRIPPED : ["version","seq","state"] safeParse ok : true `version` 是其中最要紧的一个:它正是 ADR-0008 乐观并发链在用的令牌 —— 下次写入 把它作为 `If-Match` 回传,并发编辑就会得到 409 `metadata_conflict` 而不是被静默 覆盖。此前它在线上被携带却没有契约兜底,于是解析响应的消费者恰好丢掉了 OCC 握手所需的那个值。 逐字段按实测定型(证据见 PR 正文): - `version: string` required —— 不透明内容哈希,ADR-0008 `If-Match` 令牌 - `seq: number` (int) required —— 元数据事件序号,排序用,不是 OCC 令牌 - `state: 'draft' | 'active'` required —— 落库的生命周期 - `projectionApplied?: { success, error? }` optional —— ADR-0094 投影结果,仅在 该类型注册了 projector 时出现;缺席表示"没有 projector 跑过",而不是"投影失败" 三个字段定 required 是因为实测生产者恒发,而非假设:`saveMetaItem` 唯一可达的 成功返回是仓储写路径,REST 路由把该对象原样交给 `res.json()`。它的另一个 return (legacy raw-engine,三者皆无)不可达 —— 其上游 code-only 闸门拒绝的正是能走到 那里的类型。 运行时行为不变:路由本就返回这些字段,且此前没有任何地方用该 schema 解析响应。 `client.meta.saveItem` 的返回类型注解按裁定留给 cli 车道(#5545),以便对着已落地 的契约标注而不是提前撒谎。 测试: - `packages/spec/src/api/protocol.test.ts` —— schema 侧钉住四字段的存在性、 可选性与类型;断言落在"解析后没有键被 strip"上,而不是 `safeParse` 的判定 (后者改前改后都是绿的,正是本单要修的静默失败形态) - `packages/objectql/src/save-meta-response-conformance.test.ts` —— 生产者侧 conformance:驱动真实 protocol + 真实 ObjectQL 引擎,断言真实响应体过 schema 零 strip,并用 `agent` → NOT_CREATABLE 钉住"legacy 返回不可达"这条 required 的前提 反向验证(方向预先写死为红,实测吻合):把 schema 改回 origin/main 后 conformance 3/5 转红,stripped keys 为 `["version","seq","projectionApplied", "state"]`;仍绿的两例分别是"safeParse 恒绿"与死代码绊线,符合预期。 生成物走 os-regen;`authorable-surface.base.json` 的重锚漂移按 #5358 剔除 (本单是加键,锚点不前移)。 Fixes #5745 Co-Authored-By: Claude <noreply@anthropic.com> * fix(spec,metadata-protocol): state 定为字面量联合,并随 #5782 更新不可达性论证 合并 origin/main 后带进 PR #5782(#5264):`saveMetaItem` 的 legacy raw-engine 分支已被删除,现在只剩唯一一个成功 return。这与本单改前的独立实测结论一致, 并且让 `version` / `seq` / `state` 定 required 的依据更干净 —— 不再是"另一条 返回不可达",而是"只有一条返回"。相应更新 schema 头注释、conformance 测试注释 与 changeset 的措辞。 同时,新声明的 `state: z.enum(['draft','active'])` 让 `ObjectStackProtocolImplementation` 不再满足 `MetadataProtocol.saveMetaItem` (TS2416):对象字面量属性会把两个字面量的三元表达式**加宽成 `string`**,所以 实现的返回类型说 `state: string`,而契约现在说的是它一直在发的那个闭集。 这正是收紧契约要抓的松动:此前消费者读到的 `state` 是 `string`,把它和拼错的 `'publshed'` 比较不会有任何类型错误。修在生产端(Prime Directive #12 契约优先), 只加一处类型断言,**取值不变、运行时行为不变**。 未采用的替代方案:把 spec 里的 `state` 降级为 `z.string()` 或 `z.string().refine(...)` 就能不动 metadata-protocol,但那是为了绕过文件面边界而 明知故犯地弱化契约(Prime Directive #5「不要 workaround」),且恰好丢掉在编写期 阻止错误的那一半价值。 证据:`tsc --noEmit -p packages/metadata-protocol` 在本改动下为 **63** 个错误, 与 origin/main 基线一致(该包无 typecheck script,是已计量 DEBT 项);若只改 spec 而不加这一行则为 64 —— 多出来的正是上述 TS2416。 Co-Authored-By: Claude <noreply@anthropic.com> * chore(spec): 随 main 重新渲染生成物(strictness-ledger counts / docs / authorable-surface) #5861 被合并队列踢出,签名是 `strictness-ledger-doc.test.ts > is checked in current`。 归因:#5849 / #5857 在入队前合入 main(#5857 给 `api/` 新增了一个 z.object 站点), 本分支的 counts.md 是在旧树上渲染的,合并树上不再等于现渲染 —— 单体生成物的串行税 (#5837 正在治的病),不是实现回归。 在合并树上重跑 os-regen:`api/` 站点数 395 → 396(main 的 +1 与本单 `projectionApplied` 嵌套对象的 +1),docs 与 authorable-surface 一并重渲。相对 origin/main 的净增量仍只有 本单四个键。`authorable-surface.base.json` 本轮无重锚漂移(已核对与 main 一致)。 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e2bfa6c commit 5c94f83

8 files changed

Lines changed: 401 additions & 2 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `SaveMetaItemResponseSchema` declares the whole save response — `version` / `seq` / `state` / `projectionApplied` (#5745)
6+
7+
`PUT /api/v1/meta/:type/:name` has always answered with more than the schema
8+
admitted. The declaration stopped at `{ success, message? }` while the route
9+
returns `{ success, version, seq, state, message }` — plus `projectionApplied`
10+
when a projector is registered — so the contract described a proper subset of
11+
the real body.
12+
13+
Because these are plain `z.object` schemas, the gap failed in the quietest way
14+
available: `safeParse` stayed **green** and the undeclared keys were silently
15+
**stripped**. Measured on `origin/main` before the change:
16+
17+
```
18+
raw keys : ["success","version","seq","state","message"]
19+
after parse : ["success","message"]
20+
STRIPPED : ["version","seq","state"]
21+
safeParse ok : true
22+
```
23+
24+
`version` is the field this matters most for: it is the token the ADR-0008
25+
optimistic-concurrency chain already runs on — echo it back as `If-Match` on
26+
the next write and a concurrent edit returns 409 `metadata_conflict` instead of
27+
silently overwriting. It was being carried on the wire with no contract behind
28+
it, so a consumer that parsed the response lost exactly the value the OCC
29+
handshake needs.
30+
31+
**Consumer-visible change.** Before, a `SaveMetaItemResponseSchema.parse()`
32+
dropped the four fields and `SaveMetaItemResponse` could not name them at the
33+
type level. Now they survive the parse and are typed:
34+
35+
- `version: string` — required. Opaque content hash; the ADR-0008 `If-Match`
36+
token. Echo it verbatim, never parse it.
37+
- `seq: number` (integer) — required. Metadata-event sequence number; orders
38+
writes, but is not an OCC token.
39+
- `state: 'draft' | 'active'` — required. The lifecycle the body landed in.
40+
- `projectionApplied?: { success: boolean; error?: string }` — optional. The
41+
ADR-0094 mutation-projector outcome, present only when a projector is
42+
registered for that metadata type. Its absence means "no projector ran",
43+
never "the projection failed"; a caller that needs the derived read model to
44+
be live must check `projectionApplied.success` rather than trust the 200.
45+
46+
The three required fields are required because measurement says the producer
47+
always emits them, not by assumption: `saveMetaItem` has a single success
48+
return — the repository write path — and the REST route hands that object to
49+
`res.json()` verbatim. A second, receipt-less legacy return would have forced
50+
all three to be optional; it was proved unreachable and deleted in #5264 /
51+
PR #5782, which is what makes `required` safe to state here.
52+
53+
No runtime behaviour changes: the route already returned these fields, and
54+
nothing parsed the response through this schema. `client.meta.saveItem`'s
55+
return-type annotation is deliberately left for the cli lane (#5545) so it is
56+
written against the landed contract rather than ahead of it.

content/docs/references/api/protocol.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1421,6 +1421,10 @@ List packages response
14211421
| Property | Type | Required | Description |
14221422
| :--- | :--- | :--- | :--- |
14231423
| **success** | `boolean` || |
1424+
| **version** | `string` || Content hash of the just-committed body, and the token the ADR-0008 optimistic-concurrency chain runs on: send it back as the `If-Match` request header on the next write to that item and a concurrent edit is reported as 409 `metadata_conflict` instead of silently overwritten. Opaque to callers — echo it verbatim, never parse it. Currently emitted as `sha256:<64 hex chars>`, but the format is not part of this contract. |
1425+
| **seq** | `integer` || Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. |
1426+
| **state** | `Enum<'draft' \| 'active'>` || Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. |
1427+
| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `permission``sys_permission_set`). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. |
14241428
| **message** | `string` | optional | |
14251429

14261430

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ directory rather than per file.
262262
| Dir | Sites |
263263
|---|---|
264264
| `ai/` | 77 |
265-
| `api/` | 395 |
265+
| `api/` | 396 |
266266
| `cloud/` | 82 |
267267
| `identity/` | 33 |
268268
| `integration/` | 10 |

packages/metadata-protocol/src/protocol.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7938,7 +7938,12 @@ export class ObjectStackProtocolImplementation implements
79387938
version: result.version,
79397939
seq: result.seq,
79407940
...(projectionApplied ? { projectionApplied } : {}),
7941-
state: mode === 'draft' ? 'draft' : 'active',
7941+
// #5745 — the literal union, not `string`. An object-literal
7942+
// property widens a two-literal ternary to `string`, which made
7943+
// this method fail to satisfy `MetadataProtocol.saveMetaItem`
7944+
// once the spec declared `state` as the closed set it has always
7945+
// emitted. Type-only: the value is unchanged.
7946+
state: (mode === 'draft' ? 'draft' : 'active') as 'draft' | 'active',
79427947
message: orgId
79437948
? `Saved customization overlay (org=${orgId}, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]`
79447949
: `Saved customization overlay (env-wide, state=${mode === 'draft' ? 'draft' : 'active'}) — type=${request.type}, name=${request.name} [seq=${result.seq}]`,
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #5745 — conformance gate: the body `saveMetaItem` really returns must parse
5+
* through `SaveMetaItemResponseSchema` with NOTHING stripped.
6+
*
7+
* This is the producer side of the declaration. The spec-side suite
8+
* (`packages/spec/src/api/protocol.test.ts`) pins what the schema says; this
9+
* one pins that the schema still matches what the code emits, driving the REAL
10+
* protocol against a REAL ObjectQL engine. The two together are what makes
11+
* "declared = returned" checkable — a future field added to the response, or an
12+
* existing one dropped, turns this red instead of silently vanishing at parse.
13+
*
14+
* Why the REST layer needs no separate case: the route hands this exact object
15+
* to `res.json()` verbatim (`rest-server.ts`, `PUT /meta/:type/:name`), so the
16+
* protocol return IS the wire body.
17+
*
18+
* Before the #5745 declaration this file's first assertion was red in a
19+
* specific, quiet way: `safeParse` SUCCEEDED and `version` / `seq` / `state`
20+
* were dropped from the parsed result, so the "stripped keys" set was
21+
* non-empty. That is the direction it must never drift back to.
22+
*/
23+
import { describe, it, expect } from 'vitest';
24+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
25+
import { SaveMetaItemResponseSchema } from '@objectstack/spec/api';
26+
import { ObjectQL } from './engine.js';
27+
28+
const sysMetadataObject = {
29+
name: 'sys_metadata',
30+
label: 'System Metadata',
31+
fields: {
32+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
33+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
34+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
35+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
36+
metadata: { name: 'metadata', label: 'Body', type: 'longtext' as const },
37+
checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
38+
state: { name: 'state', label: 'State', type: 'text' as const },
39+
version: { name: 'version', label: 'Version', type: 'number' as const },
40+
created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const },
41+
updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const },
42+
},
43+
};
44+
45+
function makeMemoryDriver() {
46+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
47+
const storeFor = (obj: string) => {
48+
let s = stores.get(obj);
49+
if (!s) { s = new Map(); stores.set(obj, s); }
50+
return s;
51+
};
52+
let nextId = 0;
53+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
54+
if (!where || typeof where !== 'object') return true;
55+
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matchesWhere(row, w));
56+
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matchesWhere(row, w));
57+
for (const [k, v] of Object.entries(where)) {
58+
if (k.startsWith('$')) continue;
59+
const rowVal = row[k];
60+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
61+
const a = rowVal === undefined ? null : rowVal;
62+
const b = expected === undefined ? null : expected;
63+
if (a !== b) return false;
64+
}
65+
return true;
66+
};
67+
const driver: any = {
68+
name: 'memory', version: '0.0.0', supports: {} as any,
69+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
70+
async execute() { return null; },
71+
async find(object: string, ast: any) {
72+
return Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
73+
},
74+
async findOne(object: string, ast: any) {
75+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
76+
return null;
77+
},
78+
async create(object: string, data: Record<string, unknown>) {
79+
nextId += 1;
80+
const id = (data.id as string) ?? `r_${nextId}`;
81+
const row = { ...data, id };
82+
storeFor(object).set(id, row);
83+
return row;
84+
},
85+
async update(object: string, id: string, data: Record<string, unknown>) {
86+
const s = storeFor(object);
87+
const cur = s.get(id);
88+
if (!cur) throw new Error(`not found: ${object}/${id}`);
89+
const updated = { ...cur, ...data, id };
90+
s.set(id, updated);
91+
return updated;
92+
},
93+
async upsert(object: string, data: Record<string, unknown>) {
94+
const id = data.id as string | undefined;
95+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
96+
return this.create(object, data);
97+
},
98+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
99+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
100+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
101+
return Promise.all(rows.map((r) => this.create(object, r)));
102+
},
103+
async bulkUpdate() { return []; }, async bulkDelete() {},
104+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
105+
async commit() {}, async rollback() {},
106+
};
107+
return { driver, stores };
108+
}
109+
110+
async function makeProtocol() {
111+
const engine = new ObjectQL();
112+
const { driver } = makeMemoryDriver();
113+
engine.registerDriver(driver, true);
114+
await engine.init();
115+
engine.registry.registerObject(sysMetadataObject as any);
116+
return new ObjectStackProtocolImplementation(engine);
117+
}
118+
119+
const LOG = (...a: any[]) => appendFileSync(OUT, a.join(' ') + '\n');
120+
121+
const viewBody = (label: string) => ({ name: 'cases', type: 'grid', label, columns: ['id'] });
122+
123+
/** Keys the producer emitted that the schema refused to carry through. */
124+
function strippedKeys(raw: Record<string, unknown>): string[] {
125+
const parsed = SaveMetaItemResponseSchema.parse(raw) as Record<string, unknown>;
126+
return Object.keys(raw).filter((k) => !(k in parsed));
127+
}
128+
129+
describe('saveMetaItem response conforms to SaveMetaItemResponseSchema (#5745)', () => {
130+
it('publish-mode save: parses green and strips nothing', async () => {
131+
const p = await makeProtocol();
132+
const raw: any = await p.saveMetaItem({
133+
type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('A'),
134+
});
135+
136+
expect(strippedKeys(raw)).toEqual([]);
137+
const parsed = SaveMetaItemResponseSchema.parse(raw);
138+
expect(parsed.success).toBe(true);
139+
expect(parsed.state).toBe('active');
140+
expect(parsed.seq).toBe(1);
141+
// The ADR-0008 OCC token survives parse — this is the value a caller
142+
// echoes back as `If-Match` on the next write to this item.
143+
expect(parsed.version).toBe(raw.version);
144+
expect(typeof parsed.version).toBe('string');
145+
});
146+
147+
it('draft-mode save: state is "draft" and still strips nothing', async () => {
148+
const p = await makeProtocol();
149+
const raw: any = await p.saveMetaItem({
150+
type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('D'), mode: 'draft',
151+
});
152+
153+
expect(strippedKeys(raw)).toEqual([]);
154+
expect(SaveMetaItemResponseSchema.parse(raw).state).toBe('draft');
155+
});
156+
157+
it('with an ADR-0094 projector registered: projectionApplied is carried through', async () => {
158+
const p = await makeProtocol();
159+
p.registerMutationProjector('view', async () => { throw new Error('boom-from-projector'); });
160+
161+
const raw: any = await p.saveMetaItem({
162+
type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('P'),
163+
});
164+
165+
expect(Object.keys(raw)).toContain('projectionApplied');
166+
expect(strippedKeys(raw)).toEqual([]);
167+
const parsed = SaveMetaItemResponseSchema.parse(raw);
168+
// Best-effort by contract: the projector threw, the write still succeeded,
169+
// and the failure is reported here rather than as a non-200.
170+
expect(parsed.success).toBe(true);
171+
expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' });
172+
});
173+
174+
it('no projector registered → projectionApplied is absent, which is why it alone is optional', async () => {
175+
const p = await makeProtocol();
176+
const raw: any = await p.saveMetaItem({
177+
type: 'view', name: 'cases', organizationId: 'org_x', item: viewBody('N'),
178+
});
179+
180+
expect(raw.projectionApplied).toBeUndefined();
181+
expect(SaveMetaItemResponseSchema.safeParse(raw).success).toBe(true);
182+
});
183+
184+
it('version / seq / state are required because no reachable success return omits them', async () => {
185+
// `saveMetaItem` now has exactly ONE success return — the repository
186+
// write path — and it always sets all three. The shape that carried
187+
// none of them was the legacy raw-engine return, deleted in #5264 /
188+
// PR #5782 after being proved unreachable; the gate that made it
189+
// unreachable is the one exercised here, and it is still what keeps a
190+
// second, receipt-less write path from appearing. A type declaring
191+
// neither `allowOrgOverride` nor `allowRuntimeCreate` (`agent`, `job`)
192+
// is refused outright rather than persisted without a receipt.
193+
//
194+
// This is the tripwire for the `required` decision: if that gate is
195+
// ever relaxed so such a type is written some other way, whatever
196+
// receipt that path returns has to be re-measured before these three
197+
// fields can stay required.
198+
const p = await makeProtocol();
199+
await expect(
200+
p.saveMetaItem({ type: 'agent', name: 'helper', organizationId: 'org_x', item: { name: 'helper' } }),
201+
).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 });
202+
});
203+
});

packages/spec/authorable-surface.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,7 +1811,11 @@
18111811
"api/SaveMetaItemRequest:name",
18121812
"api/SaveMetaItemRequest:type",
18131813
"api/SaveMetaItemResponse:message",
1814+
"api/SaveMetaItemResponse:projectionApplied",
1815+
"api/SaveMetaItemResponse:seq",
1816+
"api/SaveMetaItemResponse:state",
18141817
"api/SaveMetaItemResponse:success",
1818+
"api/SaveMetaItemResponse:version",
18151819
"api/ScheduleExportRequest:delivery",
18161820
"api/ScheduleExportRequest:fields",
18171821
"api/ScheduleExportRequest:filter",

0 commit comments

Comments
 (0)