diff --git a/.changeset/28564-extension-field-suffix.md b/.changeset/28564-extension-field-suffix.md
new file mode 100644
index 00000000000..927f2d3d2dd
--- /dev/null
+++ b/.changeset/28564-extension-field-suffix.md
@@ -0,0 +1,35 @@
+---
+'@objectstack/metadata-protocol': minor
+'@objectstack/cli': minor
+'@objectstack/cloud-connection': minor
+---
+
+**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)
+
+Clause-②: yes (narrowing)
+Metadata-protocol: narrowed
+Skills-docs: none — no file under `skills/**` teaches a field spelled `__c`, an `objectExtensions` field name or a `.custom` package; the measured count below found none.
+
+
+
+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.
+
+**What is refused**, given the package a body is written into:
+
+- **(a) `__c` outside a `.custom` pair.** In a package that is not a base's paired `.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.
+- **(b) An unsuffixed extension field in a `.custom` pair.** Every `objectExtensions[].fields` key without the suffix: "name it `__c`, or change a base field's display properties in the extension's patch (`fieldPatches`)."
+
+A `.custom` pair's own new objects are not judged (their fields are its own), and neither is a write bound to no package.
+
+**Where**, each door in its own envelope, no new code:
+
+- 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;
+- `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`;
+- `os validate`: exit 1, each field's path on `errors` (`code: 'VALIDATION_ERROR'`, `rule`, `packageId`, `message`);
+- 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.
+
+`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.
+
+**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.
+
+`@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.
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
index 75bd3b08a0c..7ab23b4b0bb 100644
--- a/packages/cli/src/commands/validate.ts
+++ b/packages/cli/src/commands/validate.ts
@@ -77,6 +77,7 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js'
// [ADR-0146 D2, #28410] The override write gate's author-time half: the publish
// door's own judge, walked over the artifact's packages.
import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js';
+import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffix.js';
// A field `picklist` that names no picklist the stack declares — refused, or
// reported when the declaring package depends on packages outside the stack.
// Walked the way the load path registers, like the refusal above.
@@ -690,6 +691,34 @@ export default class Validate extends Command {
this.exit(1);
}
+ // 3a-iv. [ADR-0146 D10, #28564] The `__c` extension-field reservation,
+ // at author time: a field spelled `__c` only in a base's paired
+ // `.custom` package, and every `objectExtensions[].fields` key of a
+ // `.custom` pair suffixed. The verdict is the save and install doors'
+ // own (`judgeExtensionFieldSuffix`), so a green validate is an
+ // artifact they admit; see `utils/extension-field-suffix.ts`.
+ const suffixRefusals = findExtensionFieldSuffixRefusals(result.data as Record);
+ if (suffixRefusals.length > 0) {
+ if (flags.json) {
+ await emitJson({
+ valid: false,
+ errors: suffixRefusals,
+ warnings: warningsSoFar(),
+ conversions: conversionNotices,
+ duration: timer.elapsed(),
+ });
+ this.exit(1);
+ }
+ const n = suffixRefusals.length;
+ console.log('');
+ printError(`${n} field${n > 1 ? 's break' : ' breaks'} the \`__c\` extension-field reservation (ADR-0146 D10)`);
+ printBulletList(
+ suffixRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`),
+ { noun: 'extension-field refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
+ );
+ this.exit(1);
+ }
+
// 3b. [#3366] Installable-provider preflight — the shift-left of the
// `serve`-time capability check. `os validate` previously only checked
// the `requires` tokens against the vocabulary (ADR-0066), never
diff --git a/packages/cli/src/utils/extension-field-suffix.test.ts b/packages/cli/src/utils/extension-field-suffix.test.ts
new file mode 100644
index 00000000000..bbc18372aa9
--- /dev/null
+++ b/packages/cli/src/utils/extension-field-suffix.test.ts
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D10, #28564] `findExtensionFieldSuffixRefusals` — what `os
+ * validate` hands the `__c` reservation's judge.
+ *
+ * The verdict is `@objectstack/metadata-protocol`'s `judgeExtensionFieldSuffix`,
+ * the one the save and install doors ask; the protocol's own file pins its
+ * sentences. What is pinned here is which packages the walk hands it and under
+ * which id: each `packages[]` entry under its own, or the stack itself under
+ * `manifest.id`, every row's message EQUAL to the judge's for the same subject.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol';
+import { findExtensionFieldSuffixRefusals } from './extension-field-suffix.js';
+
+const VENDOR = 'com.acme.crm';
+const PAIR = 'com.acme.crm.custom';
+
+const objects = [{ name: 'crm_account', label: 'Account', fields: { name: { type: 'text' }, region__c: { type: 'text' } } }];
+const pairExtensions = [{ extend: 'crm_account', fields: { region: { type: 'text' }, tier__c: { type: 'text' } } }];
+
+describe('#28564 — the walk os validate hands the `__c` judge', () => {
+ it('(a) a stack under a plain id: its own `__c` field is refused under that id, located in the stack', () => {
+ const rows = findExtensionFieldSuffixRefusals({ manifest: { id: VENDOR }, objects });
+ expect(rows).toEqual([{
+ path: 'objects[0].fields.region__c', code: 'VALIDATION_ERROR', rule: 'reserved_outside_pair', packageId: VENDOR,
+ message: judgeExtensionFieldSuffix({ packageId: VENDOR, objects })[0]!.message,
+ }]);
+ });
+
+ it('(b) a stack under its `.custom` id: an unsuffixed extension field is refused, the suffixed one admitted', () => {
+ const rows = findExtensionFieldSuffixRefusals({ manifest: { id: PAIR }, objectExtensions: pairExtensions });
+ expect(rows.map((r) => [r.path, r.rule, r.packageId])).toEqual([
+ ['objectExtensions[0].fields.region', 'unsuffixed_in_pair', PAIR],
+ ]);
+ expect(rows[0]!.message).toBe(judgeExtensionFieldSuffix({ packageId: PAIR, objectExtensions: pairExtensions })[0]!.message);
+ });
+
+ it('an artifact\'s `packages[]`: each entry judged under its own id, the stack\'s top level not re-judged', () => {
+ const rows = findExtensionFieldSuffixRefusals({
+ manifest: { id: 'com.acme.bundle' },
+ objects,
+ packages: [
+ { manifest: { id: VENDOR, objects } },
+ { manifest: { id: PAIR, objectExtensions: pairExtensions } },
+ ],
+ });
+ expect(rows.map((r) => [r.path, r.rule, r.packageId])).toEqual([
+ ['packages[0].manifest.objects[0].fields.region__c', 'reserved_outside_pair', VENDOR],
+ ['packages[1].manifest.objectExtensions[0].fields.region', 'unsuffixed_in_pair', PAIR],
+ ]);
+ });
+
+ it('CONTROL: a stack naming no package id is not judged; a clean stack yields nothing', () => {
+ expect(findExtensionFieldSuffixRefusals({ objects })).toEqual([]);
+ expect(findExtensionFieldSuffixRefusals({
+ manifest: { id: PAIR }, objects, objectExtensions: [{ extend: 'crm_account', fields: { region__c: { type: 'text' } } }],
+ })).toEqual([]);
+ });
+});
diff --git a/packages/cli/src/utils/extension-field-suffix.ts b/packages/cli/src/utils/extension-field-suffix.ts
new file mode 100644
index 00000000000..aa3292a76a3
--- /dev/null
+++ b/packages/cli/src/utils/extension-field-suffix.ts
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0146 D10, #28564 — the `__c` extension-field reservation at `os validate`.
+ *
+ * The verdict is `@objectstack/metadata-protocol`'s `judgeExtensionFieldSuffix`,
+ * the one the save, restore, publish, duplicate and install doors ask, so a
+ * green validate is an artifact those doors admit: `__c` fields only in a
+ * base's paired `.custom` package, and every `objectExtensions[].fields` key of
+ * a `.custom` pair suffixed. This module only hands it the artifact's packages
+ * — each `packages[]` entry under its own id, or the stack itself as one
+ * package under `manifest.id` — the reading `findOverrideDeclarationRefusals`
+ * (`./override-declarations.ts`) takes. A stack naming no package id is not
+ * judged, as the judge skips a write bound to no package.
+ */
+
+import { judgeExtensionFieldSuffix, type ExtensionFieldSuffixRule } from '@objectstack/metadata-protocol';
+
+import { artifactPackages } from './artifact-packages.js';
+
+/** One refused field, located. `message` is the doors' sentence, verbatim. */
+export interface ExtensionFieldSuffixRefusalRow {
+ /** Where the field sits in the parsed stack, e.g. `packages[1].manifest.objectExtensions[0].fields.region`. */
+ path: string;
+ /** The code the active doors refuse the same field with. */
+ code: 'VALIDATION_ERROR';
+ /** Which of the two cases this is (`judgeExtensionFieldSuffix`'s `rule`). */
+ rule: ExtensionFieldSuffixRule;
+ /** The package the field is declared in. */
+ packageId: string;
+ message: string;
+}
+
+const isRecord = (v: unknown): v is Record =>
+ v !== null && typeof v === 'object' && !Array.isArray(v);
+
+/** Every field of the parsed stack the reservation refuses. */
+export function findExtensionFieldSuffixRefusals(parsed: Record): ExtensionFieldSuffixRefusalRow[] {
+ const entries = artifactPackages(parsed);
+ const subjects = entries.length > 0
+ ? entries.map(({ index, id, body }) => ({
+ packageId: id, objects: body.objects, objectExtensions: body.objectExtensions, at: `packages[${index}].manifest.`,
+ }))
+ : [{
+ packageId: isRecord(parsed.manifest) ? (parsed.manifest.id as string | undefined) : undefined,
+ objects: parsed.objects,
+ objectExtensions: parsed.objectExtensions,
+ }];
+ return subjects.flatMap((subject) => judgeExtensionFieldSuffix(subject).map((r) => ({
+ path: r.path, code: 'VALIDATION_ERROR' as const, rule: r.rule, packageId: r.packageId, message: r.message,
+ })));
+}
diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts
index 6c21bb4acee..b2e27c70c0c 100644
--- a/packages/cli/test/validate-build-gate-parity.test.ts
+++ b/packages/cli/test/validate-build-gate-parity.test.ts
@@ -235,13 +235,23 @@ const BUILD_ONLY_GATES: Readonly> = {
* SHARED_NON_REGISTRY_GATES instead.
*
* EMPTY is this ledger's steady state: every row is a gap the build carries.
- * Both rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts`
+ * Two rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts`
* gained the call: `findViewContainerNameRefusals` (#20331 → #20393) and
- * `findOverrideDeclarationRefusals` (#28410 → #28643). The ledger stays,
- * empty, because it is the only honest place the closed roster has for the
- * next validate-only gate.
+ * `findOverrideDeclarationRefusals` (#28410 → #28643). It holds one today,
+ * `findExtensionFieldSuffixRefusals` (#28564), whose card did not name the
+ * build.
*/
-const VALIDATE_ONLY_GATES: Readonly> = {};
+const VALIDATE_ONLY_GATES: Readonly> = {
+ // [#28564] A real gap in the build's direction, reported and not closed —
+ // the shape `findOverrideDeclarationRefusals` held until #28643 closed it.
+ // Card #28564 (ADR-0146 D10, decided item 7) named the `__c` reservation's
+ // doors — the save, restore and publish doors, `os validate`, the install
+ // doors and the duplicate — and `os build` is not one of them. The artifact
+ // a build emits is still judged where it lands: the marketplace install and
+ // the save and publish doors refuse the same fields with the same judge.
+ findExtensionFieldSuffixRefusals:
+ '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.',
+};
/**
* Everything else the two commands call, and the reason each one is NOT an
diff --git a/packages/cli/test/validate-extension-field-suffix.test.ts b/packages/cli/test/validate-extension-field-suffix.test.ts
new file mode 100644
index 00000000000..2bc70acd7b1
--- /dev/null
+++ b/packages/cli/test/validate-extension-field-suffix.test.ts
@@ -0,0 +1,106 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D10, #28564] `os validate` asks the `__c` extension-field
+ * reservation — the save and install doors' own judge
+ * (`judgeExtensionFieldSuffix`), over the walk `utils/extension-field-suffix.ts`
+ * takes, whose own file pins which packages it judges. What is pinned here is
+ * the command: case (a) and case (b) each exit 1 with the field's path on
+ * `errors` (and printed without `--json`); the base's paired `.custom`
+ * declaring `region__c` in its extension validates clean.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { execFile } from 'node:child_process';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { childEnv } from './helpers/serve-process.js';
+import { linkSpec } from './helpers/define-stack-fixture.js';
+
+const HERE = resolve(fileURLToPath(import.meta.url), '..');
+const CLI = resolve(HERE, '../bin/run-dev.js');
+const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
+
+/** A stack under `id` extending the base's `crm_account` with one field named `field`. */
+const stack = (id: string, namespace: string, field: string) => `
+import { defineStack } from '@objectstack/spec';
+
+export default defineStack({
+ manifest: {
+ id: '${id}', name: 'ext', version: '1.0.0', type: 'app', namespace: '${namespace}',
+ dependencies: { 'com.acme.crm': '^1.0.0' },
+ },
+ objectExtensions: [
+ { extend: 'crm_account', fields: { ${field}: { type: 'text', label: 'Region' } } },
+ ],
+}, { strict: false });
+`;
+
+interface Run {
+ code: number;
+ stdout: string;
+ stderr: string;
+}
+
+function runValidate(cwd: string, json = true): Promise {
+ return new Promise((resolvePromise) => {
+ execFile(
+ TSX,
+ [CLI, 'validate', ...(json ? ['--json'] : [])],
+ // `childEnv()`, never a bulk `process.env` copy (check:cli-test-child-env).
+ { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
+ (err, stdout, stderr) => {
+ resolvePromise({
+ code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
+ stdout: String(stdout),
+ stderr: String(stderr),
+ });
+ },
+ );
+ });
+}
+
+const dirs: Record<'vendor' | 'pairPlain' | 'pairSuffixed', string> = { vendor: '', pairPlain: '', pairSuffixed: '' };
+
+beforeAll(() => {
+ const make = (key: keyof typeof dirs, source: string) => {
+ dirs[key] = mkdtempSync(join(tmpdir(), `os-validate-ext-suffix-${key}-`));
+ writeFileSync(join(dirs[key], 'objectstack.config.ts'), source);
+ linkSpec(dirs[key]);
+ };
+ make('vendor', stack('com.acme.partner', 'partner', 'region__c'));
+ make('pairPlain', stack('com.acme.crm.custom', 'crm_custom', 'region'));
+ make('pairSuffixed', stack('com.acme.crm.custom', 'crm_custom', 'region__c'));
+});
+
+afterAll(() => {
+ for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true });
+});
+
+describe('#28564 — `os validate` refuses a field the `__c` reservation refuses', () => {
+ it('(a) a plain package extending with `region__c` exits 1, the field\'s path on `errors`', async () => {
+ const run = await runValidate(dirs.vendor);
+ expect(run.code, run.stdout + run.stderr).toBe(1);
+ const payload = JSON.parse(run.stdout) as { valid: boolean; errors: Array> };
+ expect(payload.valid).toBe(false);
+ expect(payload.errors.map((e) => [e.path, e.code, e.rule, e.packageId])).toEqual([
+ ['objectExtensions[0].fields.region__c', 'VALIDATION_ERROR', 'reserved_outside_pair', 'com.acme.partner'],
+ ]);
+ expect(String(payload.errors[0]!.message)).toContain('the `__c` suffix is reserved to a base\'s paired `.custom` package');
+ }, 120_000);
+
+ it('(b) the `.custom` pair extending with a plain `region` exits 1, the path printed without `--json`', async () => {
+ const run = await runValidate(dirs.pairPlain, false);
+ expect(run.code, run.stdout + run.stderr).toBe(1);
+ expect(run.stdout + run.stderr).toContain('objectExtensions[0].fields.region [VALIDATION_ERROR]');
+ expect(run.stdout + run.stderr).toContain('name it `region__c`');
+ }, 120_000);
+
+ it('CONTROL — the `.custom` pair extending with `region__c` validates clean', async () => {
+ const run = await runValidate(dirs.pairSuffixed);
+ expect(run.code, run.stdout + run.stderr).toBe(0);
+ expect((JSON.parse(run.stdout) as { valid: boolean }).valid).toBe(true);
+ }, 120_000);
+});
diff --git a/packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts b/packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts
new file mode 100644
index 00000000000..2dc11c9ad6c
--- /dev/null
+++ b/packages/cloud-connection/src/marketplace-install-local-extension-field-suffix.test.ts
@@ -0,0 +1,200 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D10, #28564] Step 1c'' of `POST /api/v1/marketplace/install-local`
+ * — the `__c` extension-field reservation at the marketplace install, at the
+ * dependency check's position, before the manifest service sees the artifact.
+ *
+ * The verdict is `@objectstack/metadata-protocol`'s `judgeExtensionFieldSuffix`,
+ * the save door's own; its sentences are pinned in that package. What is
+ * pinned here is the door:
+ *
+ * - (a) a plain package declaring a `__c` field (in an extension or on its own
+ * object) and (b) a `.custom` pair's extension field without the suffix
+ * stop the install `422 VALIDATION_ERROR` (this door's existing body
+ * refusal), one row per field in `details.refusals`, before any write —
+ * nothing registered, no ledger entry;
+ * - each `packages[]` entry is judged under its own id;
+ * - the refusal comes before the install-mode judgement, so the `unmanaged`
+ * branch is refused too;
+ * - the pair's `region__c` and a vendor extension's plain field install.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+// The install binds the package's handlers through `@objectstack/runtime` (a
+// lazy `import()` inside the plugin); its first load is paid here, at module
+// top — never inside a clocked `it` (`scripts/check-test-source-alias.mjs`).
+import '@objectstack/runtime';
+import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol';
+import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
+import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';
+
+type Handler = (c: any) => Promise;
+
+const ROUTE = 'POST /api/v1/marketplace/install-local';
+const BASE = 'com.acme.crm';
+const PAIR = 'com.acme.crm.custom';
+const PARTNER = 'com.acme.partner';
+
+const deps = (...ids: string[]) => Object.fromEntries(ids.map((id) => [id, '*']));
+
+/** What each installed package holds, as the protocol's per-package listing answers it. */
+type Holdings = Record>>>;
+
+function makeRawApp() {
+ const routes = new Map();
+ return {
+ routes,
+ get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
+ post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
+ delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
+ };
+}
+
+/**
+ * A kernel whose registry holds `installed` (BASE read-only — a code or
+ * marketplace package is in `manifests`) and whose protocol lists `holdings`.
+ */
+function makeCtx(rawApp: any, installed: Array<{ id: string; dependencies?: Record }>, holdings: Holdings) {
+ const hooks = new Map();
+ const registered: any[] = [];
+ const records = installed.map((p) => ({ manifest: { id: p.id, version: '1.0.0', ...(p.dependencies ? { dependencies: p.dependencies } : {}) } }));
+ const services: Record = {
+ manifest: { register: (m: any) => registered.push({ manifest: m }) },
+ auth: installerAuthService(),
+ objectql: withInstallerGrants({
+ syncSchemas: async () => undefined,
+ manifests: new Map(installed.some((p) => p.id === BASE) ? [[BASE, {}]] : []),
+ registry: {
+ getAllPackages: () => [...records, ...registered],
+ getPackage: (id: string) => records.find((r) => r.manifest.id === id),
+ },
+ }),
+ protocol: {
+ getMetaItems: async ({ type, packageId }: { type: string; packageId?: string }) => ({
+ type,
+ items: (holdings[packageId ?? '']?.[type] ?? []).map((i) => ({ ...i, _packageId: packageId })),
+ }),
+ },
+ metadata: { get: async () => undefined },
+ };
+ return {
+ registered,
+ ctx: {
+ hook: (e: string, h: any) => hooks.set(e, h),
+ getService: (name: string) => {
+ if (name === 'http-server') return { getRawApp: () => rawApp };
+ const svc = services[name];
+ if (svc === undefined) throw new Error(`no ${name}`);
+ return svc;
+ },
+ registerService: (name: string, svc: unknown) => { services[name] = svc; },
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+ },
+ fire: async () => { await hooks.get('kernel:ready')?.(); },
+ };
+}
+
+function makeC(body: any) {
+ const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
+ return { req: { url: 'http://localhost:3000/x', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json };
+}
+
+let dir: string;
+beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-ext-suffix-')); });
+afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
+
+const BASE_HOLDINGS: Holdings = {
+ [BASE]: {
+ dashboard: [{ name: 'crm_board', label: 'Board' }],
+ object: [{ name: 'crm_account' }],
+ },
+};
+
+async function install(
+ manifest: Record,
+ installed: Array<{ id: string; dependencies?: Record }> = [{ id: BASE }],
+ holdings: Holdings = BASE_HOLDINGS,
+ extraBody: Record = {},
+) {
+ const rawApp = makeRawApp();
+ const k = makeCtx(rawApp, installed, holdings);
+ const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
+ await plugin.start(k.ctx as any);
+ await k.fire();
+ const res = await rawApp.routes.get(ROUTE)!(makeC({ manifest, ...extraBody }));
+ const registered = k.registered.map((r) => r.manifest.id).filter((id) => id === manifest.id);
+ return { res, registered, ledger: readdirSync(dir) };
+}
+
+const extension = (field: string) => ({ extend: 'crm_account', fields: { [field]: { type: 'text', label: 'Region' } } });
+
+/** The refusal this file pins: the door's envelope and code, every field located, nothing written. */
+function expectRefused(out: Awaited>, expected: ReturnType) {
+ expect(out.res.status, JSON.stringify(out.res.payload)).toBe(422);
+ expect(out.res.payload?.success).toBe(false);
+ expect(out.res.payload?.error?.code).toBe('VALIDATION_ERROR');
+ expect(out.res.payload.error.details.refusals).toEqual(expected);
+ expect(out.res.payload.error.message).toContain(expected[0]!.message);
+ expect(out.res.payload.error.message).toContain('Nothing was installed or written.');
+ expect(out.registered).toEqual([]);
+ expect(out.ledger).toEqual([]);
+}
+
+describe("install-local 1c'' — the `__c` extension-field reservation at the marketplace install (ADR-0146 D10)", () => {
+ it('(a) a plain package extending the base with `region__c` is refused 422; nothing is registered, no ledger entry', async () => {
+ const manifest = { id: PARTNER, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region__c')] };
+ expectRefused(await install(manifest), judgeExtensionFieldSuffix({ packageId: PARTNER, objectExtensions: manifest.objectExtensions }));
+ });
+
+ it('(a) a plain package\'s own object carrying `region__c` is refused', async () => {
+ const objects = [{ name: 'partner_lead', label: 'Lead', fields: { region__c: { type: 'text' } } }];
+ const out = await install({ id: PARTNER, version: '1.0.0', dependencies: deps(BASE), objects });
+ expectRefused(out, judgeExtensionFieldSuffix({ packageId: PARTNER, objects }));
+ expect(out.res.payload.error.details.refusals[0].rule).toBe('reserved_outside_pair');
+ });
+
+ it('(b) the `.custom` pair extending the base with a plain `region` is refused 422', async () => {
+ const manifest = { id: PAIR, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region')] };
+ const out = await install(manifest);
+ expectRefused(out, judgeExtensionFieldSuffix({ packageId: PAIR, objectExtensions: manifest.objectExtensions }));
+ expect(out.res.payload.error.details.refusals[0].rule).toBe('unsuffixed_in_pair');
+ });
+
+ it('a `packages[]` artifact: each entry is judged under its own id', async () => {
+ const out = await install({
+ id: 'com.acme.bundle', version: '1.0.0',
+ packages: [
+ { manifest: { id: PARTNER, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('tier')] } },
+ { manifest: { id: PAIR, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region')] } },
+ ],
+ });
+ expect(out.res.status, JSON.stringify(out.res.payload)).toBe(422);
+ expect(out.res.payload.error.details.refusals.map((r: any) => [r.packageId, r.path, r.rule])).toEqual([
+ [PAIR, 'objectExtensions[0].fields.region', 'unsuffixed_in_pair'],
+ ]);
+ expect(out.registered).toEqual([]);
+ expect(out.ledger).toEqual([]);
+ });
+
+ it('is asked before the install-mode judgement, so an `unmanaged` request is refused by it too', async () => {
+ const manifest = { id: PARTNER, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region__c')] };
+ expectRefused(
+ await install(manifest, undefined, undefined, { installMode: 'unmanaged' }),
+ judgeExtensionFieldSuffix({ packageId: PARTNER, objectExtensions: manifest.objectExtensions }),
+ );
+ });
+
+ it('CONTROL: the `.custom` pair\'s extension field `region__c` installs, and so does a vendor extension\'s plain field', async () => {
+ const pair = await install({ id: PAIR, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region__c')] });
+ expect(pair.res.status, JSON.stringify(pair.res.payload)).toBe(200);
+ expect(pair.registered).toEqual([PAIR]);
+
+ const vendor = await install({ id: PARTNER, version: '1.0.0', dependencies: deps(BASE), objectExtensions: [extension('region')] });
+ expect(vendor.res.status, JSON.stringify(vendor.res.payload)).toBe(200);
+ expect(vendor.registered).toEqual([PARTNER]);
+ });
+});
diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
index e535832bf39..846d03e9bb4 100644
--- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
@@ -241,6 +241,8 @@ import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureT
// [ADR-0146 D2, #28410] The override write gate's one rule and its item walk,
// asked at the managed install exactly as the publish doors ask them.
import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteRule } from '@objectstack/metadata-protocol';
+// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge, step 1c''.
+import { judgeExtensionFieldSuffix } from '@objectstack/metadata-protocol';
import { resolveThrownHttpError } from '@objectstack/types';
// [#25366] The code the registry's security-catalog refusal carries, compared
// rather than `instanceof`-tested: the engine keeps the class unexported so a
@@ -1808,6 +1810,44 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}
}
+ // 1c''. [ADR-0146 D10, #28564] ⭐ THE `__c` EXTENSION-FIELD RESERVATION,
+ // at the dependency check's position and for the same reason: before
+ // the manifest service sees the artifact, so a refused install
+ // registers, writes and ledgers nothing, on the managed and the
+ // unmanaged branch alike. Every package this act delivers (each
+ // `packages[]` entry under its own id, the body itself otherwise) is
+ // judged by the save door's judge (`judgeExtensionFieldSuffix`): a
+ // field spelled `__c` only in a base's paired `.custom`, and every
+ // `objectExtensions[].fields` key of a `.custom` pair suffixed. The
+ // answer is this door's own body refusal, `VALIDATION_ERROR` / 422
+ // (the unrunnable-code refusal's code and status), one row per field
+ // in `details.refusals`. No code is minted.
+ {
+ const bodies: Array<{ id: unknown; body: Record }> = Array.isArray(manifest.packages)
+ ? manifest.packages
+ .filter((entry: unknown) => isPlainObject(entry) && isPlainObject((entry as any).manifest))
+ .map((entry: any) => ({ id: manifestIdOf(entry.manifest), body: flattenPackageBody(entry.manifest) }))
+ : [{ id: manifestId, body: flattenPackageBody(manifest) }];
+ const refusals = bodies.flatMap(({ id, body }) => judgeExtensionFieldSuffix({
+ packageId: typeof id === 'string' ? id : undefined,
+ objects: body.objects,
+ objectExtensions: body.objectExtensions,
+ }));
+ if (refusals.length > 0) {
+ const n = refusals.length;
+ return c.json({
+ success: false,
+ error: {
+ code: UNRUNNABLE_REFUSAL_CODE,
+ message: `Package '${manifestId}' cannot be installed: ${refusals[0].message}`
+ + (n > 1 ? ` (${n - 1} more in \`details.refusals\`.)` : '')
+ + ' Nothing was installed or written.',
+ details: { refusals },
+ },
+ }, UNRUNNABLE_REFUSAL_STATUS);
+ }
+ }
+
// 1e. [ADR-0131 D6] ⭐ THE INSTALL MODE — after the payload, id, D3
// walled-catalog and handshake checks and before every other
// judgement of the package and any write, because nothing an
diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts
index 7b7680e54a5..5c9fa1bc776 100644
--- a/packages/metadata-protocol/src/index.ts
+++ b/packages/metadata-protocol/src/index.ts
@@ -35,6 +35,10 @@ export type { MetadataAuthoringChannel } from './protocol.js';
// two gate points outside this package — `os validate` and the marketplace
// managed install — so all three doors ask one judgement and cannot drift.
export { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem, type OverrideWriteSubject, type OverrideWriteClosure, type OverrideWriteRule, type OverrideWriteVerdict } from './protocol-packaged-base-refusal.js';
+// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge,
+// exported for the doors outside this package — `os validate` and the
+// marketplace install — so every door that lets a field in asks one judgement.
+export { judgeExtensionFieldSuffix, extensionFieldSuffixRefusalError, objectBodyExtensionFieldSuffixRefusal, type ExtensionFieldSuffixRefusal, type ExtensionFieldSuffixRule, type ExtensionFieldSuffixSubject } from './protocol-extension-field-suffix.js';
// The probe-first replacement's shared vocabulary: the raw-SQL seam type the
// exported migrations below take, and the dialect classifier their reports
diff --git a/packages/metadata-protocol/src/protocol-authoring-gate.ts b/packages/metadata-protocol/src/protocol-authoring-gate.ts
index 21d745fc184..b4956ed6b55 100644
--- a/packages/metadata-protocol/src/protocol-authoring-gate.ts
+++ b/packages/metadata-protocol/src/protocol-authoring-gate.ts
@@ -48,6 +48,7 @@
import type { ObjectStackProtocolImplementation } from './protocol.js';
import { permissionSetOwnerBindingRefusal, runtimeHookWithoutBodyRefusal } from './protocol.js';
+import { objectBodyExtensionFieldSuffixRefusal } from './protocol-extension-field-suffix.js';
import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js';
import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js';
import type { PackagedRuleCopyRefusal, RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js';
@@ -108,6 +109,9 @@ export function restoredBodyWriter(
if (nameRefusal) throw nameRefusal;
const ownerRefusal = permissionSetOwnerBindingRefusal(type, historyBody, name, binding, 'restore');
if (ownerRefusal) throw ownerRefusal;
+ // [ADR-0146 D10, #28564] The `__c` reservation, on the same binding.
+ const suffixRefusal = objectBodyExtensionFieldSuffixRefusal(type, historyBody, name, binding);
+ if (suffixRefusal) throw suffixRefusal;
await this.refuseRestoredBodyTheSaveDoorRefuses(type, name, historyBody, binding, source);
return derive ? derive(historyBody) : historyBody;
};
diff --git a/packages/metadata-protocol/src/protocol-extension-field-suffix.ts b/packages/metadata-protocol/src/protocol-extension-field-suffix.ts
new file mode 100644
index 00000000000..8a26f552bf2
--- /dev/null
+++ b/packages/metadata-protocol/src/protocol-extension-field-suffix.ts
@@ -0,0 +1,225 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ADR-0146 D10, first rule — the `__c` extension-field reservation, judged.
+ *
+ * Maintainer, verbatim: 「扩展字段用 __c 后缀,只有 .custom 包能用。」 A
+ * customer's field on a base object is `region__c`, so a vendor's next version
+ * can never take that name, and nobody mistakes the customer's field for the
+ * vendor's. That holds only if every door that lets a field in asks the same
+ * question, so this module is the ONE judge and every door calls it:
+ *
+ * - the save, the restore and the draft promotion of `@objectstack/metadata-
+ * protocol` (`saveMetaItem`, `rollbackMetaItem`, and the promotion
+ * `publishMetaItem` / `publishPackageDrafts` share), for an `object` body,
+ * through {@link objectBodyExtensionFieldSuffixRefusal};
+ * - `duplicatePackage`, for every object the copy would write, judged against
+ * the TARGET id before the copy mints anything;
+ * - `os validate` (`@objectstack/cli`) and the marketplace install
+ * (`@objectstack/cloud-connection`), for a whole package body, through
+ * {@link judgeExtensionFieldSuffix}.
+ *
+ * `installPackage` is covered by its closed manifest, not by a call here: its
+ * `PackageInstallManifestSchema` is a `strictObject` whose `objects` are glob
+ * strings and which has no `objectExtensions` key, so a field body never
+ * reaches it (refused by that parse before any write).
+ *
+ * ## The two cases
+ *
+ * Given the package a body is written into and the objects and extensions it
+ * declares:
+ *
+ * - (a) `reserved_outside_pair` — in a package that is NOT a base's paired
+ * `.custom` ({@link isCustomizationPackage}), every field spelled `__c`
+ * ({@link isExtensionFieldName}), on its own objects or in its
+ * `objectExtensions`, is refused: rename it. A field one vendor package adds
+ * to another's object keeps the plain spelling (ADR-0029 D7).
+ * - (b) `unsuffixed_in_pair` — in a `.custom` pair, every
+ * `objectExtensions[].fields` key without the suffix is refused: suffix it,
+ * or change the base field's display properties in the extension's
+ * `fieldPatches` (the per-field patch slot, `data/object.zod.ts`).
+ *
+ * A `.custom` pair's own new objects are not judged, and neither is an
+ * extension of one of them: their fields are the pair's own. A write bound to
+ * no package is not judged: the reservation is a rule about packages, and a
+ * package-unbound row is the environment's own (as the namespace-prefix judge,
+ * `objectNamespacePrefixViolation`, skips it).
+ *
+ * The suffix and the pair rule are the spec's (`@objectstack/spec/data`
+ * `field-customization.ts`, `@objectstack/spec/kernel`
+ * `customization-package.ts`), never re-spelled here. Pure: no I/O, no throw
+ * from {@link judgeExtensionFieldSuffix}. Each door keeps its own envelope;
+ * the active doors' is {@link extensionFieldSuffixRefusalError}'s
+ * `VALIDATION_ERROR` / 400, the envelope of their other body-against-address
+ * refusals (`savedItemNameRefusal`, `permissionSetOwnerBindingRefusal`). No
+ * code is minted.
+ */
+
+import { isExtensionFieldName } from '@objectstack/spec/data';
+import { isCustomizationPackage } from '@objectstack/spec/kernel';
+import type { MetadataIssueEntry } from './protocol.js';
+
+/** Which of the two cases refused a field. */
+export type ExtensionFieldSuffixRule = 'reserved_outside_pair' | 'unsuffixed_in_pair';
+
+/** One refused field, located. */
+export interface ExtensionFieldSuffixRefusal {
+ rule: ExtensionFieldSuffixRule;
+ /** Where the field sits in the judged body, e.g. `objectExtensions[0].fields.region`. */
+ path: string;
+ /** The object the field is on: the object's own name, or the extension's `extend` target. */
+ object: string;
+ /** The field name as written. */
+ field: string;
+ /** The package the body is written into. */
+ packageId: string;
+ /** The door's sentence: what is wrong, then the prescription. */
+ message: string;
+}
+
+/** What a door hands the judge: the package written into and what it declares. */
+export interface ExtensionFieldSuffixSubject {
+ /** The package the body is written into. Not a non-empty string ⇒ not judged. */
+ packageId: string | null | undefined;
+ /** The package's own objects: an array of object bodies, or a record of them keyed by name. */
+ objects?: unknown;
+ /** The package's `objectExtensions` entries. */
+ objectExtensions?: unknown;
+ /** Prefix of every reported path, e.g. `packages[1].manifest.`. */
+ at?: string;
+}
+
+const isRecord = (v: unknown): v is Record =>
+ v !== null && typeof v === 'object' && !Array.isArray(v);
+
+/**
+ * The field names an object or extension body declares, each with its path
+ * segment: a record keyed by name (the declared shape), or an array of
+ * `{ name }` entries (a pre-protocol dialect a door may still be handed).
+ */
+function declaredFieldNames(fields: unknown): Array<{ name: string; segment: string }> {
+ if (isRecord(fields)) return Object.keys(fields).map((name) => ({ name, segment: `fields.${name}` }));
+ if (!Array.isArray(fields)) return [];
+ const out: Array<{ name: string; segment: string }> = [];
+ fields.forEach((entry, i) => {
+ if (isRecord(entry) && typeof entry.name === 'string') out.push({ name: entry.name, segment: `fields[${i}]` });
+ });
+ return out;
+}
+
+/** The entries of an `objects` collection with their path segments: an array, or a record keyed by name. */
+function objectEntries(objects: unknown): Array<{ body: Record; name: string; segment: string }> {
+ const out: Array<{ body: Record; name: string; segment: string }> = [];
+ if (Array.isArray(objects)) {
+ objects.forEach((body, i) => {
+ if (isRecord(body)) out.push({ body, name: typeof body.name === 'string' ? body.name : '', segment: `objects[${i}]` });
+ });
+ } else if (isRecord(objects)) {
+ for (const [key, body] of Object.entries(objects)) {
+ if (isRecord(body)) out.push({ body, name: typeof body.name === 'string' ? body.name : key, segment: `objects.${key}` });
+ }
+ }
+ return out;
+}
+
+/** Case (a)'s sentence. Prescription first: a 4xx message crosses the REST boundary capped at 500 characters. */
+function reservedOutsidePairMessage(field: string, object: string, packageId: string): string {
+ return `Field '${field}' on object '${object}' in package '${packageId}': the \`__c\` suffix is reserved to a base's `
+ + 'paired `.custom` package (ADR-0146 D10); rename the field.';
+}
+
+/** Case (b)'s sentence. */
+function unsuffixedInPairMessage(field: string, object: string, packageId: string): string {
+ return `Extension field '${field}' on object '${object}' in package '${packageId}': name it \`${field}__c\`, or change `
+ + 'a base field\'s display properties in the extension\'s patch (`fieldPatches`).';
+}
+
+/**
+ * Every field the reservation refuses in what `subject` declares, in
+ * declaration order (objects first, then extensions). Empty when the subject
+ * names no package or nothing is refused.
+ */
+export function judgeExtensionFieldSuffix(subject: ExtensionFieldSuffixSubject): ExtensionFieldSuffixRefusal[] {
+ const { packageId } = subject;
+ if (typeof packageId !== 'string' || packageId === '') return [];
+ const at = subject.at ?? '';
+ const pair = isCustomizationPackage(packageId);
+ const refusals: ExtensionFieldSuffixRefusal[] = [];
+ const own = objectEntries(subject.objects);
+
+ // (a) The package's own objects. A pair's own objects are not judged.
+ if (!pair) {
+ for (const { body, name, segment } of own) {
+ for (const field of declaredFieldNames(body.fields)) {
+ if (!isExtensionFieldName(field.name)) continue;
+ refusals.push({
+ rule: 'reserved_outside_pair', path: `${at}${segment}.${field.segment}`,
+ object: name, field: field.name, packageId,
+ message: reservedOutsidePairMessage(field.name, name, packageId),
+ });
+ }
+ }
+ }
+
+ // (a) or (b) on the extensions, by whether the package is the pair.
+ if (Array.isArray(subject.objectExtensions)) {
+ const ownNames = new Set(own.map((o) => o.name).filter((n) => n !== ''));
+ subject.objectExtensions.forEach((ext, i) => {
+ if (!isRecord(ext)) return;
+ const target = typeof ext.extend === 'string' ? ext.extend : '';
+ // An extension of the pair's own object adds to the pair's own fields.
+ if (pair && ownNames.has(target)) return;
+ for (const field of declaredFieldNames(ext.fields)) {
+ const suffixed = isExtensionFieldName(field.name);
+ if (pair === suffixed) continue;
+ refusals.push({
+ rule: pair ? 'unsuffixed_in_pair' : 'reserved_outside_pair',
+ path: `${at}objectExtensions[${i}].${field.segment}`,
+ object: target, field: field.name, packageId,
+ message: pair
+ ? unsuffixedInPairMessage(field.name, target, packageId)
+ : reservedOutsidePairMessage(field.name, target, packageId),
+ });
+ }
+ });
+ }
+ return refusals;
+}
+
+/**
+ * The active doors' envelope around a non-empty refusal list: `VALIDATION_ERROR`
+ * / 400, the message the first refusal's sentence (and how many more), and
+ * `issues[]` locating every field, the shape the Studio designer keys on.
+ */
+export function extensionFieldSuffixRefusalError(
+ refusals: readonly ExtensionFieldSuffixRefusal[],
+): Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] } {
+ const [first] = refusals;
+ const more = refusals.length - 1;
+ const err = new Error(
+ `${first!.message}${more > 0 ? ` (${more} more field${more === 1 ? '' : 's'} in \`issues\`.)` : ''}`,
+ ) as Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] };
+ err.code = 'VALIDATION_ERROR';
+ err.status = 400;
+ err.issues = refusals.map((r) => ({ path: r.path, message: r.message, code: 'custom' }));
+ return err;
+}
+
+/**
+ * The save, restore and promotion doors' question for one item: an `object`
+ * body written into `binding` (the RESOLVED package of the row, as
+ * `permissionSetOwnerBindingRefusal` reads it), judged as that package's own
+ * object. Any other type, and a write bound to no package, answer `undefined`.
+ */
+export function objectBodyExtensionFieldSuffixRefusal(
+ singularType: string,
+ item: unknown,
+ name: string,
+ binding: string | null | undefined,
+): (Error & { code: 'VALIDATION_ERROR'; status: 400; issues: MetadataIssueEntry[] }) | undefined {
+ if (singularType !== 'object' || !isRecord(item)) return undefined;
+ const refusals = judgeExtensionFieldSuffix({ packageId: binding, objects: { [name]: { ...item, name } } })
+ // The body is the whole item, so its paths start at the item, not at `objects.`.
+ .map((r) => ({ ...r, path: r.path.slice(`objects.${name}.`.length) }));
+ return refusals.length > 0 ? extensionFieldSuffixRefusalError(refusals) : undefined;
+}
diff --git a/packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts b/packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts
new file mode 100644
index 00000000000..76049241915
--- /dev/null
+++ b/packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts
@@ -0,0 +1,592 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D10, #28564] The `__c` extension-field reservation at every door
+ * of this package — one judge (`protocol-extension-field-suffix.ts`), asked by
+ * the save door, the restore doors, the draft promotion (`publishMetaItem` and
+ * the `publishPackageDrafts` batch that shares it) and `duplicatePackage`; and
+ * `installPackage`, which its closed manifest already covers.
+ *
+ * Maintainer, verbatim: 「扩展字段用 __c 后缀,只有 .custom 包能用。」
+ *
+ * - (a) in a package that is not a base's paired `.custom`, a field spelled
+ * `__c` is refused, on its own objects or in its `objectExtensions`;
+ * - (b) in a `.custom` pair, an `objectExtensions[].fields` key without the
+ * suffix is refused.
+ *
+ * An `object` body is the only shape that reaches the save, restore and
+ * promotion doors and `duplicatePackage`, and a `.custom` pair's own object is
+ * not judged, so those doors pin case (a) and the pair's admit; case (b) needs
+ * an `objectExtensions` entry, pinned on the judge itself here and at the two
+ * doors outside this package that receive one (`os validate` in
+ * `@objectstack/cli`, the marketplace install in `@objectstack/cloud-connection`).
+ *
+ * Every refusal asserts the active doors' envelope (`VALIDATION_ERROR` / 400,
+ * `issues[]` locating the field) and that nothing was written, and is paired
+ * with a CONTROL through the same door. The stub engine and its staging of a
+ * stored body are copied from `protocol.item-name-every-door.test.ts`, not
+ * imported, so each pin fails on its own; a body the doors now refuse is staged
+ * as a row stored before the rule (`residueField()`).
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+// [#5619] The producer's OWN write-verb dispatch decisions, imported from
+// `@objectstack/metadata-core` and NOT from `@objectstack/objectql`: objectql
+// depends on this package, so that import would close a dependency cycle.
+import {
+ assertEngineDeleteDispatch,
+ assertEngineUpdateDispatch,
+ assertEngineFindOnePredicate,
+} from '@objectstack/metadata-core';
+import { ObjectStackProtocolImplementation } from './protocol.js';
+import { judgeExtensionFieldSuffix } from './protocol-extension-field-suffix.js';
+
+const BOUND = 'app.probe-bound';
+/** {@link BOUND}'s paired customization package. */
+const PAIR = 'app.probe-bound.custom';
+
+/**
+ * [#26708] The packages this file's saves name, installed: a save naming a
+ * package no installed package has is refused (`WRITABLE_PACKAGE_REQUIRED`)
+ * before any gate this file pins is reached. Each answers a manifest that
+ * declares no dependencies, as an authored base does.
+ */
+const INSTALLED_PACKAGES: ReadonlySet = new Set([BOUND, PAIR, 'app.dupsrc.custom']);
+const installedPackage = (id: string) => (INSTALLED_PACKAGES.has(id) ? { manifest: { id } } : undefined);
+
+
+interface Row {
+ id: string;
+ type: string;
+ name: string;
+ organization_id: string | null;
+ package_id: string | null;
+ state: string;
+ metadata: string;
+ checksum?: string;
+ version?: number;
+}
+
+interface HistoryRow {
+ id: string;
+ type: string;
+ name: string;
+ version: number;
+ organization_id: string | null;
+ operation_type: string;
+ metadata?: string | null;
+}
+
+/** ADR-0048 overlay key — (type, name, org, state, package_id). */
+const keyOf = (w: Record) =>
+ `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`;
+
+/** Top-level eq + `$or` + explicit-NULL, the subset these paths emit. */
+function matchesWhere(r: Row, where: Record): boolean {
+ for (const [k, v] of Object.entries(where)) {
+ if (k === '$or') {
+ const clauses = v as Array>;
+ if (!clauses.some((c) => matchesWhere(r, c))) return false;
+ continue;
+ }
+ if (v === undefined) continue;
+ if ((r as unknown as Record)[k] !== v) return false;
+ }
+ return true;
+}
+
+/**
+ * A double that STORES rows and history and whose registry keys an item by
+ * its own `name`, the way `SchemaRegistry.registerItem` does — a no-op
+ * registry would pass every "nothing registered" assertion vacuously.
+ * Copied from `protocol.recovery-doors-emit-mutation.test.ts`, not imported,
+ * so each pin can fail on its own.
+ */
+function makeStubEngine() {
+ const rows = new Map();
+ const historyRows: HistoryRow[] = [];
+ const registered = new Map>();
+ let nextId = 0;
+ let commit: unknown = null;
+
+ const findRow = (w: Record): { key: string; row: Row } | null => {
+ if (w.id !== undefined) {
+ for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r };
+ return null;
+ }
+ if (w.package_id !== undefined) {
+ const k = keyOf(w);
+ const r = rows.get(k);
+ if (r) return { key: k, row: r };
+ }
+ for (const [k, r] of rows) if (matchesWhere(r, w)) return { key: k, row: r };
+ return null;
+ };
+
+ const matchesHistory = (h: HistoryRow, w: Record): boolean => {
+ if (w.organization_id !== undefined && h.organization_id !== w.organization_id) return false;
+ if (w.type !== undefined && h.type !== w.type) return false;
+ if (w.name !== undefined && h.name !== w.name) return false;
+ if (w.version !== undefined && h.version !== w.version) return false;
+ if (w.operation_type !== undefined && h.operation_type !== w.operation_type) return false;
+ return true;
+ };
+
+ const engine: any = {
+ async findOne(table: string, opts: { where: Record }) {
+ assertEngineFindOnePredicate(table, opts);
+ if (table === 'sys_metadata_commit') return commit;
+ if (table === 'sys_metadata_history') {
+ return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
+ }
+ if (table !== 'sys_metadata') return null;
+ return findRow(opts.where)?.row ?? null;
+ },
+ async find(table: string, opts?: { where?: Record; limit?: number }) {
+ // Hold the caller's bound AFTER the filter, by PRESENCE — the
+ // objectql-double-limit contract.
+ const bound = (all: T[]): T[] =>
+ typeof opts?.limit === 'number' ? all.slice(0, opts.limit) : all;
+ if (table === 'sys_metadata_history') {
+ return bound(historyRows.filter((h) => matchesHistory(h, opts?.where ?? {})));
+ }
+ if (table !== 'sys_metadata') return [];
+ return bound(Array.from(rows.values()).filter((r) => matchesWhere(r, opts?.where ?? {})));
+ },
+ async insert(table: string, data: Record) {
+ if (table === 'sys_metadata_history') {
+ nextId += 1;
+ const h = { ...(data as unknown as HistoryRow), id: `h_${nextId}` };
+ historyRows.push(h);
+ return { id: h.id };
+ }
+ if (table !== 'sys_metadata') return { id: 'side_effect_skip' };
+ nextId += 1;
+ const row = { ...(data as unknown as Row), id: `r_${nextId}` };
+ rows.set(keyOf(data), row);
+ return { id: row.id };
+ },
+ async update(_t: string, data: Record, opts: { where: Record }) {
+ assertEngineUpdateDispatch(data, opts);
+ const found = findRow(opts.where);
+ if (!found) return { id: null };
+ const merged = { ...found.row, ...(data as unknown as Row) };
+ rows.delete(found.key);
+ rows.set(keyOf(merged), merged);
+ return { id: found.row.id };
+ },
+ async delete(_t: string, opts: { where: Record }) {
+ assertEngineDeleteDispatch(opts);
+ const found = findRow(opts.where);
+ if (!found) return { deleted: 0 };
+ rows.delete(found.key);
+ return { deleted: 1 };
+ },
+ async count() { return 0; },
+ // A transaction that ROLLS BACK, the property `publishPackageDrafts`'
+ // Phase 1 stands on (ADR-0067 D2): a throw restores the rows and the
+ // history it started with. The registry is not part of it — Phase 1
+ // defers every registry mutation to Phase 2 — so it is not snapshotted.
+ async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise {
+ const rowsAtBegin = new Map(rows);
+ const historyAtBegin = historyRows.slice();
+ try {
+ return await cb(undefined, { owned: true });
+ } catch (error) {
+ rows.clear();
+ for (const [k, r] of rowsAtBegin) rows.set(k, r);
+ historyRows.splice(0, historyRows.length, ...historyAtBegin);
+ throw error;
+ }
+ },
+ async syncObjectSchema() { return true; },
+ async dropObjectSchema() { return true; },
+ registry: {
+ registerItem: (type: string, item: any) => {
+ if (!registered.has(type)) registered.set(type, new Map());
+ registered.get(type)!.set(item?.name, item);
+ },
+ registerObject: () => {},
+ unregisterObject: () => true,
+ removeObjectOverlay: () => {},
+ removeRuntimeShadow: () => false,
+ removeOverlayEntry: () => true,
+ listItems: (type: string) => Array.from(registered.get(type)?.values() ?? []),
+ getItem: (type: string, name: string) => registered.get(type)?.get(name),
+ getObject: () => undefined,
+ getPackage: installedPackage,
+ getArtifactItem: () => undefined,
+ isPackageDisabled: () => false,
+ },
+ };
+ return {
+ engine,
+ rows,
+ historyRows,
+ registered,
+ serveCommit: (c: unknown) => { commit = c; },
+ };
+}
+
+function makeProtocol() {
+ const h = makeStubEngine();
+ // `environmentId` UNDEFINED on purpose — see the header.
+ const protocol = new ObjectStackProtocolImplementation(h.engine, () => new Map(), undefined) as any;
+ expect(protocol.environmentId).toBeUndefined();
+ // `OS_METADATA_WRITABLE` is memoised process-wide; clear it rather than assume.
+ ObjectStackProtocolImplementation.resetEnvWritableCache();
+ const saveDoor = vi.spyOn(protocol, 'saveMetaItem');
+ return { protocol, saveDoor, ...h };
+}
+
+type Harness = ReturnType;
+
+/** An object body with one plain field and the fields named in `extra`. */
+const obj = (name: string, label: string, ...extra: string[]) => ({
+ name,
+ label,
+ sharingModel: 'private',
+ fields: Object.fromEntries([
+ ['title', { type: 'text', label: 'Title' }],
+ ...extra.map((f) => [f, { type: 'text', label: f }]),
+ ]),
+});
+
+/** Every stored object row as `{ row, state, binding, fields }`. */
+const storedObjects = (h: Harness) => Array.from(h.rows.values())
+ .filter((r) => r.type === 'object')
+ .map((r) => ({ row: r.name, state: r.state, binding: r.package_id ?? null, fields: Object.keys(JSON.parse(r.metadata).fields ?? {}) }));
+
+/**
+ * Add a field to a stored body in place — a row as it sits at rest when it was
+ * written before this rule. `version` addresses a history row.
+ */
+function residueField(h: Harness, where: { name: string; state?: string; version?: number }, field: string) {
+ const add = (metadata: string) => {
+ const body = JSON.parse(metadata);
+ return JSON.stringify({ ...body, fields: { ...body.fields, [field]: { type: 'text', label: field } } });
+ };
+ if (where.version !== undefined) {
+ const row = h.historyRows.find((r) => r.type === 'object' && r.name === where.name && r.version === where.version);
+ if (!row?.metadata) throw new Error(`no history row object/${where.name}@${where.version}`);
+ row.metadata = add(row.metadata);
+ return;
+ }
+ const row = Array.from(h.rows.values())
+ .find((r) => r.type === 'object' && r.name === where.name && r.state === (where.state ?? 'active'));
+ if (!row) throw new Error(`no ${where.state ?? 'active'} object row ${where.name}`);
+ row.metadata = add(row.metadata);
+}
+
+/** The minimum a rejection pin asserts: the ADR-0112 envelope, not "it threw". */
+function expectEnvelope(error: any) {
+ expect(error).toBeInstanceOf(Error);
+ expect(error.code).toBe('VALIDATION_ERROR');
+ expect(error.status).toBe(400);
+}
+
+/** Case (a)'s refusal of `field` on `object` in `packageId`, located at `path`. */
+function expectReservedRefusal(error: any, field: string, object: string, packageId: string, path = `fields.${field}`) {
+ expectEnvelope(error);
+ expect(error.issues).toEqual([expect.objectContaining({ path, code: 'custom' })]);
+ expect(error.message).toContain(`Field '${field}' on object '${object}' in package '${packageId}'`);
+ expect(error.message).toContain("the `__c` suffix is reserved to a base's paired `.custom` package (ADR-0146 D10); rename the field.");
+}
+
+async function attempt(run: () => Promise): Promise<{ result: T | null; error: any }> {
+ try {
+ return { result: await run(), error: null };
+ } catch (error) {
+ return { result: null, error };
+ }
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+const commitRow = (items: unknown[]) => ({
+ id: 'c1',
+ commit_id: 'c1',
+ organization_id: null,
+ operation: 'apply',
+ message: 'the commit under revert',
+ created_at: '2026-01-01T00:00:00Z',
+ items: JSON.stringify(items),
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 0. The judge
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('[ADR-0146 D10] judgeExtensionFieldSuffix — the two cases', () => {
+ const vendor = 'com.acme.crm';
+ const pair = 'com.acme.crm.custom';
+
+ it('(a) a plain package: a `__c` field on its own object and in its extension is refused, each located', () => {
+ const refusals = judgeExtensionFieldSuffix({
+ packageId: vendor,
+ objects: [obj('crm_account', 'Account', 'region__c')],
+ objectExtensions: [{ extend: 'base_contact', fields: { tier__c: { type: 'text' }, tier: { type: 'text' } } }],
+ at: 'packages[0].manifest.',
+ });
+ expect(refusals.map((r) => [r.rule, r.path, r.object, r.field])).toEqual([
+ ['reserved_outside_pair', 'packages[0].manifest.objects[0].fields.region__c', 'crm_account', 'region__c'],
+ ['reserved_outside_pair', 'packages[0].manifest.objectExtensions[0].fields.tier__c', 'base_contact', 'tier__c'],
+ ]);
+ expect(refusals[0]!.message).toBe(
+ "Field 'region__c' on object 'crm_account' in package 'com.acme.crm': the `__c` suffix is reserved to a base's "
+ + 'paired `.custom` package (ADR-0146 D10); rename the field.',
+ );
+ });
+
+ it('(b) a `.custom` pair: an extension field without the suffix is refused with the patch-slot prescription', () => {
+ const refusals = judgeExtensionFieldSuffix({
+ packageId: pair,
+ objectExtensions: [{ extend: 'crm_account', fields: { region: { type: 'text' }, region__c: { type: 'text' } } }],
+ });
+ expect(refusals.map((r) => [r.rule, r.path, r.field])).toEqual([
+ ['unsuffixed_in_pair', 'objectExtensions[0].fields.region', 'region'],
+ ]);
+ expect(refusals[0]!.message).toBe(
+ "Extension field 'region' on object 'crm_account' in package 'com.acme.crm.custom': name it `region__c`, or "
+ + "change a base field's display properties in the extension's patch (`fieldPatches`).",
+ );
+ });
+
+ it('a `.custom` extension field `region__c` is admitted, and a vendor extension\'s plain field is admitted', () => {
+ expect(judgeExtensionFieldSuffix({
+ packageId: pair, objectExtensions: [{ extend: 'crm_account', fields: { region__c: { type: 'text' } } }],
+ })).toEqual([]);
+ expect(judgeExtensionFieldSuffix({
+ packageId: vendor, objectExtensions: [{ extend: 'base_contact', fields: { tier: { type: 'text' } } }],
+ })).toEqual([]);
+ });
+
+ it('a `.custom` pair\'s own new objects are not judged, nor an extension of one of them', () => {
+ expect(judgeExtensionFieldSuffix({
+ packageId: pair,
+ objects: [obj('crm_custom_survey', 'Survey', 'score', 'note__c')],
+ objectExtensions: [{ extend: 'crm_custom_survey', fields: { extra: { type: 'text' } } }],
+ })).toEqual([]);
+ });
+
+ it('a subject bound to no package is not judged; the pair rule is the spec\'s (`a.custom.custom` is no pair)', () => {
+ for (const packageId of [undefined, null, '']) {
+ expect(judgeExtensionFieldSuffix({ packageId, objects: [obj('t', 'T', 'x__c')] })).toEqual([]);
+ }
+ expect(judgeExtensionFieldSuffix({ packageId: 'com.acme.crm.custom.custom', objects: [obj('t', 'T', 'x__c')] }))
+ .toEqual([expect.objectContaining({ rule: 'reserved_outside_pair' })]);
+ });
+
+ it('reads an `objects` record keyed by name and a `fields` array of `{ name }` entries; a bare `__c` is no extension name', () => {
+ const refusals = judgeExtensionFieldSuffix({
+ packageId: vendor,
+ objects: { crm_lead: { fields: [{ name: 'title' }, { name: 'score__c' }, { name: '__c' }] } },
+ });
+ expect(refusals.map((r) => [r.path, r.object])).toEqual([['objects.crm_lead.fields[1]', 'crm_lead']]);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 1. The save door
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('[ADR-0146 D10] the save door refuses an object field spelled `__c` outside a `.custom` pair', () => {
+ it.each(['publish', 'draft'] as const)('%s mode: an object saved into a plain package with `x__c` is refused; nothing stored', async (mode) => {
+ const h = makeProtocol();
+ const { error } = await attempt(() => h.protocol.saveMetaItem({
+ type: 'object', name: 'probe_ticket', item: obj('probe_ticket', 'Ticket', 'x__c'), packageId: BOUND, mode,
+ }));
+ expectReservedRefusal(error, 'x__c', 'probe_ticket', BOUND);
+ expect(storedObjects(h)).toEqual([]);
+ });
+
+ it('every refused field is located in `issues`, and the message counts the rest', async () => {
+ const h = makeProtocol();
+ const { error } = await attempt(() => h.protocol.saveMetaItem({
+ type: 'object', name: 'probe_ticket', item: obj('probe_ticket', 'Ticket', 'x__c', 'y__c'), packageId: BOUND,
+ }));
+ expectEnvelope(error);
+ expect(error.issues.map((i: any) => i.path)).toEqual(['fields.x__c', 'fields.y__c']);
+ expect(error.message).toContain('(1 more field in `issues`.)');
+ expect(storedObjects(h)).toEqual([]);
+ });
+
+ it('CONTROL: the same `x__c` object saved into the `.custom` pair is admitted (its own object); a plain field into the plain package too; and a save into no package is not judged', async () => {
+ const h = makeProtocol();
+ await h.protocol.saveMetaItem({ type: 'object', name: 'probe_ticket', item: obj('probe_ticket', 'Ticket', 'x__c'), packageId: PAIR });
+ await h.protocol.saveMetaItem({ type: 'object', name: 'probe_plain', item: obj('probe_plain', 'Plain', 'region'), packageId: BOUND });
+ await h.protocol.saveMetaItem({ type: 'object', name: 'probe_env', item: obj('probe_env', 'Env', 'x__c') });
+ expect(storedObjects(h)).toEqual([
+ { row: 'probe_ticket', state: 'active', binding: PAIR, fields: ['title', 'x__c'] },
+ { row: 'probe_plain', state: 'active', binding: BOUND, fields: ['title', 'region'] },
+ { row: 'probe_env', state: 'active', binding: null, fields: ['title', 'x__c'] },
+ ]);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 2. The restore doors
+// ═══════════════════════════════════════════════════════════════════════════
+
+/** probe_ticket at v1, v2, v3 through the real door into `binding`; v2's stored history body then carries `x__c`. */
+async function threeVersionsWithFieldResidueAtV2(h: Harness, binding: string) {
+ for (const label of ['v1', 'v2', 'v3']) {
+ await h.protocol.saveMetaItem({ type: 'object', name: 'probe_ticket', item: obj('probe_ticket', label), packageId: binding });
+ }
+ residueField(h, { name: 'probe_ticket', version: 2 }, 'x__c');
+ h.saveDoor.mockClear();
+ return { historyBefore: h.historyRows.length, activeBefore: JSON.stringify(storedObjects(h)) };
+}
+
+describe('[ADR-0146 D10] the restore doors refuse a stored version carrying a `__c` field outside a `.custom` pair', () => {
+ it('rollbackMetaItem: the version is refused; the active row and the history are unchanged', async () => {
+ const h = makeProtocol();
+ const before = await threeVersionsWithFieldResidueAtV2(h, BOUND);
+
+ const { error } = await attempt(() => h.protocol.rollbackMetaItem({ type: 'object', name: 'probe_ticket', toVersion: 2 }));
+
+ expectReservedRefusal(error, 'x__c', 'probe_ticket', BOUND);
+ expect(JSON.stringify(storedObjects(h))).toBe(before.activeBefore);
+ expect(h.historyRows.length).toBe(before.historyBefore);
+ expect(h.saveDoor).not.toHaveBeenCalled();
+ });
+
+ it('revertCommit\'s restore limb reports it in failed[] with the envelope\'s code; nothing is written', async () => {
+ const h = makeProtocol();
+ const before = await threeVersionsWithFieldResidueAtV2(h, BOUND);
+ h.serveCommit(commitRow([{ type: 'object', name: 'probe_ticket', existedBefore: true, prevVersion: 2 }]));
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const res = await h.protocol.revertCommit({ commitId: 'c1' });
+
+ expect(res.revertedCount).toBe(0);
+ expect(res.failed).toEqual([expect.objectContaining({ type: 'object', name: 'probe_ticket', code: 'VALIDATION_ERROR' })]);
+ expect(res.failed[0].error).toContain('the `__c` suffix is reserved');
+ expect(JSON.stringify(storedObjects(h))).toBe(before.activeBefore);
+ });
+
+ it('CONTROL: the same `x__c` version restored into the `.custom` pair\'s row is admitted, and a clean version into the plain one', async () => {
+ const pairH = makeProtocol();
+ await threeVersionsWithFieldResidueAtV2(pairH, PAIR);
+ expect((await pairH.protocol.rollbackMetaItem({ type: 'object', name: 'probe_ticket', toVersion: 2 })).success).toBe(true);
+ expect(storedObjects(pairH)).toEqual([{ row: 'probe_ticket', state: 'active', binding: PAIR, fields: ['title', 'x__c'] }]);
+
+ const plainH = makeProtocol();
+ await threeVersionsWithFieldResidueAtV2(plainH, BOUND);
+ expect((await plainH.protocol.rollbackMetaItem({ type: 'object', name: 'probe_ticket', toVersion: 1 })).success).toBe(true);
+ expect(storedObjects(plainH)).toEqual([{ row: 'probe_ticket', state: 'active', binding: BOUND, fields: ['title'] }]);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 3. The draft promotion (publishMetaItem, and the batch that shares it)
+// ═══════════════════════════════════════════════════════════════════════════
+
+/** A clean draft of probe_ticket saved into `binding` (and probe_ok beside it), the draft's body then carrying `x__c`. */
+async function draftWithFieldResidue(h: Harness, binding: string) {
+ await h.protocol.saveMetaItem({ type: 'object', name: 'probe_ok', item: obj('probe_ok', 'Ok'), packageId: binding, mode: 'draft' });
+ await h.protocol.saveMetaItem({ type: 'object', name: 'probe_ticket', item: obj('probe_ticket', 'Ticket'), packageId: binding, mode: 'draft' });
+ residueField(h, { name: 'probe_ticket', state: 'draft' }, 'x__c');
+ h.saveDoor.mockClear();
+}
+
+describe('[ADR-0146 D10] the draft promotion refuses an object draft carrying a `__c` field outside a `.custom` pair', () => {
+ it('publishMetaItem: the draft is refused and kept; nothing goes live', async () => {
+ const h = makeProtocol();
+ await draftWithFieldResidue(h, BOUND);
+
+ const { error } = await attempt(() => h.protocol.publishMetaItem({ type: 'object', name: 'probe_ticket' }));
+
+ expectReservedRefusal(error, 'x__c', 'probe_ticket', BOUND);
+ expect(storedObjects(h).filter((r) => r.row === 'probe_ticket')).toEqual([
+ { row: 'probe_ticket', state: 'draft', binding: BOUND, fields: ['title', 'x__c'] },
+ ]);
+ expect(h.saveDoor).not.toHaveBeenCalled();
+ });
+
+ it('publishPackageDrafts: the refused draft aborts the batch, reported with the envelope\'s code; nothing goes live', async () => {
+ const h = makeProtocol();
+ await draftWithFieldResidue(h, BOUND);
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const res = await h.protocol.publishPackageDrafts({ packageId: BOUND });
+
+ expect(res.outcome).toBe('refused');
+ expect(res.publishedCount).toBe(0);
+ expect(res.failed.find((f: any) => f.name === 'probe_ticket')).toMatchObject({ type: 'object', code: 'VALIDATION_ERROR' });
+ expect(storedObjects(h).every((r) => r.state === 'draft')).toBe(true);
+ });
+
+ it('CONTROL: the same batch in the `.custom` pair publishes both, the `x__c` field included', async () => {
+ const h = makeProtocol();
+ await draftWithFieldResidue(h, PAIR);
+
+ const res = await h.protocol.publishPackageDrafts({ packageId: PAIR });
+
+ expect(res.outcome).toBe('published');
+ expect(res.publishedCount).toBe(2);
+ expect(storedObjects(h).filter((r) => r.state === 'active').map((r) => [r.row, r.fields])).toEqual([
+ ['probe_ok', ['title']],
+ ['probe_ticket', ['title', 'x__c']],
+ ]);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 4. duplicatePackage, judged against the target id
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('[ADR-0146 D10] duplicatePackage from a `.custom` pair into a plain id is refused whole', () => {
+ const SRC = 'app.dupsrc.custom';
+ const DST = 'app.dupdst';
+
+ it('an object carrying `__c` would land in the plain target: refused before anything is minted or copied', async () => {
+ const h = makeProtocol();
+ await h.protocol.saveMetaItem({ type: 'object', name: 'dupsrc_survey', item: obj('dupsrc_survey', 'Survey', 'score__c'), packageId: SRC });
+ await h.protocol.saveMetaItem({ type: 'object', name: 'dupsrc_note', item: obj('dupsrc_note', 'Note'), packageId: SRC });
+ h.saveDoor.mockClear();
+
+ const { error } = await attempt(() => h.protocol.duplicatePackage({ sourcePackageId: SRC, targetPackageId: DST }));
+
+ expectEnvelope(error);
+ expect(error.issues).toEqual([expect.objectContaining({ path: 'objects.dupsrc_survey.fields.score__c', code: 'custom' })]);
+ expect(error.message).toContain(`in package '${DST}': the \`__c\` suffix is reserved`);
+ expect(h.saveDoor).not.toHaveBeenCalled();
+ expect(storedObjects(h).filter((r) => r.binding === DST)).toEqual([]);
+ });
+
+ it('CONTROL: the same source without the `__c` field copies', async () => {
+ const h = makeProtocol();
+ await h.protocol.saveMetaItem({ type: 'object', name: 'dupsrc_note', item: obj('dupsrc_note', 'Note'), packageId: SRC });
+
+ const res = await h.protocol.duplicatePackage({ sourcePackageId: SRC, targetPackageId: DST });
+
+ expect(res.failed).toEqual([]);
+ expect(res.copiedCount).toBe(1);
+ expect(storedObjects(h).filter((r) => r.binding === DST)).toHaveLength(1);
+ });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// 5. installPackage — covered by its closed manifest
+// ═══════════════════════════════════════════════════════════════════════════
+
+describe('[ADR-0146 D10] installPackage cannot carry a field body: its closed manifest refuses one before any write', () => {
+ it.each([
+ ['an object body with `x__c`', { objects: [obj('probe_ticket', 'Ticket', 'x__c')] }],
+ ['an `objectExtensions` entry with `x__c`', { objectExtensions: [{ extend: 'base_contact', fields: { x__c: { type: 'text' } } }] }],
+ ])('%s is refused 400 by `PackageInstallManifestSchema`; the registry is never written', async (_label, extra) => {
+ const h = makeProtocol();
+ const installPackage = vi.fn();
+ h.engine.registry.installPackage = installPackage;
+
+ const { error } = await attempt(() => h.protocol.installPackage({
+ manifest: { id: 'com.acme.vendor', name: 'Vendor', type: 'app', version: '1.0.0', ...extra },
+ }));
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error.statusCode).toBe(400);
+ expect(error.message).toContain('Invalid package manifest');
+ expect(installPackage).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index b2b42b025ec..e980ae11c59 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -393,6 +393,12 @@ import {
refusePackagedBaseOverride,
} from './protocol-packaged-base-refusal.js';
import type { OverrideWriteSubject, OverrideWriteVerdict } from './protocol-packaged-base-refusal.js';
+// [ADR-0146 D10, #28564] The `__c` extension-field reservation's one judge.
+import {
+ extensionFieldSuffixRefusalError,
+ judgeExtensionFieldSuffix,
+ objectBodyExtensionFieldSuffixRefusal,
+} from './protocol-extension-field-suffix.js';
// [#25467 cut 14] The publish batch's seed apply and its own pending declarations.
import { applySeedBodies, collectBatchPendingDeclarations } from './protocol-publish-batch.js';
// [#25467 cut 15] The artifact registry's lookups: an item's artifact, the packages that ship a type, and the lock's artifact layer.
@@ -21617,6 +21623,18 @@ export class ObjectStackProtocolImplementation implements
);
if (ownerRefusal) throw ownerRefusal;
}
+ // [ADR-0146 D10, #28564] …and an object body's fields against the
+ // `__c` reservation, on the same binding and with the same
+ // envelope: `__c` only in a base's paired `.custom`. Asked of every
+ // writer, as the owner judge is. See
+ // {@link objectBodyExtensionFieldSuffixRefusal}.
+ {
+ const suffixRefusal = objectBodyExtensionFieldSuffixRefusal(
+ singularType, request.item, request.name,
+ await this.saveWriteBinding(singularType, request, mode),
+ );
+ if (suffixRefusal) throw suffixRefusal;
+ }
// [harden] …and a permission set whose NAME another owner already
// holds, with `NAMESPACE_CONFLICT` / 422: a set's name is its
// identity across the deployment, and a grant resolves it by that
@@ -23709,6 +23727,16 @@ export class ObjectStackProtocolImplementation implements
);
if (ownerRefusal) throw ownerRefusal;
}
+ // [ADR-0146 D10, #28564] …and an object draft's fields against the
+ // `__c` reservation, judged against the package promoted into, with the
+ // save door's judge and envelope, before `repo.promoteDraft` writes. See
+ // {@link objectBodyExtensionFieldSuffixRefusal}.
+ if (draftForGate) {
+ const suffixRefusal = objectBodyExtensionFieldSuffixRefusal(
+ singularType, draftForGate.body, request.name, promoteInto,
+ );
+ if (suffixRefusal) throw suffixRefusal;
+ }
// [harden] …and a permission-set draft whose name a DIFFERENT owner
// already holds by the time it is published: a draft save judged the
// holders of its moment, and another owner may have taken that name
@@ -26016,6 +26044,34 @@ export class ObjectStackProtocolImplementation implements
return { fields: Object.fromEntries(out) };
};
+ // [ADR-0146 D10, #28564] ⭐ THE `__c` RESERVATION, JUDGED AGAINST THE
+ // TARGET, before the copy's first mutation (the package record below):
+ // every object the copy would write is the target's own object, so a
+ // copy of a `.custom` pair's objects into a plain id carries their
+ // `__c` fields into a package that may not declare one. Refused whole,
+ // with the save door's judge and envelope (`VALIDATION_ERROR` / 400),
+ // so no half-copied app is left behind; a copy into a pair id writes
+ // the pair's own objects, which are not judged. An unparseable row is
+ // left to the copy loop, which reports it. See
+ // {@link judgeExtensionFieldSuffix}.
+ {
+ const copiedObjects: Record = {};
+ for (const row of rows) {
+ if (row?.type !== 'object' || typeof row.name !== 'string') continue;
+ let body: unknown;
+ try {
+ body = typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata;
+ } catch {
+ continue;
+ }
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
+ copiedObjects[row.name] = { ...(body as Record), name: renameMap.get(row.name) ?? row.name };
+ }
+ }
+ const suffixRefusals = judgeExtensionFieldSuffix({ packageId: request.targetPackageId, objects: copiedObjects });
+ if (suffixRefusals.length > 0) throw extensionFieldSuffixRefusalError(suffixRefusals);
+ }
+
if (srcPkg?.manifest && typeof registry?.installPackage === 'function') {
try {
// Route through installPackage (not a bare registry write) so the
diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json
index b86fa02b285..46293626b81 100644
--- a/scripts/engine-double-contract.pinned.json
+++ b/scripts/engine-double-contract.pinned.json
@@ -836,6 +836,21 @@
"verb": "findOne",
"pinned": 1
},
+ {
+ "file": "packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts",
+ "verb": "delete",
+ "pinned": 1
+ },
+ {
+ "file": "packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts",
+ "verb": "findOne",
+ "pinned": 1
+ },
+ {
+ "file": "packages/metadata-protocol/src/protocol.extension-field-suffix.test.ts",
+ "verb": "update",
+ "pinned": 1
+ },
{
"file": "packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts",
"verb": "delete",