diff --git a/content/docs/releases/v18/18-0.mdx b/content/docs/releases/v18/18-0.mdx
new file mode 100644
index 00000000000..b201189509e
--- /dev/null
+++ b/content/docs/releases/v18/18-0.mdx
@@ -0,0 +1,709 @@
+---
+title: 18.0.0
+description: "The v18 GA release, published 2026-10-20: organization ownership is total (ADR-0131), so a database 17.x created takes the ownership ceremony before 18.0.0 serves it; protocol 18; a flow reads one expression dialect per slot; the security catalog is metadata; API keys expire; the CORS default admits no cross-origin caller. With its upgrade checklist, walked from 17.7.0."
+---
+
+## Highlights — 18.0.0
+
+- **A database 17.x created is upgraded by a ceremony before 18.0.0 serves it.**
+ ADR-0131 makes organization ownership total: no NULL `organization_id`.
+ `os serve` 18.0.0 starts on a database it created itself, or on one that
+ `os migrate organization-ownership` certified, and refuses any other before it
+ listens (`dd29bbe`, `12d1476`). The ceremony's plan reads only, and `--apply`
+ runs only the plan you reviewed (`f9b4217`). ⚠️ **Read [step 2 of the upgrade
+ checklist](#2-upgrade-the-database) before the first 18.0.0 boot of a 17.x
+ database.**
+- **Protocol 18.** `PROTOCOL_VERSION` is `18.0.0`: the handshake admits
+ `engines.protocol: '^18'` and refuses `'^17'` (`4e9fe9f`). The ADR-0087
+ conversions whose one-major window ran out leave the load path, so a retired
+ spelling is refused with its rename instead of being rewritten (`5fd2c76`,
+ `c5dd7fc`, `bc0538a`, `105902f`, `8693110`, `d176b0a`, `f606ba2`, `f901746`).
+- **A flow reads one expression dialect per slot** (ADR-0032). A value slot takes
+ a CEL value envelope and refuses the single-brace `{…}` template; a text slot
+ takes `{{ }}` holes; a CEL root the flow does not bind is refused at
+ `os validate`; `$` names belong to the engine; and every builtin node's
+ `config` is held to its executor's contract (`0e9e7b7`, `2b61f2d`, `7958bdf`,
+ `5fb1746`, `2f70c22`).
+- **Positions, permission sets and capabilities are metadata.** The four catalog
+ row objects retire (`sys_position`, `sys_permission_set`,
+ `sys_position_permission_set`, `sys_capability`), a position names its sets in
+ `permissionSets`, a grant names its set by name, and a name has one holder per
+ deployment (ADR-0131 D3/D4; `66949b7`, `bfc15d2`, `6f21216`, `fe98cc6`).
+ Declared sharing rules, webhooks and email templates are read from the registry
+ and are read-only (`5e97907`, `191e043`, `7de0265`).
+- **Page components are judged as errors.** An undeclared prop, a value its row
+ refuses or a missing binding fails `os validate`, `os build`, `os lint` and the
+ metadata save door, where 17.7.0 printed an advisory (`4f68ba1`, `32fa194`,
+ `d0c09e4`). An element binds its data through `dataSource` only (`317cddd`).
+- **Credentials and origins close by default.** A key created without an expiry
+ lives 90 days, a key unused for 90 days is refused, and a key that existed
+ with no expiry has one year from the upgrade (`1d79556`). The CORS default
+ admits no cross-origin caller, and `'*'` with credentials fails the boot
+ (`f7546a4`). The inbound `api` hook verifies only the timestamped signature
+ (`5ce6773`).
+- **The AI skills ship with the packages,** as `@objectstack/skills`, on the same
+ version as every other `@objectstack/*` package.
+
+---
+
+## Release facts, as measured
+
+Read with `npm view` and `npm pack` on 2026-10-20, after the publish:
+
+| | Measured |
+| :--- | :--- |
+| `latest` | `18.0.0`, on every `@objectstack/*` package read (`spec`, `cli`, `runtime`, `core`, `objectql`, `skills`, `organizations`) and on `create-objectstack` |
+| `next` | `18.0.0-next.5`, which is **older** than `latest`. ⛔ Never install `@next` for 18: it resolves a prerelease that 18.0.0 supersedes |
+| `rc` | `17.0.0-rc.6` (`@objectstack/skills` and `@objectstack/organizations` carry no `rc` tag) |
+| Published | `@objectstack/cli@18.0.0` at 2026-10-20 12:44 UTC |
+| Prereleases | `18.0.0-next.0` … `next.3` and `next.5`. `next.4` was never published, although the CHANGELOG carries a section for it |
+
+Install 18 by its exact version (`18.0.0`) or a caret range (`^18.0.0`); both
+resolve `latest`. Moving the stale `next` tag is a release act, outside these
+notes.
+
+### How to read the 18.0.0 CHANGELOG
+
+The `## 18.0.0` section of each package's `CHANGELOG.md` is the whole v18 line:
+every changeset of every `18.0.0-next.N`, in landing order. In
+`@objectstack/spec` it holds 341 entries; across the 53 packages whose
+CHANGELOG has an `18.0.0` section it holds 1,115 distinct entries, 340 of them
+marked breaking or narrowing. Read it with two cautions:
+
+- **A later entry can undo an earlier one.** In the `CHANGELOG.md` the published
+ `@objectstack/cli@18.0.0` tarball carries — the copy `node_modules` holds
+ after you install 18.0.0 — `ae6bd1a` says "18.0 supports fresh `single`
+ installs only — `os migrate organization-ownership` refuses, and `os serve`
+ refuses a walled posture at boot"; `12d1476` and `fc34d00`, later in the same
+ section, open the ceremony and lift the walled refusal, and the 18.0.0 code
+ does what the later entries say. The repository's
+ `packages/cli/CHANGELOG.md` no longer reads that way: #24912 amended the
+ released `ae6bd1a` entry in place (it now reads "the 18.0 pre-release
+ refusals of `os migrate organization-ownership` and of a walled posture at
+ boot, both lifted in 18.0.0") and `fc34d00`'s headline ("… supported on fresh
+ installs and on databases the ownership ceremony upgraded"); the 18.0.0
+ tarball keeps the old text. `record:json` is declared by `97b8e7a` and
+ retired by `030a7b8`; it shipped only in `18.0.0-next.3`.
+- **The refusal is the authority.** Every refusal this page quotes was read off
+ the published 18.0.0 packages, not off the CHANGELOG: where the two differ,
+ the package's own message says what the package does.
+
+The checklist below is curated from that record, not copied from it: it keeps
+the changes an application must edit for, and leaves out the ones that move only
+the platform's internals, its own tooling and the Console's code (the Console's
+changes are in the `@objectstack/console` CHANGELOG).
+
+---
+
+## Upgrade checklist
+
+⚠️ One checklist per release, for the release you are landing on **and** every
+release you cross to get there: a 17.4.0 app reads the
+[17.5.0](/docs/releases/v17/17-5#upgrade-checklist),
+[17.6.0](/docs/releases/v17/17-6#upgrade-checklist) and
+[17.7.0](/docs/releases/v17/17-7#upgrade-checklist) lists before this one. On an
+`18.0.0-next.N` prerelease, read the CHANGELOG entries after your prerelease
+instead: this list is written for 17.x.
+
+### 18.0.0
+
+
+**How far this list has been walked.** On 2026-10-20, from 17.7.0, twice: an
+app scaffolded with `create-objectstack@17.7.0` (the `blank` template, pnpm, its
+skills installed by `skills add`) was booted once on 17.7.0 with
+`objectstack start`, so that it created a SQLite database holding one admin and
+three records (the second time also an API key and a comment with an emoji
+reaction), and was then moved to 18.0.0 from npm. The first pass drafted steps
+1 and 2; the second, on a fresh scaffold, followed them as written, and both
+printed the numbers quoted below. Each line says how it was measured:
+
+- **Measured in the walk** — the step was run, and the quoted text is what 18.0.0
+ printed.
+- **Probed on 18.0.0** — the walk's app carries none of it, so a probe stack was
+ refused by the installed 18.0.0 packages (`defineStack`, or `os validate`
+ exiting `1`), and the quoted text is that refusal.
+- **Read off the 18.0.0 tarball** — the code or the message was found in the
+ published package (`npm pack @objectstack/PKG@18.0.0`), and nothing was run.
+
+⛔ The walk was one small app on SQLite, under the `single` posture, plus one
+upgrade of the same app's database created under `group` (step 4) and one of an
+`os dev` database (step 2). `isolated` databases, Postgres, MySQL and Turso were
+not walked, and no upgrade of a production application was run for this page.
+
+
+#### 1. Move the pins
+
+- **Move every `@objectstack/*` dependency to 18.0.0 together**,
+ `devDependencies` included, and regenerate the lockfile —
+ [Moving the dependency pins](/docs/upgrading#moving-the-dependency-pins). The
+ walk moved the scaffold's seven `^17.7.0` ranges (`@objectstack/cli` among
+ them) to `^18.0.0`; `pnpm install` moved all seven to 18.0.0 and printed no
+ peer-dependency warning.
+ *Measured in the walk.*
+- **Move the protocol declarations to 18.** `engines.protocol` in your
+ `defineStack` manifest becomes `'^18'`, and `specVersion` in
+ `objectstack.manifest.json` becomes `'^18.0.0'` — what
+ `create-objectstack@18.0.0` stamps. `os migrate meta --from 17 --write`
+ rewrites `engines.protocol` for you (it printed
+ `objectstack.config.ts:38 manifest.engines.protocol: '^17' → '^18'`);
+ `specVersion` is a hand edit, and neither `os validate` nor the boot reads it.
+ Left on `'^17'`, `os validate` and `os build` still pass with a warning, and
+ the boot refuses before it serves (exit `1`):
+
+ ```text
+ ⚠ Installed @objectstack/spec is v18.0.0 but this app declares engines.protocol '^17', which targets protocol v17.
+ ✗ package 'com.example.v17app' targets protocol ^17 (engines.protocol) but this runtime is protocol 18.0.0. This is a major-version break. Run: objectstack migrate meta --from 17
+ ```
+
+ *Measured in the walk.*
+- **Read `os migrate meta --from 17` as a list to search, not a list to do.** On
+ the walk's app — one object, two fields, no flow, page or permission set — it
+ printed `406 manual change(s) require your judgment`: every protocol-18 manual
+ entry it could not prove absent from the stack. Step 3 below is the curated
+ version for an application. *Measured in the walk.*
+- **Move the AI skills.** From v18 the catalog is the `@objectstack/skills`
+ package. A 17.x project has none: its skills were installed with
+ `skills add` from the repository. Remove those, add the package at your
+ `@objectstack/spec` version with **your project's package manager**, and copy
+ it into your agent's directory:
+
+ ```bash
+ npx skills remove objectstack-ai objectstack-api objectstack-automation \
+ objectstack-data objectstack-formula objectstack-i18n objectstack-platform \
+ objectstack-pm-dispatch objectstack-query objectstack-ui objectstack-upgrade -y
+ pnpm add -D "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"
+ # npm: npm install --save-dev "@objectstack/skills@…" · yarn: yarn add -D "@objectstack/skills@…"
+ npx skills@1.7.2 experimental_sync --agent claude-code --copy -y
+ ```
+
+ `npx skills list` shows what is installed first — the walk listed the ten
+ `objectstack-*` skills — and the removal deletes only the names it is given.
+ Run before the removal, the sync skips every skill a `skills add` installed,
+ exits `0`, and leaves the 17.x-era skill text in place:
+
+ ```text
+ ! Skipped objectstack-ai from @objectstack/skills: installed with `skills add` from objectstack-ai/objectstack
+ … (one line for each of the ten skills)
+ — Nothing to sync.
+ ```
+
+ After the removal it printed `Synced 10 skills`. In the walk's pnpm project
+ `npm install --save-dev` exited `1` with
+ `npm error Cannot read properties of null (reading 'edgesOut')`, so add the
+ package with the manager that installed the project (`create-objectstack`
+ uses pnpm when it is available). The command writes the exact version,
+ `"@objectstack/skills": "18.0.0"`: move it with the other pins at the next
+ upgrade. *Measured in the walk.*
+- **Run `os validate`, then `os build`.** On the walk's app both passed on 18.0.0
+ once the declarations read `'^18'`. Step 3 lists what fails a richer app.
+ *Measured in the walk.*
+
+#### 2. Upgrade the database
+
+A database that 17.x (or an `18.0.0-next.N` prerelease) created carries no
+organization-ownership marker, and 18.0.0 does not serve it until the ownership
+ceremony has certified it. A database 18.0.0 creates from empty writes its own
+birth marker and needs none of this. The overview is
+[Upgrading to 18.0](/docs/upgrading#upgrading-to-180); the command's reference is
+[`os migrate organization-ownership`](/docs/deployment/cli#os-migrate-organization-ownership);
+the lines below are the walk's measured output for each step.
+
+- **Before the first 18.0.0 boot, clear the security-catalog overlays:**
+ `os migrate security-catalog-overlays` lists the environment's stored
+ permission-set and position rows whose name a package now holds, which a v18
+ cold boot refuses with `422 NAMESPACE_CONFLICT`; the preview exits `1` when it
+ lists a row, and `--apply` deletes them (`db9cf80`, `e030d43`). On the walk's
+ 17.7.0-created database it exited `0`:
+ `✓ No environment-wide permission-set or position row is stored over a package-held name — nothing here refuses the cold boot`.
+ *Measured in the walk.*
+- **Start 18.0.0 against the database once.** It is refused before it listens,
+ exit `1`, from the `com.objectstack.organization-ownership-boot-guard` plugin
+ (error code `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`):
+
+ ```text
+ ✗ This database carries no organization-ownership marker: 17.x or an 18.0 pre-release (18.0.0-next.*) created it, and this release serves only a database it created itself or one its ownership ceremony upgraded. sys_audit_log, sys_flow_credential, sys_flow_dispatch, sys_job, sys_job_queue, sys_job_run, sys_metadata, sys_metadata_audit, sys_metadata_commit, sys_metadata_history, sys_migration, sys_migration_journal, sys_secret still carry a physical organization_id column its object no longer declares. The server refuses to start rather than serve it in a shape it was never migrated to. Run 'os migrate organization-ownership --out PLAN' to write and review its plan, take a backup, then run 'os migrate organization-ownership --apply --plan-file PLAN --backup-taken', and start again.
+ ```
+
+ The refused boot is not wasted: its schema sync creates the tables 18.0.0
+ adds, which the ceremony moves rows into. In the walk, a plan drawn before
+ that boot covered 77 tables and one drawn after it 81. *Measured in the walk.*
+- **Do not reach for `os migrate apply --allow-destructive` first.** Before the
+ ceremony, the boot log and `os migrate plan` list the `organization_id`
+ columns as orphaned and name `os migrate apply --allow-destructive` (28
+ destructive changes in the walk, with no word of the ceremony). Run on a copy
+ of the walk's database, that apply exited `1` and dropped nothing:
+ `✗ Refusing to drop sys_comment.reactions: the emoji reactions stored in it are not yet carried into sys_comment_reaction records …`.
+ The ceremony comes first. *Measured in the walk, on a copy.*
+- **Take a backup.** The platform takes none, and `--apply` asks you to state
+ that you took one. For SQLite, stop the server and copy the database file
+ with its `-wal` and `-shm` files, or use `sqlite3 FILE ".backup …"`.
+ *Measured in the walk (a stopped-server copy).*
+- **Write the plan and review it:**
+ `os migrate organization-ownership --out plan.json`. It reads only; per table
+ it prints the fate (`attribution`, `column-drop`, `mirror-deletion`,
+ `report`), the row count, the rows with no organization, and whether the
+ table will receive `NOT NULL`. The walk's plan: 81 tables, 34 to receive
+ `NOT NULL`, `unattributableRows: 0`. A row whose owner cannot be derived
+ blocks certification until you give it an organization or delete it.
+ *Measured in the walk.*
+- **Apply the reviewed plan:**
+ `os migrate organization-ownership --apply --plan-file plan.json --backup-taken`.
+ The walk's run attributed 32 rows, rewrote 2 grants to name their permission
+ set, deleted 49 mirror rows (the retired catalog rows and the seeded email
+ templates), dropped 14 `organization_id` columns, set `NOT NULL` on 32 tables,
+ and ended `✓ The ceremony is complete; recorded as adr-0131-organization-ownership.`
+ The walk ran it with no server running. *Measured in the walk.*
+- **Start the server.** In the walk 18.0.0 served the database: the 17.7.0
+ admin signed in with the same password and kept `platform_admin`,
+ `org_owner` and `admin_full_access`; the three notes read back with their
+ organization, and a fourth was written; an API key minted on 17.7.0 still
+ answered `200`. *Measured in the walk.*
+- **Then clear the drift the ceremony leaves, and only that.** The first served
+ boot still logged three orphaned columns (`sys_comment.reactions`,
+ `sys_sso_provider.created_by` / `updated_by`; on the `group` walk's database
+ also the index `idx_sys_sso_provider_organization_id`). Carry the stored emoji
+ reactions into `sys_comment_reaction` records first —
+ `os migrate comment-reactions` to preview, then
+ `os migrate comment-reactions --apply --yes`, which printed
+ `✓ Converted 1 reaction(s); … the run is recorded as verified` (`9963322`,
+ `6673c80`); without `--yes` it applies nothing and asks for it. Then run
+ `os migrate plan` and read its destructive entries: ⛔ go on only if they are
+ the ceremony's leftovers listed here. `os migrate apply --allow-destructive`
+ applies **every** destructive change in the plan, drops and tightenings
+ alike, so any other entry is your app's own — a column that may hold data —
+ and is decided separately, not in this step. Then run
+ `os migrate apply --allow-destructive` in a terminal: it prints the changes
+ and asks `Apply 4 change(s) to DATABASE? [y/N]` (four on the `group` walk's copy); answer
+ `y` only when the list is the one you checked (`n` printed
+ `ℹ Aborted — no changes made.`). In a non-interactive shell it applies
+ nothing, prints
+ `⚠ Confirmation required. Re-run with --yes to apply, or use "os migrate plan" to preview.`
+ and still exits `0`: read the output, not the exit code. The walk itself ran
+ `os migrate apply --allow-destructive --yes` after that check, which dropped
+ the three columns; the next boot logged no drift, and the reaction read back
+ from `sys_comment_reaction`. *Measured in the walk.*
+
+**A development database (`os dev`).** Measured on a 17.7.0 app whose database
+17.7.0's `os dev` created: `os serve --dev` against that database file was
+refused with the ceremony named in its `--dev` form
+(`Run 'os migrate organization-ownership --dev --out PLAN' …`), and `os dev`
+18.0.0 upgraded it in place — `✓ Upgraded the development database to the v18
+organization ownership`, with a copy kept under `.objectstack/data/backups/` —
+then served it. ⚠️ **Run `os build` first:** while `dist/objectstack.json` was
+still the artifact 17.7.0's `os start` had compiled, the same `os dev` refused
+its plan with the protocol-`^17` refusal and changed nothing
+(`✗ The upgrade plan was refused, and nothing was changed.`). *Measured in the
+walk.*
+
+#### 3. Edit what 18.0.0 refuses
+
+Grouped by what you edit. Each line names the change in the 18.0.0 CHANGELOG
+(grep its hash) and what 18.0.0 answers when the old shape is still there. Most
+are refused at author time, so `os validate` finds them before a deploy: a
+schema refusal throws `STACK_SCHEMA_INVALID` (422) from `defineStack`, a
+cross-reference one `STACK_CROSS_REFERENCE_INVALID` (422), and an author-time
+rule fails `os validate` with exit `1`. The metadata save door (Studio,
+`/api/v1/meta`, MCP) refuses the same shapes on a save: in the walk,
+`PUT /api/v1/meta/page/NAME` answered `422 INVALID_METADATA` for an undeclared
+`page:header` prop and for `kind: 'jsx'`.
+
+##### Flows: one expression dialect per slot
+
+```text
+✗ flows.0.nodes.1.config.outputVariable: This `get_record` node's config is refused at `outputVariable` by the get_record contract: `$deal` is a `$` name, and the `$` names are reserved for the flow engine's own variables …
+• flow 'pp_f1' · node 'u' (update_record) update_record field value at config.fields.owner: A value slot no longer reads the `{…}` template dialect: a string here is the literal text it spells, so a `{…}` token in it is refused rather than stored with its braces. …
+• flow 'pp_f5' · node 'u' (notify) notify title at config.title: A flow text slot reads `{{ }}` template holes (ADR-0032 §3), not the single-brace `{…}` dialect …
+• flow 'pp_f7' · node 'u' (update_record) update_record field value at config.fields.owner: `user` is not bound in a flow expression, so the run fails with `Unknown variable: user` …
+```
+
+- **Value slots take a CEL value envelope.** The `fields` of `create_record` /
+ `update_record`, `assignment` values, each operand of a CRUD node's `filter`,
+ a `notify` node's `recipients`, `sourceId` and `templateData` values, a
+ `loop` (with a `body`) or `map` `collection`, a `subflow` / `map` `input`, a
+ `script` node's `inputs`, a `screen` node's `defaults`, `defaultValue` and
+ `recordId`, and an `http` node's `body`. `'{record.owner}'` →
+ `{ dialect: 'cel', source: 'record.owner' }`; `'{$User.Id}'` →
+ `current_user.id` (guard it with `current_user != null` in a flow that can
+ run without a user); `'{TODAY()}'` → `isoDate(today())`, `'{TODAY() + 7}'` →
+ `isoDate(daysFromNow(7))`, `'{NOW()}'` → `isoDatetime(now())`. CEL fails the
+ run on an absent variable where the template wrote nothing: guard one that may
+ be absent, `has(vars.x) ? vars.x : null`. A query placeholder in a CRUD filter
+ (`'{current_user_id}'`, `'{today}'`) stays as it is. The refusal names the CEL
+ spelling of each token (`0e9e7b7`, `6a06e3b`, `d85615d`, `615c696`,
+ `111ffff`, `9fdb127`, `f66fdc7`, `12b9daf`, `968acc8`). *Probed on 18.0.0
+ (`os validate`) at each of those positions but `sourceId`, `templateData`,
+ `defaults` and `recordId`.*
+- **Text slots take `{{ }}` holes.** A `notify` node's `title`, `message` and
+ `actionUrl`, a `screen` node's `title` and `description`, a refusing `end`
+ node's `message`, an `http` node's `url` and `headers` values, and the
+ translated screen heading `flows.FLOW.screens.NODE.title`: `{x}` → `{{ x }}`.
+ Compute anything else into a variable first, and a run-user value too —
+ `{{ $User.Id }}` is refused (`2b61f2d`, `3073b72`, `968acc8`, `d973c34`).
+ *Probed on 18.0.0 (`os validate`) on `notify` `title`, `message` and
+ `actionUrl`, `screen` `title` and `http` `url`.*
+- **CEL reads only the roots the flow binds.** `user.id`, `ctx.user.id`,
+ `os.user.id` → `current_user.id`. `record` is bound only on a flow an
+ entrance hands a record (a record trigger, a `type: 'flow'` action, an inbound
+ hook, …): elsewhere read the variable by its name or as `vars.NAME`. A
+ condition is CEL: `'{amount} > 100'` → `'amount > 100'` — the old spelling is
+ refused as `invalid CEL predicate` (`5fb1746`, `0f77ff5`, `243dd3c`,
+ `7958bdf`). *Probed on 18.0.0 (`os validate`).*
+- **No `$` names of your own.** An `outputVariable`, an `errorVariable` other
+ than the default `$error`, an `iteratorVariable` / `indexVariable`, an
+ `idVariable`, a screen field's `name` and a declared variable's `name` drop
+ the `$`, and every read of them follows (`3e72f93`, `9f5eca5`). *Probed on
+ 18.0.0 (`defineStack`) for `outputVariable`, `errorVariable` and
+ `iteratorVariable`.*
+- **Node config is held to its executor's contract.** An undeclared key, or a
+ value of the wrong type, is refused at its location: `fieldValues` → `fields`;
+ `bulk` / `all` / `multiple` → `multi: true`; a screen field's `visibleIf` →
+ `visibleWhen`; a loop's `itemVariable` → `iteratorVariable`; delete an `http`
+ node's `outputVariable`; a `subflow` `config.timeoutMs` moves onto the node;
+ `limit: '10'` → `limit: 10`; a `{token}` in a literal key (`limit: '{x}'`) →
+ the literal (`2f70c22`, `78f841b`, `ac9f8bd`, `ca0bdbc`). *Probed on 18.0.0
+ (`defineStack`), each.*
+- **The retired aliases are refused with their rename.** A CRUD node's
+ `config.object` → `objectName` and `config.filters` → `filter`; a `notify`
+ node's `to` / `subject` / `body` / `url` → `recipients` / `title` / `message`
+ / `actionUrl`, and `source: { object, id }` → `sourceObject` / `sourceId`;
+ `map` / `subflow` `flow` → `flowName`; `script` `functionName` / `input` →
+ `function` / `inputs`; node types `http_request`, `http_call` and `webhook` →
+ `http`; a `wait` node's loose `config` keys → `waitEventConfig`
+ (`duration` → `timerDuration`, `signal` → `signalName`, `timeoutMs` →
+ `timerDuration` as a string) and a `connector_action` node's →
+ `connectorConfig`. `os migrate meta --from 16 --write` writes the ones it can
+ prove (`5fd2c76`, `f901746`, `d176b0a`). *Probed on 18.0.0 (`defineStack`),
+ each but the nested `source` pair.*
+- **An edge names two nodes of its graph, once.** An edge whose `source` or
+ `target` is no node, or that repeats an earlier edge, is refused: delete it or
+ re-point it (`db4c45b`). *Probed on 18.0.0 (`defineStack`).*
+- **A retry block states its retries.** A job `retryPolicy` with no
+ `maxRetries`, or `0`, is refused: write `maxRetries: 3` (what such a block ran
+ with before 17), or delete the block for a single attempt;
+ `backoffMultiplier` defaults to `1`, so state `2` to keep the old curve. A
+ retry policy refuses a key it does not declare (`retry.maxRetry`) (`f606ba2`,
+ `ac8f2c5`). *Probed on 18.0.0 (`defineStack`).*
+
+##### Pages, views and actions
+
+```text
+• page "pp_a14" · page:header: `bogusProp` is not a prop `page:header` declares (ComponentPropsMap, @objectstack/spec/ui): Unrecognized key(s) on this `page:header`: `bogusProp`. …
+• page "pp_a3" · element:record_picker: dataSource.object: `element:record_picker` reads its records from the node-level `dataSource` binding only, and this node names no object there …
+✗ pages.0.kind: Page `kind: 'jsx'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `kind: 'html'`. …
+✗ Object 'pp_deal' has two default record pages in `pages`: 'pp_r1' (stack.pages[0]) and 'pp_r2' (stack.pages[1]). Set `isDefault: false` on all but one of them. …
+```
+
+- **A page component's props are errors, not advisories.** A prop its
+ `ComponentPropsMap` row does not declare, a value the row refuses, a retired
+ prop, or a missing data binding fails `os validate`, `os build` and `os lint`,
+ and the metadata save door refuses the page (`4f68ba1`, `32fa194`,
+ `d0c09e4`). The `component-props-*` advisories you left from 17.7.0 now stop
+ the build. *Probed on 18.0.0 (`os validate`).*
+- **Elements bind data through `dataSource`.** The flat `object` / `filter` /
+ `sort` / `limit` of `element:record_picker`, `element:number` and
+ `element:repeater` move to the node-level `dataSource`, a sibling of `type`;
+ `object-grid` `defaultFilters` → `filter` where `filter` is empty, deleted
+ where it is not (`317cddd`). *Probed on 18.0.0 (`os validate`).*
+- **Renamed and retired page keys.** `kind: 'jsx'` → `'html'` (`c5dd7fc`); a
+ page component's `visibility` and a form field's `visibleOn` → `visibleWhen`
+ (`105902f`); `page:header` `description` → `subtitle` (`8693110`); delete
+ `page:header` `showStar` (`ae0ca66`); delete a `marketplace:installed-list`
+ component (`14a8bd2`). *Probed on 18.0.0.*
+- **Page layout refusals.** A `main` region takes only `width: 'full'` — delete
+ `width` to keep the page as it rendered (`3a734e0`); a record page that
+ authors both `slots.details` and `slots.tabs` is refused — move
+ `record:details` into the first tab's `children` (`e223152`); at most one
+ `isDefault: true` record page per object (`5f37d36`). *Probed on 18.0.0
+ (`defineStack`).*
+- **List views.** A `type: 'chart'` view binds `chart: { dataset, values }`
+ (`20e7d52`); delete `filterableFields` — `userFilters.fields` declares a quick
+ filter bar (`4bc3bec`); `data: { provider: 'api' }` is retired — bind the view
+ to its object or static rows, and move an endpoint read onto a page
+ component's `dataSource` (`1912af4`, `05ab9ef`). *Probed on 18.0.0
+ (`defineStack`).*
+- **List views are editable in place by default.** A `userActions` block that
+ leaves `editInline` out now parses to `editInline: true`: write
+ `userActions: { editInline: false }` on a list that must stay read-only in
+ place — a log, an audit trail (`f53f14b`). *Probed on 18.0.0
+ (`ListViewSchema`).*
+- **A url target opens an address or an in-app path.** A `type: 'url'` action's
+ `target`, a page action row's url target, a dashboard header action's
+ `actionUrl` and `onSuccess.navigate` take `http://`, `https://`, `mailto:` or
+ a path starting `/` or `./`; `javascript:`, `data:` and every other scheme are
+ refused (`9b37edb`, `f931e76`). *Probed on 18.0.0 (`defineStack`).*
+
+##### Objects, fields, formulas and validation rules
+
+- **Retired object and index keys.** Delete `externalSharingModel` (`301eec6`).
+ A declared index's `unique: true` → `unique: 'global'` (the same index, nothing
+ on disk changes) or `unique: 'organization'` (`ef1fcb2`). *Probed on 18.0.0
+ (`defineStack`).*
+- **A `controlled_by_parent` object has exactly one required `master_detail`.**
+ Anything else is refused, as an object or as an `objectExtensions` entry; if
+ the rows must outlive their parent, give the object its own `sharingModel`
+ (`76815b9`, `4e0838d`, `ace0a53`). *Probed on 18.0.0 (`defineStack`).*
+- **A stage field's options declare their lifecycle.** Every option of the field
+ an object names as `stageField` carries `kind: 'open' | 'won' | 'lost'`
+ (`adad73f`). *Probed on 18.0.0 (`defineStack`).*
+- **Formulas and validation rules read one hop, and only declared fields.** A
+ second relationship hop (`record.acct.parent.name`) is refused — store the
+ value one hop away; an unknown field is refused in every spelling
+ (`record['typo']`, `record.?typo`), and through a reference a field the
+ target does not declare (`d537a31`, `94379f4`, `45bec43`, `913fd6d`). *Probed
+ on 18.0.0 (`os validate`).*
+- **A select option's `visibleWhen` reads what the server binds:** `record`,
+ `previous` and the acting user (`current_user`, and its aliases `user`, `ctx`,
+ `os`). `parent`, `ctx.locale`, `os.env` and `current_user.email` are
+ refused, and a gate the server
+ cannot evaluate refuses the write instead of admitting the value (`bb4f5cc`,
+ `b1f7a7a`, `46692c1`, `440bed6`). *Probed on 18.0.0 (`os validate`).*
+- **A relationship does not target a stored-metadata table** (`sys_metadata` and
+ its siblings): store the item's type and name instead (`3983965`). *Probed on
+ 18.0.0 (`os validate`).*
+- **One name, one entry.** Two entries with one name in one of 27 stack
+ collections, and two `functions` with one `name`, are refused, naming both
+ (`1959aac`, `147a0d1`). `defineSeed()` refuses a record key that is no column
+ of the object (`9ad3d24`). *Probed on 18.0.0 for `objects`, `functions` and
+ `defineSeed`.*
+
+##### Positions, permission sets and sharing rules
+
+- **A position names its permission sets** in `permissionSets` on its
+ definition. A boot-time binder that wrote `sys_position_permission_set` rows
+ has nothing left to write to: the four catalog row objects are retired
+ (`bfc15d2`, `66949b7`). *Probed on 18.0.0 (`defineStack` accepts
+ `permissionSets`); see the data line below for the rows.*
+- **Permission-set keys.** Delete `managedBy` (`bbdfc05`) and
+ `adminScope.manageBindings` / `adminScope.authorEnvironmentSets` (`be48c5e`); a
+ body `packageId` that disagrees with the package shipping the set is refused —
+ drop it (`eeb95f7`); a field-permission key with no object part (`'.name'`) is
+ refused — write `'OBJECT.FIELD'` (`30b0fc2`). *Probed on 18.0.0.*
+- **One holder per name.** A package that registers a position, permission-set
+ or capability name another package, the environment catalog or a built-in
+ holds is refused `422 NAMESPACE_CONFLICT`, naming both (`fe98cc6`, `e030d43`).
+ *Read off the 18.0.0 tarball (`@objectstack/objectql`).*
+- **Sharing rules.** `accessLevel: 'full'` → `'edit'` (`5fd2c76`). A declared
+ rule (`defineStack({ sharingRules })`) is read from the registry and is
+ read-only: `sharing_rule` is no longer written through `/api/v1/meta`, and no
+ stored rule may take a declared name — clone it under a new name to customize
+ it, and switch the declared one off through the activation ledger (`1e8b628`,
+ `964a237`, `5e97907`). *`'full'` probed on 18.0.0; the rest read off the
+ tarball (`@objectstack/plugin-sharing`).*
+- **A package manifest's `permissions` is the structured block**
+ `{ services, hooks, network, fs }`; a flat list of strings is refused
+ (`fec87e7`). *Probed on 18.0.0 (`defineStack`).*
+
+##### Datasources, email templates, webhooks and inbound hooks
+
+- **`driver: 'mongo'` → `'mongodb'`,** and `OS_DATABASE_DRIVER=mongo` no longer
+ selects a driver: `os serve` exits `1` with
+ `✗ Unsupported driver "mongo" (OS_DATABASE_DRIVER / --database-driver).`
+ (`bc0538a`). *Probed on 18.0.0 (`defineStack`, and `os serve` in the walk's
+ app).*
+- **Email templates.** Delete `category` and `isSystem` (`ec7f7f8`). Templates
+ resolve from the registry: declare them in code — an organization can no
+ longer create or edit a `sys_email_template` row, and the built-in seed is
+ gone (`7de0265`, `8c5aa50`). *`category` probed on 18.0.0; the row refusal
+ (`an organization cannot … an email template row`) read off the 18.0.0
+ tarball (`@objectstack/plugin-email`). The ceremony deleted the 24 seeded
+ template rows in the walk.*
+- **Declared webhooks are read from the registry.** No `sys_webhook` row is
+ written for one, a `sys_webhook` row cannot take a declared name
+ (`409 RESOURCE_CONFLICT`), and one is switched on and off through the
+ activation ledger (`191e043`, `fc34d00`). *Read off the 18.0.0 tarball
+ (`DeclaredWebhookNameConflictError`, `@objectstack/plugin-webhooks`).*
+- **An inbound `api` hook verifies only the timestamped signature.** Send
+ `x-objectstack-signature: t=UNIX_SECONDS,v1=HEX`, the HMAC-SHA256 of
+ `UNIX_SECONDS.RAW_BODY` under the flow's secret, within 300 seconds of the
+ server clock; the body-only `sha256=…` form is refused (`5ce6773`). *Read off
+ the 18.0.0 tarball (`signature-body-only`, `@objectstack/trigger-api`).*
+
+##### Hooks and code against the published packages
+
+- **`objectstack build` refuses some hook bodies it used to lower:** one that
+ reaches `ctx.dispatch.scope` or `ctx.submitted` — do the work at
+ `ctx.dispatch.index === 0` and read `ctx.previous` — and a parameter list it
+ cannot rebind to the sandbox's `ctx` (`1eff322`, `6d36017`). *Read off the
+ 18.0.0 tarball (`@objectstack/cli`, `utils/extract-hook-body.js`).*
+- **An in-process engine call carries a context:** the caller's, or
+ `{ isSystem: true }`. A context with no principal that is not a system context
+ is refused (`a3bcbcf`). *Read off the 18.0.0 tarball
+ (`@objectstack/plugin-security`).*
+- **Removed exports** — each is a `TS2305` after the upgrade: delete the import.
+ `normalizeVisibleWhen`, `VISIBILITY_ALIAS_KEYS` and `VISIBILITY_STRICT_OPTIONS`
+ (read `visibleWhen`; `2c702fd`, `ef1fcb2`); `CONVERSION_CONFLICT_CODE` and the
+ conversion-conflict channel (`a65a59b`); `checkPageComponentViewData`
+ (`a35f7a6`); `RunProvenanceContext` (`746637e`); the zero-reader document
+ schemas (`326a90a`); `ISecurityService.discardPermissionSetOverlay` (`60bcfe7`).
+ *Probed on 18.0.0 for the first five names (absent from the package).*
+- **Renamed registry key.** A custom metadata type registration's
+ `allowOrgOverride` → `allowEnvironmentOverlay`, also on `GET /meta/types`
+ (`e23eccb`). *Read off the 18.0.0 tarball (`@objectstack/spec`).*
+- **Hand-built kernels.** `I18nServicePlugin` loses `registerRoutes` /
+ `basePath` — the runtime dispatcher owns `/api/v1/i18n` (`249130f`);
+ `SecurityPlugin` refuses a kernel that registers no `sys_user` / `sys_member`
+ (`c6fe02d`). *Read off the 18.0.0 tarball (`@objectstack/service-i18n`,
+ `@objectstack/plugin-security`).*
+- **`os meta resync` is deleted** (`9e9e6f9`): it answers
+ `Error: command meta:resync not found`, exit `2`. *Measured in the walk.*
+- **Tests on `@objectstack/verify`.** `bootStack` composes what
+ `objectstack serve` composes from the same configuration, and boots the
+ production `single` shape (`7098aca`, `97610a5`, `34dba5a`);
+ `runDashboardWindowProofs` takes no options (`06dbc4e`);
+ `hooks.run(object, 'delete', …)` resolves with the engine's answer
+ (`a6f3a13`). *Read off the 18.0.0 tarball (`@objectstack/verify`).*
+
+##### Clients of the REST API, the SDK and API keys
+
+Every line here was answered by the walk's 18.0.0 server after the ceremony.
+
+- **API keys expire.** A key minted with no `expires_at` gets 90 days, and the
+ response echoes the date; one asked for beyond a year is refused; a key unused
+ for 90 days answers `403 API_KEY_INACTIVE`; a revoked or expired key answers
+ `403 API_KEY_REVOKED` / `API_KEY_EXPIRED` where it used to answer `401`. A key
+ that existed with no expiry keeps working for one year from the first 18.0.0
+ boot, which stamps that date on it: rotate those keys within the year. The
+ windows are the `auth` settings `api_key_default_expiry_days`,
+ `api_key_max_expiry_days` and `api_key_inactivity_days` (`1d79556`). In the
+ walk the 17.7.0 key still answered `200`, with `expires_at` stamped one year
+ after the first 18.0.0 boot; a new key got `expires_at` 90 days out; and a
+ two-year key was refused:
+
+ ```text
+ 400 API_KEY_EXPIRY_TOO_LONG: expires_at 2028-10-20T00:00:00.000Z is further away than this deployment allows: an API key lives at most 365 days, so the latest expiry a key signed now may have is 2027-10-20T14:27:27.641Z. …
+ ```
+
+ *Measured in the walk; the `403` codes read off the 18.0.0 tarball
+ (`@objectstack/core`).*
+- **Name your cross-origin callers.** With no origin list, the CORS answer
+ admits no other origin: the walk's GET and preflight from
+ `https://app.example.com` came back with no `access-control-allow-origin`.
+ Set `OS_CORS_ORIGIN=https://app.example.com` (comma-separated for several), or
+ the server plugin's `cors.origins`. A list holding `'*'` while credentials are
+ on fails the boot: name the origins, or set `OS_CORS_CREDENTIALS=false`. A
+ loopback origin is admitted on a development boot only. Same-origin requests,
+ the Console included, need nothing (`f7546a4`). *Measured in the walk.*
+- **No anonymous reads of the API description or the translations.**
+ `GET /api/v1/openapi.json` and `GET /api/v1/i18n/locales` answered
+ `401 UNAUTHENTICATED` with no session (`166a94f`, `b53b949`). *Measured in
+ the walk.*
+- **Request bodies are read as sent.** A JSON body sent as `text/plain` answered
+ `400 INVALID_REQUEST` (`The request body was sent as text/plain, which this server does not read into a request body.`);
+ a `null` or non-record body on a `/data` create answered
+ `400 VALIDATION_FAILED`, as did a `PATCH` whose `expectedVersion` was the
+ number `3` — send the version you read as a string (`76d9dca`, `75ced06`,
+ `7c6b7f4`, `cac0be8`, `1f3ac4f`). *Measured in the walk.*
+- **The catalog row objects are gone.** `GET /api/v1/data/sys_permission_set`,
+ `sys_position`, `sys_position_permission_set` and `sys_capability` answered
+ `404`; read positions and permission sets through
+ `/api/v1/meta/position` and `/api/v1/meta/permission`. A grant row in
+ `sys_user_permission_set` names its set in `permission_set` (the set's name),
+ and carries no `permission_set_id` (`66949b7`, `6f21216`, `0253192`).
+ *Measured in the walk.*
+- **Upload scopes are a closed list:** `user`, `tenant`, `private`, `temp`,
+ `attachments`. `scope: 'public'` answered `400 INVALID_REQUEST` (`scope 'public' is not accepted: a storage scope never made a file publicly readable.`):
+ a file is served without sign-in only when its file record carries
+ `acl: 'public_read'`. The storage settings' `max_upload_mb` is enforced
+ (`413 PAYLOAD_TOO_LARGE`) (`6a3fe25`, `ee8751d`, `59d993c`). *Scope measured
+ in the walk; `PAYLOAD_TOO_LARGE` read off the 18.0.0 tarball
+ (`@objectstack/service-storage`).*
+- **Emoji reactions are records.** `sys_comment.reactions` is retired: read and
+ write `sys_comment_reaction` (`9963322`). *Measured in the walk.*
+- **A public form's submit answers `{ id }` only,** not the stored record
+ (`7806a14`). An import's `runAutomations: false` and
+ `treatAsHistorical: true` are honoured only for the system principal and
+ refused `403 PERMISSION_DENIED` to any other caller (`741aa85`). A search over
+ an object with no searchable field is refused `400 INVALID_FIELD` instead of
+ answering every row (`ec749fb`). *Read off the 18.0.0 tarball
+ (`@objectstack/rest`, `@objectstack/objectql`).*
+
+#### 4. What 18.0.0 does not carry yet, and what it carries unlisted
+
+The v18 line keeps landing on `main`, and the version commit did not take
+everything that landed before it. Read on `main` at `71696121a6`: 26 pending
+changesets, ten of them `minor`, so the release that publishes them is
+**18.1.0**. They fall in two sets.
+
+**Shipped in 18.0.0, listed in no 18.0.0 CHANGELOG.** Eight of the 26 belong to
+commits that landed before the 18.0.0 version commit (`06cab4849a`), so their
+code is in the 18.0.0 packages while their entries will first appear in
+18.1.0's CHANGELOG. Two of them reach an upgrade:
+
+- **A production `os serve` boot with no auth secret is refused** (18.x step 7,
+ part 7b-3, #24845; it also stops an organization-less row drawing its
+ autonumber from the `__global__` counter). Measured on 18.0.0 — `os serve`
+ exited `1` before it listened:
+
+ ```text
+ ✗ This stack enables auth, but no auth secret is set, so AuthPlugin was not composed and no caller can authenticate — anonymous access to object data is always denied, so the data API would be unusable, and with no organization every row this boot writes would have no owner.
+ ```
+
+ Set `OS_AUTH_SECRET` to a long random value, identical across restarts and
+ nodes (the walk set the older `AUTH_SECRET`, which 18.0.0 still reads); `os dev`
+ uses a development fallback. `os start` is not refused: with no secret set it
+ wrote `HOME_DIR/auth-secret` (mode `0600`) on its first run and served
+ sign-up. *Measured on 18.0.0.*
+- **A declared sharing rule served under `single` carries
+ `deploymentWide: true`** where it carried `organization_id: null`, and the
+ organization-less readers of sharing rules, approval units and the seed
+ loader retire (18.x step 7, part 7a-3, #24828). *Read off the 18.0.0 tarball
+ (`deploymentWide`, `@objectstack/plugin-sharing`).*
+
+The other six are fixes with no edit for an upgrader, among them
+`invite-member`'s refusal of a membership grade outside its vocabulary with
+`code: 'ROLE_NOT_FOUND'`
+(#24846, in the 18.0.0 `@objectstack/plugin-auth`).
+
+**Not in 18.0.0.** Eighteen changesets belong to commits after the version
+commit, and only 18.1.0 carries them. Six are `minor`:
+
+- **18.x step 7, part 7a-2** (#24872, merged after the cut): a grant applies only
+ in its own organization — an organization-less grant row confers nothing, and
+ the Default Organization's `admin_full_access` grant is the only
+ platform-admin anchor;
+- `os migrate apply` on an empty database writes the organization-ownership
+ birth marker (#24875);
+- `POST /api/v1/packages/:id/adopt-orphans` refuses a read-only package with
+ `422 WRITABLE_PACKAGE_REQUIRED` (#24861; absent from the 18.0.0
+ `@objectstack/runtime`);
+- on PostgreSQL, `introspectSchema` reads each table in its own schema
+ (#24920); `os lint` reports a retired `filterableFields` by name (#24878);
+ and a team record no longer lists Sessions (#24881).
+
+The rest are fixes: masked password inputs in the identity action dialogs, an
+impersonation session that a target's forced password reset no longer holds,
+`server.trustProxy` read for the vendored auth library's client address, and
+others.
+
+**The walled postures on 18.0.0, measured.** `os serve` 18.0.0 boots `group`
+and `isolated` on a fresh database (the `18.0.0-next.5` refusal of a walled
+posture is gone). Each prerequisite it names is refused at boot, in this order,
+until it is met:
+
+```text
+✖ FATAL: tenancy posture 'group' was requested but @objectstack/organizations could not be loaded, so the organization wall is INACTIVE. Refusing to boot …
+✗ [auth] tenancy posture 'group' requires OS_PLATFORM_OWNER_EMAIL to be set. …
+✗ ✖ FATAL: tenancy posture 'group' is walled, but this deployment never declared a membership policy. …
+```
+
+With `@objectstack/organizations@18.0.0` declared in the app,
+`OS_PLATFORM_OWNER_EMAIL` set, a membership policy declared
+(`OS_AUTH_MEMBERSHIP_POLICY=invite-only`) and a stable `OS_SECRET_KEY`, both
+postures printed `✓ Server is ready` with `Tenancy: group` /
+`Tenancy: isolated`. *Measured on a fresh database.*
+
+**A walled 17.x database upgrades through the same ceremony.** Measured on a
+17.7.0 app booted under `group` with `@objectstack/organizations@17.7.0` (its
+owner signed up and wrote two records): after the move to 18.0.0 — the exact
+`"@objectstack/organizations": "17.7.0"` pin moved by hand, like every
+`@objectstack/*` pin — `os migrate security-catalog-overlays` exited `0`, the
+boot was refused as in step 2, the plan read `posture group` (81 tables,
+`unattributableRows: 0`), the apply completed, and 18.0.0 served the database
+under `Tenancy: group` with both records in their organization. *Measured in
+the walk.* An `isolated` database was not walked.
+
+#### 5. The npm tags
+
+`latest` is `18.0.0`; `next` still names `18.0.0-next.5`, which is older — see
+[Release facts](#release-facts-as-measured). Pin `18.0.0` or `^18.0.0`, and ⛔
+never `@next`. *Measured with `npm view`.*
diff --git a/content/docs/releases/v18/index.mdx b/content/docs/releases/v18/index.mdx
index d5979172723..661bfe61026 100644
--- a/content/docs/releases/v18/index.mdx
+++ b/content/docs/releases/v18/index.mdx
@@ -43,12 +43,17 @@ ceremony**, `os migrate organization-ownership` (ADR-0131 D10).
(`os dev`, `os serve --dev`), for example a local one with no
`OS_AUTH_SECRET`, both ceremony commands take `--dev`, as the refusal
`os serve --dev` prints names them. `os dev` runs the ceremony itself for a
- local SQLite development database.
+ local SQLite development database, once `os build` has replaced the artifact
+ 17.x compiled: with that artifact in `dist/`, its in-place upgrade refused its
+ plan and changed nothing.
- **Walled postures boot.** `OS_TENANCY_POSTURE=group` and `isolated` (and
`OS_MULTI_ORG_ENABLED=true`) boot on a fresh install and on a database the
ceremony upgraded, under the same prerequisites as 17.x: the enterprise
`@objectstack/organizations` runtime and `OS_PLATFORM_OWNER_EMAIL`. The
- 18.0 pre-releases refused them; 18.0.0 does not.
+ 18.0 pre-releases refused them; 18.0.0 does not. 18.0.0 also refuses a walled
+ boot until a membership policy is declared
+ (`OS_AUTH_MEMBERSHIP_POLICY=invite-only`, or `auto` chosen knowingly):
+ `✖ FATAL: tenancy posture 'group' is walled, but this deployment never declared a membership policy.`
- **A production `os serve` needs an auth secret.** A production `os serve`
whose stack enables auth and which resolves no `OS_AUTH_SECRET` is refused
before any plugin starts: with no AuthPlugin, no caller can
@@ -58,7 +63,9 @@ ceremony**, `os migrate organization-ownership` (ADR-0131 D10).
generates an auth secret on its first run and persists it to
`/auth-secret`.
-The step-by-step is [Upgrading to 18.0](/docs/upgrading#upgrading-to-180), and
+The step-by-step is [Upgrading to 18.0](/docs/upgrading#upgrading-to-180), each
+step's measured output is in the walked
+[18.0.0 upgrade checklist](/docs/releases/v18/18-0#2-upgrade-the-database), and
every flag is in
[`os migrate organization-ownership`](/docs/deployment/cli#os-migrate-organization-ownership).
@@ -96,13 +103,25 @@ every flag is in
npx skills remove objectstack-ai objectstack-api objectstack-automation \
objectstack-data objectstack-formula objectstack-i18n objectstack-platform \
objectstack-pm-dispatch objectstack-query objectstack-ui objectstack-upgrade -y
- npm install --save-dev "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"
+ pnpm add -D "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"
npx skills@1.7.2 experimental_sync --agent claude-code --copy -y
```
- `npx skills remove --all` replaces the first command when these are the only
- skills the project has.
+ Add the package with the manager that installed the project: in a pnpm
+ project, `npm install --save-dev` fails (`npm error Cannot read properties of
+ null (reading 'edgesOut')`, measured on the [18.0.0 upgrade
+ walk](/docs/releases/v18/18-0#1-move-the-pins)). `npx skills remove --all`
+ replaces the first command when these are the only skills the project has:
+ run `npx skills list` first, because `--all` removes every installed skill
+ without asking.
- **18.0.0 ends the prerelease.** It is the GA cut: it leaves pre mode and
- publishes the line to `latest`. Curated notes for the line's releases are
- compiled at release time and are not written yet; until then, the changesets
- are the record.
+ publishes the line to `latest`.
+
+## Per-release notes
+
+Each release below is a self-contained page: what it changed, what breaks, and
+its own upgrade checklist.
+
+- **[18.0.0](/docs/releases/v18/18-0)** — current; the GA cut. Its
+ [upgrade checklist](/docs/releases/v18/18-0#upgrade-checklist) moves a 17.x
+ app and its database to 18.0.0, and was walked from 17.7.0.
diff --git a/content/docs/releases/v18/meta.json b/content/docs/releases/v18/meta.json
index cb104bd0f03..2dd5e729de1 100644
--- a/content/docs/releases/v18/meta.json
+++ b/content/docs/releases/v18/meta.json
@@ -1,6 +1,7 @@
{
"title": "v18",
"pages": [
- "index"
+ "index",
+ "18-0"
]
}
diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx
index b6dd9319706..e858d1052df 100644
--- a/content/docs/upgrading.mdx
+++ b/content/docs/upgrading.mdx
@@ -114,7 +114,9 @@ command for this move; it is four steps you run yourself.
The list names every skill the catalog carried during 17.x, and a name that
is not installed is skipped; `npx skills remove --all` does the same when
- these are the only skills the project has.
+ these are the only skills the project has. Run `npx skills list` first:
+ `--all` removes every installed skill without asking, so use it only when
+ the list shows nothing but these.
Then run [the loop](#the-loop) below — `os validate` before `os build` — because
moving the pins can move the gate.
@@ -350,16 +352,35 @@ command.
**The sequence:**
-1. Start 18.0 against the database once (`os serve` / `os start`). The boot is
+1. Before the first 18.0 boot, run `os migrate security-catalog-overlays`. It
+ lists the stored permission-set and position rows whose name a package now
+ holds, which an 18.0 cold boot refuses with `422 NAMESPACE_CONFLICT`; it exits
+ `1` when it lists one, and `--apply` deletes them.
+2. Start 18.0 against the database once (`os serve` / `os start`). The boot is
refused, but its schema sync has already created the tables 18.0 adds, which
the ceremony moves rows into.
-2. Take a backup. The platform takes none.
-3. Write the plan: `os migrate organization-ownership --out plan.json`. It reads
+3. Take a backup. The platform takes none.
+4. Write the plan: `os migrate organization-ownership --out plan.json`. It reads
only.
-4. Review the plan.
-5. Apply exactly that plan:
+5. Review the plan.
+6. Apply exactly that plan:
`os migrate organization-ownership --apply --plan-file plan.json --backup-taken`.
-6. Start the server again.
+7. Start the server again.
+8. Clear the orphaned columns the first served boot still reports, and only
+ those. Preview the reactions with `os migrate comment-reactions`, then carry
+ them with `os migrate comment-reactions --apply --yes`. Then run
+ `os migrate plan` and check that its destructive entries are only the
+ columns the ceremony left behind (in the walk: `sys_comment.reactions`,
+ `sys_sso_provider.created_by`, `sys_sso_provider.updated_by`). Any other
+ destructive entry is your app's own and is decided separately, not in this
+ step: `os migrate apply --allow-destructive` applies every destructive change
+ in the plan. Then run `os migrate apply --allow-destructive` in a terminal and
+ answer its `[y/N]` prompt after reading the changes it lists. In a
+ non-interactive shell it applies nothing, prints
+ `⚠ Confirmation required. Re-run with --yes to apply …` and still exits `0`.
+
+Each step's measured output, from a walk of a 17.7.0 app to 18.0.0, is in
+[the 18.0.0 upgrade checklist, step 2](/docs/releases/v18/18-0#2-upgrade-the-database).
```bash
os migrate organization-ownership --out plan.json
@@ -371,12 +392,16 @@ with `--dev` (`os dev`, `os serve --dev`), for example a local one with no
`OS_AUTH_SECRET`, composes as a development boot, and the ceremony has to boot
the same way to see the same database. The refusal `os serve --dev` prints names
the command with `--dev`. `os dev` runs the whole sequence itself for a local
-SQLite development database.
+SQLite development database. Run `os build` before it: while
+`dist/objectstack.json` was still the artifact 17.x compiled, `os dev`'s
+in-place upgrade refused its plan and changed nothing.
**Walled postures upgrade too.** `OS_TENANCY_POSTURE=group` and `isolated` boot
in 18.0 on a fresh install and on a database the ceremony upgraded. On a walled
database, a customized copy of a declared webhook or sharing rule blocks its
-step and names the row.
+step and names the row. A walled boot also needs a declared membership policy,
+`OS_AUTH_MEMBERSHIP_POLICY=invite-only` (or `auto`, chosen knowingly); without
+one it is refused: `✖ FATAL: tenancy posture 'group' is walled, but this deployment never declared a membership policy.`
**A production `os serve` deployment needs an auth secret.** A production
`os serve` whose stack enables auth and which resolves no `OS_AUTH_SECRET` is
@@ -404,8 +429,11 @@ have no other channel to reach you than the page below.
| Release | Where its upgrade notes live |
| :--- | :--- |
-| v18.0.0 | [Upgrading to 18.0](#upgrading-to-180) (the ownership ceremony) · [v18](/docs/releases/v18) |
-| v17.4.0 | [17.4.0](/docs/releases/v17/17-4) — ⛔ checklist not written; machine-draft notes only |
+| v18.0.0 | [Upgrade checklist — 18.0.0](/docs/releases/v18/18-0#upgrade-checklist) · [Upgrading to 18.0](#upgrading-to-180) (the ownership ceremony) · [v18](/docs/releases/v18) |
+| v17.7.0 | [Upgrade checklist — 17.7.0](/docs/releases/v17/17-7#upgrade-checklist) |
+| v17.6.0 | [Upgrade checklist — 17.6.0](/docs/releases/v17/17-6#upgrade-checklist) |
+| v17.5.0 | [Upgrade checklist — 17.5.0](/docs/releases/v17/17-5#upgrade-checklist) |
+| v17.4.0 | [Upgrade checklist — 17.4.0](/docs/releases/v17/17-4#upgrade-checklist) |
| v17.3.0 | [Upgrade checklist — 17.3.0](/docs/releases/v17/17-3#upgrade-checklist) |
| v17.2.0 | [Upgrade checklist — 17.2.0](/docs/releases/v17/17-2#upgrade-checklist) |
| v17.1.0 | [Upgrade checklist — 17.1.0](/docs/releases/v17/17-1#upgrade-checklist) |
@@ -417,7 +445,7 @@ have no other channel to reach you than the page below.
| v12.0.0 | [Upgrade checklist](/docs/releases/v12#upgrade-checklist) |
| v9.0.0 | [Upgrade checklist](/docs/releases/v9#upgrade-checklist) |
-Only the v17 line is broken out per release. Each of its three minors shipped
+The v17 and v18 lines are broken out per release. Each v17 minor shipped
breaking changes under the repo's launch-window convention — which ships a
breaking change as `minor` rather than `major`, so the version number is not the
migration signal and the changelog entry is. Earlier majors' release pages carry
diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json
index 8a8be41dd6c..12c30fc41b3 100644
--- a/scripts/docs-audit/handwritten-docs.json
+++ b/scripts/docs-audit/handwritten-docs.json
@@ -205,6 +205,7 @@
"content/docs/releases/v17/17-6.mdx",
"content/docs/releases/v17/17-7.mdx",
"content/docs/releases/v17/index.mdx",
+ "content/docs/releases/v18/18-0.mdx",
"content/docs/releases/v18/index.mdx",
"content/docs/releases/v9.mdx",
"content/docs/ui/actions.mdx",