diff --git a/.changeset/28410-override-write-gate.md b/.changeset/28410-override-write-gate.md
new file mode 100644
index 00000000000..9c95ba99fea
--- /dev/null
+++ b/.changeset/28410-override-write-gate.md
@@ -0,0 +1,38 @@
+---
+'@objectstack/metadata-protocol': minor
+'@objectstack/cli': minor
+'@objectstack/cloud-connection': minor
+'@objectstack/spec': patch
+---
+
+**BREAKING** feat(metadata-protocol)!: the override write gate — only a base's paired `.custom` package declares an override, and a dependent's item under a base item's name is refused (ADR-0146 D2, #28410)
+
+Clause-②: yes (narrowing)
+Metadata-protocol: narrowed
+Skills-docs: none — no file under `skills/**` teaches the `overrides` declaration, a `.custom` package or the sealed-write sentence; the `content/docs/references/**` pages that show `overrides` are generated from the spec and do not move.
+
+
+
+A dependent package used to be able to ship, publish or install an item under the name an item of a package in its dependency closure already has, and nothing said which one a reader would get. Ruling A on #28390 settles it: only the base's paired `.custom` package declares an override (`overrides: { package: '', basedOn }`), and a dependent's same-named item has one fix, rename.
+
+**What is refused**, `403 NOT_OVERRIDABLE` (the code and status the sealed write already answers), each with its own sentence:
+
+- **An undeclared same name inside the closure.** A dependent's item bearing the name of an item a read-only (installed) package in its dependency closure ships, on a type a declared override can replace. In `.custom` the sentence says to declare it or rename it; in any other dependent, to rename it.
+- **`overrides` outside the pair.** A declaration on a package that is not the base's paired `.custom`.
+- **A declaration the closure cannot back.** `overrides.package` outside the declaring package's dependency closure (the sentence says to declare the dependency), or a base that ships no item of that type and name.
+- **A type that takes no override.** An excluded type (permission, position, capability, the code-only types…), pointing to creating a new item under a new name; an `object`, pointing to `objectExtensions`.
+- **A channel not yet open.** A declared `flow` ("flow customization is not yet available") or `action`.
+
+**Where.** The save door (`saveMetaItem`, so `PUT /api/v1/meta/:type/:name` and every authoring path through it), the promotion (`publishMetaItem` and `publishPackageDrafts`), `os validate` (each package of the artifact against its closure inside the artifact; exit 1), and the marketplace managed install (`POST /api/v1/marketplace/install-local`, before any write). The undeclared-same-name refusal is asked last at the publish doors, so every refusal those doors already answered keeps its code and status.
+
+**Admitted.** The base's paired `.custom` declaring an override of an item its base ships, on an open type: the save and the promotion write it as the pair's own row, and the managed install registers it.
+
+**The install answer lists upgrade collisions.** An install of a base whose new version ships a name an installed dependent already holds, undeclared, answers `200` with `overrideCollisions` (`type`, `name`, `packageId`, `rule`, `message`), one row per held item; the key is omitted when there is none. The install is not refused: the dependent's item is the one to rename, or in `.custom`, to declare.
+
+**The sealed sentence.** A write over a sealed item of a type a declared override can replace now names the writer's route in place of none. Written from the base's paired `.custom`, or naming no package, it ends "To change it, declare an override from '.custom' (overrides: { package: '', basedOn }), or rename the item."; written from any other package, "To change it, rename the item: only '.custom' may declare an override of ''."
+
+**A malformed `overrides` is not a declaration.** A value the spec's `OverrideDeclarationSchema` refuses (null, not an object, no or an empty `package`, no `basedOn`) is not judged by the gate; the save door's schema gate answers it (`422 INVALID_METADATA`), and `os validate` reports it at its schema step.
+
+`@objectstack/metadata-protocol` exports the gate's rule, `judgeOverrideWrite`, and its item walk, `overrideWriteItemsOf`, so the three doors ask one judgement.
+
+`@objectstack/spec` records the two new emitters of `NOT_OVERRIDABLE` in its error-code provenance ledger (`api/error-code-ledger.zod.ts`, under `@objectstack/cli` and `@objectstack/cloud-connection`). The code was already registered, so the union, casing and every schema are unchanged.
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
index 9acd638b311..75bd3b08a0c 100644
--- a/packages/cli/src/commands/validate.ts
+++ b/packages/cli/src/commands/validate.ts
@@ -74,6 +74,9 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s
// over the parsed stack the way the load path registers it. The verdict is
// `@objectstack/objectql`'s; see the module header.
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';
// 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.
@@ -658,6 +661,35 @@ export default class Validate extends Command {
}
}
+ // 3a-iii. [ADR-0146 D2, #28410] The override write gate, at author time.
+ // Only the base's paired `.custom` declares an override; a
+ // dependent's item bearing the name a package in its closure ships is
+ // refused (rename), and an `overrides` declaration the gate does not
+ // admit is refused. The verdict is the publish door's own
+ // (`judgeOverrideWrite`), so a green validate is a publish the door
+ // accepts; see `utils/override-declarations.ts` for the walk.
+ const overrideRefusals = findOverrideDeclarationRefusals(result.data as Record);
+ if (overrideRefusals.length > 0) {
+ if (flags.json) {
+ await emitJson({
+ valid: false,
+ errors: overrideRefusals,
+ warnings: warningsSoFar(),
+ conversions: conversionNotices,
+ duration: timer.elapsed(),
+ });
+ this.exit(1);
+ }
+ const n = overrideRefusals.length;
+ console.log('');
+ printError(`The publish door would refuse ${n} item${n > 1 ? 's' : ''} under the override write gate (ADR-0146 D2)`);
+ printBulletList(
+ overrideRefusals.map((r) => `${r.path} [${r.code}] ${r.message}`),
+ { noun: 'override 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/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts
new file mode 100644
index 00000000000..f40b9f49338
--- /dev/null
+++ b/packages/cli/src/utils/override-declarations.test.ts
@@ -0,0 +1,134 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D2, #28410] `findOverrideDeclarationRefusals` — the WALK `os
+ * validate` runs the override write gate's judge over.
+ *
+ * The verdict is `@objectstack/metadata-protocol`'s `judgeOverrideWrite`, the
+ * function the publish doors ask; the protocol's own file pins its sentences.
+ * What is pinned here is which items the walk hands the judge, under which
+ * package and against which closure: every row's message is asserted EQUAL to
+ * the judge's answer for the same subject, never re-spelled.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { judgeOverrideWrite } from '@objectstack/metadata-protocol';
+import { findOverrideDeclarationRefusals } from './override-declarations.js';
+
+const BASE = 'com.acme.crm';
+const PAIR = 'com.acme.crm.custom';
+const PARTNER = 'com.acme.partner';
+
+const manifest = (id: string, dependencies: string[] = []) => ({
+ id, name: id, version: '1.0.0', type: 'app',
+ ...(dependencies.length ? { dependencies: Object.fromEntries(dependencies.map((d) => [d, '*'])) } : {}),
+});
+const pkg = (id: string, dependencies: string[], collections: Record) =>
+ ({ manifest: { ...manifest(id, dependencies), ...collections } });
+
+const base = pkg(BASE, [], {
+ dashboards: [{ name: 'crm_board', label: 'Board' }],
+ pages: [{ name: 'crm_home', label: 'Home' }],
+ actions: [{ name: 'log_call', objectName: 'crm_account', label: 'Log' }],
+});
+
+const judged = (type: string, name: string, packageId: string, item: unknown, dependencies: string[], shippers: string[]) => {
+ const v = judgeOverrideWrite({ type, name, packageId, item }, { dependencies, shippers });
+ if (v.verdict !== 'refuse') throw new Error(`expected a refusal, got ${v.verdict}`);
+ return v.message;
+};
+
+describe('#28410 — findOverrideDeclarationRefusals walks the artifact under the publish door\'s judge', () => {
+ it('a partner\'s item bearing a name the base in its closure ships is refused, in the judge\'s words', () => {
+ const item = { name: 'crm_board', label: 'Mine' };
+ const rows = findOverrideDeclarationRefusals({
+ manifest: manifest('com.acme.bundle'),
+ packages: [base, pkg(PARTNER, [BASE], { dashboards: [item] })],
+ });
+ expect(rows).toEqual([{
+ path: 'packages[1].manifest.dashboards[0]',
+ code: 'NOT_OVERRIDABLE',
+ rule: 'undeclared_same_name',
+ packageId: PARTNER,
+ message: judged('dashboard', 'crm_board', PARTNER, item, [BASE], [BASE]),
+ }]);
+ });
+
+ it('the closure is transitive through the artifact\'s packages', () => {
+ const rows = findOverrideDeclarationRefusals({
+ manifest: manifest('com.acme.bundle'),
+ packages: [base, pkg('com.acme.mid', [BASE], {}), pkg(PARTNER, ['com.acme.mid'], { pages: [{ name: 'crm_home' }] })],
+ });
+ expect(rows.map((r) => [r.packageId, r.rule])).toEqual([[PARTNER, 'undeclared_same_name']]);
+ });
+
+ it('controls: a package outside the closure, an own name, and an action on another object are not refused', () => {
+ expect(findOverrideDeclarationRefusals({
+ manifest: manifest('com.acme.bundle'),
+ packages: [
+ base,
+ pkg('com.other', [], { dashboards: [{ name: 'crm_board' }] }),
+ pkg(PARTNER, [BASE], {
+ dashboards: [{ name: 'partner_board' }],
+ actions: [{ name: 'log_call', objectName: 'partner_lead', label: 'Log' }],
+ }),
+ ],
+ })).toEqual([]);
+ });
+
+ it('the pair\'s declared override is admitted; its undeclared same name is told to declare or rename', () => {
+ const declared = { name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } };
+ const undeclared = { name: 'crm_home' };
+ const rows = findOverrideDeclarationRefusals({
+ manifest: manifest('com.acme.bundle'),
+ packages: [base, pkg(PAIR, [BASE], { dashboards: [declared], pages: [undeclared] })],
+ });
+ expect(rows).toEqual([{
+ path: 'packages[1].manifest.pages[0]',
+ code: 'NOT_OVERRIDABLE',
+ rule: 'undeclared_same_name',
+ packageId: PAIR,
+ message: judged('page', 'crm_home', PAIR, undeclared, [BASE], [BASE]),
+ }]);
+ expect(rows[0].message).toMatch(/Declare it/);
+ });
+
+ it('a one-package .custom stack whose base is installed elsewhere: declarations are judged, the shipped name is left to the door', () => {
+ const rows = findOverrideDeclarationRefusals({
+ manifest: manifest(PAIR, [BASE]),
+ dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ flows: [{ name: 'crm_flow', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ pages: [{ name: 'crm_home', overrides: { package: 'com.unlisted', basedOn: '1.0.0' } }],
+ });
+ expect(rows.map((r) => [r.path, r.rule])).toEqual([
+ ['objects[0]', 'declared_object'],
+ ['flows[0]', 'declared_type_not_yet_available'],
+ ['pages[0]', 'declared_outside_pair'],
+ ]);
+ });
+
+ it('a dependent carrying `overrides` is refused, and a declaration of an item the base does not ship is refused', () => {
+ const rows = findOverrideDeclarationRefusals({
+ manifest: manifest('com.acme.bundle'),
+ packages: [
+ base,
+ pkg(PARTNER, [BASE], { dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }] }),
+ pkg(PAIR, [BASE], { dashboards: [{ name: 'no_such_board', overrides: { package: BASE, basedOn: '1.0.0' } }] }),
+ ],
+ });
+ expect(rows.map((r) => [r.packageId, r.rule])).toEqual([
+ [PARTNER, 'declared_outside_pair'],
+ [PAIR, 'declared_package_ships_no_such_item'],
+ ]);
+ });
+
+ it('a pair that does not depend on its base is told to declare the dependency', () => {
+ const rows = findOverrideDeclarationRefusals({
+ manifest: manifest('com.acme.bundle'),
+ packages: [base, pkg(PAIR, [], { dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }] })],
+ });
+ expect(rows.map((r) => r.rule)).toEqual(['declared_package_outside_closure']);
+ expect(rows[0].message).toMatch(/Declare a dependency on 'com\.acme\.crm'/);
+ });
+});
diff --git a/packages/cli/src/utils/override-declarations.ts b/packages/cli/src/utils/override-declarations.ts
new file mode 100644
index 00000000000..953aa2a68f4
--- /dev/null
+++ b/packages/cli/src/utils/override-declarations.ts
@@ -0,0 +1,134 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os validate`'s half of the override write gate (ADR-0146 D2, ruling A on
+ * #28390; card #28410).
+ *
+ * ## One judge, not a second rule
+ *
+ * The verdict is `@objectstack/metadata-protocol`'s `judgeOverrideWrite`, the
+ * SAME function the publish doors (`saveMetaItem`, `publishMetaItem`,
+ * `publishPackageDrafts`) and the marketplace managed install ask. ⛔ Not an
+ * `@objectstack/lint` rule and not a re-spelling of it here: a twin agrees
+ * with the doors only until one of the two is edited. What this module owns
+ * is the WALK — which items an artifact carries, under which package, and
+ * which packages of the artifact's closure ship the same name — and nothing
+ * about the verdict.
+ *
+ * ## The walk
+ *
+ * Each package of the artifact (`packages[]`, ADR-0130 D4), or the stack
+ * itself when it carries none, is judged against its dependency closure:
+ * the manifest's `dependencies`, followed transitively through the packages
+ * this same artifact carries.
+ *
+ * - A same-named item is found only where both packages are in this
+ * artifact: a base the artifact does not carry is not visible here, and
+ * the publish door judges that write against the installed base.
+ * - A DECLARED item (`overrides: { package, basedOn }`) is judged whole —
+ * the pair predicate, the closure, the type — wherever its base lives.
+ * When the base is in the closure but not in this artifact, whether it
+ * ships the name is the one fact this door cannot read, so it is assumed
+ * and the publish door answers it.
+ *
+ * Items are named the way the registry names them (`overrideWriteItemsOf`,
+ * the walk the marketplace install door asks too): an `action` by
+ * `:`, a `views:` container by the views it expands into.
+ */
+
+import { judgeOverrideWrite, overrideWriteItemsOf, type OverrideWriteItem } from '@objectstack/metadata-protocol';
+import { OverrideDeclarationSchema } from '@objectstack/spec/kernel';
+
+import { artifactPackages } from './artifact-packages.js';
+
+/** One refusal, located. `message` is the publish door's, verbatim. */
+export interface OverrideDeclarationRefusalRow {
+ /** Where the item sits in the parsed stack, e.g. `packages[1].manifest.views[0]`. */
+ path: string;
+ /** The code the publish door refuses the same write with. */
+ code: 'NOT_OVERRIDABLE';
+ /** Which of the gate's refusals this is (`judgeOverrideWrite`'s `rule`). */
+ rule: string;
+ /** The package the item is written into. */
+ packageId: string;
+ message: string;
+}
+
+interface WalkedPackage {
+ id: string;
+ dependencies: string[];
+ items: OverrideWriteItem[];
+}
+
+const isRecord = (v: unknown): v is Record =>
+ v !== null && typeof v === 'object' && !Array.isArray(v);
+
+const dependencyIds = (manifest: Record | undefined): string[] => {
+ const declared = manifest?.dependencies;
+ if (Array.isArray(declared)) return declared.filter((d): d is string => typeof d === 'string');
+ return isRecord(declared) ? Object.keys(declared) : [];
+};
+
+/** The artifact's packages: `packages[]`, or the stack itself as one package. */
+function packagesOf(parsed: Record): WalkedPackage[] {
+ const entries = artifactPackages(parsed);
+ if (entries.length > 0) {
+ return entries.map(({ index, id, body }) => ({
+ id,
+ dependencies: dependencyIds(body),
+ items: overrideWriteItemsOf(body, `packages[${index}].manifest.`),
+ }));
+ }
+ const manifest = isRecord(parsed.manifest) ? parsed.manifest : undefined;
+ const id = typeof manifest?.id === 'string' && manifest.id !== '' ? manifest.id : undefined;
+ if (id === undefined) return [];
+ return [{ id, dependencies: dependencyIds(manifest), items: overrideWriteItemsOf(parsed, '') }];
+}
+
+/** `id`'s dependency closure, followed through the packages this artifact carries. */
+function closureOf(id: string, byId: ReadonlyMap): string[] {
+ const seen = new Set();
+ const queue = [...(byId.get(id)?.dependencies ?? [])];
+ while (queue.length > 0) {
+ const next = queue.shift()!;
+ if (next === id || seen.has(next)) continue;
+ seen.add(next);
+ queue.push(...(byId.get(next)?.dependencies ?? []));
+ }
+ return [...seen];
+}
+
+/**
+ * Every item of the parsed stack the publish door would refuse under the
+ * override write gate — an undeclared same name inside the closure, or an
+ * `overrides` declaration the gate does not admit.
+ */
+export function findOverrideDeclarationRefusals(parsed: Record): OverrideDeclarationRefusalRow[] {
+ const packages = packagesOf(parsed);
+ const byId = new Map(packages.map((p) => [p.id, p]));
+ const shipped = new Map(packages.map((p) => [p.id, new Set(p.items.map((i) => `${i.type}\u0000${i.identity}`))]));
+ const rows: OverrideDeclarationRefusalRow[] = [];
+ for (const pkg of packages) {
+ const dependencies = closureOf(pkg.id, byId);
+ for (const walked of pkg.items) {
+ const key = `${walked.type}\u0000${walked.identity}`;
+ const shippers = dependencies.filter((d) => shipped.get(d)?.has(key));
+ // A malformed `overrides` is no declaration (the schema step reports
+ // it), so it names no base here either, as the gate reads it.
+ const declared = isRecord(walked.item) ? OverrideDeclarationSchema.safeParse(walked.item.overrides) : undefined;
+ const base = declared?.success ? declared.data.package : undefined;
+ // A declared base the closure names but this artifact does not carry:
+ // whether it ships the name is read at the publish door.
+ if (base !== undefined && dependencies.includes(base) && !byId.has(base) && !shippers.includes(base)) {
+ shippers.push(base);
+ }
+ const verdict = judgeOverrideWrite(
+ { type: walked.type, name: walked.name, packageId: pkg.id, item: walked.item },
+ { dependencies, shippers },
+ );
+ if (verdict.verdict !== 'refuse') continue;
+ rows.push({ path: walked.path, code: 'NOT_OVERRIDABLE', rule: verdict.rule, packageId: pkg.id, message: verdict.message });
+ }
+ }
+ return rows;
+}
diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts
index c4808ea6452..076046367ff 100644
--- a/packages/cli/test/validate-build-gate-parity.test.ts
+++ b/packages/cli/test/validate-build-gate-parity.test.ts
@@ -226,7 +226,17 @@ const BUILD_ONLY_GATES: Readonly> = {
* ledger stays, empty, because it is the only honest place the closed roster
* has for the next validate-only gate.
*/
-const VALIDATE_ONLY_GATES: Readonly> = {};
+const VALIDATE_ONLY_GATES: Readonly> = {
+ // [#28410] A real gap in the build's direction, reported and not closed:
+ // card #28410 (ADR-0146 D2, ruling A on #28390) named three gate points for
+ // the override write gate — the publish doors, `os validate` and the
+ // install door — and `os build` is not one of them. The artifact a build
+ // emits is still judged where it lands: the marketplace managed install
+ // and the publish doors refuse the same items with the same judge. Whether
+ // the build refuses too is a separate decision, raised on the card.
+ findOverrideDeclarationRefusals:
+ 'card #28410 placed the override write gate at publish, os validate and install; os build is a fourth point it did not name (open question on #28410). The install and publish doors refuse the same items 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-override-declarations.test.ts b/packages/cli/test/validate-override-declarations.test.ts
new file mode 100644
index 00000000000..7d75aa71f26
--- /dev/null
+++ b/packages/cli/test/validate-override-declarations.test.ts
@@ -0,0 +1,99 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D2, #28410] `os validate` asks the override write gate — the
+ * publish door's own judge (`judgeOverrideWrite`), over the walk
+ * `utils/override-declarations.ts` takes, whose own file pins which items it
+ * judges. What is pinned here is the command: a stack whose `overrides`
+ * declaration the gate does not admit exits 1, the refusal on `errors` with
+ * the door's code; the base's paired `.custom` declaring the same override
+ * 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');
+
+const stack = (id: string) => `
+import { defineStack } from '@objectstack/spec';
+
+export default defineStack({
+ manifest: {
+ id: '${id}', name: 'ovr', version: '1.0.0', type: 'app', namespace: 'ovr',
+ dependencies: { 'com.acme.crm': '^1.0.0' },
+ },
+ dashboards: [
+ { name: 'crm_board', label: 'Board', widgets: [], overrides: { package: 'com.acme.crm', basedOn: '1.0.0' } },
+ ],
+}, { strict: false });
+`;
+
+interface Run {
+ code: number;
+ stdout: string;
+ stderr: string;
+}
+
+function runValidate(cwd: string): Promise {
+ return new Promise((resolvePromise) => {
+ execFile(
+ TSX,
+ [CLI, 'validate', '--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),
+ });
+ },
+ );
+ });
+}
+
+let partnerDir: string;
+let pairDir: string;
+
+beforeAll(() => {
+ partnerDir = mkdtempSync(join(tmpdir(), 'os-validate-override-partner-'));
+ writeFileSync(join(partnerDir, 'objectstack.config.ts'), stack('com.acme.partner'));
+ linkSpec(partnerDir);
+ pairDir = mkdtempSync(join(tmpdir(), 'os-validate-override-pair-'));
+ writeFileSync(join(pairDir, 'objectstack.config.ts'), stack('com.acme.crm.custom'));
+ linkSpec(pairDir);
+});
+
+afterAll(() => {
+ rmSync(partnerDir, { recursive: true, force: true });
+ rmSync(pairDir, { recursive: true, force: true });
+});
+
+describe('#28410 — `os validate` refuses an override declaration the publish door refuses', () => {
+ it('a dependent carrying `overrides` exits 1, with the gate\'s refusal on `errors`', async () => {
+ const run = await runValidate(partnerDir);
+ 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([
+ ['dashboards[0]', 'NOT_OVERRIDABLE', 'declared_outside_pair', 'com.acme.partner'],
+ ]);
+ expect(String(payload.errors[0].message)).toContain("only 'com.acme.crm.custom' may declare an override of 'com.acme.crm'");
+ }, 120_000);
+
+ it('CONTROL — the base\'s paired .custom declaring the same override validates clean', async () => {
+ const run = await runValidate(pairDir);
+ expect(run.code, run.stdout + run.stderr).toBe(0);
+ const payload = JSON.parse(run.stdout) as { valid: boolean; errors?: unknown[] };
+ expect(payload.valid).toBe(true);
+ }, 120_000);
+});
diff --git a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts
new file mode 100644
index 00000000000..8be40f717c5
--- /dev/null
+++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts
@@ -0,0 +1,217 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D2, ruling A on #28390; #28410] Step 2d of `POST
+ * /api/v1/marketplace/install-local` — the override write gate at the managed
+ * install, the door a package's items enter through as code, past every
+ * publish door.
+ *
+ * The verdict is `@objectstack/metadata-protocol`'s `judgeOverrideWrite`, the
+ * publish doors' own; its sentences are pinned in that package. What is
+ * pinned here is the door:
+ *
+ * - an incoming item the gate refuses stops the install `403
+ * NOT_OVERRIDABLE`, one entry per item in `details.refusals`, before any
+ * write — nothing registered, no ledger entry;
+ * - the base's paired `.custom` installs its admitted declarations;
+ * - an install of a base lists, on its answer (`overrideCollisions`), the
+ * names installed dependents already hold, undeclared, that it now ships —
+ * and omits the key when there are none.
+ */
+
+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 { judgeOverrideWrite } 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-override-gate-')); });
+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,
+) {
+ 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 }));
+ const registered = k.registered.map((r) => r.manifest.id).filter((id) => id === manifest.id);
+ return { res, registered, ledger: readdirSync(dir) };
+}
+
+const refusedOf = (res: any) => (res.payload?.error?.details?.refusals ?? []).map((r: any) => [r.type, r.name, r.rule]);
+
+describe('install-local 2d — the override write gate at the managed install (ADR-0146 D2)', () => {
+ it('a dependent shipping an item under the name its base ships is refused 403, and nothing is written', async () => {
+ const item = { name: 'crm_board', label: 'Mine' };
+ const { res, registered, ledger } = await install({
+ id: PARTNER, version: '1.0.0', dependencies: deps(BASE), dashboards: [item],
+ });
+ expect(res.status, JSON.stringify(res.payload)).toBe(403);
+ expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE');
+ const verdict = judgeOverrideWrite(
+ { type: 'dashboard', name: 'crm_board', packageId: PARTNER, item },
+ { dependencies: [BASE], shippers: [BASE] },
+ );
+ expect(res.payload.error.details.refusals).toEqual([
+ { type: 'dashboard', name: 'crm_board', rule: 'undeclared_same_name', message: (verdict as any).message },
+ ]);
+ expect(res.payload.error.message).toContain((verdict as any).message);
+ expect(registered).toEqual([]);
+ expect(ledger).toEqual([]);
+ });
+
+ it('a dependent carrying `overrides` is refused: only the base\'s paired .custom declares', async () => {
+ const { res, registered } = await install({
+ id: PARTNER, version: '1.0.0', dependencies: deps(BASE),
+ dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ });
+ expect(res.status).toBe(403);
+ expect(refusedOf(res)).toEqual([['dashboard', 'crm_board', 'declared_outside_pair']]);
+ expect(registered).toEqual([]);
+ });
+
+ it('the paired .custom declaring an object override is refused (objects are extended)', async () => {
+ const { res } = await install({
+ id: PAIR, version: '1.0.0', dependencies: deps(BASE),
+ objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' }, fields: {} }],
+ });
+ expect(res.status).toBe(403);
+ expect(refusedOf(res)).toEqual([['object', 'crm_account', 'declared_object']]);
+ });
+
+ it('the paired .custom installs its declared override of an item the base ships', async () => {
+ const { res, registered } = await install({
+ id: PAIR, version: '1.0.0', dependencies: deps(BASE),
+ dashboards: [{ name: 'crm_board', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ });
+ expect(res.status, JSON.stringify(res.payload)).toBe(200);
+ expect(registered).toEqual([PAIR]);
+ expect(res.payload.data).not.toHaveProperty('overrideCollisions');
+ });
+
+ it('CONTROL: a dependent shipping its own names installs, and its answer carries no collision key', async () => {
+ const { res, registered } = await install({
+ id: PARTNER, version: '1.0.0', dependencies: deps(BASE), dashboards: [{ name: 'partner_board' }],
+ });
+ expect(res.status, JSON.stringify(res.payload)).toBe(200);
+ expect(registered).toEqual([PARTNER]);
+ expect(res.payload.data).not.toHaveProperty('overrideCollisions');
+ });
+
+ it('an install of a base lists the names installed dependents already hold undeclared, and never a declared override', async () => {
+ const partnerItem = { name: 'crm_new', label: 'Partner new' };
+ const { res, registered } = await install(
+ {
+ id: BASE, version: '2.0.0',
+ dashboards: [{ name: 'crm_board' }, { name: 'crm_new' }],
+ pages: [{ name: 'crm_home' }],
+ },
+ [{ id: PARTNER, dependencies: deps(BASE) }, { id: PAIR, dependencies: deps(BASE) }],
+ {
+ [PARTNER]: { dashboard: [partnerItem] },
+ [PAIR]: {
+ dashboard: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ page: [{ name: 'crm_home' }],
+ },
+ },
+ );
+ expect(res.status, JSON.stringify(res.payload)).toBe(200);
+ expect(registered).toEqual([BASE]);
+ const collisions = res.payload.data.overrideCollisions;
+ expect(collisions.map((c: any) => [c.packageId, c.type, c.name, c.rule])).toEqual([
+ [PARTNER, 'dashboard', 'crm_new', 'undeclared_same_name'],
+ [PAIR, 'page', 'crm_home', 'undeclared_same_name'],
+ ]);
+ expect(collisions[1].message).toMatch(/Declare it/);
+ });
+});
diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
index 45d260fb57a..a31ad81e098 100644
--- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
@@ -238,6 +238,9 @@ import type {
// before any write), the read-only-package rule (a namespace held by a WRITABLE base is
// this environment's own metadata), and the client-facing sentence rule.
import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol';
+// [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';
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
@@ -516,6 +519,71 @@ type NamespaceHoldingEngine = PackageWritabilityEngine & {
};
};
+/**
+ * [ADR-0146 D2, #28410] What the managed install's override gate reads: the
+ * registry's package records (each package's declared `dependencies`) and the
+ * protocol's per-package item listing (`getMetaItems`, code-registered and
+ * authored items alike — a `.custom` an administrator built in Studio
+ * holds its items in the store, not the registry).
+ */
+type OverrideGateEngine = PackageWritabilityEngine & {
+ registry?: PackageWritabilityEngine['registry'] & {
+ getAllPackages?(): unknown;
+ };
+};
+type OverrideGateLister = {
+ getMetaItems?(request: { type: string; packageId?: string }): Promise | unknown;
+};
+
+/** One item the managed install refuses under the override write gate. */
+export interface OverrideInstallRefusal {
+ type: string;
+ name: string;
+ /** The delivered package the item belongs to — on a multi-package artifact only. */
+ packageId?: string;
+ rule: OverrideWriteRule;
+ message: string;
+}
+
+/**
+ * One name an installed dependent already holds that this (re)install of its
+ * base now ships, undeclared — listed on the answer, never refused: a base
+ * upgrade is the vendor's, and the dependent's item is the one to rename (or,
+ * in the base's paired `.custom`, to declare).
+ */
+export interface OverrideUpgradeCollision {
+ type: string;
+ name: string;
+ /** The installed package holding the name. */
+ packageId: string;
+ rule: OverrideWriteRule;
+ message: string;
+}
+
+function dependencyIdsOf(manifest: unknown): string[] {
+ const declared = (manifest as { dependencies?: unknown } | null | undefined)?.dependencies;
+ if (Array.isArray(declared)) return declared.filter((d): d is string => typeof d === 'string' && d !== '');
+ return declared !== null && typeof declared === 'object' ? Object.keys(declared) : [];
+}
+
+/** A listing answer as an item array: `getMetaItems` answers `{ items }`, a double may answer the array. */
+function listedItems(answer: unknown): Record[] {
+ const items = Array.isArray(answer) ? answer : (answer as { items?: unknown } | null | undefined)?.items;
+ return Array.isArray(items)
+ ? items.filter((i): i is Record => i !== null && typeof i === 'object' && !Array.isArray(i))
+ : [];
+}
+
+/** An item's registry identity — `overrideWriteItemsOf`'s: an action is `:`. */
+function listedIdentity(type: string, item: Record): string | undefined {
+ const name = typeof item.name === 'string' && item.name !== '' ? item.name : undefined;
+ if (name === undefined) return undefined;
+ if (type === 'action' && typeof item.objectName === 'string' && item.objectName !== '' && !name.includes(':')) {
+ return `${item.objectName}:${name}`;
+ }
+ return name;
+}
+
/** A `{ … }` object — not an array, not null. */
function isPlainObject(value: unknown): value is Record {
return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -1935,6 +2003,33 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
accessUpgradeConfirmation = access.confirmation;
}
+ // 2d. [ADR-0146 D2, #28410] ⭐ THE OVERRIDE WRITE GATE. A managed
+ // install registers the package's items as code, past every publish
+ // door, so the gate those doors ask is asked here, on the converted
+ // body, before any write: only the base's paired `.custom`
+ // declares an override, a dependent's item bearing a name its
+ // closure ships is refused (rename), and a declaration the gate does
+ // not admit is refused — `403 NOT_OVERRIDABLE`, the publish doors'
+ // code and status, one sentence per item in `details`. On a base
+ // (re)install, the names installed dependents already hold that
+ // this version now ships are listed on the answer
+ // (`overrideCollisions`), never refused.
+ const overrideGate = await this.evaluateOverrideWriteGate(ctx, manifestId, converted);
+ if (overrideGate.refusals.length > 0) {
+ const n = overrideGate.refusals.length;
+ return c.json({
+ success: false,
+ error: {
+ code: 'NOT_OVERRIDABLE',
+ message: `Package '${manifestId}' cannot be installed: ${n} of its item${n > 1 ? 's are' : ' is'} refused by `
+ + `the override write gate (ADR-0146 D2). ${overrideGate.refusals[0].message}`
+ + (n > 1 ? ` (${n - 1} more in \`details.refusals\`.)` : '')
+ + ' Nothing was installed or written.',
+ details: { refusals: overrideGate.refusals },
+ },
+ }, 403);
+ }
+
// 3. Hot-register FIRST so a malformed inline manifest fails the
// install loudly rather than persisting a broken record that
// would also fail on every subsequent rehydrate.
@@ -2113,6 +2208,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
...(datasources !== undefined ? { datasources } : {}),
...(unsetRefs.length > 0 ? { credentialNote: installedCredentialNote(unsetRefs, { door }) } : {}),
upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null,
+ // [ADR-0146 D2, #28410] Names installed dependents already held,
+ // undeclared, that this version now ships (step 2d). Omitted when
+ // there are none, so that answer's key set is unchanged.
+ ...(overrideGate.collisions.length > 0 ? { overrideCollisions: overrideGate.collisions } : {}),
translationsLoaded: seededSummary.translationsLoaded,
seeded: seededSummary.seeded,
// #6721: the RESOLVED ledger directory on THIS host — the same
@@ -3071,6 +3170,148 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
return null;
};
+ /**
+ * [ADR-0146 D2, ruling A on #28390; #28410] THE OVERRIDE WRITE GATE at the
+ * managed install — the door a package's items enter through as code
+ * (`manifest.register`), so the publish doors never see them. The rule is
+ * the publish doors' own (`judgeOverrideWrite`), over the walk `os
+ * validate` takes (`overrideWriteItemsOf`):
+ *
+ * - `refusals` — the incoming package's items the gate refuses: an
+ * undeclared same name a read-only package in its dependency closure
+ * ships, or an `overrides` declaration the gate does not admit (a
+ * dependent declaring, a base outside the closure, an excluded type,
+ * an object, a flow or action while their channel waits). A `.custom`
+ * package's admitted declarations install as at publish.
+ * - `collisions` — on a (re)install of a base, the names an installed
+ * dependent (a package whose closure holds this one) already holds,
+ * undeclared, that this version now ships. Listed on the answer, never
+ * refused (see {@link OverrideUpgradeCollision}).
+ *
+ * The card placed this gate point at `installPackage`; that door takes a
+ * manifest and no items (`PackageInstallManifestSchema`), and the
+ * unmanaged install copies through the save door (already gated), so the
+ * managed install is where a package's items arrive unjudged.
+ *
+ * Reads nothing it cannot: with no `objectql` registry or no protocol
+ * listing, the gate has no closure to judge and answers empty.
+ */
+ private evaluateOverrideWriteGate = async (
+ ctx: PluginContext,
+ manifestId: string,
+ converted: Record,
+ ): Promise<{ refusals: OverrideInstallRefusal[]; collisions: OverrideUpgradeCollision[] }> => {
+ const none = { refusals: [], collisions: [] };
+ let ql: OverrideGateEngine | undefined;
+ let lister: OverrideGateLister | undefined;
+ try { ql = ctx.getService('objectql'); } catch { return none; }
+ try { lister = ctx.getService('protocol'); } catch { return none; }
+ const registry = ql?.registry;
+ if (!registry || typeof lister?.getMetaItems !== 'function') return none;
+ const getMetaItems = lister.getMetaItems.bind(lister);
+ // The packages this install delivers — each `packages[]` entry of a
+ // multi-package artifact (ADR-0130 D4), or the body itself — judged
+ // each under its own id. A delivered package is in no registry yet: its
+ // dependencies and its items are read off the body, and it registers
+ // read-only, so it is a base a sibling's closure can hold.
+ const delivered = new Map();
+ const entries: Record[] = Array.isArray(converted.packages)
+ ? converted.packages
+ .map((entry: unknown) => (isPlainObject(entry) && isPlainObject(entry.manifest) ? entry.manifest : undefined))
+ .filter((m: unknown): m is Record => m !== undefined)
+ : [flattenPackageBody(converted)];
+ for (const body of entries) {
+ const id = entries.length === 1 && !Array.isArray(converted.packages) ? manifestId : manifestIdOf(body);
+ if (typeof id !== 'string' || id === '') continue;
+ delivered.set(id, { dependencies: dependencyIdsOf(declaredPackageManifest(body)), items: overrideWriteItemsOf(body) });
+ }
+ const dependenciesOf = (id: string): string[] => delivered.get(id)?.dependencies
+ ?? dependencyIdsOf((registry.getPackage?.(id) as { manifest?: unknown } | undefined)?.manifest);
+ const closureOf = (id: string): string[] => {
+ const seen = new Set();
+ const queue = [...dependenciesOf(id)];
+ while (queue.length > 0) {
+ const next = queue.shift()!;
+ if (next === id || seen.has(next)) continue;
+ seen.add(next);
+ queue.push(...dependenciesOf(next));
+ }
+ return [...seen];
+ };
+ // One listing per (type, package), read once; a delivered package's
+ // own items are read off its body.
+ const held = new Map>>();
+ const heldBy = (type: string, packageId: string): Promise