Skip to content

Commit 447a2a6

Browse files
feat(metadata-protocol,cli,cloud-connection)!: __c extension fields only in a base's paired .custom package, judged at every door (ADR-0146 D10) (#28724)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XB8y3FqD5csjtFmNMzvKsg5)_ Fixes #28564 Clause-②: yes (narrowing) New refusals at the save, restore and publish doors, `duplicatePackage`, `os validate` and the marketplace install narrow the accepted set: a field spelled `__c` outside a base's paired `.custom` package, and a `.custom` extension field without the suffix. No `packages/spec/**` file is touched; no schema, type, error code or runtime interface moves. Metadata-protocol: narrowed Skills-docs: none — no file under `skills/**` teaches a field spelled `__c`, an `objectExtensions` field name or a `BASE.custom` package; the measured count below found none. ## 维护者速读 - 改了什么:ADR-0146 D10 的第一条规则落地——`__c` 后缀只给基础包配对的 `.custom` 包用。一个判官(`judgeExtensionFieldSuffix`),保存/恢复/发布三道门、复制包、`os validate`、市场安装都问它。非 `.custom` 包里出现 `xxx__c` 字段被拒(让改名);`.custom` 包的对象扩展字段不带 `__c` 被拒(让加后缀,或用 `fieldPatches` 改基础字段的显示属性)。 - 为什么改:客户字段叫 `region__c`,厂商下个版本永远不会占用这个名字,也没人会把客户字段当成厂商字段——前提是每道门都拒。之前后缀只在 spec 里声明,没有一道门读它。 - 风险与回滚:收窄只拦一个今天没人用的拼法:在 `2678600d` 上实测 `examples/**`、`packages/*/src`、`skills/**`(不含测试)里 `[a-z0-9]__c` 字段名 0 个(7 处文本命中全在文档注释里)。每道门沿用自己的信封,不新增错误码。回滚即 revert,无数据迁移。 - 先看:`packages/metadata-protocol/src/protocol-extension-field-suffix.ts`(判官与两句话);`protocol.ts` 的保存门、`promoteDraftForPublish`、`duplicatePackage` 三处接线;`cloud-connection` 安装门的步骤 1c''。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.extension-field-suffix.test.ts`,预期 `Tests 20 passed (20)`。 - 需要你做的:无,席位落地(合同审查后)。 ## What changes - **One judge** (`@objectstack/metadata-protocol`, new `protocol-extension-field-suffix.ts`, exported from the index). `judgeExtensionFieldSuffix({ packageId, objects, objectExtensions, at })` is pure and answers every refused field, located. It reads the spec's predicates, `isExtensionFieldName` (`@objectstack/spec/data`) and `isCustomizationPackage` (`@objectstack/spec/kernel`), and spells neither the suffix nor the pair rule. - (a) `reserved_outside_pair`: in a package that is not a `.custom` pair, a field spelled `__c` on its own objects or in its `objectExtensions`: "Field 'F' on object 'O' in package 'P': the `__c` suffix is reserved to a base's paired `.custom` package (ADR-0146 D10); rename the field." - (b) `unsuffixed_in_pair`: in a `.custom` pair, an `objectExtensions[].fields` key without the suffix: "Extension field 'F' on object 'O' in package 'P': name it `F__c`, or change a base field's display properties in the extension's patch (`fieldPatches`)." The patch slot is named as it stands on `main` (#28560's `ObjectExtensionSchema.fieldPatches`). - Not judged: a `.custom` pair's own new objects, an extension of one of them, and a write bound to no package (the package-unbound row is the environment's own, as `objectNamespacePrefixViolation` already skips it). - Beside it: `extensionFieldSuffixRefusalError` (the active doors' `VALIDATION_ERROR` / 400 envelope, `issues[]` locating each field) and `objectBodyExtensionFieldSuffixRefusal(type, item, name, binding)` (the one-item question the save, restore and promotion doors ask, on the RESOLVED binding, as `permissionSetOwnerBindingRefusal` reads it). - **The three `active` doors**, each beside the `permissionSetOwnerBindingRefusal` call: the save door (every writer, both modes, binding from `saveWriteBinding`), the restore (`restoredBodyWriter`, so `rollbackMetaItem` and `revertCommit`'s restore limb), and the draft promotion `publishMetaItem` and `publishPackageDrafts` share (`promoteDraftForPublish`, against `promoteInto`, before `repo.promoteDraft` writes; a batch publish reports it in `failed` and aborts). - **`duplicatePackage`**, judged against the target id after the rows are read and before the copy's first mutation (the package record): a copy of a `.custom` pair's `__c` objects into a plain id is refused whole, `400 VALIDATION_ERROR`, nothing minted or copied. - **`os validate`** (`@objectstack/cli`, new `utils/extension-field-suffix.ts`, step 3a-iv beside N-09's 3a-iii): each `packages[]` entry under its own id, or the stack under `manifest.id`; exit 1, the rows on `errors` with `path`, `code: 'VALIDATION_ERROR'`, `rule`, `packageId`, `message`, and printed with the path without `--json`. The build/validate parity ledger records the call under `VALIDATE_ONLY_GATES` with its reason (ADR-0146 decided item 7 names publish, validate and install, not `os build`). - **Marketplace install** (`@objectstack/cloud-connection`, step 1c'' right after the dependency check): every delivered package (each `packages[]` entry under its own id, the body otherwise) is judged before the manifest service sees the artifact and before the install-mode branch, so the managed and the unmanaged install are both covered. The answer is this door's existing body refusal, `422 VALIDATION_ERROR` (the unrunnable-code refusal's code and status), with `details.refusals`; nothing is registered and no ledger entry is written. - `scripts/engine-double-contract.pinned.json`: the new test file's three engine doubles, written by `check-engine-double-contract.mjs --write`. ## Acceptance notes - **`installPackage` gets no call: its closed manifest covers it** (seat 1 accepted this reading before the first edit). `PackageInstallManifestSchema` is a `strictObject` whose `objects` are glob strings and which has no `objectExtensions` key, so an `x__c` field body never reaches the door: it is refused `400` by that parse before any write. A judge call there would judge an empty set. N-09 (#28410, PR #28554) met the same fact and moved its install gate to the marketplace install. Pinned in `protocol.extension-field-suffix.test.ts` section 5. - **Case (b) is pinned where an extension can arrive** (seat 1 accepted). Only `os validate` and the marketplace install receive `objectExtensions`; the save, restore and promotion doors and `duplicatePackage` only see `object` bodies, where a `.custom` pair's own object is not judged. Those doors pin case (a) and the pair's admit; the judge's own section pins (b), and the validate and install files pin it at those doors. #28574 (the runtime `object_extension` write door) asks this judge from its own card. - **handleInstall's position.** The call sits at the dependency check's early position (step 1c''), as the card asks, so it also covers the unmanaged branch, which leaves before N-09's step 2d. It judges the body as delivered (before the ADR-0087 forward conversion), which does not touch field names. #28565's patch-target judge adds its own separate line; the two are not folded. - A draft save that names no package is judged at its promotion, not at the save: `saveWriteBinding` resolves the store-read binding for permission sets only, and an object draft is not live until a door promotes it. - `protocol-packaged-base-refusal.ts` is not edited (#28412 holds it). ## Tests - `packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts` (20): the judge's two cases, its admits and what it does not judge (6); the save door in publish and draft mode, every field located, and the control (pair admit, plain field, no package) (4); `rollbackMetaItem`, `revertCommit`'s restore limb and the control (3); `publishMetaItem`, `publishPackageDrafts` and the pair's batch control (3); `duplicatePackage` from `app.dupsrc.custom` into a plain id refused whole, and its control (2); `installPackage` refusing an object body and an `objectExtensions` entry at its parse (2). - `packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts` (6): (a) on an extension and on an own object, (b), a `packages[]` artifact judged per entry, the refusal ahead of the install-mode judgement (`installMode: 'unmanaged'`), and the control (the pair's `region__c` and a vendor extension's plain field install). Each refusal asserts nothing registered and no ledger entry. - `packages/cli/src/utils/extension-field-suffix.test.ts` (4, unit) and `packages/cli/test/validate-extension-field-suffix.test.ts` (3, the command, integration tier): (a) exits 1 with the path on `errors`, (b) exits 1 with the path printed, the pair's `region__c` validates clean. - **Red first on base**: the four door files restored to `2678600d57` (judge module and tests kept): metadata-protocol 8 failed / 12 passed (every door pin red; the judge, the controls and the `installPackage` coverage pins green), cloud-connection 5 failed / 1 passed (the control green), cli command 2 failed / 5 passed. Restore proved by `git diff HEAD` empty. - **Ablations, one per door**, each through `scripts/ablation-replace.mjs` (anchor hit once, blob changed, restore blob equal to HEAD, `git diff HEAD` empty after): save door (call handed another type) 3 failed; promotion 2 failed; restore 2 failed; `duplicatePackage` (throw condition inverted) 1 failed; `os validate` (judged `{}`) 2 failed; marketplace install (package id dropped) 5 failed. No `dist/` is on any of these resolution paths (metadata-protocol and cloud-connection tests import their own `src`, the cli command runs its `src` through tsx). - Existing install and validate fixtures stay green: the full suites below. ## Verification Run on `40dfde1a` (`737815d4` + the engine-double ledger commit `6923eb9f` + one merge of `origin/main` at `da6f0ce3`), each test command under `os-verify-lock` (`VERDICT command-exit 0`). - Merge: one conflict, `packages/cli/test/validate-build-gate-parity.test.ts`. #28681 (card #28643) moved `findOverrideDeclarationRefusals` out of `VALIDATE_ONLY_GATES` by wiring `os build`; this branch keeps that and records `findExtensionFieldSuffixRefusals` there (the card names no build door), with the docblock updated. `pnpm install --frozen-lockfile` exit 0; `turbo run build` (docs and `ui/` excluded) exit 0. - `@objectstack/metadata-protocol` full suite: Test Files 292 passed, 4 skipped (296); Tests 21970 passed, 26 skipped. - `@objectstack/cloud-connection` full suite: Test Files 66 passed (66); Tests 802 passed (802). - `@objectstack/cli` unit tier: Test Files 365 passed (365); Tests 5171 passed (5171). Integration tier, the two validate gate files (`validate-extension-field-suffix`, `validate-override-declarations`): 2 files, 5 tests passed. The rest of the integration tier is CI's. - Typecheck metadata-protocol, cli, cloud-connection: exit 0. `eslint --no-inline-config packages/metadata-protocol packages/cli packages/cloud-connection`: exit 0. - Dogfood battery (`pnpm --filter @objectstack/dogfood test`, the narrowing arm): Test Files 369 passed, 3 skipped (372); Tests 2981 passed, 28 skipped. - Derived gates (`dispatch-gates.mjs --commands`, reconciled with `--ran`): `✓ dispatch-gates --ran: 74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED`. `check:engine-double-contract` first read 1 (the new file's three doubles unrecorded) and is green after `--write`; `check:type-check-debt` first read 3 (lock wait behind this branch's own dogfood run) and is green on its re-run. - Count (migration note): `git grep -nE "[a-z0-9]__c\b"` over `examples/**`, `packages/*/src/**`, `skills/**`, tests excluded, at `2678600d57`: 7 hits, all in doc comments (`field-customization.ts`, `object.zod.ts`, `validation.zod.ts`); 0 field names. <details><summary>Live probe: the stock showcase, through the save door</summary> `pnpm --filter @objectstack/example-showcase dev -- --fresh -p 4310` (the `ui/` dists were not built, so `pnpm dev`'s prerequisite check was skipped; REST only), signed in as the seeded dev admin with `Origin: http://localhost:4310`. 1. `POST /api/v1/packages` with `{"manifest":{"id":"app.probevendor","name":"Probe vendor","version":"1.0.0","type":"app"},"enableOnInstall":true}`: 201. 2. `PUT /api/v1/meta/object/probevendor_ticket?package=app.probevendor` with fields `title` and `x__c`: **400**, `code: VALIDATION_ERROR`, `issues: [{ path: 'fields.x__c', code: 'custom' }]`, message: "Field 'x__c' on object 'probevendor_ticket' in package 'app.probevendor': the `__c` suffix is reserved to a base's paired `.custom` package (ADR-0146 D10); rename the field." 3. The same object without `x__c`: 200, saved active in `app.probevendor`. 4. `POST /api/v1/packages/com.example.showcase/ensure-customization`: 201, pair `com.example.showcase.custom`, namespace `showcase_custom`. (The ensure door answers 409 for the writable `app.probevendor`: a pair is for a read-only base.) 5. `PUT /api/v1/meta/object/showcase_custom_ticket?package=com.example.showcase.custom` with fields `title` and `x__c`: **200**, saved active in the pair. The save door takes no `objectExtensions` entry (#28574's door), so the pair's own object stands in for "the pair's extension"; the extension admit is pinned at `os validate` and the install door. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5kk6y5435oc5AmphgU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8bceb6a commit 447a2a6

14 files changed

Lines changed: 1435 additions & 5 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/cloud-connection': minor
5+
---
6+
7+
**BREAKING** feat(metadata-protocol)!: `__c` extension fields only in a base's paired `.custom` package, refused at the save, restore and publish doors, `os validate`, the marketplace install and `duplicatePackage` (ADR-0146 D10, #28564)
8+
9+
Clause-②: yes (narrowing)
10+
Metadata-protocol: narrowed
11+
Skills-docs: none — no file under `skills/**` teaches a field spelled `__c`, an `objectExtensions` field name or a `<base>.custom` package; the measured count below found none.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) The narrowing refuses a field NAME spelling at the write doors: a field ending in `__c` outside a base's paired `.custom` package, and an `objectExtensions[].fields` key without the suffix inside one. No metadata key, accepted value of a key, export or error code is removed or renamed, and no stored shape changes. The remedy is a rename of a field, an authoring choice a person makes (which name a vendor or a customer field should carry); there is no mechanical conversion between two metadata versions for `objectstack migrate meta` to apply. Measured at `2678600d57`: 0 fields spelled `[a-z0-9]__c` in `examples/**`, `packages/*/src` and `skills/**` outside tests (7 textual hits, all in doc comments). The other categories are closed on facts: all three packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no `packages/spec/**` file is touched, so no schema, type or runtime interface moves (not type-surface-only, not runtime-interface-only); and no export is retired (not runtime-export-retired). -->
14+
15+
ADR-0146 D10 reserves the `__c` field suffix to the customer's layer — maintainer, verbatim: 「扩展字段用 __c 后缀,只有 .custom 包能用。」 So a customer's field on a base object is `region__c`, a vendor's next version can never take that name, and nobody mistakes the customer's field for the vendor's. The reservation was declared in `@objectstack/spec` (`EXTENSION_FIELD_SUFFIX`, `isExtensionFieldName`) and read by no door.
16+
17+
**What is refused**, given the package a body is written into:
18+
19+
- **(a) `__c` outside a `.custom` pair.** In a package that is not a base's paired `<base>.custom`, every field spelled `__c`, on its own objects or in its `objectExtensions`: "the `__c` suffix is reserved to a base's paired `.custom` package (ADR-0146 D10); rename the field." A field one vendor package adds to another's object keeps the plain spelling.
20+
- **(b) An unsuffixed extension field in a `.custom` pair.** Every `objectExtensions[].fields` key without the suffix: "name it `<name>__c`, or change a base field's display properties in the extension's patch (`fieldPatches`)."
21+
22+
A `.custom` pair's own new objects are not judged (their fields are its own), and neither is a write bound to no package.
23+
24+
**Where**, each door in its own envelope, no new code:
25+
26+
- the save door (`saveMetaItem`, so `PUT /api/v1/meta/object/:name`), the restore (`rollbackMetaItem` and `revertCommit`'s restore limb) and the draft promotion (`publishMetaItem`, `publishPackageDrafts`), for an `object` body: `400 VALIDATION_ERROR`, `issues[]` locating each field;
27+
- `duplicatePackage`, judged against the target id before the copy writes anything: a copy of a `.custom` pair's `__c` objects into a plain id is refused whole, `400 VALIDATION_ERROR`;
28+
- `os validate`: exit 1, each field's path on `errors` (`code: 'VALIDATION_ERROR'`, `rule`, `packageId`, `message`);
29+
- the marketplace install (`POST /api/v1/marketplace/install-local`), at the dependency check's position, before the manifest service sees the artifact, on the managed and the unmanaged branch: `422 VALIDATION_ERROR` (the door's existing body refusal), one row per field in `details.refusals`; nothing is registered and no ledger entry is written.
30+
31+
`installPackage` needs no call: its manifest (`PackageInstallManifestSchema`) is closed, its `objects` are glob strings and it has no `objectExtensions` key, so a field body never reaches it.
32+
33+
**Migration.** None needed for this repository: 0 fields affected (measured above). A package that ships a `__c` field outside `.custom`, or a `.custom` extension field without the suffix, renames the field.
34+
35+
`@objectstack/metadata-protocol` exports the judge, `judgeExtensionFieldSuffix`, with the active doors' envelope (`extensionFieldSuffixRefusalError`) and the one-item question those doors ask (`objectBodyExtensionFieldSuffixRefusal`), so every door asks one judgement.

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js'
7777
// [ADR-0146 D2, #28410] The override write gate's author-time half: the publish
7878
// door's own judge, walked over the artifact's packages.
7979
import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js';
80+
import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffix.js';
8081
// A field `picklist` that names no picklist the stack declares — refused, or
8182
// reported when the declaring package depends on packages outside the stack.
8283
// Walked the way the load path registers, like the refusal above.
@@ -690,6 +691,34 @@ export default class Validate extends Command {
690691
this.exit(1);
691692
}
692693

694+
// 3a-iv. [ADR-0146 D10, #28564] The `__c` extension-field reservation,
695+
// at author time: a field spelled `__c` only in a base's paired
696+
// `.custom` package, and every `objectExtensions[].fields` key of a
697+
// `.custom` pair suffixed. The verdict is the save and install doors'
698+
// own (`judgeExtensionFieldSuffix`), so a green validate is an
699+
// artifact they admit; see `utils/extension-field-suffix.ts`.
700+
const suffixRefusals = findExtensionFieldSuffixRefusals(result.data as Record<string, unknown>);
701+
if (suffixRefusals.length > 0) {
702+
if (flags.json) {
703+
await emitJson({
704+
valid: false,
705+
errors: suffixRefusals,
706+
warnings: warningsSoFar(),
707+
conversions: conversionNotices,
708+
duration: timer.elapsed(),
709+
});
710+
this.exit(1);
711+
}
712+
const n = suffixRefusals.length;
713+
console.log('');
714+
printError(`${n} field${n > 1 ? 's break' : ' breaks'} the \`__c\` extension-field reservation (ADR-0146 D10)`);
715+
printBulletList(
716+
suffixRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`),
717+
{ noun: 'extension-field refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
718+
);
719+
this.exit(1);
720+
}
721+
693722
// 3b. [#3366] Installable-provider preflight — the shift-left of the
694723
// `serve`-time capability check. `os validate` previously only checked
695724
// the `requires` tokens against the vocabulary (ADR-0066), never
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0146 D10, #28564] `findExtensionFieldSuffixRefusals` — what `os
5+
* validate` hands the `__c` reservation's judge.
6+
*
7+
* The verdict is `@objectstack/metadata-protocol`'s `judgeExtensionFieldSuffix`,
8+
* the one the save and install doors ask; the protocol's own file pins its
9+
* sentences. What is pinned here is which packages the walk hands it and under
10+
* which id: each `packages[]` entry under its own, or the stack itself under
11+
* `manifest.id`, every row's message EQUAL to the judge's for the same subject.
12+
*/
13+
14+
import { describe, expect, it } from 'vitest';
15+
import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol';
16+
import { findExtensionFieldSuffixRefusals } from './extension-field-suffix.js';
17+
18+
const VENDOR = 'com.acme.crm';
19+
const PAIR = 'com.acme.crm.custom';
20+
21+
const objects = [{ name: 'crm_account', label: 'Account', fields: { name: { type: 'text' }, region__c: { type: 'text' } } }];
22+
const pairExtensions = [{ extend: 'crm_account', fields: { region: { type: 'text' }, tier__c: { type: 'text' } } }];
23+
24+
describe('#28564 — the walk os validate hands the `__c` judge', () => {
25+
it('(a) a stack under a plain id: its own `__c` field is refused under that id, located in the stack', () => {
26+
const rows = findExtensionFieldSuffixRefusals({ manifest: { id: VENDOR }, objects });
27+
expect(rows).toEqual([{
28+
path: 'objects[0].fields.region__c', code: 'VALIDATION_ERROR', rule: 'reserved_outside_pair', packageId: VENDOR,
29+
message: judgeExtensionFieldSuffix({ packageId: VENDOR, objects })[0]!.message,
30+
}]);
31+
});
32+
33+
it('(b) a stack under its `.custom` id: an unsuffixed extension field is refused, the suffixed one admitted', () => {
34+
const rows = findExtensionFieldSuffixRefusals({ manifest: { id: PAIR }, objectExtensions: pairExtensions });
35+
expect(rows.map((r) => [r.path, r.rule, r.packageId])).toEqual([
36+
['objectExtensions[0].fields.region', 'unsuffixed_in_pair', PAIR],
37+
]);
38+
expect(rows[0]!.message).toBe(judgeExtensionFieldSuffix({ packageId: PAIR, objectExtensions: pairExtensions })[0]!.message);
39+
});
40+
41+
it('an artifact\'s `packages[]`: each entry judged under its own id, the stack\'s top level not re-judged', () => {
42+
const rows = findExtensionFieldSuffixRefusals({
43+
manifest: { id: 'com.acme.bundle' },
44+
objects,
45+
packages: [
46+
{ manifest: { id: VENDOR, objects } },
47+
{ manifest: { id: PAIR, objectExtensions: pairExtensions } },
48+
],
49+
});
50+
expect(rows.map((r) => [r.path, r.rule, r.packageId])).toEqual([
51+
['packages[0].manifest.objects[0].fields.region__c', 'reserved_outside_pair', VENDOR],
52+
['packages[1].manifest.objectExtensions[0].fields.region', 'unsuffixed_in_pair', PAIR],
53+
]);
54+
});
55+
56+
it('CONTROL: a stack naming no package id is not judged; a clean stack yields nothing', () => {
57+
expect(findExtensionFieldSuffixRefusals({ objects })).toEqual([]);
58+
expect(findExtensionFieldSuffixRefusals({
59+
manifest: { id: PAIR }, objects, objectExtensions: [{ extend: 'crm_account', fields: { region__c: { type: 'text' } } }],
60+
})).toEqual([]);
61+
});
62+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0146 D10, #28564 — the `__c` extension-field reservation at `os validate`.
5+
*
6+
* The verdict is `@objectstack/metadata-protocol`'s `judgeExtensionFieldSuffix`,
7+
* the one the save, restore, publish, duplicate and install doors ask, so a
8+
* green validate is an artifact those doors admit: `__c` fields only in a
9+
* base's paired `.custom` package, and every `objectExtensions[].fields` key of
10+
* a `.custom` pair suffixed. This module only hands it the artifact's packages
11+
* — each `packages[]` entry under its own id, or the stack itself as one
12+
* package under `manifest.id` — the reading `findOverrideDeclarationRefusals`
13+
* (`./override-declarations.ts`) takes. A stack naming no package id is not
14+
* judged, as the judge skips a write bound to no package.
15+
*/
16+
17+
import { judgeExtensionFieldSuffix, type ExtensionFieldSuffixRule } from '@objectstack/metadata-protocol';
18+
19+
import { artifactPackages } from './artifact-packages.js';
20+
21+
/** One refused field, located. `message` is the doors' sentence, verbatim. */
22+
export interface ExtensionFieldSuffixRefusalRow {
23+
/** Where the field sits in the parsed stack, e.g. `packages[1].manifest.objectExtensions[0].fields.region`. */
24+
path: string;
25+
/** The code the active doors refuse the same field with. */
26+
code: 'VALIDATION_ERROR';
27+
/** Which of the two cases this is (`judgeExtensionFieldSuffix`'s `rule`). */
28+
rule: ExtensionFieldSuffixRule;
29+
/** The package the field is declared in. */
30+
packageId: string;
31+
message: string;
32+
}
33+
34+
const isRecord = (v: unknown): v is Record<string, unknown> =>
35+
v !== null && typeof v === 'object' && !Array.isArray(v);
36+
37+
/** Every field of the parsed stack the reservation refuses. */
38+
export function findExtensionFieldSuffixRefusals(parsed: Record<string, unknown>): ExtensionFieldSuffixRefusalRow[] {
39+
const entries = artifactPackages(parsed);
40+
const subjects = entries.length > 0
41+
? entries.map(({ index, id, body }) => ({
42+
packageId: id, objects: body.objects, objectExtensions: body.objectExtensions, at: `packages[${index}].manifest.`,
43+
}))
44+
: [{
45+
packageId: isRecord(parsed.manifest) ? (parsed.manifest.id as string | undefined) : undefined,
46+
objects: parsed.objects,
47+
objectExtensions: parsed.objectExtensions,
48+
}];
49+
return subjects.flatMap((subject) => judgeExtensionFieldSuffix(subject).map((r) => ({
50+
path: r.path, code: 'VALIDATION_ERROR' as const, rule: r.rule, packageId: r.packageId, message: r.message,
51+
})));
52+
}

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,13 +235,23 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {
235235
* SHARED_NON_REGISTRY_GATES instead.
236236
*
237237
* EMPTY is this ledger's steady state: every row is a gap the build carries.
238-
* Both rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts`
238+
* Two rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts`
239239
* gained the call: `findViewContainerNameRefusals` (#20331 → #20393) and
240-
* `findOverrideDeclarationRefusals` (#28410 → #28643). The ledger stays,
241-
* empty, because it is the only honest place the closed roster has for the
242-
* next validate-only gate.
240+
* `findOverrideDeclarationRefusals` (#28410 → #28643). It holds one today,
241+
* `findExtensionFieldSuffixRefusals` (#28564), whose card did not name the
242+
* build.
243243
*/
244-
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {};
244+
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
245+
// [#28564] A real gap in the build's direction, reported and not closed —
246+
// the shape `findOverrideDeclarationRefusals` held until #28643 closed it.
247+
// Card #28564 (ADR-0146 D10, decided item 7) named the `__c` reservation's
248+
// doors — the save, restore and publish doors, `os validate`, the install
249+
// doors and the duplicate — and `os build` is not one of them. The artifact
250+
// a build emits is still judged where it lands: the marketplace install and
251+
// the save and publish doors refuse the same fields with the same judge.
252+
findExtensionFieldSuffixRefusals:
253+
'card #28564 placed the __c reservation at the save/restore/publish doors, os validate, the install doors and duplicate; os build is a further point it did not name (as #28410 left the override gate until #28643). The install and publish doors refuse the same fields with the same judge.',
254+
};
245255

246256
/**
247257
* Everything else the two commands call, and the reason each one is NOT an
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0146 D10, #28564] `os validate` asks the `__c` extension-field
5+
* reservation — the save and install doors' own judge
6+
* (`judgeExtensionFieldSuffix`), over the walk `utils/extension-field-suffix.ts`
7+
* takes, whose own file pins which packages it judges. What is pinned here is
8+
* the command: case (a) and case (b) each exit 1 with the field's path on
9+
* `errors` (and printed without `--json`); the base's paired `.custom`
10+
* declaring `region__c` in its extension validates clean.
11+
*/
12+
13+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14+
import { execFile } from 'node:child_process';
15+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
16+
import { tmpdir } from 'node:os';
17+
import { join, resolve } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
19+
import { childEnv } from './helpers/serve-process.js';
20+
import { linkSpec } from './helpers/define-stack-fixture.js';
21+
22+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
23+
const CLI = resolve(HERE, '../bin/run-dev.js');
24+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
25+
26+
/** A stack under `id` extending the base's `crm_account` with one field named `field`. */
27+
const stack = (id: string, namespace: string, field: string) => `
28+
import { defineStack } from '@objectstack/spec';
29+
30+
export default defineStack({
31+
manifest: {
32+
id: '${id}', name: 'ext', version: '1.0.0', type: 'app', namespace: '${namespace}',
33+
dependencies: { 'com.acme.crm': '^1.0.0' },
34+
},
35+
objectExtensions: [
36+
{ extend: 'crm_account', fields: { ${field}: { type: 'text', label: 'Region' } } },
37+
],
38+
}, { strict: false });
39+
`;
40+
41+
interface Run {
42+
code: number;
43+
stdout: string;
44+
stderr: string;
45+
}
46+
47+
function runValidate(cwd: string, json = true): Promise<Run> {
48+
return new Promise((resolvePromise) => {
49+
execFile(
50+
TSX,
51+
[CLI, 'validate', ...(json ? ['--json'] : [])],
52+
// `childEnv()`, never a bulk `process.env` copy (check:cli-test-child-env).
53+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
54+
(err, stdout, stderr) => {
55+
resolvePromise({
56+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
57+
stdout: String(stdout),
58+
stderr: String(stderr),
59+
});
60+
},
61+
);
62+
});
63+
}
64+
65+
const dirs: Record<'vendor' | 'pairPlain' | 'pairSuffixed', string> = { vendor: '', pairPlain: '', pairSuffixed: '' };
66+
67+
beforeAll(() => {
68+
const make = (key: keyof typeof dirs, source: string) => {
69+
dirs[key] = mkdtempSync(join(tmpdir(), `os-validate-ext-suffix-${key}-`));
70+
writeFileSync(join(dirs[key], 'objectstack.config.ts'), source);
71+
linkSpec(dirs[key]);
72+
};
73+
make('vendor', stack('com.acme.partner', 'partner', 'region__c'));
74+
make('pairPlain', stack('com.acme.crm.custom', 'crm_custom', 'region'));
75+
make('pairSuffixed', stack('com.acme.crm.custom', 'crm_custom', 'region__c'));
76+
});
77+
78+
afterAll(() => {
79+
for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true });
80+
});
81+
82+
describe('#28564 — `os validate` refuses a field the `__c` reservation refuses', () => {
83+
it('(a) a plain package extending with `region__c` exits 1, the field\'s path on `errors`', async () => {
84+
const run = await runValidate(dirs.vendor);
85+
expect(run.code, run.stdout + run.stderr).toBe(1);
86+
const payload = JSON.parse(run.stdout) as { valid: boolean; errors: Array<Record<string, unknown>> };
87+
expect(payload.valid).toBe(false);
88+
expect(payload.errors.map((e) => [e.path, e.code, e.rule, e.packageId])).toEqual([
89+
['objectExtensions[0].fields.region__c', 'VALIDATION_ERROR', 'reserved_outside_pair', 'com.acme.partner'],
90+
]);
91+
expect(String(payload.errors[0]!.message)).toContain('the `__c` suffix is reserved to a base\'s paired `.custom` package');
92+
}, 120_000);
93+
94+
it('(b) the `.custom` pair extending with a plain `region` exits 1, the path printed without `--json`', async () => {
95+
const run = await runValidate(dirs.pairPlain, false);
96+
expect(run.code, run.stdout + run.stderr).toBe(1);
97+
expect(run.stdout + run.stderr).toContain('objectExtensions[0].fields.region [VALIDATION_ERROR]');
98+
expect(run.stdout + run.stderr).toContain('name it `region__c`');
99+
}, 120_000);
100+
101+
it('CONTROL — the `.custom` pair extending with `region__c` validates clean', async () => {
102+
const run = await runValidate(dirs.pairSuffixed);
103+
expect(run.code, run.stdout + run.stderr).toBe(0);
104+
expect((JSON.parse(run.stdout) as { valid: boolean }).valid).toBe(true);
105+
}, 120_000);
106+
});

0 commit comments

Comments
 (0)