Skip to content

Commit e170d42

Browse files
feat(spec)!: judge a collection item's _packageId against its owning body at defineStack, os validate and os build (#28685)
🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V Fixes #28627 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — no `skills/**` file teaches writing `_packageId`; the one mention (`skills/objectstack-data/references/examples-objects.md`, Metadata Protection) already says the runtime populates it ## 维护者速读 - **改了什么**:在 `defineStack`、`os validate`、`os build` 和制品解析这一层,就拒绝"条目自己写了别的包的 `_packageId`"的元数据,报错定位到该条目的 `_packageId`,并给出改法(删掉这个键)。判定逻辑抽成 spec 里的一个纯函数,objectql 启动/安装时的拒绝也改为调用它,两道门不可能判得不一样。 - **为什么改**:PR #28587 只在启动或安装时拒绝这种写法,作者(通常是 AI)要等到管理员安装失败才知道;这次让作者在写的地方、跑 `os validate` 时就看到。 - **风险与回滚**:这是一次收窄(`Metadata-protocol: narrowed`),spec 升 minor,objectql 只是改为调用共享判定(patch,行为和文字都不变)。受影响的只有手写了别的包 `_packageId` 的栈,正常构建、导出、安装都不会写这个键;启动时本来就拒绝它们。回滚就是 revert 这个 PR。 - **先看**:`packages/spec/src/kernel/collection-item-owner-stamp.ts`(判定函数与判定集合),`packages/spec/src/stack.zod.ts` 里的 `applyCollectionItemOwnerStampGate`,`packages/objectql/src/engine.ts` 里改写后的 `collectionItemOwnerStampRefusal`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/collection-item-owner-stamp-doors.test.ts`,预期 3 passed(其中一条逐键证明构建门判的集合与启动门拒绝的集合完全相同)。 - **需要你做的**:无,席位在契约复审 PASS 后落地。 ## What changes - **`packages/spec/src/kernel/collection-item-owner-stamp.ts`** (new): `judgeCollectionItemOwnerStamp(item, type, name, owner, door)`, one pure judge shaped like ADR-0086 D3's `judgePermissionSetOwnerBinding`. It returns `{ stamped, owner, message, issueMessage }` or `undefined`, builds no envelope and throws nothing. Doors: `build` and `register`. The verdict table mirrors the boot exactly: an absent stamp is accepted; a stamp equal to the owner is accepted (it is what an item re-registered under its own package carries, and the boot accepts it); any other value is refused, `null` and `''` included; under no owner any stamp is refused. The two doors' words differ only in the clause naming where the owner comes from (`it is registered under …` / `this stack ships it from …`). Also exports `OWNER_STAMP_JUDGED_COLLECTIONS`, the 28 stack collections the registration door judges (the `registerItem` collections plus seed `data`). - **`packages/spec/src/stack.zod.ts`**: `applyCollectionItemOwnerStampGate`, a `superRefine` beside the permission-set owner-binding gate. On the schema, so `defineStack`, `os validate`, `os build` (`compile.ts`) and the artifact parse all run it once; no CLI file changed. The owner is `manifest.id` (none without a manifest); for `packages[]` each body's own `id`; a top-level item no body declares in the same collection under the same name is the residual, judged against `manifest.id`; a top-level copy of a body's item is not judged. - **`packages/objectql/src/engine.ts`**: `collectionItemOwnerStampRefusal` calls the judge with door `register`. Kept: its `VALIDATION_ERROR` / 400 envelope with `issues[]` at `_packageId`, and the `REGISTRATION_WRITTEN_STAMPS` exemption, which stays in objectql (process state, not a body rule). The boot words are byte-identical, so no boot pin flipped. - Regenerated by `check:generated --fix`: `packages/spec/api-surface/kernel.json`, `packages/spec/export-origins/kernel.json` (five additions each). - Changesets: `@objectstack/spec` minor (`28627-item-owner-stamp-build-door.md`), `@objectstack/objectql` patch (`28627-item-owner-stamp-shared-judge.md`). Each carries both declaration lines, a BREAKING banner, `adr-0087: not-required (no-migration-prescription)` and the remedy "drop `_packageId` from the body", in the shape #28587 used. ## Tests - `packages/spec/src/kernel/collection-item-owner-stamp.test.ts` (9): the verdict table row by row at both doors, the two doors' words, the list's integrity. - `packages/spec/src/stack-collection-item-owner-stamp.test.ts` (11): `defineStack` refuses a foreign stamp (`STACK_SCHEMA_INVALID` / 422, issue `custom` at `connectors.0._packageId`, message equal to the judge's build words); the control without the stamp, and with a stamp equal to `manifest.id`, passes; `''` refused by the judge, `null` by the item schema's type check at the same key; no `manifest` refuses any stamp; map format; one item per shape class (action, seed dataset, view container) reaches the judge through the parse; a release artifact's body judged against its own id, its residual against `manifest.id`, a top-level copy not judged. - `packages/objectql/src/collection-item-owner-stamp-doors.test.ts` (3): the same stamp gets the same verdict at `registerApp` and at the stack parse, each door's message equal to the judge's for its door; and, key by key over all of `STACK_DEFINITION_KEYS`, the set of collections in which `registerApp` refuses a foreign stamp equals `OWNER_STAMP_JUDGED_COLLECTIONS`. - `packages/cli/test/collection-item-owner-stamp-validate-door.test.ts` (2, integration tier, spawns the source CLI): `os validate --json` on a foreign-stamped connector exits 1 with exactly one error, `custom` at `connectors.0._packageId`, message equal to the judge's build words, and `ObjectQL.registerApp` refuses the same body with the judge's register words; the control without the stamp exits 0. - `packages/objectql/src/collection-item-owner-stamp.test.ts` (#28587's 12) is unchanged and green. ## Acceptance notes - Independent review: none run (quota); the PR waits for the seat's contract-review hand-off. - Measured, the PM assumption on nested bodies: the build door judges the stack's own collections and each `packages[]` body. A stack's `plugins` is `z.array(z.unknown())` (runtime plugin instances), not a body the parse can read, so a nested plugin's items are judged only by the registration door, as before. - Measured, parse order: all 28 judged collections' item schemas declare `_packageId` (`MetadataProtectionFields`; the authorable-surface ratchet holds the key), and the stack refinement reads the parsed stack. The action schema's output JSON schema does not show the key, so the parse was driven for an action, a seed dataset, a view container and a connector: each reaches the judge (pinned in the spec test). Consequence of the refinement position: like the permission-set gate, the issue appears once the rest of the stack parses; `null` is refused by the item schema's own type check, not the judge. - Not judged at either door, unchanged by this PR: `objects`, `objectExtensions`, `apps`, `picklistExtensions`, `datasources`, `datasourceMapping`, `translations`. The objectql key-by-key test pins that the build door's set equals the boot's. - Words: the boot's `register` words are byte-identical to #28587's; the build words change only the owner clause. ## Verification All readings below at head `7f7161f2a6` (after the one merge of `origin/main`, which touched no `packages/spec`, `packages/objectql` or `packages/cli` file), each under `scripts/pm/os-verify-lock.sh`, exit captured before any pipe. <details><summary>Suites and typecheck</summary> ```text pnpm --filter @objectstack/spec test exit 0 Test Files 784 passed (784) · Tests 23142 passed | 1 todo (23143) pnpm --filter @objectstack/objectql test exit 0 Test Files 513 passed (513) · Tests 9901 passed (9901) pnpm --filter @objectstack/metadata-protocol test exit 0 Test Files 291 passed | 4 skipped (295) · Tests 21944 passed | 26 skipped (21970) pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 exit 0 Test Files 363 passed (363) · Tests 5162 passed (5162) pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=2 test/collection-item-owner-stamp-validate-door.test.ts exit 0 Test Files 1 passed (1) · Tests 2 passed (2) pnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/objectql typecheck && pnpm --filter @objectstack/cli typecheck exit 0 ``` The rest of the cli integration tier is declared to CI (the diff adds one integration file, run above). </details> <details><summary>Dogfood battery (the (narrowing) arm), whole package</summary> ```text pnpm --filter @objectstack/dogfood test VERDICT command-exit 0 (lock held 29m41s) Test Files 369 passed | 3 skipped (372) Tests 2981 passed | 28 skipped (3009) no failure ``` </details> <details><summary>Ablation of the build door (once)</summary> `node scripts/ablation-replace.mjs --file packages/spec/src/stack.zod.ts --anchor " .superRefine(applyCollectionItemOwnerStampGate)" --replacement " /* ABLATED_28627 */"` (wrap mode, at `3df38f8d81`): ```text anchor x1 -> x0 · blob 3fcb415 -> 4c4c845a6935 pnpm --filter @objectstack/spec build ablation-dist-preflight @objectstack/spec 'superRefine(applyCollectionItemOwnerStampGate)' --absent ✓ dist/: marker absent from all 588 built files collection-item-owner-stamp-validate-door.test.ts: × exit 1: located at the item's `_packageId` ... -> AssertionError: expected exit 1: expected +0 to be 1 ✓ CONTROL: the same body without the stamp exits 0 Tests 1 failed | 1 passed (2) restore: blob after restore == HEAD 3fcb415, `git diff HEAD` empty rebuild + preflight (default mode): ✓ marker present in 4 built files; ✓ tree: working tree clean against HEAD same file re-run: Tests 2 passed (2) ``` </details> <details><summary>Generated artifacts</summary> ```text pnpm --filter @objectstack/spec check:generated --fix (before the merge) regenerated api-surface/ and export-origins/ (2 of 2 proved stale) after the merge: pnpm install --frozen-lockfile; gen:schema && gen:docs && check:generated --fix ✓ All 14 generated artifacts are up to date — measured against packages/spec/dist, BUILT BY THIS RUN (no file changed) ``` </details> <details><summary>Local gates (the derived set; CI runs the full farm)</summary> ```text node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (tree 7f7161f) -> 88 commands first pass: 87 exit 0; 1 exit 3 PREREQUISITE NOT MET pnpm check:dual-build-cjs-loads (seven dists outside this diff missing) -> built via turbo (cache) -> re-run exit 0 (115 published require entry points across 70 packages load) dispatch-gates --ran (record with exit codes, the re-run's code for that one gate): ✓ 88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED ``` Local-only: `pnpm check:dev-prereqs` exit 1, "35 of 106 workspace packages declare an entry point under dist/ that is not on disk" (`ui/**` packages outside this diff's closure): a reading of this container's build state, not of the change. Not run, declared: `check-platform-checklist` (no export renamed or deleted; five exports added); `test:repo` (no repo-project test touched; CI's Test Core runs it). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent da6f0ce commit e170d42

12 files changed

Lines changed: 824 additions & 12 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `defineStack`, `os validate` and `os build` refuse a collection item whose body `_packageId` names a package other than the one that ships it
6+
7+
The stack schema (`ObjectStackDefinitionSchema`) now judges every item of the collections the registration door files under their package (`OWNER_STAMP_JUDGED_COLLECTIONS`: connectors, flows, views, actions, permission sets, seed `data` and the rest) and raises an issue at the item's `_packageId` when the body carries a stamp other than the package that ships it: the stack's `manifest.id` (no package without a manifest), or for a release artifact each `packages[]` body's own `id`, with a top-level item no body declares judged against `manifest.id`. `defineStack` answers `STACK_SCHEMA_INVALID` / 422; `os validate`, `os build` and the artifact door's parse report the located issue. The judge is one pure function, `judgeCollectionItemOwnerStamp` (`@objectstack/spec/kernel`), which `@objectstack/objectql`'s registration refusal calls too, so the build door and the boot cannot disagree.
8+
9+
Clause-②: no (narrowing) — the stack schema refuses a collection item whose body `_packageId` names a package other than the one that ships it, which it parsed before and the registration refused only at boot or install. Nothing is widened.
10+
11+
Metadata-protocol: narrowed (a collection item's `_packageId`, declared by `MetadataProtectionFields`, is refused at the build doors unless it is absent or names the package that ships the item)
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key is added, removed or renamed, and no value a legitimate producer writes is refused: the registration is the only writer of `_packageId` (`os build` writes none, the package export strips every underscore key), and a stamp equal to the shipping package is still accepted. A foreign stamp exists only in a body somebody wrote by hand, which the registration door already refuses at boot or install; this moves the same refusal to where the body is written. A conversion could not tell a foreign stamp from an agreeing one without the owner that judges it, and the refusal names the item, both values and the remedy. -->
14+
15+
**BREAKING** accept-set narrowing at the build doors (`@objectstack/spec`), shipped as `minor`.
16+
17+
- **Before.** `defineStack({ manifest: { id: 'com.acme.app', … }, connectors: [{ name: 'billing', …, _packageId: 'com.acme.host' }] })` parsed, and `os validate` exited 0; `ObjectQL.registerApp` refused the stack at boot or install with `VALIDATION_ERROR` / 400 at `_packageId`.
18+
- **Now.** The parse raises an issue at `connectors.0._packageId` whose message starts "Drop `_packageId` from connector 'billing'", names the stamp and the package that ships the item, and `os validate` / `os build` exit 1. `''` is refused the same way; `null` is refused by the item schema's own type check at the same key.
19+
- **Unchanged.** An absent `_packageId` and one equal to the shipping package parse as before. Objects, object extensions, apps, picklist extensions, datasources, datasource mappings and translations are not judged at either door. A top-level copy of a `packages[]` body's item is not judged against the artifact's id. A stack's `plugins` (runtime plugin instances) are not read by the parse; the registration door judges a nested plugin's items when it registers them.
20+
- **Who sees it.** Whoever hand-writes `_packageId` on a stack item naming another package, or on any item of a stack with no `manifest`, which no build, export or install writes.
21+
22+
**The remedy.** Drop `_packageId` from the body. The package an item belongs to is the one that ships and registers it, stamped by the registration.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
refactor(objectql): the registration door's `_packageId` refusal calls the shared spec judge
6+
7+
`collectionItemOwnerStampRefusal` now asks `judgeCollectionItemOwnerStamp` (`@objectstack/spec/kernel`) for the verdict and the words, the judge the stack schema runs at `defineStack`, `os validate` and `os build`, so the build door and the boot cannot disagree. Its `VALIDATION_ERROR` / 400 envelope with `issues[]` at `_packageId`, its words, and its exemption for a stamp a registration of this process wrote onto the caller's object are unchanged.
8+
9+
Clause-②: no (narrowing) — carried by `@objectstack/spec`'s `minor` in the same change: the build doors now refuse the body `_packageId` this door already refused. This package's own accept set is unchanged. Nothing is widened.
10+
11+
Metadata-protocol: narrowed (a collection item's `_packageId` is refused at the build doors as it already was at registration)
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) This package changes no key, value or verdict: the registration door refuses exactly what it refused before, in the same words. The narrowing it travels with is the build doors', declared on `@objectstack/spec`. -->
14+
15+
**BREAKING** accept-set narrowing at the build doors, declared on `@objectstack/spec`'s `minor`; this `patch` carries the shared judge's caller only.
16+
17+
- **Before.** The registration door compared a body's `_packageId` with the registering package itself.
18+
- **Now.** It calls the spec judge, which also runs at the build doors.
19+
- **Unchanged.** Every verdict and every word at `registerApp`, the seed datasets, the `viewItems:` channel and the `manifest` service's residual registration.
20+
- **Who sees it.** Nobody at this door; an author now hears the same refusal at `os validate`.
21+
22+
**The remedy.** Drop `_packageId` from the body.
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+
* [ADR-0010] `os validate` — the build door an author runs before shipping —
5+
* refuses a collection item whose body carries another package's `_packageId`,
6+
* run for real, located at the item's key.
7+
*
8+
* ## The defect, measured before the fix
9+
*
10+
* A stack whose connector body carried `_packageId: 'com.example.host'` (a
11+
* stamp only the registration writes) validated clean and exited 0; the
12+
* registration door (`ObjectQL.registerApp`) refused it at boot or install
13+
* with `VALIDATION_ERROR` / 400 at `_packageId`. The author heard about it
14+
* from a failed install, not where they wrote it.
15+
*
16+
* ## One judge
17+
*
18+
* Both doors call `judgeCollectionItemOwnerStamp` (`@objectstack/spec/kernel`).
19+
* So the headline pin asserts equality with the judge's own build-door words,
20+
* and with the verdict the registration door returns for the same stack,
21+
* driven through `ObjectQL.registerApp`, rather than a literal copy of the
22+
* words, which would be a second spelling of them.
23+
*
24+
* Integration tier: it spawns the source CLI (`packages/cli/vitest-tiers.ts`).
25+
* Two spawns, one per fixture. The CLI runs through `bin/run-dev.js` (source,
26+
* under `node`), and `@objectstack/spec` resolves through `exports` to `dist/`.
27+
*/
28+
29+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
30+
import { execFile } from 'node:child_process';
31+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
32+
import { tmpdir } from 'node:os';
33+
import { join, resolve } from 'node:path';
34+
import { fileURLToPath } from 'node:url';
35+
import { ObjectQL } from '@objectstack/objectql';
36+
import { judgeCollectionItemOwnerStamp } from '@objectstack/spec/kernel';
37+
import { childEnv } from './helpers/serve-process.js';
38+
import { writeDefineStackConfig } from './helpers/define-stack-fixture.js';
39+
40+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
41+
const CLI = resolve(HERE, '../bin/run-dev.js');
42+
/** A cold spawn of the CLI source entry runs well past vitest's 5 s default. */
43+
const SPAWN_TIMEOUT_MS = 180_000;
44+
45+
const OWNER = 'com.example.ownerstamp';
46+
const FOREIGN = 'com.example.host';
47+
48+
const manifest = {
49+
id: OWNER,
50+
namespace: 'ost',
51+
name: 'Owner stamp door probe',
52+
version: '1.0.0',
53+
type: 'app',
54+
engines: { protocol: '^18' },
55+
};
56+
57+
const connector = (stamp?: string) => ({
58+
name: 'ost_billing',
59+
label: 'Billing',
60+
type: 'api',
61+
provider: 'openapi',
62+
providerConfig: { spec: './specs/billing.json' },
63+
...(stamp === undefined ? {} : { _packageId: stamp }),
64+
});
65+
66+
/** The smallest OpenAPI document the connector's `providerConfig.spec` can name. */
67+
const OPENAPI_DOC = { openapi: '3.0.3', info: { title: 'Billing', version: '1.0.0' }, paths: {} };
68+
69+
const stackOf = (stamp?: string) => ({ manifest, connectors: [connector(stamp)] });
70+
71+
const FIXTURES = {
72+
/** The connector body names another package as its owner. */
73+
stamped: stackOf(FOREIGN),
74+
/** CONTROL: the same body without the stamp. */
75+
unstamped: stackOf(),
76+
} as const;
77+
78+
interface Run {
79+
code: number;
80+
stdout: string;
81+
stderr: string;
82+
}
83+
84+
function runCli(args: string[], cwd: string): Promise<Run> {
85+
return new Promise((resolvePromise) => {
86+
execFile(
87+
process.execPath,
88+
[CLI, ...args],
89+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
90+
(err, stdout, stderr) => {
91+
resolvePromise({
92+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
93+
stdout: String(stdout),
94+
stderr: String(stderr),
95+
});
96+
},
97+
);
98+
});
99+
}
100+
101+
interface ValidateError {
102+
code?: string;
103+
path?: unknown[];
104+
message?: string;
105+
}
106+
107+
/** `os validate --json` on failure: the parse's issues ride `errors`. */
108+
const errorsOf = (stdout: string): ValidateError[] => (JSON.parse(stdout) as { errors?: ValidateError[] }).errors ?? [];
109+
110+
const dirs = new Map<keyof typeof FIXTURES, string>();
111+
112+
beforeAll(() => {
113+
for (const [kind, stack] of Object.entries(FIXTURES) as Array<[keyof typeof FIXTURES, unknown]>) {
114+
const dir = mkdtempSync(join(tmpdir(), `os-owner-stamp-door-${kind}-`));
115+
// `strict: false`: the door's own parse must judge the content, not `defineStack` at load.
116+
writeDefineStackConfig(dir, stack, { strict: false });
117+
// The connector's spec file, so the control's only verdict is the stamp's.
118+
mkdirSync(join(dir, 'specs'));
119+
writeFileSync(join(dir, 'specs', 'billing.json'), JSON.stringify(OPENAPI_DOC));
120+
dirs.set(kind, dir);
121+
}
122+
});
123+
124+
afterAll(() => {
125+
for (const dir of dirs.values()) rmSync(dir, { recursive: true, force: true });
126+
});
127+
128+
describe('os validate refuses a collection item whose body names another package as its owner', () => {
129+
it('exit 1: located at the item\'s `_packageId`, in the judge\'s build words, and the registration door refuses the same body', async () => {
130+
const run = await runCli(['validate', '--json'], dirs.get('stamped')!);
131+
expect(run.code, `expected exit 1:\n${run.stdout}\n${run.stderr}`).toBe(1);
132+
const errors = errorsOf(run.stdout);
133+
expect(errors.map((e) => ({ code: e.code, path: (e.path ?? []).join('.') })))
134+
.toEqual([{ code: 'custom', path: 'connectors.0._packageId' }]);
135+
expect(errors[0].message).toBe(judgeCollectionItemOwnerStamp(connector(FOREIGN), 'connector', 'ost_billing', OWNER, 'build')!.message);
136+
137+
// The same verdict at the registration door, for the same body.
138+
let boot: any;
139+
try {
140+
new ObjectQL().registerApp({ ...manifest, connectors: [connector(FOREIGN)] } as any);
141+
} catch (e) {
142+
boot = e;
143+
}
144+
expect(boot).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 });
145+
expect(boot.issues).toEqual([expect.objectContaining({ path: '_packageId' })]);
146+
expect(boot.message).toBe(judgeCollectionItemOwnerStamp(connector(FOREIGN), 'connector', 'ost_billing', OWNER, 'register')!.message);
147+
}, SPAWN_TIMEOUT_MS);
148+
149+
it('CONTROL: the same body without the stamp exits 0', async () => {
150+
const run = await runCli(['validate', '--json'], dirs.get('unstamped')!);
151+
expect(run.code, `expected exit 0:\n${run.stdout}\n${run.stderr}`).toBe(0);
152+
expect((JSON.parse(run.stdout) as { valid?: boolean }).valid).toBe(true);
153+
}, SPAWN_TIMEOUT_MS);
154+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0010] The registration door and the build door judge a collection
5+
* item's body `_packageId` with ONE judge (`judgeCollectionItemOwnerStamp`,
6+
* `@objectstack/spec/kernel`), so the two cannot disagree. This file holds
7+
* them equal from the registration side:
8+
*
9+
* - the same stamp gets the same verdict at both doors — `registerApp`
10+
* (boot / install) and the stack schema's parse (`defineStack`,
11+
* `os validate`, `os build`) — and each door's words are the judge's own;
12+
* - the build door judges exactly the collections `registerApp` refuses a
13+
* foreign stamp in (`OWNER_STAMP_JUDGED_COLLECTIONS`), key by key over the
14+
* stack schema's whole key set, so the build door neither misses a
15+
* collection the boot refuses nor refuses one the boot accepts.
16+
*
17+
* The registration door's envelope and its exemption for a stamp a
18+
* registration wrote stay pinned in `collection-item-owner-stamp.test.ts`.
19+
*/
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { ObjectStackDefinitionSchema, STACK_DEFINITION_KEYS } from '@objectstack/spec';
23+
import { judgeCollectionItemOwnerStamp, OWNER_STAMP_JUDGED_COLLECTIONS } from '@objectstack/spec/kernel';
24+
import { ObjectQL } from './engine.js';
25+
26+
const PKG = 'com.probe.pkg';
27+
const FOREIGN = 'com.probe.host';
28+
29+
const connector = (stamp?: unknown) => ({
30+
name: 'probe_conn',
31+
label: 'Probe',
32+
type: 'api',
33+
provider: 'openapi',
34+
providerConfig: { spec: './specs/billing.json' },
35+
...(stamp === undefined ? {} : { _packageId: stamp }),
36+
});
37+
38+
/** The registration door's answer: the thrown error, or `undefined`. */
39+
function boot(body: Record<string, unknown>): any {
40+
try {
41+
new ObjectQL().registerApp(body as any);
42+
return undefined;
43+
} catch (e) {
44+
return e;
45+
}
46+
}
47+
48+
/** The build door's answer: the parse's issues at a `_packageId`. */
49+
function build(stack: Record<string, unknown>) {
50+
const result = ObjectStackDefinitionSchema.safeParse(stack);
51+
return result.success ? [] : result.error.issues.filter((i) => i.path[i.path.length - 1] === '_packageId');
52+
}
53+
54+
describe('[ADR-0010] one verdict at the registration door and the build door', () => {
55+
const manifest = { id: PKG, name: 'probe', namespace: 'probe', version: '1.0.0', type: 'app' };
56+
57+
it('a foreign stamp, `\'\'`, an equal stamp and no stamp: refused at both or accepted at both', () => {
58+
const cases: Array<[unknown, boolean]> = [[FOREIGN, true], ['', true], [PKG, false], [undefined, false]];
59+
for (const [stamp, refused] of cases) {
60+
const atBoot = boot({ ...manifest, connectors: [connector(stamp)] });
61+
const atBuild = build({ manifest, connectors: [connector(stamp)] });
62+
const label = `stamp ${JSON.stringify(stamp)}`;
63+
if (!refused) {
64+
expect(atBoot, label).toBeUndefined();
65+
expect(atBuild, label).toEqual([]);
66+
continue;
67+
}
68+
expect(atBoot, label).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 });
69+
expect(atBoot.issues, label).toEqual([{ path: '_packageId', code: 'custom', message: expect.any(String) }]);
70+
expect(atBuild.map((i) => [i.path.join('.'), i.code]), label).toEqual([['connectors.0._packageId', 'custom']]);
71+
// Each door's words are the one judge's, for its own door.
72+
const judged = (door: 'build' | 'register') => judgeCollectionItemOwnerStamp(connector(stamp), 'connector', 'probe_conn', PKG, door)!;
73+
expect(atBoot.message, label).toBe(judged('register').message);
74+
expect(atBoot.issues[0].message, label).toBe(judged('register').issueMessage);
75+
expect(atBuild[0].message, label).toBe(judged('build').message);
76+
}
77+
});
78+
79+
it('`null`: refused at both — by the judge at boot, by the item schema\'s type check at build, both at `_packageId`', () => {
80+
expect(boot({ ...manifest, connectors: [connector(null)] })).toMatchObject({ code: 'VALIDATION_ERROR', status: 400 });
81+
expect(build({ manifest, connectors: [connector(null)] }).map((i) => [i.path.join('.'), i.code]))
82+
.toEqual([['connectors.0._packageId', 'invalid_type']]);
83+
});
84+
});
85+
86+
describe('[ADR-0010] the build door judges exactly the collections the registration door refuses a foreign stamp in', () => {
87+
it('key by key over the stack schema\'s whole key set', () => {
88+
const refusedAtBoot: string[] = [];
89+
for (const key of STACK_DEFINITION_KEYS) {
90+
const probe = { name: 'probe_item', object: 'probe_item', records: [], _packageId: FOREIGN };
91+
const error = boot({ id: PKG, name: 'probe', [key]: [probe] });
92+
const atStamp = error?.code === 'VALIDATION_ERROR'
93+
&& Array.isArray(error.issues) && error.issues.some((i: any) => i?.path === '_packageId');
94+
if (atStamp) refusedAtBoot.push(key);
95+
}
96+
expect(refusedAtBoot.sort()).toEqual([...OWNER_STAMP_JUDGED_COLLECTIONS].sort());
97+
});
98+
});

0 commit comments

Comments
 (0)