Skip to content

Commit abecc12

Browse files
test(dogfood): pin five more api-backend checklist items at their oracle's layer (#26829)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8VsCYkn9rw8Z1i5vq5MHxL4)_ Refs #26572 (slice 2 of about 3, items: packaged-action-disabled-dispatch, action-activation-door-contract, rest-crud-config-contract, rest-batch-config-contract, rest-metadata-config-contract) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:给 api-backend 清单里又 5 个被 18.2.0 Tier 1 扫描判为 PARTIAL 的条目补钉子:动作停用与激活门在真实 HTTP、MCP 和冷重启上验证,三个 REST 配置契约的默认挂载在启动的服务上验证、非默认配置在真实 RestServer 上验证;并更新这 5 条的 automated.ref、verify、revision 和 history。 - 为什么改:这些条款的判据在 HTTP 层或真实 RestServer 上,旧钉子只到单元层或根本没有,每次发版扫描都要人工补跑;现在变成常驻、可重跑的证明。 - 风险与回滚:只加测试、一个 scratch fixture 和清单文本,不改任何产品代码;回滚就是 revert 本 PR。dogfood 只在 main 的定时/推送批次跑,不进 PR 和合并队列。 - 先看:`packages/qa/dogfood/test/packaged-action-disabled-dispatch-live.dogfood.test.ts`(MCP 与冷重启)、`packages/qa/dogfood/test/rest-config-contract-harness.test.ts`(真实 RestServer 的路由表与缓存分支)、`docs/qa/platform-checklist/areas/api-backend.json`。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/rest-config-contract-harness.test.ts`,预期 7 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Five more api-backend checklist items that the release-18.2.0 Tier 1 sweep (#26468) scored PARTIAL now carry pins at the layer their oracle names. The `api` legs drive a booted stack through `@objectstack/verify`'s real HTTP surface (Hono request injection), plus the live MCP transport and a cold boot on one database file. The `test` legs construct a real `RestServer`, which is the method the three config items prescribe because no shipped boot path authors a `RestServerConfig` (the #15543 posture). | Item | Clauses pinned | Pin | |---|---|---| | `api-backend.packaged-action-disabled-dispatch` | acceptance[0]–[6], negative[0]–[2]: exact 409 `ACTION_DISABLED` and sentence with the task re-read unchanged, the param-gallery 409/400 ordering pair, the byte-identical 403s, a live `POST /api/v1/mcp` `run_action` refusal carrying the REST door's exact message, the flow-type action's own switch, the anonymous 401, and the cold-boot durability leg | `packaged-action-disabled-dispatch-live.dogfood.test.ts` (new) | | `api-backend.action-activation-door-contract` | acceptance[0]–[6], negative[0]–[2]: authority before body and lookup, the 2-/4-segment shapes against the routing-404 control, the `fields[0]` codes, `{}` enabling, zero ledger rows after refusals, exactly one row updated in place, and the ambiguity arm live | `action-activation-door-live.dogfood.test.ts` + `fixtures/activation-ambiguity-fixture.ts` (new) | | `api-backend.rest-crud-config-contract` | acceptance[0] (six default mounts vs the control), [2] (live `OBJECT_API_DISABLED` body vs the control, and the switched-off route absent), [3] (the whole table re-based at `dataPrefix: '/records'`), [4] (both tombstones' schema and prescription) | `rest-config-contract-live.dogfood.test.ts`, `rest-config-contract-harness.test.ts` (new) | | `api-backend.rest-batch-config-contract` | acceptance[0] (four bulk doors and `/batch` vs the control), [4] (201 records → 400 `BATCH_TOO_LARGE` max 200, nothing created), [5] (both tombstones' schema and replacement) | same two files | | `api-backend.rest-metadata-config-contract` | acceptance[0] (default `/meta` mounts vs the control), [1] (whole table re-based at `prefix: '/metadata'`), [3] (the `client_liaison` member's served `showcase_project` schema omits budget, spent and budget_remaining), [4] (`enableCache` and each of its carve-outs), [5] (both tombstones' schema and replacement) | same two files | Each item's `automated.ref` names the new pins and what they assert. The verify texts that said "unit only" or "no pin" now name the pin that carries the clause, `revision` is bumped and a `history` row cites #26572. `action-activation-door-contract`'s `fixtures.knownGaps[0]` gains one sentence saying the colliding scratch package now exists. No clause text changed. No product code changed. ## Why The card's ruled scope (triage 6437722483) is to pin the 11 api-backend Tier 1 clauses the 18.2.0 sweep scored PARTIAL at the layer their oracle names. The pins go in `packages/qa/dogfood/**`, with the checklist item revisions bumped, in slices of about five items, and with no new CI lane. Slice 1 landed as #26787. This is slice 2, the next five items in card order. `rest-route-generation-tombstones` is left for slice 3. ## Tests The touched test files on head 14a3213 (after merging `main` and rebuilding the dogfood closure), under the verify lock: ``` pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2 test/rest-config-contract-harness.test.ts test/rest-config-contract-live.dogfood.test.ts test/action-activation-door-live.dogfood.test.ts test/packaged-action-disabled-dispatch-live.dogfood.test.ts Test Files 4 passed (4) Tests 29 passed (29) ``` Every expectation was first measured against the live answer on a probe boot, then checked against the clause text before it was written down. No ablation was run: the diff changes no tested behaviour, it only adds pins over behaviour that already holds. ## Acceptance notes - Dogfood runs only in `main`'s scheduled and push battery (`Dogfood Regression Gate`'s `if:` excludes `pull_request` and `merge_group`), so the PR's CI does not run these files. The local run above is their evidence until the next `main` battery. - The harness file imports `RestServer` through `@objectstack/runtime`'s re-export. `@objectstack/rest` is not a dogfood dependency, and adding one would move `pnpm-lock.yaml`, which is outside this card's file surface. - The MCP leg composes `MCPServerPlugin` the way `showcase-mcp-http-identity.dogfood.test.ts` does. `showcase_portfolio_snapshot` is the only stock `ai.exposed` action, so the MCP refusal is read on it, and the REST door's message for the same action is compared byte for byte. - The cold-boot leg stops the stack and boots it again on the same `databaseFile`, the shape `automation-authoring-doors-durable.dogfood.test.ts` uses. - The ambiguity fixture declares `actamb_archive` inside two objects' own `actions`. The door refuses from either object, names both, writes no row, and the action still dispatches afterwards. - Still not covered, as the items already record: the activation door's metadata-plane 503 and its 501/503 durability arms (no stock door). ## Verification <details><summary>Gates (dispatch-gates --commands, reconciled with --ran)</summary> - `node scripts/check-platform-checklist.mjs`: exit 0, `OK — 16 areas, 329 items (324 active, 1 planned)`. - 55 derived commands at 37105b7 (measured on the identical tree before its message was reworded): 54 exit 0; 1 exit 3 (prerequisite not met, nothing measured): `pnpm check:dual-build-cjs-loads`, which reads built output of packages outside the dogfood closure. It runs in CI's `Lint & Repo Gates`. - After merging `main` (0ea90c7), the derived list is unchanged. The gates whose scripts the merge moved, plus the checklist gate, were re-run on the merge head: `check-closing-keyword-parity` (and `--self-test`), `check:platform-checklist`, `check-issue-citations`, all exit 0. - `pnpm --filter @objectstack/dogfood run typecheck`: exit 0. - eslint over the five touched TypeScript files: exit 0. - `dispatch-gates --ran`: 55 derived families accounted for, 54 run, 1 NOT-MEASURED, 0 unrun. </details> No changeset: `@objectstack/dogfood` is private and `docs/qa/**` ships in no package, so this PR carries `skip-changeset`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint over the touched files) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset. (nothing publishes) - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbWUpGqdpDRiD79LqXV --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWUpGqdpDRiD79LqXV)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1bfde3 commit abecc12

6 files changed

Lines changed: 978 additions & 22 deletions

‎docs/qa/platform-checklist/areas/api-backend.json‎

Lines changed: 52 additions & 22 deletions
Large diffs are not rendered by default.
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26572] `api-backend.action-activation-door-contract`, pinned at the layer
4+
// its `api` oracle names: `POST /api/v1/actions/_activation/:object/:action`
5+
// on a booted showcase, and on a scratch app that declares one action name on
6+
// two objects. The 18.2.0 Tier 1 sweep (#26468) scored the item PARTIAL: the
7+
// authority-first ordering, the field-level 400 codes, the empty-body 200 body,
8+
// the zero-row ledger read after refusals, the exactly-one-row count and the
9+
// ambiguity arm were asserted only at unit level (action-activation-posture-gate.test.ts,
10+
// a dispatcher over fakes) or not at all.
11+
//
12+
// ## What each case pins
13+
//
14+
// - acceptance[0] / negative[1]: a member without `manage_metadata` sending a
15+
// mis-bodied request for an undeclared action gets the 403 with the
16+
// capability sentence and no clone recommendation, never the body's 400 or
17+
// the lookup's 404; the anonymous twin gets the 401 floor;
18+
// - acceptance[1]: the 2-segment path is this door's shape 400, the
19+
// 4-segment path is the router's 404, byte-identical to an unmounted path;
20+
// - acceptance[2] / negative[0]: `{enable:false}`, `{enabled:"false"}` and a
21+
// non-object body each answer 400 VALIDATION_FAILED with the named
22+
// `fields[0]`, and the one-letter-off probe does not answer 200;
23+
// - acceptance[4] / negative[2]: an undeclared action is 404 "nothing to
24+
// switch off", and no refused probe of any arm leaves a ledger row;
25+
// - acceptance[3]: an empty body answers 200 with `enabled: true`;
26+
// - acceptance[5]: the flip answers `{name, objectName, enabled}`, writes
27+
// exactly one row with no tenant column, and flipping back updates that row;
28+
// - acceptance[6] (scratch app): a name declared on two objects answers 409
29+
// RESOURCE_CONFLICT naming both owners, from either object, writes nothing,
30+
// and leaves the action armed.
31+
//
32+
// acceptance[4]'s metadata-plane 503 arm and acceptance[7]'s 501/503 arms stay
33+
// where the item records them (no stock door).
34+
35+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
36+
import { bootStack, type VerifyStack } from '@objectstack/verify';
37+
import { bootShowcase } from './showcase-boot.js';
38+
import { activationAmbiguityStack, SHARED_ACTION, OWNERS } from './fixtures/activation-ambiguity-fixture.js';
39+
40+
const LEDGER = 'sys_metadata_activation';
41+
const SYS = { isSystem: true, positions: [], permissions: [] };
42+
const DOOR = '/actions/_activation/showcase_task/showcase_mark_done';
43+
const UNDECLARED = '/actions/_activation/showcase_task/no_such_action';
44+
45+
interface Answer { status: number; text: string; json: any }
46+
47+
async function answer(stack: VerifyStack, token: string | null, method: string, path: string, body?: unknown): Promise<Answer> {
48+
const res = token
49+
? await stack.apiAs(token, method, path, body)
50+
: await stack.api(path, {
51+
method,
52+
headers: { 'Content-Type': 'application/json' },
53+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
54+
});
55+
const text = await res.text();
56+
let json: any;
57+
try { json = JSON.parse(text); } catch { json = undefined; }
58+
return { status: res.status, text, json };
59+
}
60+
61+
async function ledgerRows(stack: VerifyStack): Promise<Array<Record<string, unknown>>> {
62+
const ql: any = await stack.kernel.getServiceAsync('objectql');
63+
return ql.find(LEDGER, { where: {}, context: SYS });
64+
}
65+
66+
describe('[#26572] the action activation door\'s ordered arms, on a booted showcase', () => {
67+
let stack: VerifyStack;
68+
let admin: string;
69+
let member: string;
70+
const call = (token: string | null, method: string, path: string, body?: unknown) => answer(stack, token, method, path, body);
71+
72+
beforeAll(async () => {
73+
stack = await bootShowcase();
74+
admin = await stack.signIn();
75+
member = await stack.signUp('member.26572@activation-door.test');
76+
}, 240_000);
77+
78+
afterAll(async () => {
79+
await stack?.stop();
80+
});
81+
82+
it('acceptance[0]: authority runs first — the member gets the capability 403, the anonymous caller the 401', async () => {
83+
const refused = await call(member, 'POST', UNDECLARED, { enable: false });
84+
expect(refused.status, refused.text).toBe(403);
85+
expect(refused.json.error.code).toBe('PERMISSION_DENIED');
86+
expect(refused.json.error.message).toContain('`manage_metadata`');
87+
expect(refused.json.error.message).toContain('switching a shipped artifact off is functionally equivalent to deleting it');
88+
expect(refused.json.error.message).not.toMatch(/clone/i);
89+
// Neither the body contract nor the action namespace leaked.
90+
expect(refused.text).not.toContain('enabled');
91+
expect(refused.text).not.toContain('no_such_action');
92+
93+
const anonymous = await call(null, 'POST', UNDECLARED, { enable: false });
94+
expect(anonymous.status, anonymous.text).toBe(401);
95+
expect(anonymous.json.error.code).toBe('UNAUTHENTICATED');
96+
});
97+
98+
it('acceptance[1]: the 2-segment path is the door\'s shape 400; the 4-segment path is the router\'s 404', async () => {
99+
const two = await call(admin, 'POST', '/actions/_activation/showcase_mark_done', {});
100+
expect(two.status, two.text).toBe(400);
101+
expect(two.json.error.message).toBe(
102+
'Path must be /actions/_activation/:object/:action (use `global` for an object-less action)',
103+
);
104+
105+
const four = await call(admin, 'POST', `${DOOR}/extra`, {});
106+
const control = await call(admin, 'POST', '/definitely-not-a-route-26572', {});
107+
expect(control.status).toBe(404);
108+
expect(four.status).toBe(404);
109+
expect(four.text).toBe(control.text);
110+
});
111+
112+
it('acceptance[2]: the body contract refuses with named field codes, never inverts', async () => {
113+
const typo = await call(admin, 'POST', DOOR, { enable: false });
114+
expect(typo.status, typo.text).not.toBe(200);
115+
expect(typo.status).toBe(400);
116+
expect(typo.json.error.code).toBe('VALIDATION_FAILED');
117+
expect(typo.json.error.details.fields[0]).toMatchObject({ field: 'enable', code: 'unknown_field' });
118+
expect(typo.json.error.details.fields[0].message).toContain('`enabled`');
119+
120+
const stringly = await call(admin, 'POST', DOOR, { enabled: 'false' });
121+
expect(stringly.status, stringly.text).toBe(400);
122+
expect(stringly.json.error.code).toBe('VALIDATION_FAILED');
123+
expect(stringly.json.error.details.fields[0]).toMatchObject({ field: 'enabled', code: 'invalid_type' });
124+
125+
const array = await call(admin, 'POST', DOOR, []);
126+
expect(array.status, array.text).toBe(400);
127+
expect(array.json.error.code).toBe('VALIDATION_FAILED');
128+
expect(array.json.error.details.fields[0]).toMatchObject({ field: '(body)', code: 'invalid_type' });
129+
});
130+
131+
it('acceptance[4]: an undeclared action is 404 "nothing to switch off", and no refusal so far wrote a row', async () => {
132+
const res = await call(admin, 'POST', UNDECLARED, { enabled: false });
133+
expect(res.status, res.text).toBe(404);
134+
expect(res.json.error.message).toContain('has no declaration — there is nothing to switch off');
135+
136+
expect(await ledgerRows(stack)).toEqual([]);
137+
});
138+
139+
it('acceptance[3]: an empty body ENABLES — 200 with enabled true', async () => {
140+
const res = await call(admin, 'POST', DOOR, {});
141+
expect(res.status, res.text).toBe(200);
142+
expect(res.json.data).toEqual({ name: 'showcase_mark_done', objectName: 'showcase_task', enabled: true });
143+
});
144+
145+
it('acceptance[5]: the flip answers its body and keeps exactly ONE deployment-level row, updated in place', async () => {
146+
const off = await call(admin, 'POST', DOOR, { enabled: false });
147+
expect(off.status, off.text).toBe(200);
148+
expect(off.json.data).toEqual({ name: 'showcase_mark_done', objectName: 'showcase_task', enabled: false });
149+
150+
const offRows = (await ledgerRows(stack)).filter((r) => r.name === 'showcase_mark_done');
151+
expect(offRows).toHaveLength(1);
152+
expect(offRows[0].metadata_type).toBe('action');
153+
expect(offRows[0].active === false || offRows[0].active === 0).toBe(true);
154+
// A key-set reading off the driver's own SELECT: the table has no tenant column.
155+
expect(Object.keys(offRows[0])).not.toContain('organization_id');
156+
expect(Object.keys(offRows[0])).toEqual(expect.arrayContaining(['id', 'metadata_type', 'name', 'active']));
157+
158+
const on = await call(admin, 'POST', DOOR, { enabled: true });
159+
expect(on.status, on.text).toBe(200);
160+
expect(on.json.data).toEqual({ name: 'showcase_mark_done', objectName: 'showcase_task', enabled: true });
161+
162+
const onRows = (await ledgerRows(stack)).filter((r) => r.name === 'showcase_mark_done');
163+
expect(onRows).toHaveLength(1);
164+
expect(onRows[0].id).toBe(offRows[0].id);
165+
expect(onRows[0].active === true || onRows[0].active === 1).toBe(true);
166+
});
167+
});
168+
169+
describe('[#26572] the ambiguity arm, on a scratch app declaring one action name on two objects', () => {
170+
let stack: VerifyStack;
171+
let admin: string;
172+
173+
beforeAll(async () => {
174+
stack = await bootStack(activationAmbiguityStack as never, {});
175+
admin = await stack.signIn();
176+
}, 180_000);
177+
178+
afterAll(async () => {
179+
await stack?.stop();
180+
});
181+
182+
it('acceptance[6]: 409 RESOURCE_CONFLICT naming every owner, from either object, and nothing written', async () => {
183+
for (const object of OWNERS) {
184+
const res = await answer(stack, admin, 'POST', `/actions/_activation/${object}/${SHARED_ACTION}`, { enabled: false });
185+
expect(res.status, res.text).toBe(409);
186+
expect(res.json.error.code).toBe('RESOURCE_CONFLICT');
187+
for (const named of OWNERS) expect(res.json.error.message).toContain(`'${named}'`);
188+
}
189+
expect(await ledgerRows(stack)).toEqual([]);
190+
191+
// The refusal switched nothing off: the action still dispatches.
192+
const created = await answer(stack, admin, 'POST', `/data/${OWNERS[0]}`, { name: 'still armed' });
193+
expect(created.status, created.text).toBeLessThan(300);
194+
const id = created.json.id ?? created.json.record?.id;
195+
const run = await answer(stack, admin, 'POST', `/actions/${OWNERS[0]}/${SHARED_ACTION}`, { recordId: id });
196+
expect(run.status, run.text).toBe(200);
197+
});
198+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26572] The colliding scratch package `api-backend.action-activation-door-contract`
4+
// names for its ambiguity arm (fixtures.knownGaps[0]): no stock app declares
5+
// one action name on two objects, so the 409 RESOURCE_CONFLICT arm has no live
6+
// door without it.
7+
//
8+
// Two objects each declare `actamb_archive` inside their own `actions`. The
9+
// activation ledger addresses an action by its machine name alone (ADR-0126
10+
// §4), so one row would switch both: the door must refuse, naming every
11+
// owner, and write nothing.
12+
import { defineStack } from '@objectstack/spec';
13+
import { ObjectSchema, Field } from '@objectstack/spec/data';
14+
15+
/** The one machine name both objects declare. */
16+
export const SHARED_ACTION = 'actamb_archive';
17+
export const OWNERS = ['actamb_invoice', 'actamb_order'] as const;
18+
19+
const owner = (name: (typeof OWNERS)[number]) =>
20+
ObjectSchema.create({
21+
name,
22+
sharingModel: 'public_read_write',
23+
label: name === 'actamb_invoice' ? 'Invoice' : 'Order',
24+
pluralLabel: name === 'actamb_invoice' ? 'Invoices' : 'Orders',
25+
fields: { name: Field.text({ label: 'Name' }) },
26+
actions: [
27+
{ name: SHARED_ACTION, label: 'Archive', objectName: name, type: 'script', operation: 'update', patch: { name: 'archived' } },
28+
],
29+
} as any);
30+
31+
export const activationAmbiguityStack = defineStack({
32+
manifest: {
33+
id: 'com.dogfood.activation-ambiguity',
34+
namespace: 'actamb',
35+
version: '0.0.0',
36+
type: 'app',
37+
name: 'Activation Ambiguity Fixture',
38+
description: 'One action name declared on two objects.',
39+
},
40+
objects: OWNERS.map(owner),
41+
} as any);

0 commit comments

Comments
 (0)