diff --git a/.changeset/shared-tenancy-posture-hints.md b/.changeset/shared-tenancy-posture-hints.md
new file mode 100644
index 0000000000..240b445977
--- /dev/null
+++ b/.changeset/shared-tenancy-posture-hints.md
@@ -0,0 +1,42 @@
+---
+"@objectstack/cli": patch
+---
+
+refactor(cli): single-source the tenancy posture hint table `os serve` and `os doctor` both print (#12492)
+
+`serve.ts` and `doctor.ts` each declared their own `TENANCY_POSTURE_FIX_HINTS`,
+and the two tables were **byte-identical** — sha256 `97497ea8…` on both, modulo
+the expression spelling the package name — under no cross-check of any kind.
+
+The `isolated` entry at least carried a package literal the spec-owned
+`PLATFORM_PLUGIN_WIRED_RUNTIMES` roster could be pinned against (#12464 / PR
+#12496). **`single` and `group` were the worse half**: they touch no roster, so
+nothing anywhere could ever have noticed them drift apart. A reword of one
+command's copy left the other describing the same posture differently to the
+same operator, with every gate green.
+
+Both tables now come from one CLI-internal module,
+`packages/cli/src/utils/tenancy-posture-hints.ts`, which also holds the single
+`ORGANIZATIONS_RUNTIME_PKG` declaration. `Serve.ORGANIZATIONS_RUNTIME_PKG`
+becomes a **re-export** of it rather than a second declaration, keeping the
+stable handle `serve`'s boot path and the roster pin already address; the
+module-local const #12464 added to `doctor.ts` is **deleted**, which is the
+deletion condition that const's own docblock recorded against this card. The
+literal is now declared twice (the roster key and this module) instead of three
+times.
+
+Each command keeps its **own bullet assembly** — doctor renders
+`• OS_TENANCY_POSTURE=
— ` inside a health-check fix list, serve renders
+`• set OS_TENANCY_POSTURE= — ` inside a FATAL refusal, at different
+indents. Only the table was ever duplicated.
+
+The two sibling spelling pins are **retargeted, not dropped** — including the
+roster leg, which is the load-bearing one (a rename of the package value leaves
+the rendered leg green; only the roster leg catches it). Each gains one new leg
+asserting that every posture bullet, `single` and `group` included, renders the
+**shared** table's entry verbatim — so a command that re-grows a local copy goes
+red instead of drifting in silence.
+
+**No behaviour change.** The rendered text is byte-identical before and after
+for all three postures at both commands, verified by capturing both commands'
+full rendered fix lists on `origin/main` and on this branch and diffing them.
diff --git a/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts b/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts
index 55ac5bf5d5..aa8d0384b3 100644
--- a/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts
+++ b/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts
@@ -16,18 +16,35 @@
* declaration, and nothing read this hint table's text.
*
* The defect being closed is that SILENT DRIFT, not the duplication as such.
- * The literal is still declared three times (the roster key, serve's static,
- * doctor's const) and this file does not change that — see the const's own
- * docblock for why the roster cannot supply the name, and for the deletion
- * condition that ends the duplication properly.
*
- * ── Two legs, and the second is the point ────────────────────────────────
+ * ── Retargeted by #12492, not rewritten ──────────────────────────────────
*
- * (i) RENDERED — the `isolated` bullet is rendered through the real gate
- * and compared, whitespace included, against text built from the
- * declaration.
- * (ii) ROSTER — that declaration IS a key of the spec-owned
- * `PLATFORM_PLUGIN_WIRED_RUNTIMES`.
+ * That deletion condition has since been met. `doctor.ts` no longer declares
+ * `ORGANIZATIONS_RUNTIME_PKG` or its own hint table: both moved to
+ * `../utils/tenancy-posture-hints.ts`, which `os serve` reads too. These pins
+ * moved with the declaration — ⛔ none of them was dropped, because what they
+ * measure did not change: what `os doctor` RENDERS. Leg (ii) especially, which
+ * is the load-bearing one (see below).
+ *
+ * ⚠️ The literal is still declared three times, not two: the roster key, the
+ * shared module this file now reads, and `Serve.ORGANIZATIONS_RUNTIME_PKG`,
+ * which must stay a string LITERAL in `serve.ts` or the host-anchoring sweep in
+ * `serve-cluster-host-resolution.test.ts` can no longer resolve which package
+ * that command's `import()` names. What changed is that no copy can drift in
+ * silence any more: the serve↔shared pair is pinned equal by site 8 of
+ * `serve-organizations-message-spelling.test.ts`, and each copy is separately
+ * pinned as a roster key. Ending the duplication needs that sweep's resolver to
+ * follow one more hop — a file this card does not own.
+ *
+ * ── Three legs, and the second is the point ──────────────────────────────
+ *
+ * (i) RENDERED — the `isolated` bullet is rendered through the real gate
+ * and compared, whitespace included, against text built from the
+ * declaration.
+ * (ii) ROSTER — that declaration IS a key of the spec-owned
+ * `PLATFORM_PLUGIN_WIRED_RUNTIMES`.
+ * (iii) SHARED TABLE (#12492) — every posture bullet, `single` and `group`
+ * included, renders the shared table's entry verbatim.
*
* ⭐ (ii) is the entire difference between a CHECKED duplicate and a third
* SILENT copy. Leg (i) on its own pins doctor against itself: rename the
@@ -35,6 +52,14 @@
* green forever while `os doctor` names a package that no longer exists.
* (ii) is what makes that rename loud. Neither leg is optional.
*
+ * (iii) covers the half neither of the other two can reach. `single` and
+ * `group` carry no package literal, so no roster leg is possible for them and
+ * nothing ever watched them — that is the defect #12492 filed. Leg (iii) does
+ * not check the PROSE (a reword moves the shared table and this expectation
+ * together, and leg (i)'s hard-coded text is what reddens then); it checks that
+ * doctor renders THE SHARED TABLE. Re-grow a module-local copy in `doctor.ts` —
+ * exactly the state this card found — and it goes red on every posture at once.
+ *
* ── Why it reads the RENDERED text, not the source ───────────────────────
*
* "No bare literal outside the declaration" is the tempting stronger form and
@@ -51,9 +76,16 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PLATFORM_PLUGIN_WIRED_RUNTIMES } from '@objectstack/spec/kernel';
+import { TENANCY_POSTURES } from '@objectstack/spec/security';
+// The declaration and the table both live here since #12492 — `os serve` reads
+// the same module, which is what makes leg (iii) below a reading of the SHARING
+// rather than of one command talking to itself.
import {
ORGANIZATIONS_RUNTIME_PKG,
+ TENANCY_POSTURE_FIX_HINTS,
+} from '../utils/tenancy-posture-hints.js';
+import {
resolveTenancyPostureOrFinding,
readDotenvFiles,
type DotenvReading,
@@ -108,17 +140,20 @@ afterEach(() => {
}
});
-/** The `isolated` fix-list bullet, rendered through the real gate. */
-const renderIsolatedBullet = (): string => {
+/** One posture's fix-list bullet, rendered through the real gate. */
+const renderPostureBullet = (posture: string): string => {
process.env.OS_TENANCY_POSTURE = 'not-a-posture';
const reading = resolveTenancyPostureOrFinding(shellOnly);
expect(reading.ok, 'the gate accepted a value that is not a posture').toBe(false);
if (reading.ok) throw new Error('unreachable — guarded above');
- const bullet = lines(reading.result.fix ?? '').find((l) => l.includes('OS_TENANCY_POSTURE=isolated'));
- expect(bullet, "the fix list no longer offers an `isolated` bullet at all").toBeDefined();
+ const bullet = lines(reading.result.fix ?? '').find((l) => l.includes(`OS_TENANCY_POSTURE=${posture}`));
+ expect(bullet, `the fix list no longer offers a '${posture}' bullet at all`).toBeDefined();
return bullet as string;
};
+/** The `isolated` fix-list bullet, rendered through the real gate. */
+const renderIsolatedBullet = (): string => renderPostureBullet('isolated');
+
describe('doctor — the posture description an operator reads names the declaration (#12464)', () => {
// LEG (i). Rendered through `resolveTenancyPostureOrFinding` rather than by
// reading the hint table: the bullet's assembly (indent, `• OS_TENANCY_POSTURE=`,
@@ -148,6 +183,28 @@ describe('doctor — the posture description an operator reads names the declara
expect(row.edition, `edition drift for the runtime doctor names ('${PKG}')`).toBe('enterprise');
});
+ // LEG (iii) — the half legs (i) and (ii) cannot reach (#12492). `single` and
+ // `group` carry no package literal, so no roster leg is possible for them;
+ // before this card nothing anywhere read their text at either command, and a
+ // reword of one command's copy drifted from the other in total silence. What
+ // closes that is not a pin on the PROSE — it is this: the bullets an operator
+ // reads here are assembled from the SHARED table, the same one `os serve`
+ // renders. A module-local hint table re-grown in `doctor.ts` reddens this.
+ it('leg (iii) — every posture bullet renders the SHARED hint table verbatim, `single` and `group` included', () => {
+ for (const posture of TENANCY_POSTURES) {
+ const hint = TENANCY_POSTURE_FIX_HINTS[posture];
+ expect(renderPostureBullet(posture)).toBe(
+ ` • OS_TENANCY_POSTURE=${posture}${hint ? ` — ${hint}` : ''}`,
+ );
+ }
+ // …and the sweep above actually swept. A posture vocabulary that went empty
+ // would satisfy every assertion inside the loop without reading anything —
+ // the two entries this card is ABOUT are named explicitly for that reason.
+ expect(TENANCY_POSTURES).toContain('single');
+ expect(TENANCY_POSTURES).toContain('group');
+ expect(TENANCY_POSTURES).toContain('isolated');
+ });
+
it('no posture bullet an operator reads names any OTHER scoped package', () => {
// The sweep the excluded source-scan form was reaching for, done over the
// rendering instead — where comments cannot reach and no exclusion list is
@@ -185,6 +242,21 @@ describe('#12464 CONTROL — these pins can say no', () => {
expect(renderIsolatedBullet()).toBe(expected);
});
+ it('the shared-table comparison rejects a bullet whose hint was reworded (#12492)', () => {
+ // If leg (iii) could not tell a reworded hint from the shared one it would
+ // be decorative. Anchored on `group` — one of the two entries that had
+ // nothing watching them at all before this card, and deliberately NOT a
+ // substring game: 'closed engine' is a different claim, not a truncation.
+ const real = ` • OS_TENANCY_POSTURE=group — ${TENANCY_POSTURE_FIX_HINTS.group}`;
+ expect(
+ ' • OS_TENANCY_POSTURE=group — organization wall enforced by the closed engine, one shared database',
+ ).not.toBe(real);
+ expect(' • OS_TENANCY_POSTURE=group').not.toBe(real);
+ // …and says yes to the real thing, so the two `not.toBe`s are a reading
+ // rather than a pair of vacuous truths.
+ expect(renderPostureBullet('group')).toBe(real);
+ });
+
it('the roster key check rejects a name the roster does not declare', () => {
// Anchored on a term that is NOT a substring of the one under test: a
// membership assertion is not a reading until the same instrument answers no.
diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts
index 6ea7de7bd5..3517c2d228 100644
--- a/packages/cli/src/commands/doctor.ts
+++ b/packages/cli/src/commands/doctor.ts
@@ -15,6 +15,7 @@ import { checkSpecVersionGap } from '../utils/spec-version.js';
// them apart. That classification lives in one place, with the measurements
// behind it written down there.
import { loadOptionalPackage } from '../utils/optional-package.js';
+import { TENANCY_POSTURE_FIX_HINTS } from '../utils/tenancy-posture-hints.js';
import { validateWidgetBindings } from '@objectstack/lint';
import {
resolveTenancyPosture,
@@ -523,66 +524,21 @@ export function environmentSourcesCheck(
// ─── Tenancy Posture ────────────────────────────────────────────────
/**
- * The `plugins[]`-wired multi-org runtime this command NAMES in its posture
- * advice, spelled once so the sentence below cannot drift in silence (#12464).
- *
- * ⚠️ This is the THIRD declaration of this literal, and that is an accepted
- * cost rather than an oversight. The roster KEY in `PLATFORM_PLUGIN_WIRED_RUNTIMES`
- * is one; `Serve.ORGANIZATIONS_RUNTIME_PKG` (`serve.ts`, #11614) is another.
- * What this const buys is NOT fewer copies — it is that this copy can no longer
- * drift unnoticed: `doctor-organizations-message-spelling.test.ts` pins the
- * RENDERED bullet against this value and pins this value as a roster key, so a
- * roster rename turns a test red instead of leaving `os doctor` printing a
- * package name that boot no longer resolves with every gate green. Three
- * declarations is structurally worse than two; a duplicate that can drift
- * silently and one that cannot are different things.
- *
- * ── Why this does not read the name from the spec roster ─────────────────
- *
- * Because the roster cannot supply it. `PLATFORM_PLUGIN_WIRED_RUNTIMES` is
- * keyed BY package name, and its row type `PlatformPluginWiredRuntime` carries
- * no `package` field — deliberately: *"here the package name is the KEY, so it
- * cannot be `null` and cannot drift from a duplicate field"*. Its own header
- * settles the rest: *"What this roster deliberately is NOT: a resolution
- * registry … the rows record that fact as prose provenance, they do not encode
- * it as a lookup."* Both rows are `edition: 'enterprise'`, so nothing
- * machine-readable selects this one. The roster VALIDATES a name you already
- * hold; it does not hand you one — which is why the pin reads it as a KEY
- * CHECK, the only first-class read it actually offers.
- *
- * ⛔ Do NOT replace this with an import of `Serve.ORGANIZATIONS_RUNTIME_PKG`: a
- * diagnostic command taking a dependency on a `serve` command's export, in
- * order to spell a package name, is a worse coupling than the duplication it
- * removes.
- *
- * ── Deletion condition ───────────────────────────────────────────────────
- *
- * This const goes away the day a shared tenancy-hint table lands (tracked at
- * #12492). The whole `TENANCY_POSTURE_FIX_HINTS` table below is duplicated
- * between here and `serve.ts` — `single` and `group` byte-identical too, and
- * those two touch no roster, so nothing could ever notice them drift. One
- * shared table single-sources all three sentences AND this package name at
- * once. This is a step toward that, not the end state.
- *
- * Exported for the same reason `serve` exposes its own as a static: one
- * declaration, two readers — the sentence and the pin. A pin that read a copy
- * of this value instead of this value would be pinning the test against itself.
+ * The posture prose below comes from `../utils/tenancy-posture-hints.ts`, which
+ * `os serve` reads too (#12492).
+ *
+ * #12464 added a module-local `ORGANIZATIONS_RUNTIME_PKG` here and a
+ * `TENANCY_POSTURE_FIX_HINTS` table that repeated `serve`'s, byte for byte. That
+ * const's docblock named THIS card as its deletion condition, so it is gone: the
+ * declaration moved to the shared module and doctor reads it from there. ⛔ Not
+ * from `serve.ts` — a diagnostic command depending on a `serve` command's export
+ * in order to spell a package name is the coupling that docblock ruled out, and
+ * a neutral utility both commands sit above is not that.
+ *
+ * Doctor keeps its OWN bullet assembly (indent, `• OS_TENANCY_POSTURE=`, the
+ * ` — ` separator): serve renders the same hints at a different indent inside a
+ * FATAL refusal, and only the TABLE was ever duplicated.
*/
-export const ORGANIZATIONS_RUNTIME_PKG = '@objectstack/organizations';
-
-/**
- * One-line descriptions of the accepted postures, keyed by the vocabulary
- * `@objectstack/spec/security` owns. A posture declared there but not described
- * here is still listed by the fix list (bare, without prose) rather than
- * silently dropped — the advice can go terse, never stale.
- */
-const TENANCY_POSTURE_FIX_HINTS: Readonly> = {
- single: 'one organization, no organization wall — the default',
- group: 'organization wall enforced by the open engine, one shared database',
- isolated:
- `organization wall + the enterprise ${ORGANIZATIONS_RUNTIME_PKG} runtime `
- + "(the legacy spelling 'multi' is accepted and normalizes to this)",
-};
/**
* What doctor's tenancy-posture read decided (#5382).
diff --git a/packages/cli/src/commands/serve-organizations-message-spelling.test.ts b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts
index 802832abdd..68b66e502d 100644
--- a/packages/cli/src/commands/serve-organizations-message-spelling.test.ts
+++ b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts
@@ -33,11 +33,42 @@
* from a literal on purpose. Rename the roster key and these keep passing —
* the prose moved with it. Re-spell it inline in any of these messages and the
* rendered line stops matching the declaration, which is the exact drift this
- * file exists to catch.
+ * file exists to catch. (The roster leg that makes that safe is not here: it is
+ * `test/serve-capability-vocabulary.test.ts`, which pins the same static as a
+ * roster KEY. Both halves are required; they simply live in two files.)
+ *
+ * ── Widened by #12492, not rewritten ─────────────────────────────────────
+ *
+ * `serve.ts` and `doctor.ts` each carried a byte-identical `TENANCY_POSTURE_FIX_HINTS`
+ * table. It now lives once, in `../utils/tenancy-posture-hints.ts`, which both
+ * commands read. ⛔ Nothing here was dropped; two pins were added.
+ *
+ * **Site 7** is the reading that makes the sharing REAL rather than textual: the
+ * bullets `serve` renders come from that shared table, `single` and `group`
+ * included — the two entries that touch no roster and that, before #12492,
+ * nothing at either command ever read.
+ *
+ * **Site 8** covers what the shared table could NOT absorb.
+ * `Serve.ORGANIZATIONS_RUNTIME_PKG` deliberately stays a string LITERAL in
+ * `serve.ts` (its docblock says why: `serve-cluster-host-resolution.test.ts`
+ * resolves the organizations `import()` through that static and needs the
+ * literal in that file, or the load drops out of the host-anchoring sweep
+ * silently). So the spelling is declared twice inside `packages/cli`, and site 8
+ * is what keeps that duplication CHECKED instead of silent — it asserts the two
+ * declarations are equal. Each is separately pinned as a roster key, here via
+ * `test/serve-capability-vocabulary.test.ts` and there via doctor's leg (ii).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { HostDeclaration } from '@objectstack/types/node';
+import { TENANCY_POSTURES } from '@objectstack/spec/security';
+
+// The table `doctor` renders too (#12492). Read here so site 7 measures the
+// SHARING, not this command talking to itself.
+import {
+ ORGANIZATIONS_RUNTIME_PKG as SHARED_ORGANIZATIONS_RUNTIME_PKG,
+ TENANCY_POSTURE_FIX_HINTS,
+} from '../utils/tenancy-posture-hints.js';
import Serve, {
formatDegradedTenancyWarning,
@@ -47,7 +78,15 @@ import Serve, {
resolveTenancyPostureOrRefusal,
} from './serve.js';
-/** The one declaration. Every expectation below is built from THIS, never from a literal. */
+/**
+ * The one declaration, read through the handle `serve` exposes.
+ *
+ * Reading it as `Serve.…` is deliberate: what these pins are about is what THIS
+ * COMMAND puts in front of an operator, and the static is the seam the boot path
+ * and the roster pin already address. It is a literal in `serve.ts`, not a
+ * re-export of the shared hints module — site 8 below is what holds the two
+ * equal.
+ */
const PKG = Serve.ORGANIZATIONS_RUNTIME_PKG;
/**
@@ -176,6 +215,50 @@ describe('serve — the posture description an operator reads names the declarat
+ "(the legacy spelling 'multi' is accepted and normalizes to this)",
);
});
+
+ // #12492. Site 6 pins the `isolated` PROSE, hard-coded, and that is what
+ // reddens on a reword. This pins something site 6 cannot: that the bullets
+ // come from the table `os doctor` renders too — `single` and `group`
+ // included, the two entries no roster touches and nothing anywhere read
+ // before this card. Re-grow a module-local table in `serve.ts` and this goes
+ // red on every posture at once.
+ it('site 7 — every posture bullet renders the SHARED hint table verbatim, `single` and `group` included', () => {
+ process.env.OS_TENANCY_POSTURE = 'not-a-posture';
+ const verdict = resolveTenancyPostureOrRefusal();
+ expect(verdict.ok, 'the gate accepted a value that is not a posture').toBe(false);
+ if (verdict.ok) return;
+ const rendered = lines(verdict.fatal);
+ for (const posture of TENANCY_POSTURES) {
+ const hint = TENANCY_POSTURE_FIX_HINTS[posture];
+ expect(rendered).toContain(
+ ` • set OS_TENANCY_POSTURE=${posture}${hint ? ` — ${hint}` : ''}`,
+ );
+ }
+ // …and the sweep above actually swept. An empty posture vocabulary would
+ // satisfy every assertion inside the loop without reading a thing — the two
+ // entries this card is ABOUT are named explicitly for that reason.
+ expect(TENANCY_POSTURES).toContain('single');
+ expect(TENANCY_POSTURES).toContain('group');
+ expect(TENANCY_POSTURES).toContain('isolated');
+ });
+
+ // #12492. The one thing the shared table could not absorb: the package name is
+ // declared twice inside `packages/cli` on purpose — see
+ // `Serve.ORGANIZATIONS_RUNTIME_PKG`'s docblock, and the shared module's. This
+ // is the assertion that makes that duplication a CHECKED one. Drift either
+ // copy and `os serve` and `os doctor` start naming different packages at
+ // operators; without this, nothing anywhere would say so.
+ it('site 8 — serve\'s literal and the shared hints module declare the SAME package', () => {
+ expect(
+ PKG,
+ 'Serve.ORGANIZATIONS_RUNTIME_PKG and the shared tenancy-hints module disagree about the '
+ + 'multi-org runtime, so `os serve` and `os doctor` now name different packages at operators',
+ ).toBe(SHARED_ORGANIZATIONS_RUNTIME_PKG);
+
+ // …and the isolated hint an operator reads is built from the shared copy, so
+ // the equality above is load-bearing rather than a spare assertion.
+ expect(TENANCY_POSTURE_FIX_HINTS.isolated).toContain(SHARED_ORGANIZATIONS_RUNTIME_PKG);
+ });
});
describe('#12151 CONTROL — these pins can say no', () => {
@@ -189,6 +272,32 @@ describe('#12151 CONTROL — these pins can say no', () => {
expect(lines(remedyUndeclared())[0]).toBe(expected);
});
+ it('the shared-table comparison rejects a bullet whose hint was reworded (#12492)', () => {
+ // If site 7 could not tell a reworded hint from the shared one it would be
+ // decorative. Anchored on `group`, one of the two entries that had nothing
+ // watching them before this card, and against a term that is NOT a
+ // substring of the one under test: 'closed engine' is a different claim.
+ const real = ` • set OS_TENANCY_POSTURE=group — ${TENANCY_POSTURE_FIX_HINTS.group}`;
+ expect(
+ ' • set OS_TENANCY_POSTURE=group — organization wall enforced by the closed engine, one shared database',
+ ).not.toBe(real);
+ expect(' • set OS_TENANCY_POSTURE=group').not.toBe(real);
+ // …and says yes to the real thing — rendered through the real gate, not
+ // restated. `expect(X).toBe(X)` here would be a vacuous truth dressed as a
+ // control, which is the failure mode a control exists to rule out.
+ const saved = process.env.OS_TENANCY_POSTURE;
+ try {
+ process.env.OS_TENANCY_POSTURE = 'not-a-posture';
+ const verdict = resolveTenancyPostureOrRefusal();
+ expect(verdict.ok).toBe(false);
+ if (verdict.ok) return;
+ expect(lines(verdict.fatal)).toContain(real);
+ } finally {
+ if (saved === undefined) delete process.env.OS_TENANCY_POSTURE;
+ else process.env.OS_TENANCY_POSTURE = saved;
+ }
+ });
+
it('the scoped-name reader returns a positive on a foreign package and empty on none', () => {
// Anchored on a term that is NOT a substring of the one under test: a
// zero-hit sweep is not a reading until the same instrument answers yes.
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 3b93962e6b..0cc49f8ae7 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -52,6 +52,8 @@ import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
+// The posture prose `os serve` and `os doctor` BOTH print, declared once (#12492).
+import { TENANCY_POSTURE_FIX_HINTS } from '../utils/tenancy-posture-hints.js';
// Shared with @objectstack/verify and the dogfood multi-org probes (#4700) —
// node-only, hence the `/node` subpath rather than the edge-safe root export.
import {
@@ -514,6 +516,31 @@ export default class Serve extends Command {
* Exposed as a static for the same reason ALWAYS_ON_CAPABILITIES above is:
* one declaration, two readers — the boot path and the pin.
*
+ * ⛔ This MUST stay a string LITERAL in this file — do not turn it into a
+ * re-export of `../utils/tenancy-posture-hints.ts`, however tempting that is
+ * now that the posture hints live there (#12492).
+ *
+ * `serve-cluster-host-resolution.test.ts` sweeps this file for every dynamic
+ * `import()` and proves each app-declarable one is host-anchored. To do that
+ * it must know WHICH package a load site names, and the organizations load is
+ * spelled `const organizationsPkg = Serve.ORGANIZATIONS_RUNTIME_PKG;` — so the
+ * sweep resolves the identifier one hop, to this static, and then reads the
+ * literal. Written as `= SOME_IMPORTED_CONST`, the literal is no longer in
+ * this file, the specifier becomes unresolvable, and that load drops OUT of
+ * the swept population rather than failing inside it. That is the exact
+ * silent-vacuity failure #11614 already paid for once; the named half of that
+ * sweep's vacuity guard is what catches it, and it does (measured on this
+ * card, which tried the re-export and was refused by it).
+ *
+ * The consequence is that the spelling is declared twice inside this package
+ * — here, and in the shared hints module `os doctor` also reads. That
+ * duplication is CHECKED, not silent: `serve-organizations-message-spelling.test.ts`
+ * asserts the two are equal, and each is independently pinned as a key of the
+ * spec-owned roster (here via `test/serve-capability-vocabulary.test.ts`,
+ * there via `doctor-organizations-message-spelling.test.ts`). Single-sourcing
+ * it properly needs that sweep's resolver to follow one more hop, into a
+ * sibling module — an edit to a file this card does not own.
+ *
* This single-sources the SPELLING and nothing else. Which postures load the
* runtime, and what each of the two failure stages means, stay exactly where
* they are; the roster is deliberately not a resolution registry.
@@ -4264,20 +4291,6 @@ export type TenancyPostureGateVerdict =
| { ok: true; posture: TenancyPosture }
| { ok: false; fatal: string };
-/**
- * One-line prescriptions for the accepted postures, keyed by the vocabulary
- * `@objectstack/spec/security` owns. A posture added there but not described
- * here still gets listed by the gate (bare, without prose) rather than silently
- * dropped from the advice — the fix list can go terse, never stale.
- */
-const TENANCY_POSTURE_FIX_HINTS: Readonly> = {
- single: 'one organization, no organization wall — the default',
- group: 'organization wall enforced by the open engine, one shared database',
- isolated:
- `organization wall + the enterprise ${Serve.ORGANIZATIONS_RUNTIME_PKG} runtime `
- + "(the legacy spelling 'multi' is accepted and normalizes to this)",
-};
-
/**
* Resolve the deployment's requested tenancy posture, or produce the FATAL text
* that refuses the boot (#5359).
diff --git a/packages/cli/src/utils/tenancy-posture-hints.ts b/packages/cli/src/utils/tenancy-posture-hints.ts
new file mode 100644
index 0000000000..eb4b32afee
--- /dev/null
+++ b/packages/cli/src/utils/tenancy-posture-hints.ts
@@ -0,0 +1,129 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The one-line posture descriptions `os serve` and `os doctor` BOTH put in front
+ * of an operator, declared ONCE (#12492).
+ *
+ * ── What this closes ──────────────────────────────────────────────────────
+ *
+ * `serve.ts` and `doctor.ts` each carried their own `TENANCY_POSTURE_FIX_HINTS`
+ * table, and the two tables were byte-identical (modulo the expression that
+ * spells the package name) under no cross-check of any kind. `isolated` at
+ * least carried a package literal the spec-owned roster could be pinned against
+ * (#12464 / PR #12496); `single` and `group` touch no roster, so there was
+ * nothing anywhere that could ever have noticed those two drift apart — reword
+ * one command's copy and every gate stays green while the two commands describe
+ * the same posture differently to the same operator.
+ *
+ * ⭐ The two UNCOVERED entries were the worse half, which is why all three moved
+ * here together. Single-sourcing only `isolated` would have closed the half that
+ * was already covered and left the other two exactly as silent as before.
+ *
+ * What moved is the TABLE. The package name the `isolated` sentence interpolates
+ * is declared here too, but `serve.ts` still holds its own copy for a reason
+ * recorded at that const below — read it before "finishing the job".
+ *
+ * ── Why this is CLI-INTERNAL and not `packages/spec` ─────────────────────
+ *
+ * `packages/spec` owns the posture VOCABULARY (`TENANCY_POSTURES`,
+ * `TenancyPosture`) and these hints are keyed by it. The prose is not part of
+ * that contract: it is operator advice, in the imperative voice of a terminal,
+ * read by exactly two CLI commands and by nothing else. That was measured
+ * rather than assumed before this module was placed here — no reader outside
+ * `packages/cli` consumes any of the three strings (the near hits are a comment
+ * in `plugin-auth` that happens to phrase `group` similarly, and ADR-0105's own
+ * different sentence about a shared database).
+ *
+ * Publishing it from `packages/spec` instead would widen public surface to buy
+ * nothing: it would export operator prose to every consumer of the protocol
+ * contract, and the drift this closes is entirely internal to two files that
+ * sit three directories apart. A posture declared in the spec but not described
+ * here is still listed by both commands — bare, without prose — rather than
+ * silently dropped, so the vocabulary stays the authority and this table stays
+ * an optional gloss on it. The advice can go terse; it can never go stale.
+ *
+ * ── What is shared here, and what deliberately is not ────────────────────
+ *
+ * Only the TABLE. Each command keeps its own bullet assembly, because the two
+ * renderings genuinely differ and always did: doctor emits
+ * ` • OS_TENANCY_POSTURE= — ` inside a health-check `fix`, serve
+ * emits ` • set OS_TENANCY_POSTURE= — ` inside a FATAL refusal, at
+ * different indents. Hoisting those too would force one of the two messages to
+ * change shape, and this change is a refactor with byte-identical output.
+ *
+ * ⛔ Do not add rendering, chalk, or posture POLICY here. What loads the
+ * multi-org runtime, and what each failure stage means, stay where they are.
+ */
+
+/**
+ * The `plugins[]`-wired multi-org runtime both commands NAME in their posture
+ * advice, spelled once FOR THIS TABLE (#11614 → #12464 → #12492).
+ *
+ * ⚠️ This is not the only declaration of the literal inside `packages/cli`, and
+ * that is a measured constraint rather than an oversight. `serve.ts` keeps its
+ * own `Serve.ORGANIZATIONS_RUNTIME_PKG` literal because
+ * `serve-cluster-host-resolution.test.ts` sweeps `serve.ts` for every dynamic
+ * `import()` and resolves the organizations load site through that static to a
+ * LITERAL IN THAT FILE. Rewrite the static as a re-export of this const and the
+ * specifier stops resolving, so that load drops OUT of the host-anchoring sweep
+ * instead of failing inside it — the silent-vacuity mode #11614 already paid
+ * for. This card tried exactly that and the sweep's named vacuity guard refused
+ * it, by name, which is the guard working.
+ *
+ * ⭐ So the spelling is declared twice in this package, and both copies are
+ * CHECKED rather than silent — a duplicate that can drift unnoticed and one
+ * that cannot are different things:
+ *
+ * · `serve-organizations-message-spelling.test.ts` asserts the two are EQUAL.
+ * · this one is a key of the spec-owned `PLATFORM_PLUGIN_WIRED_RUNTIMES`
+ * (`doctor-organizations-message-spelling.test.ts`, leg (ii)).
+ * · serve's is a key of the same roster (`test/serve-capability-vocabulary.test.ts`).
+ *
+ * Ending the duplication for real needs that sweep's `resolveIdentifier()` to
+ * follow one further hop — an import alias into a sibling module — which is an
+ * edit to a file #12492 does not own, and which was in flight elsewhere when
+ * this landed. It is a two-line change to a resolver whose own docblock records
+ * that resolving one hop further "strictly WIDENS what the sweep judges; it can
+ * never excuse a load".
+ *
+ * ⛔ Do NOT close the gap by importing `Serve.ORGANIZATIONS_RUNTIME_PKG` here
+ * instead: this module is read by `os doctor`, and a diagnostic command taking a
+ * dependency on a `serve` command's export in order to spell a package name is
+ * a worse coupling than the duplication it removes (#12464's ruling, unchanged).
+ *
+ * ── Why neither copy can read the name from the spec roster ──────────────
+ *
+ * Because the roster cannot supply it. `PLATFORM_PLUGIN_WIRED_RUNTIMES` is keyed
+ * BY package name and its row type `PlatformPluginWiredRuntime` carries no
+ * `package` field — deliberately, so the name "cannot be `null` and cannot drift
+ * from a duplicate field" — and its own header records that it is "not a
+ * resolution registry". Both organization rows are `edition: 'enterprise'`, so
+ * nothing machine-readable selects this one. The roster VALIDATES a name you
+ * already hold; it does not hand you one. That is why every pin over this value
+ * reads it as a roster KEY CHECK, the only first-class read the roster offers.
+ */
+export const ORGANIZATIONS_RUNTIME_PKG = '@objectstack/organizations';
+
+/**
+ * One-line descriptions of the accepted postures, keyed by the vocabulary
+ * `@objectstack/spec/security` owns.
+ *
+ * A posture declared there but not described here is still listed by both
+ * commands (bare, without prose) rather than silently dropped — both call sites
+ * read this table with an optional-hint guard, and that is contract, not
+ * defensiveness.
+ *
+ * ⚠️ Reword an entry here and BOTH commands change together. That is the point,
+ * and it is what the rendered pins in `serve-organizations-message-spelling.test.ts`
+ * and `doctor-organizations-message-spelling.test.ts` measure: those two files
+ * hold hard-coded expectations of this prose, so a reword here reddens a pin on
+ * each command from one edit. If only one ever reddened, the table would be
+ * shared in name only.
+ */
+export const TENANCY_POSTURE_FIX_HINTS: Readonly> = {
+ single: 'one organization, no organization wall — the default',
+ group: 'organization wall enforced by the open engine, one shared database',
+ isolated:
+ `organization wall + the enterprise ${ORGANIZATIONS_RUNTIME_PKG} runtime `
+ + "(the legacy spelling 'multi' is accepted and normalizes to this)",
+};