Skip to content

feat(cli)!: 18.0 guards — os migrate organization-ownership refuses, and os serve refuses a walled posture at boot - #24480

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-15212-180-guards
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-15212-180-guards

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #15212
Clause-②: yes (narrowing)

The 18.0 guards stage (plan amendment 14 on #15212, from ruling records 6259608309 and 6259698357 on #15194). 18.0 ships for fresh installs under the single posture only. This PR makes the two things 18.0 does not support refuse loudly. It deletes no ceremony code, and it adds no re-enable flag or environment escape hatch.

What changes

  1. os migrate organization-ownership refuses in 18.0, plan and apply alike.
    • Exit 1 with the standard ADR-0112 code NOT_IMPLEMENTED. Under --json it prints { error: 'plan_refused' | 'apply_refused', reason: 'upgrade-unavailable', code: 'NOT_IMPLEMENTED', detail }.
    • The message says 18.0 supports fresh installs only and that the v17 → v18 upgrade path ships in an 18.x release.
    • It reads and writes nothing. No posture, supplement, plan file, occupancy probe or boot is touched before it.
    • Shape: the ceremony class is kept whole as the named export OrganizationOwnershipCeremony. The registered default command is a subclass of it whose run() refuses: same flags, same help, plus a description prefix. The 18.x release that carries the upgrade path deletes the subclass. The ceremony's ~45 existing in-process tests now import the ceremony class, a one-line import change in each of 16 files, so the 18.x ceremony keeps its coverage. organization-ownership-*.ts utils are untouched.
  2. os serve refuses a walled posture (group / isolated) at boot.
    • It refuses at its existing tenancy-posture gate, in the same FATAL plus process.exit(1) shape as an unrecognised posture: before a config is read, a plugin is mounted or anything is written. OS_MULTI_ORG_ENABLED=true resolves to isolated and is refused too.
    • The FATAL names NOT_IMPLEMENTED, the 18.x release and the fixes: OS_TENANCY_POSTURE=single, or stay on 17.x. OS_ALLOW_DEGRADED_TENANCY does not lift it.
    • os dev and os start spawn serve, so this is every operator boot. Under single nothing changes.

Where the walled refusal sits (measured)

candidate operator-facing? walled fixtures it breaks
os serve's posture gate (chosen) every CLI boot (dev and start spawn serve) the two CLI e2e files that spawn serve under isolated — rewritten, see below
os dev / os start only misses production os serve none
runtime / kernel (resolveTenancyPosture, createStandaloneStack, bootStack) also every embedded and test boot every kernel-level walled suite: the dogfood wall-* files, verify posture tests, plugin-auth, core and driver tests

The kernel-level walled boots (@objectstack/verify's bootStack, the dogfood wall suites, the unit tests that set OS_TENANCY_POSTURE=isolated) never pass through serve, and they keep working. The only walled boots this refusal breaks are the two nightly-tier CLI e2e files that spawn os serve under isolated. Those tested the organizations runtime's host resolution and its mount-stage classification, which no 18.0 walled boot reaches. They keep their fixtures and now pin, over the real process, that the 18.0 refusal comes first: whatever the package state (installed in the app, absent, reachable only through NODE_PATH, present-and-refusing) and with or without OS_ALLOW_DEGRADED_TENANCY=1, no organizations stage runs. The 18.x release restores their resolution cases from git history.

The os dev automatic ceremony (A1)

os dev runs the ceremony only when the boot guard refuses the database. That never happens on a fresh database: the guard passes a database with no retired column and no marker. When it does run, it spawns this same command, so it now gets the refusal. It prints the command's own refusal line, says nothing was changed, and keeps the copy it took. No os dev change was needed.

Docs

content/docs/deployment/cli.mdx (the command's section, its table row, and os dev's in-place upgrade section), tenancy-modes.mdx, environment-variables.mdx (the OS_TENANCY_POSTURE row) and self-hosting.mdx (walled first boot) each gain the sentence "18.0 supports fresh single installs; upgrades and walled postures arrive in 18.x", only where the text would otherwise be false. Nothing under content/docs/releases/.

Tests

  • New packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts (5 cases): plan and apply refuse with NOT_IMPLEMENTED and exit 1; the plain output carries the code; nothing is touched (boot and occupancy stubs that throw, an unrecognised posture, a missing supplement and plan file, no db or out file created); the default class is a subclass of the ceremony with the same flags.
  • New packages/cli/src/commands/serve-walled-posture-refusal.test.ts (5 cases): single gives null; every walled posture in the spec vocabulary is refused; the OS_MULTI_ORG_ENABLED source is named; and the real serve, run in-process under isolated and under OS_MULTI_ORG_ENABLED=true, exits 1 with nothing announced (no config read).
  • packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts: new step 2a, in which the real CLI binary refuses with NOT_IMPLEMENTED and the database columns are unchanged. Step 2's upgrade proof now runs OrganizationOwnershipCeremony from the CLI's built module in its own process (oclif auto-transpile off, as bin/run.js does), so the ceremony → boot-succeeds proof stays alive for 18.x.

Readings on head cca3540481 (the measurements below were taken on 9141352b, before the merge of main, which touched only unrelated cli tests; the ratchet families were re-run on cca3540481):

  • cli typecheck: exit 0.
  • cli unit project: 312 files / 4615 tests passed.
  • The 16 changed ceremony test files plus the two new pins and serve-tenancy-posture-gate: 18 passed, 1 skipped (remote-face, which needs sqld), 199 tests.
  • The two nightly e2e files (OS_TEST_TIERS=nightly): 7/7.
  • The dogfood file: 5/5.

Ablations, each through scripts/ablation-replace.mjs, mutation landed and restored with git diff HEAD empty:

  • the registered command's run replaced by the ceremony's → 4/5 refusal cases red, the ceremony answering posture-unresolved;
  • serve's walledPostureRefusal(tenancyPosture) call replaced by null → both in-process boot cases red;
  • the single early return deleted → the single case red.

Acceptance notes

  • The boot guard (organization-ownership-boot-guard.ts), left as is per the dispatch, still names os migrate organization-ownership as the remedy for a v17-shaped database. In 18.0 that command refuses, and its refusal says to stay on 17.x. The guard's prescription is therefore incomplete for 18.0. Whether to reword it is the seat's call: the dispatch said to leave the guard alone.
  • os serve's unrecognised-posture FATAL still lists group and isolated among its fixes (from TENANCY_POSTURE_FIX_HINTS). Following either now reaches the 18.0 refusal on the next boot, which is loud, so it is not touched here.
  • Embedded hosts that boot the kernel directly (adapters, createStandaloneStack) are not refused: they are not the operator's os boot path, and refusing there would break the kernel-level walled suites.
  • os doctor still reports a walled posture without saying 18.0 refuses it.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 9 documentable anchor(s).

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/api/data-api.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/api/error-catalog.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/api/wire-format.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/automation/flows.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/concepts/metadata-lifecycle.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/deployment/cli.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE), os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/deployment/tenancy-modes.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/permissions/authentication.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/permissions/capabilities.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/permission-sets.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/releases/v17/17-5.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/releases/v17/17-6.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))
  • content/docs/releases/v17/17-7.mdx (via NOT_IMPLEMENTED (literal, a string literal in ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 35 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3bd4a20994cb28b8052abb5af100e05855f15863 — the merge of head cca354048165cb46cdbfb3d1a55401b636bcc1b1 into base 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3bd4a20994cb28b8052abb5af100e05855f15863 && git checkout 3bd4a20994cb28b8052abb5af100e05855f15863
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb cca354048165cb46cdbfb3d1a55401b636bcc1b1 && git checkout -B drift-repro 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb && git merge --no-ff cca354048165cb46cdbfb3d1a55401b636bcc1b1

node scripts/docs-audit/affected-docs.mjs --json 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 31ce45c071bbcb6e92a730ce19ebf30dcfa361cb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cca354048165cb46cdbfb3d1a55401b636bcc1b1
Local-runs: none
Scope: packages/cli/src/commands/migrate/organization-ownership.ts, packages/cli/src/commands/serve.ts, packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts, packages/cli/src/commands/serve-walled-posture-refusal.test.ts, packages/cli/test/serve-organizations-host-resolution.e2e.test.ts, packages/cli/test/serve-organizations-mount-failure.e2e.test.ts, packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts, packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts, packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts, packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts, packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts, packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts, packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts, packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts, packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts, packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts, packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts + packages/runtime/src/organization-ownership-boot-guard.ts, packages/cli/src/utils/dev-organization-ownership-upgrade.ts, packages/cli/src/commands/dev.ts, packages/cli/src/commands/start.ts, packages/types/src/env.ts, packages/spec/src/security/tenancy-posture.ts, packages/cli/src/commands/migrate/apply.ts, packages/cli/src/utils/organization-ownership-drop-gate.ts, packages/cli/src/utils/format.ts, packages/cli/src/utils/tenancy-posture-hints.ts, packages/cli/src/commands/serve-organizations-message-spelling.test.ts, packages/cli/src/commands/serve-tenancy-posture-gate.test.ts, packages/plugins/plugin-auth/src/tenancy-service.test.ts, content/docs/deployment/single-project-mode.mdx, docs/adr/0131-total-organization-ownership-no-null-organization-id.md (D10, D11), docs/adr/0087-metadata-protocol-upgrade-contract.md, scripts/check-adr-0087-registration.mjs, scripts/pm/clause2-line.mjs (the prose faces — the changeset and the four content/docs/deployment/* pages — are read under ① (e) and ②)

Head resolved at review time: cca354048165cb46cdbfb3d1a55401b636bcc1b1, the head the brief named; it did not move. Rendered by an isolated at-tier reviewer subagent of the adopting seat, read-only: the PR body, file list and comments, git diff origin/main...refs/review/pr-24480 (28 files, +565/−234), the binding records 6259608309 / 6259698357 / 6259899486 on #15194, plan amendment 14 (6259710449), the claim (6259824312) and the os-dev-report (6261098758) on #15212, the head's check-runs. Nothing built, run or re-run.

① Derived judgments

(a) The ceremony refusal — holds.

  • Refuses before any read or write. The registered command is now MigrateOrganizationOwnership extends OrganizationOwnershipCeremony (packages/cli/src/commands/migrate/organization-ownership.ts:1123); its run() (:1128-1145) does this.parse(...) and nothing else before refusing. Every ceremony flag is Flags.string / Flags.boolean with no exists or custom parse (:320-395); the only environment read is OS_DATABASE_URL for --database-url, so parse touches no file and no database. No resolveTenancyPosture(), no supplement or plan-file read, no probeMigrationTarget, no bootSchemaStack. Under --json, emitJson({ error: plan_refused | apply_refused, reason: 'upgrade-unavailable', code: 'NOT_IMPLEMENTED', detail }, 1, { compact: true }) writes the payload and sets process.exitCode = 1 (utils/format.ts:304-311); otherwise printError(...) then this.exit(1). The release-refusal pin stubs the boot and the probe to throw, sets an unrecognised posture and missing files, and asserts no db or out file appears.
  • ADR-0112 code: the standard NOT_IMPLEMENTED (:1115); no spec, ledger or docs regeneration touched. The message (:1110-1112) names "18.0", "fresh installs only", "an 18.x release", "Keep a v17 database on a 17.x release", "Nothing was read or written".
  • Class kept whole. OrganizationOwnershipCeremony (:319) is the former default class renamed; the only change inside it is its own this.parse(OrganizationOwnershipCeremony) self-reference. The subclass overrides description and run() only; flags is inherited by identity (pinned). 18.x restores the ceremony by deleting :1095-1146 and re-adding default to :319 — one step, no flag to remove.
  • No bypass. os dev's automatic ceremony (utils/dev-organization-ownership-upgrade.ts:85-96) spawns the real binary, [binPath, 'migrate', 'organization-ownership', '--dev', ...], so it receives this refusal and reports it (:149, "The upgrade plan was refused, and nothing was changed"). It runs only from coordinator.recover when the serve --dev child has sent objectstack:organization-ownership-required (commands/dev.ts:767, :815-835), which the boot guard emits only for a v17-shaped database or an unverified marker; a fresh database (tables.length === 0 && marker === 'absent') is never refused, so on a fresh database the ceremony never runs. On a v17 development database the helper copies the file into the backups directory before it spawns the plan (:131-134); the copy and a ceremony.log are written, the database is not. The only other non-test importer of the ceremony's utils is commands/migrate/apply.ts:45, which imports the drop gate solely to REFUSE --allow-destructive drops of organization_id until the ceremony's marker is verified — it runs no ceremony step, and in 18.0 the marker can never be verified, so those drops stay refused (D10 item 5 kept). No importer of the module outside packages/cli except the dogfood test.

(b) The walled-boot refusal at os serve — holds.

  • Narrowest operator entry: os dev spawns serve --dev (commands/dev.ts:736-741), os start spawns serve (commands/start.ts:524-528), so serve.ts:2419-2423 is the single funnel for all three CLI boots. It sits immediately after resolveTenancyPostureOrRefusal() and before isDevelopmentBoot / anchorServedApp (the config path): before a config is read, a plugin is mounted or the kernel bootstraps. Ahead of it run only this.parse, the stdout reservation, the port probe and loadProjectEnvFiles (necessarily — the posture lives in .env); the FATAL's statements ("No config has been loaded, no plugin has been mounted, the HTTP server was never started") are true.
  • Same reading as the runtime: the gate wraps resolveTenancyPosture() from @objectstack/types (types/src/env.ts:147-164: OS_TENANCY_POSTURE normalised, else OS_MULTI_ORG_ENABLED ⇒ isolated / single), the same function runtime/src/app-plugin.ts:2225, plugin-auth/src/auth-plugin.ts:661, and objectql/src/registry.ts:2316 call; the walled set is postureEnforcesWall from @objectstack/spec/security, the runtime's own predicate. walledPostureRefusal (serve.ts:6266-6285) re-reads the environment only to NAME the source in the message.
  • Escape hatch: none. walledPostureRefusal(posture) consults no flag and no variable for its verdict; OS_ALLOW_DEGRADED_TENANCY is read only downstream (serve.ts:4373+), and both e2e files pin that it does not lift the refusal. Refused via process.exit(1), like the sibling gate, so no catch can demote it.
  • Kernel-level and dogfood walled fixtures: @objectstack/verify's bootStack, createStandaloneStack, the five packages/qa/dogfood/test/wall-*.dogfood.test.ts, plugin-auth's walled-owner tests never pass through serve; none is touched by the diff.
  • The two e2e rewrites (−191): all seven deleted cases boot the real os serve process under SERVE_ENV.OS_TENANCY_POSTURE: 'isolated' (host-resolution:179-186, mount-failure:168-175), so every one is a walled os serve boot 18.0 refuses. What they covered: a walled boot reaching the banner with the package in the app; the ADR-0093 D5 absent-package fail-fast and the createHostRequire 认 NODE_PATH,于是 #4699 立下的「host app 必须自己声明」在 pnpm 工作区里根本没被强制 #4719 NODE_PATH shim; the degraded boot under the hatch; the serve.ts 的 ADR-0093 D5 catch 把 OrganizationsPlugin 抛出的任何错误都改写成「包加载不出来」,并给出会误导的 OS_ALLOW_DEGRADED_TENANCY 解法 #4818 import-vs-mount classification and the hatch not swallowing a mount refusal. What stays live: the message sites 3–5 (D5 fatal, degraded warning, mount refusal) in-process in serve-organizations-message-spelling.test.ts; the createHostRequire 认 NODE_PATH,于是 #4699 立下的「host app 必须自己声明」在 pnpm 工作区里根本没被强制 #4719 host-root resolution rule in serve-config-plugin-host-resolution.test.ts and serve-cluster-host-resolution.test.ts; the wall and the degraded state at kernel level in plugin-auth/src/tenancy-service.test.ts:36,139 ("degraded: isolated requested but isolation NOT active", never guesses) and the dogfood wall suites. The organizations-runtime classification over a real process is the one thing with no live coverage, and it is unreachable code in 18.0. Acceptable: the fixtures (writeApp, writeOrganizationsPackage, runServe) are kept, the new cases pin that neither stage runs, and both headers name the restore point. Flagged under ③ as an obligation on the 18.x PR.
  • Other operator entries that still boot walled in 18.0 — named, as the scope is "the operator's boot path": (i) the one-shot kernel boots of the os migrate family, os secret rewrap / orphans, os storage orphans and os verify (bootSchemaStack / createStandaloneStack / bootStack, 23 command files) open the database under whatever posture is set and are not refused; they serve no request. (ii) An embedded host — createStandaloneStack / createDefaultHostConfig, documented at content/docs/deployment/single-project-mode.mdx:85-100, and @objectstack/adapter-hono — boots walled unrefused. Plan amendment 14 offered serve / dev / start OR the runtime host and ranked keeping the kernel-level walled fixtures above a wider refusal; a refusal at createStandaloneStack is exactly the hard-blocker case it named. So (ii) is inside the ruled latitude, and the single-project-mode page makes no posture claim, so no doc is false. Both are boundary flags, not blocks.

(c) single unchanged — holds. walledPostureRefusal('single') answers null (postureEnforcesWall false), pinned with its ablation (A3); nothing else in serve.ts changes. The boot guard (runtime/src/organization-ownership-boot-guard.ts) is untouched and never refuses a fresh database. A fresh single install boots and serves exactly as before.

(d) The 16 test-file import changes — hold. Each swaps default for OrganizationOwnershipCeremony while keeping the local name MigrateOrganizationOwnership (two lines where a type Command alias exists), so every assertion body is byte-identical and the ceremony's coverage is kept, not weakened. The dogfood file moves step 2 from the binary to the ceremony class in its own process (oclif auto-transpile off, as bin/run.js does) and adds step 2a, in which the binary refuses with NOT_IMPLEMENTED, writes no plan file, and sys_metadata's columns are unchanged. What the suite no longer proves — that the REGISTERED command runs the ceremony — is the narrowing itself, and the release-refusal pin's fifth case proves the registered class is the ceremony's subclass with its flags and a different run.

(e) Docs — true and minimal. cli.mdx (the os dev upgrade callout :206-209, the table row :1205, the command callout :1484-1489), environment-variables.mdx:101, self-hosting.mdx:469-470, tenancy-modes.mdx:19-22: one sentence each, each true against the code (NOT_IMPLEMENTED, exit 1, code beside error under --json, os serve and so os dev / os start refuse group and isolated before a config is read). One looseness, not a falsity: cli.mdx:207 "stops at step 2 ... and changes nothing" — step 1's backup copy and log are written on a v17 development database; the database is not changed, and "stops at step 2" says step 1 ran. Nothing under content/docs/releases/ is touched (file list; the docs-drift bot's four release pages are read-only hits on the literal NOT_IMPLEMENTED).

② Semver level

  • .changeset/15212-180-guards-fresh-single-only.md: '@objectstack/cli': minor — the launch-window level for a breaking change (AGENTS.md § Post-Task Checklist 3; check-changeset-no-major.mjs). The body carries **BREAKING** and feat(cli)!:, and states each narrowing as a **FROM** … **TO** … pair with a one-line **Fix:** (stay on 17.x; OS_TENANCY_POSTURE=single or unset both variables), plus a "What stays as it was" paragraph. The AGENTS.md FROM → TO rule binds only a removal or rename of something an author can write; this change removes none, and the changeset states the pairs anyway.
  • ADR-0087 marker: exactly one, adr-0087: not-required (no-migration-prescription), and its claim is a positive fact, not a detector miss: no spec key, export, config field or error code is removed or renamed (MigrateOrganizationOwnership stays the default export; the new exports are additions; NOT_IMPLEMENTED already exists). The two pairs are operator-behaviour mappings with operator remedies, not consumer-code rewrites — the shape the gate's framing detector is built to leave alone. Gates: Check Changeset ✓, Lint · CI, release & engine gates ✓ (check-adr-0087-registration, check-changeset-no-major).
  • Clause-②: yes (narrowing): a valid spelling and the right arm. clause2-line.mjs reads yes (narrowing) as "a diff that widens one surface and narrows another"; reviewFace maps ANY (narrowing) arm, yes (narrowing) included, to the narrowing face at CONTRACT_REVIEW_TIER (record-recognisers.mjs:187-188, :238). The claim pre-measured no (narrowing); the dev's yes is defensible — the --json refusal payload gains a new reason value (upgrade-unavailable) and a code key, and the command's description gains a prefix — and either value lands at minor on this face. The PR body and the changeset carry the same line. Not blocking.
  • Part of #15212: right. feat(spec,drivers,objectql,plugin-security): organization_id NOT NULL per cleared table; one predicate for Layer 0 and every driver; both orWhereNull arms, the __global__ sentinel and the #13491 ledger retire (ADR-0131 D1/D8/D9) — protocol 18 #15212 carries plan amendment 14 and the claim for claude/issue-15212-180-guards; the gates "The card this PR closes must claim this branch" and "Part-of PR must not also close its card" are green.

③ Boundary flags

Check-runs on the head at review time: 33 success, 5 skipped (opt-in / matrix placeholders), 2 in progress — Test Core (3/6) and Test Core (4/6). Their conclusions are the gate verdicts; this record judges the contract and does not stand in for them. The merge waits for both.

The report's open questions:

  1. Where the refusal sits — A (os serve's gate) is right, for the reasons in ① (b). The two boot classes it does not reach are named there: the os migrate / os verify one-shot kernel boots and embedded hosts (createStandaloneStack, adapter-hono). Both are inside plan amendment 14's latitude; the seat should carry (ii) into the D14 dated note on docs(adr): ADR-0131 D6 charts sharing_rule under regime C; D7 ledger reading and lines for #24377 and #24435; D14 records the 18.0 scope #24422 so the 18.0 scope statement reads "the os boot path", not "every boot".
  2. The refusing subclass over a kept ceremony class — right; no flag.
  3. NOT_IMPLEMENTED — right; no vocabulary member for a temporary state. The docs-drift bot's rows on the literal are advisory.
  4. The boot guard's prescription — escalated to the seat, not blocking. On a v17 database the guard names a command that refuses, and the refusal says to stay on 17.x: the chain terminates loudly and correctly, but in two hops. B (one sentence in organizationOwnershipRefusalMessage, runtime/src/organization-ownership-boot-guard.ts:211-240) is a runtime-package change outside this stage's claimed surface; recommend a one-line follow-up card on the 18.0 list, or a rider on C11 (docs: close out the #13564 family under ADR-0131; supersede #13636; the tenancy docs state the three sentences #15214).

The report's out-of-scope findings:

  1. TENANCY_POSTURE_FIX_HINTS (cli/src/utils/tenancy-posture-hints.ts:135-141) still offers group / isolated as fixes for an unrecognised posture — following either reaches this PR's loud refusal on the next boot. Acceptable for 18.0; a one-line hint is a candidate rider for C11.
  2. os doctor reports a walled posture without the 18.0 sentence — the next os serve refuses loudly. Acceptable; same C11 rider.
  3. Embedded hosts are not refused — answered under open question 1: inside the ruled latitude, and the right trade against the kernel-level walled suites. Name it in the D14 note.

Obligation on the 18.x restore, so it is not lost: delete the subclass (organization-ownership.ts:1095-1146); delete the walledPostureRefusal call and function (serve.ts:2411-2423, :6255-6285); restore the seven e2e cases of serve-organizations-host-resolution.e2e.test.ts and serve-organizations-mount-failure.e2e.test.ts from the parent of this PR's squash commit, in the same landing; put dogfood step 2 back on the binary and drop step 2a; remove the six docs sentences. Recommend the seat opens the 18.x card with this list now.

Implemented-by: claude/issue-15212-180-guards
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 19, 2026 06:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 19, 2026 06:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit ae6bd1a Oct 19, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-15212-180-guards branch October 19, 2026 07:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants