diff --git a/.changeset/28643-build-override-write-gate.md b/.changeset/28643-build-override-write-gate.md
new file mode 100644
index 00000000000..b0b733a0437
--- /dev/null
+++ b/.changeset/28643-build-override-write-gate.md
@@ -0,0 +1,12 @@
+---
+'@objectstack/cli': patch
+---
+
+fix(cli): `os build` runs the override write gate `os validate` runs (ADR-0146 D2, #28643)
+
+Clause-②: no (narrowing)
+Metadata-protocol: none
+
+`os build` now calls `findOverrideDeclarationRefusals` beside the dependency-range check, the same walk over the publish door's own judge (`judgeOverrideWrite`) that `os validate`, the publish doors and the managed install ask. A stack carrying an `overrides` declaration the gate does not admit, or a dependent's item bearing a name a package in its dependency closure ships, is refused at build with the rows `os validate` prints (`NOT_OVERRIDABLE`, exit 1), and no artifact is written. Before, the build emitted that artifact and the managed install or a publish door refused it later with the same judge; what any door accepts is unchanged.
+
+
diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts
index daba252de55..101f6bc62f2 100644
--- a/packages/cli/src/commands/compile.ts
+++ b/packages/cli/src/commands/compile.ts
@@ -76,6 +76,9 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js'
// A field `picklist` that names no picklist the stack declares — the walk
// `os validate` step 2d runs, over the same judge.
import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js';
+// [ADR-0146 D2, #28643] The override write gate — the walk `os validate` step
+// 3a-iii runs, over the publish door's own judge (`judgeOverrideWrite`).
+import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js';
export default class Compile extends Command {
static override description = 'Compile ObjectStack configuration to JSON artifact';
@@ -768,6 +771,37 @@ export default class Compile extends Command {
);
}
+ // 3b-iii. [ADR-0146 D2, #28643] The override write gate, at build time —
+ // the SAME call `os validate` makes at its step 3a-iii, over the
+ // walk `utils/override-declarations.ts` takes and the publish door's
+ // own judge (`judgeOverrideWrite`). Only the base's paired
+ // `.custom` declares an override; a dependent's item bearing a
+ // name a package in its closure ships, and an `overrides` declaration
+ // the gate does not admit, are refused. Before this the build emitted
+ // such an artifact and the managed install or a publish door refused
+ // it later with the same judge; now the door that ships refuses it
+ // and writes no artifact.
+ //
+ // Beside the dependency-range check below and ahead of every artifact
+ // write. The `--json` face is this command's failure envelope
+ // (`errors`, the rows `os validate --json` carries); the text face is
+ // `os validate`'s. No step line, as on `os validate`.
+ const overrideRefusals = findOverrideDeclarationRefusals(result.data as Record);
+ if (overrideRefusals.length > 0) {
+ if (flags.json) {
+ await emitJson({ success: false, errors: overrideRefusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
+ 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-quater. ADR-0141 D2, author side — every `manifest.dependencies`
// range of the packages one install act would deliver, judged by the
// install's own predicate (`utils/dependency-range-check.ts`).
diff --git a/packages/cli/test/build-override-declarations.test.ts b/packages/cli/test/build-override-declarations.test.ts
new file mode 100644
index 00000000000..d24a6e91d34
--- /dev/null
+++ b/packages/cli/test/build-override-declarations.test.ts
@@ -0,0 +1,164 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [ADR-0146 D2, #28643] `os build` asks the override write gate that `os
+ * validate`, the publish doors and the managed install ask, and refuses the
+ * build with the rows `os validate` prints: same rows, same exit code, and
+ * NO artifact written.
+ *
+ * ## The defect, measured before the fix
+ *
+ * On a dependent package carrying `overrides: { package: 'com.acme.crm', … }`
+ * (only `com.acme.crm.custom` may declare one), and on a `packages[]` stack
+ * whose dependent ships a dashboard under the base's name, `os build --json`
+ * exited 0 and wrote `dist/objectstack.json`, while `os validate --json`
+ * exited 1 with one `NOT_OVERRIDABLE` row each. The managed install and the
+ * publish doors refused that artifact later with the same judge.
+ *
+ * ## One judge, one walk
+ *
+ * `compile.ts` makes the call `validate.ts` makes
+ * (`findOverrideDeclarationRefusals`, over `judgeOverrideWrite`), so the pins
+ * below assert EQUALITY with what `os validate` answers for the same config,
+ * run in the same process, rather than a copy of the judge's words. Both
+ * commands run in-process (`Command.run`), not as spawned CLIs.
+ */
+
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import Compile from '../src/commands/compile';
+import Validate from '../src/commands/validate';
+import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js';
+
+const CLI_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
+const BASE = 'com.acme.crm';
+
+interface Run {
+ /** The exit code `this.exit(n)` threw, or 0 when the command returned. */
+ code: number;
+ stdout: string;
+}
+
+/** Runs one command in-process; stdout captured ANSI-free, the write callback honoured. */
+async function run(command: typeof Compile | typeof Validate, argv: string[]): Promise {
+ const chunks: string[] = [];
+ const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
+ chunks.push(String(chunk));
+ const done = rest.find((a) => typeof a === 'function') as ((e?: Error | null) => void) | undefined;
+ done?.(null);
+ return true;
+ }) as never);
+ const exitCodeBefore = process.exitCode;
+ let code = 0;
+ try {
+ await command.run(argv, { root: CLI_ROOT });
+ } catch (error) {
+ const exit = (error as { oclif?: { exit?: unknown } }).oclif?.exit;
+ if (typeof exit !== 'number') throw error;
+ code = exit;
+ } finally {
+ spy.mockRestore();
+ process.exitCode = exitCodeBefore;
+ }
+ // eslint-disable-next-line no-control-regex
+ return { code, stdout: chunks.join('').replace(/\u001b\[[0-9;]*m/g, '') };
+}
+
+const json = (r: Run, label: string): Record => {
+ try {
+ return JSON.parse(r.stdout) as Record;
+ } catch {
+ throw new Error(`${label}: stdout was not one JSON document (exit ${r.code})\n${r.stdout}`);
+ }
+};
+
+const dashboard = (name: string, extra: Record = {}) => ({ name, label: 'Board', widgets: [], ...extra });
+
+/** A single-package stack declaring an override of the base's dashboard. */
+const declaring = (id: string): Record => ({
+ manifest: { id, name: 'ovr', version: '1.0.0', type: 'app', namespace: 'ovr', dependencies: { [BASE]: '^1.0.0' } },
+ dashboards: [dashboard('crm_board', { overrides: { package: BASE, basedOn: '1.0.0' } })],
+});
+
+/** A `packages[]` stack whose dependent ships the base's dashboard name, undeclared. */
+const undeclaredSameName = (): Record => ({
+ manifest: { id: 'com.acme.bundle', name: 'bundle', version: '1.0.0', type: 'app' },
+ packages: [
+ { manifest: { id: BASE, name: 'crm', version: '1.0.0', type: 'app', dashboards: [dashboard('crm_board')] } },
+ {
+ manifest: {
+ id: 'com.acme.partner', name: 'partner', version: '1.0.0', type: 'app',
+ dependencies: { [BASE]: '^1.0.0' }, dashboards: [dashboard('crm_board')],
+ },
+ },
+ ],
+});
+
+let root = '';
+const fixtures: Record = {};
+
+beforeAll(() => {
+ root = mkdtempSync(join(tmpdir(), 'os-build-override-declarations-'));
+ const make = (label: string, s: Record) => {
+ const dir = join(root, label);
+ mkdirSync(dir, { recursive: true });
+ // `strict: false`: the content is the door's to judge, not the producer's.
+ writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSource(s, { strict: false }));
+ linkSpec(dir);
+ fixtures[label] = { config: join(dir, 'objectstack.config.ts'), artifact: join(dir, 'dist', 'objectstack.json') };
+ };
+ make('partner', declaring('com.acme.partner'));
+ make('undeclared', undeclaredSameName());
+ make('pair', declaring('com.acme.crm.custom'));
+});
+
+afterAll(() => {
+ if (root) rmSync(root, { recursive: true, force: true });
+});
+
+const build = (label: string, ...flags: string[]) =>
+ run(Compile, [fixtures[label].config, '--output', fixtures[label].artifact, ...flags]);
+const validate = (label: string, ...flags: string[]) => run(Validate, [fixtures[label].config, ...flags]);
+
+describe('#28643 — os build refuses what os validate refuses under the override write gate, and ships nothing', () => {
+ it.each(['partner', 'undeclared'])('THE PIN (%s): --json exits as validate does, with validate\'s rows, and writes no artifact', async (label) => {
+ const validated = await validate(label, '--json');
+ expect(validated.code, validated.stdout).toBe(1);
+ const expected = json(validated, `validate ${label}`).errors as Array>;
+ // Anti-vacuity: the judge refuses this config, under the override gate.
+ expect(expected.map((e) => e.code)).toEqual(['NOT_OVERRIDABLE']);
+
+ const built = await build(label, '--json');
+ const payload = json(built, `build ${label}`);
+ expect(built.code).toBe(validated.code);
+ expect(payload.success).toBe(false);
+ expect(payload.errors).toEqual(expected);
+ expect(existsSync(fixtures[label].artifact), 'os build wrote an artifact the publish door refuses').toBe(false);
+ }, 60_000);
+
+ it('the text face exits 1 and prints the refusal block os validate prints', async () => {
+ const validated = await validate('undeclared');
+ expect(validated.code).toBe(1);
+ const block = validated.stdout.slice(validated.stdout.indexOf('✗ The publish door would refuse')).trimEnd();
+ expect(block).toContain('[NOT_OVERRIDABLE]');
+
+ const built = await build('undeclared');
+ expect(built.code).toBe(1);
+ expect(built.stdout).toContain(block);
+ expect(built.stdout).not.toContain('Build complete');
+ expect(existsSync(fixtures.undeclared.artifact)).toBe(false);
+ }, 60_000);
+
+ it('CONTROL: the base\'s paired .custom declaring the same override builds, and validates clean', async () => {
+ expect((await validate('pair', '--json')).code).toBe(0);
+ const built = await build('pair', '--json');
+ const payload = json(built, 'build pair');
+ expect(payload.success, JSON.stringify(payload.errors ?? payload.error)).toBe(true);
+ expect(built.code).toBe(0);
+ const artifact = JSON.parse(readFileSync(fixtures.pair.artifact, 'utf8')) as Record;
+ expect(artifact.dashboards.map((d: { name: string }) => d.name)).toEqual(['crm_board']);
+ }, 60_000);
+});
diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts
index 076046367ff..6c21bb4acee 100644
--- a/packages/cli/test/validate-build-gate-parity.test.ts
+++ b/packages/cli/test/validate-build-gate-parity.test.ts
@@ -183,6 +183,20 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [
// and the walk reads every dataset where it sits, the package bodies
// included, across the whole artifact.
'judgeSeedRecordValues',
+ // [ADR-0146 D2, #28410, #28643] The override write gate: an item bearing a
+ // name a package in its dependency closure ships, or an `overrides`
+ // declaration the gate does not admit, judged by the publish door's own
+ // `judgeOverrideWrite`. Not a registry rule: the WALK decides which packages
+ // of the artifact are in each package's closure and which of them ship the
+ // same name, so it is cross-package by construction.
+ //
+ // ⭐ This row is the #28410 VALIDATE_ONLY_GATES entry CLOSED. That entry read
+ // "os build is a fourth point it did not name", and it was right: the build
+ // emitted an artifact the managed install and the publish doors refused
+ // later. `compile.ts` now makes the same call (#28643), so the row moved
+ // here — deleted there rather than reworded, for the reason the
+ // `runPerPackageAuthoringRules` row above gives.
+ 'findOverrideDeclarationRefusals',
];
/**
@@ -221,22 +235,13 @@ 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.
- * Its one row, `findViewContainerNameRefusals` (#20331), moved to
- * SHARED_NON_REGISTRY_GATES when `compile.ts` gained the call (#20393). The
- * ledger stays, empty, because it is the only honest place the closed roster
- * has for the next validate-only gate.
+ * Both 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.
*/
-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.',
-};
+const VALIDATE_ONLY_GATES: Readonly> = {};
/**
* Everything else the two commands call, and the reason each one is NOT an