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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/serve-organizations-pkg-single-sourced.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
"@objectstack/cli": patch
---

chore(cli): spell the multi-org runtime from ONE declaration, with the static keeping its name (#12579)

`Serve.ORGANIZATIONS_RUNTIME_PKG` carried a second copy of
`@objectstack/organizations`, beside the declaration in
`utils/tenancy-posture-hints.ts` that `os doctor` reads. That duplication was
deliberate: the host-anchoring sweep in `serve-cluster-host-resolution.test.ts`
resolved serve's organizations `import()` through that static and needed a
LITERAL in that file — written as a re-export, the specifier stopped resolving
and the load dropped OUT of the swept population instead of failing inside it
(#11614's silent-vacuity mode, which #12492 hit and measured). That constraint
died at `1ca763b60` (#12533), which taught the sweep to follow an import alias
into a sibling module of the same package.

The static is now assigned from the shared const. It keeps its NAME — the roster
pin (`test/serve-capability-vocabulary.test.ts`), the sweep and the
rendered-message pins all address `Serve.ORGANIZATIONS_RUNTIME_PKG`, and only the
spelling moved — and the equality assertion that kept the duplication CHECKED
(site 8 of `serve-organizations-message-spelling.test.ts`) retires with its
subject, in the same change. The gap is still never closed in the other
direction: `os doctor` must not depend on a `serve` export to spell a package
name (#12464's coupling ruling, untouched).

**No behaviour change, measured rather than argued.** Every operator-facing
string that names the runtime — both install-remedy branches, the ADR-0093 D5
fatal refusal, the degraded-boot warning, the stage-2 mount refusal and the
tenancy-posture fix list — was rendered before and after the change and compared:
byte-identical, 5257 bytes, sha256 `d545248b91e52d05…`.
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,19 +26,21 @@
* 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` in
* `serve.ts`. ⛔ That third copy is no longer REQUIRED, and this paragraph is
* the fourth place that said it was. It used to read: it must stay a string
* LITERAL or the host-anchoring sweep in `serve-cluster-host-resolution.test.ts`
* can no longer resolve which package that command's `import()` names. ⭐ That
* died at `1ca763b60` (#12533), which taught the sweep to follow an import alias
* into a sibling module; whether to end the duplication is now an open,
* maintainer-facing decision at #12579. ⛔ None of it changes what THIS file
* measures. What holds either way is that no copy can drift in silence: 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.
* ⚠️ The literal is declared twice now, not three times: the roster key, and
* the shared module this file reads. `Serve.ORGANIZATIONS_RUNTIME_PKG` was the
* third copy; since #12579 it is ASSIGNED FROM that shared module instead of
* spelling the package again. ⛔ It had stopped being REQUIRED before that, and
* this paragraph is the fourth place that said it was. It used to read: it must
* stay a string LITERAL or the host-anchoring sweep in
* `serve-cluster-host-resolution.test.ts` can no longer resolve which package
* that command's `import()` names. ⭐ That died at `1ca763b60` (#12533), which
* taught the sweep to follow an import alias into a sibling module; ending the
* duplication was then ruled on by the maintainer (2026-08-27, #12579, Option
* A). ⛔ None of it changes what THIS file measures. What holds throughout is
* that no copy can drift in silence: the equality pin that held serve's literal
* to this module's — site 8 of `serve-organizations-message-spelling.test.ts` —
* retired with its subject, and each surviving declaration keeps the roster-key
* leg that made the pair safe in the first place.
*
* ── Three legs, and the second is the point ──────────────────────────────
*
Expand Down
65 changes: 43 additions & 22 deletions packages/cli/src/commands/serve-cluster-host-resolution.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -96,8 +96,11 @@ const HERE = dirname(fileURLToPath(import.meta.url));
* lives in (what `./x.js` is relative TO) and the package that file belongs to
* (the fence the hop may not cross). Both are parameters rather than constants so
* the synthetic sources below can be scanned against a temp package instead of
* this one — the hop has no live site in the tree yet, so a fixture package is
* the only way to exercise it at all.
* this one: the REFUSAL cases need siblings that must never exist inside
* `packages/cli` — a non-literal export, a module outside the package, a
* re-export, a missing file — so a fixture package is the only way to exercise
* them. The hop's live site (#12579) proves what it RESOLVES; it can say nothing
* about what it refuses.
*/
type ScanContext = {
/** Directory of the scanned file — the base a relative specifier resolves against. */
Expand DownExpand Up@@ -1145,25 +1148,41 @@ function makeScannedPackage(files: Record<string, string>): ScanContext {
}

/**
* The import-alias hop (#12533), proven against synthetic source.
* The import-alias hop (#12533), proven against synthetic source — and, since
* #12579, carrying a LIVE site.
*
* ── Why synthetic, and why that is stated here rather than assumed ──────────
* ── The live consumer, and what it costs whoever narrows this hop ───────────
*
* ⚠️ NOTHING IN THE TREE USES THIS HOP YET. That is deliberate: the hop is the
* capability, and its consumer — single-sourcing a package spelling out of
* `serve.ts` — is a follow-up. A capability and the reversal it licenses do not
* land together, because `Serve.ORGANIZATIONS_RUNTIME_PKG` is deliberately a
* duplicated LITERAL today, with the reasoning written at both ends
* (`utils/tenancy-posture-hints.ts` and `serve-organizations-message-spelling.test.ts`),
* and a diff that both adds the safety net and removes the thing it protects
* cannot be reviewed as either.
* ⭐ `serve.ts` declares `static readonly ORGANIZATIONS_RUNTIME_PKG =
* SHARED_ORGANIZATIONS_RUNTIME_PKG;` — an import alias of the const in
* `packages/cli/src/utils/tenancy-posture-hints.ts` — so the
* `@objectstack/organizations` load the sweep above judges is resolved THROUGH
* this hop rather than beside it. Until then the branch was reached only from
* the cases below, which is the state the maintainer ruling of 2026-08-27
* (#12579, Option A) deliberately ended: a capability nothing live reaches is
* the defect class this lane keeps filing, one level down.
*
* ⛔ So do NOT read a resolver branch with no live caller as dead code. It is
* reached from every case below, and deleting it puts the tree back in the state
* where single-sourcing a spelling silently empties the sweep — which is how
* this file lost the `@objectstack/organizations` load once already.
* ⚠️ The cost came with it, and this paragraph is where it is written down.
* NARROWING THIS HOP IS NOW A LIVE REGRESSION, not a synthetic one. Refuse a
* spelling these cases accept and the result is no longer "a fixture case went
* red": `serve.ts`'s organizations load drops out of the judged population and
* the NAMED half of the vacuity guard above fires by package name — the loud
* path, but a whole command's load rather than a fixture. ⛔ So do not narrow
* this hop to make some other case pass. Read what the live site spells first,
* and move both or neither.
*
* The first case is the strongest anchor available without a live site: the
* ⛔ And should that site ever change shape again, still do not read the branch
* as dead code: deleting it puts the tree back where single-sourcing a spelling
* silently empties the sweep — which is how this file lost the
* `@objectstack/organizations` load once already.
*
* ── Why the cases stay synthetic ────────────────────────────────────────────
*
* The live site proves what the hop RESOLVES; it can prove nothing about what
* the hop REFUSES, and the refusals are the fence. Those cases need siblings
* that must never exist in `packages/cli` — a non-literal export, a module
* outside the package, a re-export, a missing file — so they are written against
* a temp package. The first case is the anchor between the two halves: its
* source is synthetic, but the sibling it follows is the REAL
* `packages/cli/src/utils/tenancy-posture-hints.ts`, read through `serve.ts`'s
* own scan context.
Expand All@@ -1178,10 +1197,10 @@ describe('os serve → the resolver follows ONE import alias into a sibling modu

it('resolves an alias to a literal in a REAL sibling module of packages/cli', () => {
// No fixture: `SERVE_CONTEXT`, so the sibling read is the live
// `packages/cli/src/utils/tenancy-posture-hints.ts` — the module a
// single-sourcing refactor would actually import from. If that export is
// renamed or stops being a string literal, this goes red HERE, naming the
// hop, instead of the sweep quietly judging one load fewer.
// `packages/cli/src/utils/tenancy-posture-hints.ts` — the module `serve.ts`
// imports the spelling from since #12579. If that export is renamed or stops
// being a string literal, this goes red HERE, naming the hop, instead of the
// sweep quietly judging one load fewer.
expect(
shape([
"import { ORGANIZATIONS_RUNTIME_PKG } from '../utils/tenancy-posture-hints.js';",
Expand All@@ -1205,7 +1224,9 @@ describe('os serve → the resolver follows ONE import alias into a sibling modu
// The static keeps its NAME (separate pins read it as a roster key); only
// the spelling moves to the shared module. This is the exact rewrite that
// came back as `the sweep no longer sees the @objectstack/organizations
// load` before this hop existed.
// load` before this hop existed — and, since #12579, the shape `serve.ts`
// itself carries. Kept synthetic anyway: the live site is judged by the
// sweep above, and this case is what names the SHAPE when it moves.
const context = makeScannedPackage({ 'pkg/src/utils/spelling.ts': SIBLING });
expect(
shape([
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,34 +48,42 @@
* 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` is still a string LITERAL in `serve.ts`, 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).
* **Site 8 retired WITH its subject (#12579).** It asserted that
* `Serve.ORGANIZATIONS_RUNTIME_PKG` and the shared hints module declared the
* same package — the assertion that kept a deliberate duplication CHECKED
* instead of silent. The maintainer ruling of 2026-08-27 (Option A) ended the
* duplication: the spelling is declared once in
* `../utils/tenancy-posture-hints.ts` and serve's static is assigned from it, so
* an equality pin between two declarations has nothing left to compare. ⛔ It
* was deleted in the diff that removed its subject and not one diff earlier —
* while two declarations existed the pin had a job, which is why #12533 left it
* alone.
*
* ⚠️ This paragraph used to say the literal HAD to stay, because
* ⛔ Nothing it measured went with it. Its second assertion — that the
* `isolated` hint an operator reads interpolates that same spelling — is what
* site 6 renders through the real gate and compares hard-coded against `PKG`;
* and both ends of the vanished pair are still pinned as a roster KEY, serve's
* via `test/serve-capability-vocabulary.test.ts` and the shared const's via
* doctor's leg (ii).
*
* ⚠️ This paragraph used to say the literal HAD to stay in `serve.ts`, because
* `serve-cluster-host-resolution.test.ts` resolved the organizations `import()`
* through that static and needed the literal in that file or the load dropped
* out of the host-anchoring sweep silently. ⭐ That reason died at `1ca763b60`
* (#12533): the sweep now follows an import alias into a sibling module. Site 8
* never rested on it — while there are two declarations the pin has a subject,
* whichever way the open decision at #12579 goes — so ⛔ do not delete site 8 to
* "finish" that decision. It falls with its subject or not at all. The reading
* of what survives the hop is on `Serve.ORGANIZATIONS_RUNTIME_PKG`'s docblock.
* (#12533): the sweep now follows an import alias into a sibling module — and
* serve's static is the live site it follows. The reading of what survives the
* hop is on `Serve.ORGANIZATIONS_RUNTIME_PKG`'s docblock.
*/

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';
// SHARING, not this command talking to itself. The spelling itself is no longer
// imported beside it: since #12579 there is one declaration, and `PKG` below
// reaches it through the handle these pins are actually about.
import { TENANCY_POSTURE_FIX_HINTS } from '../utils/tenancy-posture-hints.js';

import Serve, {
formatDegradedTenancyWarning,
Expand All@@ -90,9 +98,10 @@ import Serve, {
*
* 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.
* and the roster pin already address. Since #12579 that static is assigned from
* the shared hints module rather than being a second literal, so this reads the
* ONE declaration through serve's own handle — which is what these pins want,
* the value an `os serve` message actually interpolates.
*/
const PKG = Serve.ORGANIZATIONS_RUNTIME_PKG;

Expand DownExpand Up@@ -248,24 +257,6 @@ describe('serve — the posture description an operator reads names the declarat
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', () => {
Expand Down
Loading
Loading