Skip to content

Commit a91ad42

Browse files
fix(metadata-protocol,cli,cloud-connection)!: refuse an object extension that re-declares an owner's field beyond its display properties (ADR-0146 D10) (#30712)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DQCNH4WvC9WPtKsTwjY6Nrq)_ Fixes #30610 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: no move — `skills/**` and `content/docs/**` already teach `fieldPatches` as the way to change a base field's display, and no page teaches taking an owner field over through `fields`; the prescription ships in the refusal text. ADR-0087 disposition (in the changeset): `not-required (no-migration-prescription)` — a refused shape with no mechanical rewrite (which keys of a re-declared field were meant as display and which as a takeover is a judgement about the field), and no first-party artifact writes the shape (measured below). A stored row that already re-declares an owner field keeps registering with the registry's warning and is judged when next saved or published. ## 维护者速读 - 改了什么:模块包在 `objectExtensions[].fields` 里重新声明属主对象已有的字段(例如把 `amount` 的类型从数字改成文本、去掉必填、改成只读)时,现在在三道门被拒:元数据保存/发布/恢复(400 `VALIDATION_ERROR`)、`os validate`(退出码 1)、市场安装(422 `VALIDATION_ERROR`)。提示语点名 `fieldPatches` 和可改的展示属性白名单。 - 为什么改:ADR-0146 D10 规定基础字段只能改展示属性;但扩展在 `fields` 里写同名字段会整体替换属主字段,过去只有注册表打一条警告,任何门都不拦。 - 风险与回滚:属于收窄,但第一方产物(examples、HotCRM)实测 0 处使用这种写法;只改展示属性的重复声明照常放行,注册表警告保留。回滚即回退本 PR。 - 先看:`packages/metadata-protocol/src/protocol-extension-redeclared-owner-field.ts`(判定本身,`redeclaredFieldChanges` 的比较规则是风险点),`packages/metadata-protocol/src/protocol-object-extension-item.ts` 第 7 步,`packages/cloud-connection/src/marketplace-install-local-plugin.ts` 的 2e'。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol-extension-redeclared-owner-field.test.ts`,预期 12 个用例全绿(类型变更被拒、只改标签放行)。 - 需要你做的:无,席位落地(契约档复审由席位执行)。 ## What changes ADR-0146 D10, second rule: "Everything else on a base field — type, `required`, `unique`, `reference`, `defaultValue`, formulas, `options` membership — is refused on the extension with a prescription naming the whitelist." An `objectExtensions[].fields` key naming a field the extended object's owner declares replaces that field whole in `mergeObjectDefinitions` (`{ ...base.fields, ...extension.fields }`), and before this PR no door refused it; only `reportRedeclaredFields` warned. **One judge** — `packages/metadata-protocol/src/protocol-extension-redeclared-owner-field.ts`, beside the `__c` judge (`judgeExtensionFieldSuffix`): - `judgeRedeclaredOwnerFields({ packageId, objectExtensions, ownerOf, at })` returns every entry that re-declares an owner field and changes something outside `BASE_FIELD_DISPLAY_WHITELIST` (read from `@objectstack/spec/data`, never re-spelled), located, with `changed` (the keys, sorted) and the sentence. - `redeclaredFieldChanges(extensionField, ownerField)`: both bodies are read through `FieldSchema` first (a default the parse stamps on one side is no change), then compared key by key, skipping the whitelisted keys, `name` and the ADR-0010 `_` envelope. `options` is compared as membership keyed by stored `value`, each option without its whitelisted `label`, so a relabel or reorder is no change. - Not judged: an extension of an object its own package owns, an object with no known owner at that door, and a key the owner does not declare (an added field). - Envelopes: `redeclaredOwnerFieldRefusalError` (`VALIDATION_ERROR` / 400 with `issues[]`, the `__c` judge's envelope) and `redeclaredOwnerFieldInstallMessage` (the install door's sentence). **Three doors call it:** - **Publish** — `objectExtensionItemRefusal` step 7 (`protocol-object-extension-item.ts`), which the save (draft and live), the per-item and package publish, and the restore doors already ask. The owner is the registry's `getObjectOwner(target)` — its own declaration, never the resolved object, which already carries this package's earlier extension. - **`os validate`** — `packages/cli/src/utils/extension-redeclared-owner-fields.ts` walks the artifact (each `packages[]` entry under its id, or the stack under `manifest.id`); step 3a-v-bis of `validate.ts` exits 1 with the rows on `errors` (`code: VALIDATION_ERROR`). An owner outside the artifact is judged at install. - **Install** — `POST /api/v1/marketplace/install-local` step 2e', on the managed and the `.custom` pair branches, beside the field-patch target check: `422 VALIDATION_ERROR`, every refusal in `details.refusals`, before any write. The owner is a package this artifact delivers, or the installed owner (`getObjectOwner`). `reportRedeclaredFields` (`packages/objectql/src/registry.ts`) is unchanged and stays a warning. ## Measurement (the card's step 1) At `origin/main` `f4d568ba`: - No door refuses the shape: `judgeExtensionFieldSuffix` judges only the suffix; the field-patch target check judges `fieldPatches` keys; the registry warns. - First-party re-declarations of an owner field: **0**. `examples/app-showcase/src/data/extensions/account.extension.ts` (the only extension under `examples/**`) adds `loyalty_tier`, `linkedin_url`, `csat_score`, none of which `showcase_account` declares, and extends its own package's object. `packages/plugins/plugin-auth/src/manifest.ts` extends `sys_sso_provider` with `oidc_client_secret`, which `packages/platform-objects/src/identity/sys-sso-provider.object.ts` does not declare. HotCRM (`objectstack-ai/hotcrm` `6961ab1`): no `objectExtensions` anywhere. ## Tests All at head `435f30c7`. - **The judge** — `packages/metadata-protocol/src/protocol-extension-redeclared-owner-field.test.ts`, 12 cases: refused on a `type` change, a dropped `required`, `readonly` + `defaultValue`, an added option value, the array spelling located by index; admitted on a display-only re-declaration (labels, help text, option labels, reordered options), a parse-stamped default (`required: false` against an owner that omits it), an added field, the package's own object, an unknown owner, the `_` envelope; the save doors' envelope `VALIDATION_ERROR` / 400 with every entry in `issues`. - **Publish door** — a new top-level `describe` in `packages/runtime/src/domains/meta-object-extension-item.test.ts` (the real-store rig: better-sqlite3 behind `ObjectQL`, the real protocol and registry), with a writable module package that depends on the base: a `type` change is refused at the save door (`VALIDATION_ERROR` / 400 at `fields.name`, no row, no extender); a draft stored before the rule is refused at publish and stays a draft; a display-only re-declaration saves and publishes. - **`os validate`** — `packages/cli/test/validate-extension-redeclared-owner-field.test.ts` (integration tier, spawns the CLI): an assembled artifact whose second package re-declares the first's `amount` as `text` exits 1 with `packages[1].manifest.objectExtensions[0].fields.amount`, `code: VALIDATION_ERROR`, `changed: ['type']`; a label-only re-declaration validates clean. - **Install door** — a new top-level `describe` in `packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts` (the 2e harness, a real `SchemaRegistry`): a type change of the installed owner's field is refused `422 VALIDATION_ERROR` with the refusal in `details.refusals` and nothing registered, synced or written; an added option value on an owner the same artifact delivers is refused at `packages[1]…`; a display-only re-declaration installs. - `validate-build-gate-parity.test.ts`: the new gate is classified in `VALIDATE_ONLY_GATES`. <details><summary>Ablation (one-time, not kept)</summary> Committed first, then `node scripts/ablation-replace.mjs` on `protocol-extension-redeclared-owner-field.ts`: anchor `if (changed.length === 0) continue;` (x1) replaced by `if (changed.length >= 0) continue;` (the judge refuses nothing); `pnpm --filter @objectstack/metadata-protocol build`; `ablation-dist-preflight.mjs` saw the marker in `dist/`. - judge: 6 failed / 6 passed (every refusal case red) - runtime rig: 2 failed / 20 passed (the save and publish refusals) - install door: 2 failed / 9 passed (both refusals) - `os validate`: 1 failed / 1 passed (the refusal) Restored from `HEAD` (blob `e7ae83e11485` equals the HEAD blob, `git diff HEAD` empty), rebuilt, `ablation-dist-preflight.mjs --absent` passed over 24 built files; the runtime, install and `os validate` files green again. </details> ## Acceptance notes - `os build` does not run the check yet (`VALIDATE_ONLY_GATES` row in `validate-build-gate-parity.test.ts`), the same build-side gap the field-patch target check carries; the install door refuses what the build emits. - `os validate` judges only an owner declared by another package of the same artifact. A single-package module stack that extends an installed base is judged at install. - At the install door the installed owner's declaration carries the platform columns the registry injects, so re-declaring one of those (e.g. `owner_id`) is judged there and not at `os validate`. - In a `.custom` pair, an unsuffixed `fields` key is refused first by the `__c` judge, whose prescription already names `fieldPatches`. ## Verification <details><summary>Affected packages (head 435f30c)</summary> - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 318 passed, 4 skipped; Tests 22466 passed, 26 skipped — exit 0 - `pnpm --filter @objectstack/cloud-connection test`: Test Files 70 passed; Tests 893 passed — exit 0 - `pnpm --filter @objectstack/cli exec vitest run --project unit`: Test Files 389 passed; Tests 5431 passed — exit 0 - `pnpm --filter @objectstack/cli exec vitest run --project integration test/validate-extension-redeclared-owner-field.test.ts`: 2 passed — exit 0 (the rest of the integration layer is declared to CI) - `pnpm --filter @objectstack/runtime exec vitest run src/domains/meta-object-extension-item.test.ts`: 22 passed — exit 0 - typecheck: metadata-protocol, cli, cloud-connection, runtime — each exit 0 </details> <details><summary>Derived gates + lint (head 435f30c)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 64 families. All 64 ran with exit 0; `--ran` reconciliation: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED". - First pass: `check-plugin-teardown-shape --self-test`, `check:dual-build-cjs-loads` and `check:i18n-coverage` exited 3 (prerequisites). Re-run after fetching the pinned commit and a whole-tree `turbo run build` (exit 0): all three exit 0. - Three families that ran while the ablation held its mutation (`check:registry-log-declared`, `check:rest-log-spy-declared`, `check:route-envelope`) were re-run on the restored tree: exit 0. - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0. - `pnpm lint`: exit 0. </details> <details><summary>Dogfood battery (the `(narrowing)` arm)</summary> Head `435f30c7`, after the whole-tree build, under the lock: `pnpm --filter @objectstack/dogfood test` — `VERDICT command-exit 0`, Test Files 421 passed, 3 skipped (424); Tests 3513 passed, 36 skipped (3549); no failures. </details> `origin/main` moved 10 commits since the branch point (`f4d568ba`); none touches a file this PR changes, and `git merge-tree` merges clean. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cx4CnMVtuDswCA9p2c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2ea193 commit a91ad42

12 files changed

Lines changed: 856 additions & 3 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/cloud-connection': minor
5+
---
6+
7+
fix(metadata-protocol,cli,cloud-connection)!: an object extension that re-declares an owner's field in `fields` beyond its display properties is refused at the save and publish doors, at `os validate` and at the install door, with a prescription naming `fieldPatches` (ADR-0146 D10) (#30610)
8+
9+
Clause-②: no (narrowing) — the `object_extension` save, publish and restore doors, `os validate` and `POST /api/v1/marketplace/install-local` refuse an `objectExtensions[].fields` entry naming a field the extended object's owner declares when it changes anything outside the display-property whitelist, a body they accepted before. Nothing is widened.
10+
Metadata-protocol: narrowed
11+
Skills-docs: no move — `skills/**` and `content/docs/**` already teach `fieldPatches` as the way to change a base field's display, and no page teaches taking an owner field over through `fields`; the prescription ships in the refusal text.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) A refused shape with no mechanical rewrite: which keys of a re-declared field were meant as display and which as a takeover is a judgement about the field, and no first-party artifact (examples, HotCRM) writes the shape. A stored row that already re-declares an owner field keeps registering with the registry's warning, and is judged when it is next saved or published. -->
14+
15+
**BREAKING** accept-set narrowing at the metadata save and publish doors (`PUT /api/v1/meta/object_extension/NAME`, draft and live, the per-item and package publish, and the restore doors), at `os validate`, and at the marketplace install door.
16+
17+
- **Before.** An extension written into `fields` with the name of a field the extended object's owner declares replaced the owner's field whole when the registry folded the object: a module package could turn the owner's `amount: number` into `text`, drop its `required`, or make it `readonly`, and every reader judged the field on the extension's definition. Only the registry's warning named it.
18+
- **Now.** Such an entry is refused when it changes anything outside the display-property whitelist (`label`, `description`, `inlineHelpText`, `placeholder`, an option's `label`): `400 VALIDATION_ERROR` at the save and publish doors (located in `issues[]` at `fields.FIELD`), `422 VALIDATION_ERROR` at the install door (every entry in `details.refusals`), and exit 1 with the entry on `errors` at `os validate`. Each refusal names the keys the entry changes and prescribes `fieldPatches`. Both bodies are read through the field schema first, so a default the parse stamps is no change; options are compared by stored value, so a relabel or reorder is no change.
19+
- **Unchanged.** A re-declaration that changes only display properties is admitted. An extension of an object its own package owns is not judged, nor is an added field. `os validate` judges an owner declared by another package of the same artifact; a target outside the artifact is judged at install, against the installed owner. The registry's warning on a re-declared field stays. `os build` does not yet run the check.
20+
21+
**The remedy.** Move a display change of an owner's field into the extension's `fieldPatches` (the field name as its key, the whitelisted properties as its body), and add a behaviour of your own as a new field under a new name.

‎packages/cli/src/commands/validate.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffi
8181
// [ADR-0146 D10, second rule] The field-patch target check's walk; the verdict
8282
// is the marketplace install door's own judge.
8383
import { findFieldPatchTargetRefusals } from '../utils/field-patch-targets.js';
84+
// [ADR-0146 D10, second rule] An extension re-declaring an owner's field beyond
85+
// the display-property whitelist; the verdict is the save and install doors' own.
86+
import { findRedeclaredOwnerFieldRefusals } from '../utils/extension-redeclared-owner-fields.js';
8487
// [#29051] An undeclared top-level flow cycle (ADR-0044): the automation
8588
// engine's own judge (`findUndeclaredFlowCycle`, `@objectstack/metadata-core`).
8689
import { findFlowCycleRefusals } from '../utils/flow-undeclared-cycle.js';
@@ -754,6 +757,35 @@ export default class Validate extends Command {
754757
this.exit(1);
755758
}
756759

760+
// 3a-v-bis. [ADR-0146 D10, second rule] A base field changes only within
761+
// the display-property whitelist: an `objectExtensions[].fields`
762+
// entry naming a field another package of this artifact declares
763+
// replaces that field whole, so it is refused when it changes
764+
// anything else. The verdict is the save and install doors' own
765+
// (`judgeRedeclaredOwnerFields`); see
766+
// `utils/extension-redeclared-owner-fields.ts` for the walk.
767+
const redeclaredRefusals = findRedeclaredOwnerFieldRefusals(result.data as Record<string, unknown>);
768+
if (redeclaredRefusals.length > 0) {
769+
if (flags.json) {
770+
await emitJson({
771+
valid: false,
772+
errors: redeclaredRefusals,
773+
warnings: warningsSoFar(),
774+
conversions: conversionNotices,
775+
duration: timer.elapsed(),
776+
});
777+
this.exit(1);
778+
}
779+
const n = redeclaredRefusals.length;
780+
console.log('');
781+
printError(`${n} extension field${n > 1 ? 's re-declare' : ' re-declares'} an owner's field beyond its display properties (ADR-0146 D10)`);
782+
printBulletList(
783+
redeclaredRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`),
784+
{ noun: 'extension-field refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
785+
);
786+
this.exit(1);
787+
}
788+
757789
// 3a-vi. [#28893] Flow region shape (ADR-0031) is judged by the rule
758790
// table above since #29049 (`flow-region-shape-invalid`,
759791
// `@objectstack/lint`'s `validate-flow-region-shape.ts`), so all
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os validate`'s half of ADR-0146 D10's display-property whitelist: an
5+
* `objectExtensions[].fields` entry naming a field the extended object's owner
6+
* declares replaces that field whole, so it is refused when it changes
7+
* anything outside the whitelist, when the owner is another package of this
8+
* composed artifact.
9+
*
10+
* ## One judge, not a second rule
11+
*
12+
* The verdict is `@objectstack/metadata-protocol`'s `judgeRedeclaredOwnerFields`,
13+
* the SAME judge the save, publish and restore doors of an `object_extension`
14+
* item and the marketplace install door ask, so a green validate is an
15+
* extension those doors admit. What this module owns is the WALK: which
16+
* package owns each object the artifact declares — every `packages[]` entry
17+
* under its own id (ADR-0130 D4), or the stack itself as one package under
18+
* `manifest.id` — and which extensions each package carries. An extension of
19+
* a package's own object changes its own field and is not judged; a target
20+
* outside the artifact is judged at install, against the installed owner.
21+
*/
22+
23+
import { judgeRedeclaredOwnerFields, type ObjectOwnerDeclaration } from '@objectstack/metadata-protocol';
24+
25+
import { artifactPackages } from './artifact-packages.js';
26+
27+
/** One refused entry, located. `message` is the doors' sentence, verbatim. */
28+
export interface RedeclaredOwnerFieldRefusalRow {
29+
/** Where the entry sits in the parsed stack, e.g. `packages[1].manifest.objectExtensions[0].fields.amount`. */
30+
path: string;
31+
/** The code the save doors refuse the same entry with. */
32+
code: 'VALIDATION_ERROR';
33+
object: string;
34+
field: string;
35+
/** The package the extension is declared in. */
36+
packageId: string | undefined;
37+
/** The package that declares the object. */
38+
ownerPackageId: string | undefined;
39+
/** The keys the entry changes, every one outside the display-property whitelist. */
40+
changed: string[];
41+
message: string;
42+
}
43+
44+
const isRecord = (v: unknown): v is Record<string, unknown> =>
45+
v !== null && typeof v === 'object' && !Array.isArray(v);
46+
47+
/** The objects a stack body declares, by name — a list of definitions or a name-keyed map. */
48+
function declaredObjects(body: Record<string, unknown>): Array<[string, unknown]> {
49+
const objects = body.objects;
50+
if (Array.isArray(objects)) {
51+
return objects.flatMap((o) => (isRecord(o) && typeof o.name === 'string' ? [[o.name, o] as [string, unknown]] : []));
52+
}
53+
return isRecord(objects) ? Object.entries(objects) : [];
54+
}
55+
56+
/** Every extension field of the parsed stack that re-declares an in-artifact owner's field beyond its display properties. */
57+
export function findRedeclaredOwnerFieldRefusals(parsed: Record<string, unknown>): RedeclaredOwnerFieldRefusalRow[] {
58+
const entries = artifactPackages(parsed);
59+
const bodies = entries.length > 0
60+
? entries.map(({ index, id, body }) => ({ id: id as string | undefined, body, at: `packages[${index}].manifest.` }))
61+
: [{
62+
id: isRecord(parsed.manifest) && typeof parsed.manifest.id === 'string' ? parsed.manifest.id : undefined,
63+
body: parsed,
64+
at: '',
65+
}];
66+
const owners = new Map<string, ObjectOwnerDeclaration>();
67+
for (const { id, body } of bodies) {
68+
for (const [name, definition] of declaredObjects(body)) {
69+
if (!owners.has(name)) owners.set(name, { packageId: id, definition });
70+
}
71+
}
72+
return bodies.flatMap(({ id, body, at }) => judgeRedeclaredOwnerFields({
73+
packageId: id, objectExtensions: body.objectExtensions, ownerOf: (name) => owners.get(name), at,
74+
}).map((r) => ({
75+
path: r.path, code: 'VALIDATION_ERROR' as const, object: r.object, field: r.field,
76+
packageId: r.packageId, ownerPackageId: r.ownerPackageId, changed: r.changed, message: r.message,
77+
})));
78+
}

‎packages/cli/test/validate-build-gate-parity.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,15 +262,22 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {
262262
* Three rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts`
263263
* gained the call: `findViewContainerNameRefusals` (#20331 → #20393),
264264
* `findOverrideDeclarationRefusals` (#28410 → #28643) and
265-
* `findExtensionFieldSuffixRefusals` (#28564 → #28727). It holds one today,
266-
* `findFieldPatchTargetRefusals` (#28565), whose card did not name the build.
265+
* `findExtensionFieldSuffixRefusals` (#28564 → #28727). It holds two today,
266+
* `findFieldPatchTargetRefusals` (#28565) and `findRedeclaredOwnerFieldRefusals`
267+
* (#30610), whose cards did not name the build.
267268
*/
268269
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
269270
findFieldPatchTargetRefusals:
270271
'[ADR-0146 D10, second rule] An object extension\'s `fieldPatches` naming a field or option value its ' +
271272
'in-stack target lacks, judged by the marketplace install door\'s own `fieldPatchTargetRefusals`. Wired ' +
272273
'where the rollout card asked for it (`os validate` and the install door); `os build` still emits such an ' +
273274
'artifact, which the install door then refuses — the build-side gap, reported rather than closed here.',
275+
findRedeclaredOwnerFieldRefusals:
276+
'[ADR-0146 D10, second rule] An `objectExtensions[].fields` entry re-declaring a field another package of the ' +
277+
'artifact declares, beyond the display-property whitelist, judged by the save and install doors\' own ' +
278+
'`judgeRedeclaredOwnerFields`. Wired at the three doors its card named (`os validate`, the publish doors, the ' +
279+
'install door); `os build` still emits such an artifact, which the install door then refuses — the same ' +
280+
'build-side gap as the row above, reported rather than closed here.',
274281
};
275282

276283
/**
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0146 D10, second rule] `os validate` asks the display-property
5+
* whitelist judge — the save and install doors' own
6+
* (`judgeRedeclaredOwnerFields`), over the walk
7+
* `utils/extension-redeclared-owner-fields.ts` takes. What is pinned here is
8+
* the command, on an assembled artifact (ADR-0130 D4) whose second package
9+
* extends the first package's object: an `objectExtensions[].fields` entry
10+
* that changes the owner field's `type` exits 1 with the entry located on
11+
* `errors` (`code: VALIDATION_ERROR`); the same entry changing only the
12+
* label validates clean.
13+
*/
14+
15+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
16+
import { execFile } from 'node:child_process';
17+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18+
import { tmpdir } from 'node:os';
19+
import { join, resolve } from 'node:path';
20+
import { fileURLToPath } from 'node:url';
21+
import { childEnv } from './helpers/serve-process.js';
22+
import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js';
23+
24+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
25+
const CLI = resolve(HERE, '../bin/run-dev.js');
26+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
27+
28+
/** Two packages: the core owns `rof_deal`, the module re-declares its `amount` as `amount`. */
29+
const assembled = (amount: Record<string, unknown>) => ({
30+
manifest: { id: 'com.example.rof', namespace: 'rof', version: '1.0.0', name: 'Rof', type: 'app' },
31+
packages: [
32+
{
33+
manifest: {
34+
id: 'com.example.rof', namespace: 'rof', version: '1.0.0', name: 'Rof', type: 'app',
35+
objects: [{
36+
name: 'rof_deal', label: 'Deal', sharingModel: 'private',
37+
fields: { name: { type: 'text', label: 'Name' }, amount: { type: 'number', label: 'Amount' } },
38+
}],
39+
},
40+
},
41+
{
42+
manifest: {
43+
id: 'com.example.rof.revenue', namespace: 'rof', version: '1.0.0', name: 'Revenue', type: 'module',
44+
dependencies: { 'com.example.rof': '^1.0.0' },
45+
objectExtensions: [{ extend: 'rof_deal', fields: { amount } }],
46+
},
47+
},
48+
],
49+
});
50+
51+
interface Run {
52+
code: number;
53+
stdout: string;
54+
stderr: string;
55+
}
56+
57+
function runValidate(cwd: string): Promise<Run> {
58+
return new Promise((resolvePromise) => {
59+
execFile(
60+
TSX,
61+
[CLI, 'validate', '--json'],
62+
// `childEnv()`, never a bulk `process.env` copy (check:cli-test-child-env).
63+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
64+
(err, stdout, stderr) => {
65+
resolvePromise({
66+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
67+
stdout: String(stdout),
68+
stderr: String(stderr),
69+
});
70+
},
71+
);
72+
});
73+
}
74+
75+
let typeChangeDir: string;
76+
let relabelDir: string;
77+
78+
beforeAll(() => {
79+
typeChangeDir = mkdtempSync(join(tmpdir(), 'os-validate-rof-type-'));
80+
writeFileSync(join(typeChangeDir, 'objectstack.config.ts'),
81+
defineStackSource(assembled({ type: 'text', label: 'Amount' }), { strict: false }));
82+
linkSpec(typeChangeDir);
83+
relabelDir = mkdtempSync(join(tmpdir(), 'os-validate-rof-relabel-'));
84+
writeFileSync(join(relabelDir, 'objectstack.config.ts'),
85+
defineStackSource(assembled({ type: 'number', label: 'Deal size' }), { strict: false }));
86+
linkSpec(relabelDir);
87+
});
88+
89+
afterAll(() => {
90+
rmSync(typeChangeDir, { recursive: true, force: true });
91+
rmSync(relabelDir, { recursive: true, force: true });
92+
});
93+
94+
describe('os validate — an extension re-declaring an owner field beyond its display properties (ADR-0146 D10)', () => {
95+
it('exits 1 with the entry located on errors, VALIDATION_ERROR, naming what it changes', async () => {
96+
const run = await runValidate(typeChangeDir);
97+
expect(run.code, run.stderr).toBe(1);
98+
const report = JSON.parse(run.stdout);
99+
expect(report.valid).toBe(false);
100+
expect(report.errors.map((e: { path: string; code: string; changed: string[] }) => ({ path: e.path, code: e.code, changed: e.changed })))
101+
.toEqual([{ path: 'packages[1].manifest.objectExtensions[0].fields.amount', code: 'VALIDATION_ERROR', changed: ['type'] }]);
102+
expect(report.errors[0].message).toContain('`fieldPatches`');
103+
}, 120_000);
104+
105+
it('validates clean when the entry changes only the label', async () => {
106+
const run = await runValidate(relabelDir);
107+
expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0);
108+
expect(JSON.parse(run.stdout).valid).toBe(true);
109+
}, 120_000);
110+
});

‎packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
* or one the same artifact delivers: a patch reaches the owner's own fields
1212
* only, so a field another installed extension adds is not one it can name.
1313
* A patch that matches installs.
14+
*
15+
* Step 2e' sits beside it in this harness: an `objectExtensions[].fields`
16+
* entry that re-declares an owner's field, refused `422 VALIDATION_ERROR` when
17+
* it changes anything outside the display-property whitelist, admitted when
18+
* it changes only display properties.
1419
*/
1520

1621
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -227,3 +232,52 @@ describe('install-local 2e — the field-patch target check (ADR-0146 D10)', ()
227232
expect(h.installRegistrations()).toHaveLength(1);
228233
});
229234
});
235+
236+
const redeclaringPackage = (fields: Record<string, unknown>) => ({
237+
id: 'com.example.crm-takeover', name: 'CRM takeover', version: '1.0.0', scope: 'project', type: 'module',
238+
dependencies: { [BASE]: '1.x' },
239+
objectExtensions: [{ extend: 'crm_account', fields }],
240+
});
241+
242+
describe('install-local 2e\' — an extension re-declaring an owner field beyond its display properties (ADR-0146 D10)', () => {
243+
it('refuses a type change of the installed owner\'s field, and installs nothing', async () => {
244+
const h = await mount(dir);
245+
const res = await h.install({ manifest: redeclaringPackage({ industry: { type: 'text', label: 'Industry' } }) });
246+
expectRefusal(res, ['objectExtensions[0].fields.industry']);
247+
expect(res.payload.error.details.refusals[0]).toMatchObject({
248+
object: 'crm_account', field: 'industry', ownerPackageId: BASE, changed: ['options', 'type'],
249+
});
250+
expect(res.payload.error.message).toContain('`fieldPatches`');
251+
expect(h.installRegistrations()).toEqual([]);
252+
expect(h.syncs()).toBe(0);
253+
expect(readdirSync(dir)).toEqual([]);
254+
});
255+
256+
it('refuses an added option value (`options` membership), on an owner the same artifact delivers', async () => {
257+
const h = await mount(dir);
258+
const res = await h.install({
259+
manifest: {
260+
id: 'com.example.multi', name: 'Multi', version: '1.0.0', scope: 'project', type: 'app',
261+
packages: [
262+
{ manifest: { id: 'com.example.multi.core', name: 'Core', version: '1.0.0', type: 'app',
263+
objects: [{ name: 'multi_ticket', fields: { status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }] } } }] } },
264+
{ manifest: { id: 'com.example.multi.more', name: 'More', version: '1.0.0', type: 'module',
265+
objectExtensions: [{ extend: 'multi_ticket', fields: { status: { type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }, { value: 'hold', label: 'On hold' }] } } }] } },
266+
],
267+
},
268+
});
269+
expectRefusal(res, ['packages[1].manifest.objectExtensions[0].fields.status']);
270+
expect(res.payload.error.details.refusals[0]).toMatchObject({ changed: ['options'], ownerPackageId: 'com.example.multi.core' });
271+
});
272+
273+
it('lit control: a re-declaration that changes only display properties installs', async () => {
274+
const h = await mount(dir);
275+
const res = await h.install({
276+
manifest: redeclaringPackage({
277+
industry: { type: 'select', label: 'Sector', options: [{ value: 'tech', label: 'Technology' }, { value: 'retail', label: 'Retail' }] },
278+
}),
279+
});
280+
expect(res.status, JSON.stringify(res.payload)).toBe(200);
281+
expect(h.installRegistrations()).toHaveLength(1);
282+
});
283+
});

0 commit comments

Comments
 (0)