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",