Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/28564-extension-field-suffix.md
Original file line number Diff line number Diff line change
@@ -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 `<base>.custom` package; the measured count below found none.

<!-- adr-0087: not-required (no-migration-prescription) The narrowing refuses a field NAME spelling at the write doors: a field ending in `__c` outside a base's paired `.custom` package, and an `objectExtensions[].fields` key without the suffix inside one. No metadata key, accepted value of a key, export or error code is removed or renamed, and no stored shape changes. The remedy is a rename of a field, an authoring choice a person makes (which name a vendor or a customer field should carry); there is no mechanical conversion between two metadata versions for `objectstack migrate meta` to apply. Measured at `2678600d57`: 0 fields spelled `[a-z0-9]__c` in `examples/**`, `packages/*/src` and `skills/**` outside tests (7 textual hits, all in doc comments). The other categories are closed on facts: all three packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no `packages/spec/**` file is touched, so no schema, type or runtime interface moves (not type-surface-only, not runtime-interface-only); and no export is retired (not runtime-export-retired). -->

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 `<base>.custom`, every field spelled `__c`, on its own objects or in its `objectExtensions`: "the `__c` suffix is reserved to a base's paired `.custom` package (ADR-0146 D10); rename the field." A field one vendor package adds to another's object keeps the plain spelling.
- **(b) An unsuffixed extension field in a `.custom` pair.** Every `objectExtensions[].fields` key without the suffix: "name it `<name>__c`, or change a base field's display properties in the extension's patch (`fieldPatches`)."

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.
29 changes: 29 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>);
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
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/src/utils/extension-field-suffix.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
52 changes: 52 additions & 0 deletions packages/cli/src/utils/extension-field-suffix.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> =>
v !== null && typeof v === 'object' && !Array.isArray(v);

/** Every field of the parsed stack the reservation refuses. */
export function findExtensionFieldSuffixRefusals(parsed: Record<string, unknown>): 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,
})));
}
20 changes: 15 additions & 5 deletions packages/cli/test/validate-build-gate-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,23 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {
* 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<Record<string, string>> = {};
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
// [#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
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/test/validate-extension-field-suffix.test.ts
Original file line number Diff line number Diff line change
@@ -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<Run> {
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<Record<string, unknown>> };
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);
});
Loading
Loading